实现Agent对话,合格自动提交,不合格补充材料的能力

This commit is contained in:
wandering
2026-06-14 12:56:33 +08:00
parent ef9e1d771b
commit 01d4686ceb
68 changed files with 8914 additions and 1908 deletions
+242
View File
@@ -0,0 +1,242 @@
---
## last_reviewed: 2026-06-13
# src/agent — Agent 协调模块
作为调度中枢,编排信息提取、规则校验和语义校验的完整流程,驱动用户完成材料补充直到信息完整可提交。
## 模块清单
| 文件 | 作用 |
| ----------------- | ------------------------------------------ |
| `orchestrator.py` | 调度中枢:状态机管理、轮次调度、校验-修正循环编排、语义校验、用户补充处理、事件发射 |
## 架构概览
Agent 是报销系统的调度中枢,负责编排各模块完成信息提取和校验。它的核心职责:
1. **调度 LLM 提取** — 调用 `doc.llm_extractor` 的纯提取接口
2. **调度规则校验** — 调用 `doc.validator` 按报销规范检查字段完整性
3. **编排校验-修正循环** — 校验失败时构建修正提示,再次调度 LLM 修正
4. **调度语义校验** — 调用 LLM 判断提取信息在语义层面是否自洽、充分
5. **状态持久化** — 每轮结束后将会话状态写入磁盘,支持中断恢复
```mermaid
graph TB
subgraph agent["src/agent (调度中枢)"]
O[orchestrator.py]
end
subgraph doc["src/doc"]
LE[llm_extractor.py]
VA[validator.py]
end
O -->|"1. 调度 LLM 提取"| LE
O -->|"2. 调度规则校验"| VA
VA -->|"校验失败"| O
O -->|"3. 构建修正提示"| LE
O -->|"4. 调度语义校验"| LE
LE -->|"缓存读写"| C[.invoice_cache/]
O -->|"SSE 事件"| E[agent_events.log]
O -->|"状态持久化"| S[agent_state.json]
```
### 校验-修正循环
Agent 编排的校验-修正循环流程:
1. Agent 调用 `llm_extractor.llm_query_text()` 让 LLM 提取信息
2. Agent 调用 `validator.validate_extracted_info()` 规则校验
3. 校验失败则 Agent 构建修正提示(包含缺失字段列表),再次调用 LLM
4. 最多重试 3 次(`MAX_VALIDATION_RETRIES`),3 次后返回最佳结果
**关键点**:校验逻辑不内嵌在 `llm_extractor` 中,而是由 Agent 层调度。`llm_extractor` 只提供纯提取能力,`validator` 只提供纯校验能力,Agent 负责编排。
## 状态机
Agent 会话通过 `AgentState` 枚举管理生命周期:
```mermaid
stateDiagram-v2
[*] --> IDLE
IDLE --> EXTRACTING
EXTRACTING --> AWAITING_SUPPLEMENT
EXTRACTING --> READY
AWAITING_SUPPLEMENT --> EXTRACTING: 用户补充后重新校验
READY --> [*]
EXTRACTING --> ERROR: 提取失败
AWAITING_SUPPLEMENT --> ERROR: 超出最大轮次
READY --> SUBMITTING: 用户提交
SUBMITTING --> DONE
DONE --> [*]
AWAITING_SUPPLEMENT --> READY: 用户强制提交
```
### 状态说明
| 状态 | 含义 | 触发条件 |
| --------------------- | ------------------------ | ------------------------ |
| `IDLE` | 初始状态,等待启动 | 会话创建时 |
| `EXTRACTING` | 正在执行提取-校验-修正循环 | `run_agent_round()` 开始执行 |
| `AWAITING_SUPPLEMENT` | 语义校验未通过,等待用户补充材料或文字说明 | 语义校验失败 |
| `READY` | 规则校验 + 语义校验均通过,信息完整,可以提交 | 双重校验通过 |
| `SUBMITTING` | 用户确认提交,进入提交流程 | 调用提交接口 |
| `DONE` | 提交完成,终态 | 提交成功后 |
| `ERROR` | 提取失败或超出最大轮次(默认 5 轮) | 异常或轮次耗尽 |
## 数据流
### 单轮处理流程
```mermaid
flowchart TD
A[开始第 N 轮] --> B{终态保护?}
B -->|是| Z[跳过返回]
B -->|否| C{轮次超限?}
C -->|是| E[转入 ERROR]
C -->|否| D[EXTRACTING]
D --> F{缓存命中?}
F -->|是| G[读取缓存数据]
F -->|否| H[Agent 调度校验-修正循环]
H --> I[调用 LLM 提取]
I --> J[调用 validator 校验]
J --> K{校验通过?}
K -->|是| L[写入缓存]
K -->|否| M{重试次数<3?}
M -->|是| N[构建修正提示]
N --> I
M -->|否| L
G --> O[语义校验 validate_semantic_completeness]
L --> O
O --> P{语义通过?}
P -->|是| Q[READY]
P -->|否| R[发射 agent_request_supplement 事件]
R --> S[AWAITING_SUPPLEMENT]
Q --> T[发射 agent_ready 事件]
S --> U[持久化状态]
T --> U
U --> V[返回 session]
E --> U
```
### 用户补充流程
```mermaid
flowchart TD
A[用户补充] --> B{补充方式}
B -->|文件上传| C[add_supplement 记录文件名]
B -->|文字输入| D[process_user_text_supplement]
B -->|强制提交| E[force_submit 跳过校验]
D --> F[LLM 解析文字提取字段]
F --> G{有有效字段?}
G -->|是| H[合并到 extracted_info]
H --> I[更新缓存]
I --> J[触发新一轮 run_agent_round]
G -->|否| K[返回无变化提示]
E --> L[直接转入 READY]
C --> M[等待下一轮 run_agent_round]
J --> M
L --> M
K --> M
```
## 核心数据结构
### AgentSession
会话状态的完整载体,包含:
```python
{
"session_id": "str", # 会话唯一标识
"state": "AgentState", # 当前状态
"rounds": 0, # 已执行的轮次数
"max_rounds": 5, # 最大轮次限制
"invoice_type": "travel", # 发票类型: "travel" | "normal"
"extracted_info": {}, # 提取的结构化报销信息
"validation_reports": [], # 历次语义校验报告列表
"user_supplements": [], # 用户补充的文件名列表
"error_message": "" # 错误信息
}
```
### 校验报告
语义校验生成的报告条目追加到 `validation_reports`:
```python
{
"round": 1,
"type": "semantic",
"valid": False,
"issues": ["金额不一致"],
"missing_info": ["报销说明"],
"suggestion": "请确认金额一致性并补充报销说明",
"confidence": 0.6
}
```
### SSE 事件
通过 `agent_events.log` 向外部发射实时事件,每行一条 JSON:
| 事件类型 | 触发时机 |
| --------------------------- | ------------- |
| `agent_state_change` | 状态切换时 |
| `agent_ready` | 双重校验通过,信息完整 |
| `agent_request_supplement` | 校验未通过,请求用户补充 |
| `agent_supplement_received` | 收到用户补充(文件或文字) |
| `agent_force_submit` | 用户选择强制提交 |
| `agent_error` | 信息提取失败 |
| `agent_max_rounds` | 达到最大轮次限制 |
## 持久化机制
- **状态文件**:`session_dir/agent_state.json` — 原子写入(先写 `.tmp` 再 rename)
- **事件日志**:`session_dir/agent_events.log` — 追加写入,支持前端 SSE 轮询
- **缓存目录**:`session_dir/.invoice_cache/` — 存放 `travel_info.json` / `normal_info.json`
## 依赖说明
| 上游依赖 | 用途 |
| -------------------------- | ------------------------- |
| `src/doc/llm_extractor.py` | 纯 LLM 提取、语义校验、缓存加载、用户补充解析 |
| `src/doc/validator.py` | 纯规则校验 |
| `src/doc/prompt.py` | 系统提示词加载 |
Agent 是调度中枢,`llm_extractor` 提供纯提取能力,`validator` 提供纯校验能力,Agent 负责编排校验-修正循环。各模块职责清晰,不互相嵌套。
## 设计原则
- **Agent 是调度中枢**:校验-修正循环由 Agent 编排,不内嵌在 `llm_extractor` 中
- **模块职责单一**:`llm_extractor` 只管提取,`validator` 只管校验,Agent 负责编排
- **缓存优先**:信息提取优先读取 `.invoice_cache`,避免重复调用 LLM
- **轮次保护**:默认 5 轮上限,防止无限循环;校验-修正循环最多重试 3 次
- **终态保护**:`DONE` / `SUBMITTING` / `READY` 状态下不再重复处理
- **容错降级**:语义校验失败不阻断流程,仅记录警告日志;规则校验 3 次重试后返回最佳结果
+34
View File
@@ -0,0 +1,34 @@
"""Agent 模块
提供多轮对话协调、规则校验和语义校验能力。
入口:
- `orchestrator.run_agent_round()` — 执行一轮 Agent 处理
- `orchestrator.force_submit()` — 用户强制提交
- `orchestrator.add_supplement()` — 记录用户补充的文件
- `orchestrator.load_agent_state()` / `save_agent_state()` — 状态持久化
"""
from .orchestrator import (
AgentSession,
AgentState,
_emit_agent_event,
add_supplement,
force_submit,
load_agent_state,
process_user_text_supplement,
run_agent_round,
save_agent_state,
)
__all__ = [
"_emit_agent_event",
"AgentSession",
"AgentState",
"add_supplement",
"force_submit",
"load_agent_state",
"process_user_text_supplement",
"run_agent_round",
"save_agent_state",
]
+532
View File
@@ -0,0 +1,532 @@
"""Agent 协调器
作为调度中枢,编排信息提取、规则校验的完整流程。
校验-修正循环由 Agent 层调度:
1. Agent 调用 LLM 提取信息
2. Agent 调用 validator.py 校验
3. 校验失败则构建修正提示,再次调用 LLM
4. 重复直到校验通过或达到最大重试次数
5. LLM 在输出中包含 can_submit 和 suggestion 字段,用于判断信息完整性
状态机:
idle -> extracting -> awaiting_supplement -> (回到extracting)
|
(完整) -> ready -> submitting -> done
|
(用户强制) -> ready
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any
from .. import get_logger
from ..doc.llm_extractor import (
CACHE_DIR_NAME,
build_extraction_user_message,
llm_query_text,
load_cache,
load_match_result,
parse_json_response,
)
from ..doc.prompt import (
build_normal_info_system_prompt,
build_travel_info_system_prompt,
)
from ..doc.validator import validate_extracted_info
log = get_logger("agent")
# 规则校验-修正循环的最大重试次数
MAX_VALIDATION_RETRIES = 3
# ------------------------------------------------------------------
# 状态枚举
# ------------------------------------------------------------------
class AgentState(StrEnum):
IDLE = "idle"
EXTRACTING = "extracting"
AWAITING_SUPPLEMENT = "awaiting_supplement"
READY = "ready"
SUBMITTING = "submitting"
DONE = "done"
ERROR = "error"
# ------------------------------------------------------------------
# Agent 会话数据模型
# ------------------------------------------------------------------
@dataclass
class AgentSession:
"""Agent 会话状态"""
session_id: str
state: AgentState = AgentState.IDLE
rounds: int = 0
max_rounds: int = 5
invoice_type: str = "travel" # "travel" 或 "normal"
extracted_info: dict[str, Any] = field(default_factory=dict)
validation_reports: list[dict[str, Any]] = field(default_factory=list)
user_supplements: list[str] = field(default_factory=list)
error_message: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AgentSession:
# 兼容旧版本:state 可能是字符串
if "state" in data and isinstance(data["state"], str):
data["state"] = AgentState(data["state"])
return cls(**data)
# ------------------------------------------------------------------
# 持久化
# ------------------------------------------------------------------
AGENT_STATE_FILE = "agent_state.json"
def save_agent_state(session_dir: Path, session: AgentSession) -> None:
"""将 Agent 会话状态持久化到 session 目录。"""
state_path = session_dir / AGENT_STATE_FILE
tmp_path = session_dir / (AGENT_STATE_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(session.to_dict(), f, ensure_ascii=False, indent=2)
tmp_path.replace(state_path)
def load_agent_state(session_dir: Path) -> AgentSession | None:
"""从 session 目录加载 Agent 会话状态。"""
state_path = session_dir / AGENT_STATE_FILE
if not state_path.exists():
return None
try:
with open(state_path, encoding="utf-8") as f:
return AgentSession.from_dict(json.load(f))
except Exception as e:
log.warning("加载 Agent 状态失败: %s", e)
return None
# ------------------------------------------------------------------
# SSE 事件发射
# ------------------------------------------------------------------
AGENT_EVENT_LOG = "agent_events.log"
def _emit_agent_event(session_dir: Path, event_type: str, **kwargs: Any) -> None:
"""向 agent_events.log 追加一行 JSON 事件。"""
event = {"type": event_type, **kwargs}
try:
event_path = session_dir / AGENT_EVENT_LOG
with open(event_path, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception:
pass
# ------------------------------------------------------------------
# 辅助:构建修正提示
# ------------------------------------------------------------------
def _build_correction_prompt(
base_message: str,
report: Any,
) -> str:
"""根据校验报告构建修正提示,追加到原始用户消息后。"""
error_feedback = (
f"\n\n=== 上一次输出的校验结果 ===\n"
f"校验未通过,发现以下问题:\n"
f"缺失字段 ({len(report.missing_fields)} 个):{', '.join(report.missing_fields)}\n"
)
if report.missing_materials:
error_feedback += f"可能需要补充的材料:{', '.join(report.missing_materials)}\n"
if report.suggestion:
error_feedback += f"建议:{report.suggestion}\n"
error_feedback += (
"\n请根据以上校验结果修正你的输出,确保所有必填字段都有值。"
"如果某个字段确实没有数据,请给出合理的猜测值。"
"再次返回完整的 JSON 结果。"
)
return base_message + error_feedback
# ------------------------------------------------------------------
# 核心协调逻辑
# ------------------------------------------------------------------
def _do_extraction_with_validation(
session_dir: Path,
session: AgentSession,
previous_analysis: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Agent 调度的提取-校验-修正循环。
流程:
1. 加载缓存数据和匹配结果
2. 构建用户消息
3. 调用 LLM 提取
4. 调用 validator 校验
5. 校验失败则构建修正提示,回到步骤 3
6. 最多重试 MAX_VALIDATION_RETRIES 次
LLM 输出的 JSON 中额外包含 can_submit 和 suggestion 字段,
用于判断信息是否完整可提交。
Args:
session_dir: 会话目录。
session: 当前 Agent 会话。
previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。
Returns:
校验通过的结构化数据(或达到重试上限后的最佳结果)。
"""
cache_map = load_cache(session_dir)
match_result = load_match_result(session_dir)
# 选择系统提示词
if session.invoice_type == "travel":
system_prompt = build_travel_info_system_prompt()
else:
system_prompt = build_normal_info_system_prompt()
base_message = build_extraction_user_message(cache_map, match_result, previous_analysis=previous_analysis)
current_message = base_message
for attempt in range(1, MAX_VALIDATION_RETRIES + 1):
log.info(
"LLM 提取第 %d/%d 次尝试 (%s)",
attempt,
MAX_VALIDATION_RETRIES,
session.invoice_type,
)
_emit_agent_event(
session_dir,
"agent_state_change",
state=AgentState.EXTRACTING,
round=session.rounds,
attempt=attempt,
message=f"正在分析文件... (第{attempt}次)",
)
# Step 1: 调用 LLM 提取
try:
response = llm_query_text(
system_prompt=system_prompt,
text=current_message,
reasoning_effort="low",
source_dir=session_dir,
)
result = parse_json_response(response)
except Exception as e:
log.error("LLM 提取失败: %s", e)
_emit_agent_event(
session_dir,
"agent_error",
message=f"LLM 提取失败: {e}",
)
raise
# Step 2: 调用 validator 校验
report = validate_extracted_info(result, invoice_type=session.invoice_type)
if report.valid:
log.info("规则校验通过 (第 %d 次尝试)", attempt)
_emit_agent_event(
session_dir,
"agent_state_change",
state=AgentState.EXTRACTING,
round=session.rounds,
attempt=attempt,
message=f"规则校验通过 (第{attempt}次)",
)
return result
# Step 3: 校验失败,构建修正提示
log.warning(
"规则校验未通过 (第 %d/%d 次): 缺失 %d 个字段 - %s",
attempt,
MAX_VALIDATION_RETRIES,
len(report.missing_fields),
report.missing_fields,
)
_emit_agent_event(
session_dir,
"agent_state_change",
state=AgentState.EXTRACTING,
round=session.rounds,
attempt=attempt,
message=f"规则校验未通过,缺失 {len(report.missing_fields)} 个字段,正在请求 LLM 修正...",
)
current_message = _build_correction_prompt(current_message, report)
# 所有重试都失败,返回最后一次结果
log.error(
"LLM 提取经过 %d 次尝试仍未通过规则校验,返回最后一次结果 (置信度: %.0f%%)",
MAX_VALIDATION_RETRIES,
report.confidence * 100,
)
return result
def run_agent_round(
session_dir: Path,
session: AgentSession,
new_files: list[str] | None = None,
) -> AgentSession:
"""执行一轮 Agent 处理:提取-校验-修正循环。
Args:
session_dir: 会话目录。
session: 当前 Agent 会话。
new_files: 新增的文件列表(可选,补充文件时传入)。
Returns:
更新后的 Agent 会话。
注意:
- 信息提取优先从 .invoice_cache 缓存读取,避免重复调用 LLM。
- Agent 调度提取-校验-修正循环:LLM 提取 -> validator 校验 -> 失败则反馈修正。
- 若缓存缺失则执行提取后立即写回缓存(travel_info.json / normal_info.json)。
- 补充文件时(new_files 非空),加载上一轮分析结果作为历史上下文,强制重新分析。
"""
# 终态保护:会话已提交或已完成时不再重复处理
if session.state in (AgentState.DONE, AgentState.SUBMITTING, AgentState.READY):
log.info("Agent 会话已处于终态 (%s),跳过重复处理", session.state.value)
return session
if session.rounds >= session.max_rounds:
session.state = AgentState.ERROR
session.error_message = f"已达到最大轮次 ({session.max_rounds}),请检查信息或强制提交"
log.warning("Agent 达到最大轮次限制")
_emit_agent_event(
session_dir,
"agent_max_rounds",
message=session.error_message,
)
return session
session.rounds += 1
log.info("开始第 %d 轮 Agent 处理", session.rounds)
# ---- Step 1: 信息提取(Agent 调度校验-修正循环) ----
session.state = AgentState.EXTRACTING
_emit_agent_event(
session_dir,
"agent_state_change",
state=session.state,
round=session.rounds,
message="正在分析文件...",
)
# 判断是否为补充文件场景:有新文件传入时,加载上一轮分析结果作为上下文
cache_map = load_cache(session_dir)
is_supplement = bool(new_files)
previous_analysis = None
if is_supplement:
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
previous_analysis = cache_map.get(info_key)
if previous_analysis:
log.info("检测到补充文件,加载上一轮分析结果作为历史上下文")
try:
if session.invoice_type == "travel":
should_reanalyze = not cache_map.get("travel_info") or is_supplement
if should_reanalyze:
session.extracted_info = _do_extraction_with_validation(
session_dir, session, previous_analysis=previous_analysis
)
# 提取后立即写入缓存,后续步骤依赖此数据
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(session.extracted_info, f, ensure_ascii=False, indent=2)
else:
session.extracted_info = cache_map["travel_info"]
else:
should_reanalyze = not cache_map.get("normal_info") or is_supplement
if should_reanalyze:
session.extracted_info = _do_extraction_with_validation(
session_dir, session, previous_analysis=previous_analysis
)
# 提取后立即写入缓存,后续步骤依赖此数据
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(session.extracted_info, f, ensure_ascii=False, indent=2)
else:
session.extracted_info = cache_map["normal_info"]
except Exception as e:
session.state = AgentState.ERROR
session.error_message = f"信息提取失败: {e}"
log.error("Agent 信息提取失败: %s", e)
_emit_agent_event(
session_dir,
"agent_error",
message=session.error_message,
)
return session
# ---- 判断结果(从 LLM 提取结果中读 can_submit) ----
can_submit = session.extracted_info.get("can_submit", True)
suggestion = session.extracted_info.get("suggestion", "")
if can_submit:
session.state = AgentState.READY
_emit_agent_event(
session_dir,
"agent_ready",
round=session.rounds,
message="信息完整,可以提交",
)
log.info("Agent 校验通过,信息完整")
else:
session.state = AgentState.AWAITING_SUPPLEMENT
combined_suggestion = suggestion or "信息不完整,请补充材料"
_emit_agent_event(
session_dir,
"agent_request_supplement",
round=session.rounds,
missing_fields=[],
missing_materials=[],
semantic_issues=[],
suggestion=combined_suggestion,
)
log.info("Agent 请求补充: %s", combined_suggestion)
save_agent_state(session_dir, session)
return session
def force_submit(
session_dir: Path,
session: AgentSession,
) -> AgentSession:
"""用户强制提交,跳过校验。"""
session.state = AgentState.READY
log.info("用户强制提交,跳过校验")
_emit_agent_event(
session_dir,
"agent_force_submit",
message="用户选择强制提交",
)
save_agent_state(session_dir, session)
return session
def add_supplement(
session_dir: Path,
session: AgentSession,
filenames: list[str],
) -> AgentSession:
"""记录用户补充的文件。"""
session.user_supplements.extend(filenames)
_emit_agent_event(
session_dir,
"agent_supplement_received",
files=filenames,
)
log.info("收到用户补充文件: %s", filenames)
save_agent_state(session_dir, session)
return session
def process_user_text_supplement(
session_dir: Path,
session: AgentSession,
user_text: str,
) -> AgentSession:
"""处理用户通过文字补充的信息。
流程:
1. LLM 分析用户文字,提取需要更新的字段
2. 合并到已提取的信息中
3. 保存到缓存
4. 重新执行一轮 Agent 校验
Args:
session_dir: 会话目录。
session: 当前 Agent 会话。
user_text: 用户输入的文字。
Returns:
更新后的 Agent 会话。
"""
from ..doc.llm_extractor import (
merge_supplement_into_info,
process_user_supplement,
)
log.info("收到用户文字补充: %s", user_text)
_emit_agent_event(
session_dir,
"agent_supplement_received",
files=[user_text[:50]], # 简短显示
)
# Step 1: LLM 分析用户文字
supplement_result = process_user_supplement(
user_text=user_text,
extracted_info=session.extracted_info,
invoice_type=session.invoice_type,
source_dir=session_dir,
)
updated_fields = supplement_result.get("updated_fields", {})
unparsed = supplement_result.get("unparsed_info", "")
if updated_fields:
# Step 2: 合并到已提取信息
session.extracted_info = merge_supplement_into_info(
session.extracted_info,
updated_fields,
)
# Step 3: 保存到缓存
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
info_file = "travel_info.json" if session.invoice_type == "travel" else "normal_info.json"
info_path = cache_dir / info_file
with open(info_path, "w", encoding="utf-8") as f:
json.dump(session.extracted_info, f, ensure_ascii=False, indent=2)
log.info("已更新 %s", info_file)
# Step 4: 重新执行 Agent 校验
session.state = AgentState.EXTRACTING
_emit_agent_event(
session_dir,
"agent_state_change",
state=session.state,
round=session.rounds,
message="正在重新校验...",
)
session = run_agent_round(session_dir, session)
else:
# 没有可更新的字段
msg = unparsed or "未识别到可更新的报销信息"
_emit_agent_event(
session_dir,
"agent_supplement_received",
files=[msg],
)
log.info("用户补充未识别到有效信息: %s", msg)
return session
+19 -15
View File
@@ -57,22 +57,26 @@ def run(
def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") -> None:
"""填写基本信息"""
bot.page.fill("#EXPENEXPLAIN", description)
bot.page.click("#PROJECTCODE", timeout=10000)
bot.page.wait_for_timeout(1000)
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
if first_row:
first_row.click()
try:
bot.page.fill("#EXPENEXPLAIN", description)
bot.page.click("#PROJECTCODE", timeout=10000)
bot.page.wait_for_timeout(1000)
bot.page.click("#saveAndNext", timeout=5000)
bot.page.wait_for_timeout(2000)
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
bot._screenshot("step3_done")
if first_row:
first_row.click()
bot.page.wait_for_timeout(1000)
bot.page.click("#saveAndNext", timeout=5000)
bot.page.wait_for_timeout(2000)
bot._screenshot("step3_done")
except Exception as e:
log.error(f"填写基本信息失败: {e}")
bot._screenshot("basic_info_error")
raise
# ------------------------------------------------------------------
@@ -123,8 +127,8 @@ def fill_normal_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> Non
for info in payment_info:
bot.page.click("#insertPay", timeout=5000)
bot.page.wait_for_timeout(1000)
bot.page.fill("#personid2", bot.config["default_name"])
bot.page.fill("#accountname2", bot.config["default_person_id"])
bot.page.fill("#personid2", bot.config["default_person_id"])
bot.page.fill("#accountname2", bot.config["default_name"])
bot.page.fill("#receiptdate2", format_date(str(info.get("card_date", ""))))
bot.page.fill("#localaccount2", bot.config["default_card_no"])
bot.page.fill("#receiptmoney2", str(info.get("card_amount", 0)))
+5 -4
View File
@@ -74,9 +74,10 @@ def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None:
bot.page.click("#saveAndNext", timeout=5000)
bot.page.wait_for_timeout(2000)
bot._screenshot("travel_basic_done")
except Exception:
log.error("填写基本信息失败")
except Exception as e:
log.error(f"填写基本信息失败: {e}")
bot._screenshot("travel_basic_error")
raise
# ------------------------------------------------------------------
@@ -190,8 +191,8 @@ def fill_travel_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> Non
for info in payment_info:
bot.page.click("#insertPay", timeout=5000)
bot.page.wait_for_timeout(1000)
bot.page.fill("#personid2", bot.config["default_name"])
bot.page.fill("#accountname2", bot.config["default_person_id"])
bot.page.fill("#personid2", bot.config["default_person_id"])
bot.page.fill("#accountname2", bot.config["default_name"])
bot.page.fill("#receiptdate2", format_date(info["card_date"]))
bot.page.fill("#localaccount2", bot.config["default_card_no"])
bot.page.fill("#receiptmoney2", str(info["card_amount"]))
+47 -25
View File
@@ -1,6 +1,6 @@
---
last_reviewed: 2026-06-11
---
## last_reviewed: 2026-06-12
# src/doc — 文档处理模块
@@ -9,36 +9,51 @@ last_reviewed: 2026-06-11
## 模块清单
| 文件 | 作用 |
| ------------------------ | -------------------------------------------------- |
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
| `pdf.py` | PDF 图片渲染(PyMuPDF) |
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态 + 差旅/普通报销信息综合提取) |
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) |
| `prompt.py` | LLM 提示词模板加载 |
| 文件 | 作用 |
| ------------------------ | ---------------------------------------------------------------------------- |
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
| `pdf.py` | PDF 图片渲染(PyMuPDF) |
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态 + 差旅/普通报销信息综合提取) |
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) |
| `prompt.py` | LLM 提示词模板加载 |
| `prompts/` | 提示词模板文件(`invoice_system.md`、`travel_info_system.md`、`normal_info_system.md`) |
## 数据流
```
PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
支付截图 → llm_extractor.py → [刷卡记录]
↓
matcher.py(按金额贪心匹配,相对容差 3%)
↓
invoice.py 分类 → CSV(已回填刷卡日期/卡号/金额)
↓
fill_consumable_doc → 易耗品出库单.doc
```mermaid
flowchart TD
A["PDF 发票"] --> B["pdf.py<br/>PDF 图片渲染"]
B --> C["llm_extractor.py<br/>发票文本提取"]
C --> D["(发票列表)"]
[发票列表 + 匹配结果] → llm_extractor.py
↓
差旅发票 → extract_travel_info() → travel_info.json
普通发票 → extract_normal_info() → normal_info.json
E["支付截图"] --> F["llm_extractor.py<br/>多模态提取"]
F --> G["(刷卡记录)"]
D --> H["matcher.py<br/>金额贪心匹配 / 相对容差 3%"]
G --> H
H --> I["invoice.py<br/>分类 + CSV 回填"]
I --> J["CSV<br/>刷卡日期/卡号/金额"]
J --> K["fill_consumable_doc<br/>易耗品出库单"]
K --> L["易耗品出库单.doc"]
D --> M["llm_extractor.py<br/>差旅/普通信息综合提取"]
H --> M
M --> N{"发票类型判断"}
N -->|差旅发票| O["extract_travel_info()"]
N -->|普通发票| P["extract_normal_info()"]
O --> Q["travel_info.json"]
P --> R["normal_info.json"]
```
## 依赖说明
- **PyMuPDF (pymupdf)** — PDF 图片渲染
@@ -52,9 +67,16 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
- LLM 提取失败时直接报错,无正则回退
## 变更说明(2026-06-16)
- `llm_extractor.py` 新增 SSE 流式事件写入:`_emit_llm_stream()` 函数将 LLM 思考过程的 `start`/`reasoning`/`chunk`/`end`/`error` 事件写入 `source_dir/llm_stream.log`,前端通过 SSE 实时展示 AI 思考过程与正式回答
- `_llm_query_multimodal()` 新增 `source_dir` 参数,流式接收 `delta` 时同步写入 chunk 事件;同时提取 `additional_kwargs.thinking_delta` 写入 reasoning 事件
- `extract_travel_info()` 和 `extract_normal_info()` 在 LLM 调用前后写入 `start`/`end` 事件,异常时写入 `error` 事件
## 变更说明(2026-06-11)
- `llm_extractor.py` 新增 `extract_normal_info()`:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`
- `llm_extractor.py` 的 `load_cache()` 扩展支持加载 `normal_info.json`
- `prompt.py` 新增 `build_normal_info_system_prompt()`:加载 `normal_info_system.md`
- `prompts/` 新增 `normal_info_system.md`:普通发票信息提取的系统提示词
- `prompts/` 新增 `normal_info_system.md`:普通发票信息提取的系统提示词
+136 -32
View File
@@ -5,6 +5,18 @@
对外接口:
extract_invoices(directory) -> tuple[list[dict], list[dict], dict]
错误传播规则:
- 单个文件提取失败: 记录日志 + SSE error 事件,继续处理下一个文件
- 全部文件提取失败: raise ExtractionError,携带失败文件列表和原始错误
- 缓存读取失败: 静默降级,尝试重新提取
SSE 文件进度事件:
在 source_dir 下写入 file_events.log,每行一个 JSON 对象:
- {"type": "file_progress", "file": "...", "status": "processing"}
- {"type": "file_progress", "file": "...", "status": "done", "summary": {...}}
- {"type": "file_progress", "file": "...", "status": "cached"}
- {"type": "file_progress", "file": "...", "status": "error", "error": "..."}
"""
import json
@@ -12,35 +24,37 @@ from pathlib import Path
from typing import Any
from .. import get_logger
from ..exceptions import ExtractionError
from .invoice import CACHE_DIR_NAME, classify_invoice_batch
from .llm_extractor import extract_document
from .matcher import match_invoices_to_cards
log = get_logger("extractor")
# SSE 文件进度事件日志文件名
FILE_EVENTS_LOG = "file_events.log"
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
# 支持的文件扩展名
SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".webp"}
# JSON 缓存目录(相对于源文件目录)
CACHE_DIR_NAME = ".invoice_cache"
def _emit_file_event(source_dir: Path, file_name: str, status: str, **kwargs: Any) -> None:
"""向 file_events.log 追加一行 JSON 事件(线程安全,失败时静默忽略)"""
event = {
"type": "file_progress",
"file": file_name,
"status": status,
**kwargs,
}
try:
event_path = source_dir / FILE_EVENTS_LOG
with open(event_path, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception:
pass
# 支持的文件扩展名
SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".bmp"}
def _get_cache_dir(source_dir: Path) -> Path:
@@ -87,7 +101,59 @@ def _load_from_cache(json_path: Path, expected_extension: str | None = None) ->
return None
def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None:
def _build_summary(data: dict[str, Any]) -> dict[str, str]:
"""从提取结果构建前端展示摘要(人类可读格式)。
返回所有非内部字段(排除 _source_file 等下划线前缀字段),
并将 invoice_type 转换为中文标签。列表/字典类型的值会展开为可读文本。
"""
invoice_type_map = {
"train": "高铁票",
"hotel": "酒店住宿",
"general": "普通发票",
"payment": "支付记录",
"application": "出差申请单",
}
summary = {}
for key, value in data.items():
# 跳过内部字段
if key.startswith("_"):
continue
# 跳过空值
if value is None or value == "":
continue
# invoice_type 转为中文标签
if key == "invoice_type":
summary["invoice_type_label"] = invoice_type_map.get(str(value), str(value))
elif isinstance(value, list):
# 列表展开为多行可读文本
if len(value) == 0:
continue
parts = []
for item in value:
if isinstance(item, dict):
# 字典项用 "key: value" 格式拼接
pair_parts = [f"{k}: {v}" for k, v in item.items()]
parts.append(" | ".join(pair_parts))
else:
parts.append(str(item))
summary[key] = "\n".join(parts)
elif isinstance(value, dict):
# 字典展开为 "key: value" 格式
pair_parts = [f"{k}: {v}" for k, v in value.items()]
summary[key] = " | ".join(pair_parts)
else:
summary[key] = str(value)
return summary
def _extract_document(
file_path: Path,
cache_dir: Path,
source_dir: Path,
) -> tuple[dict[str, str] | None, str | None]:
"""提取单个文件的结构化信息,优先使用缓存。
根据 LLM 返回的「invoice_type」字段自动分类:
@@ -99,28 +165,43 @@ def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None
Args:
file_path: 文件路径(PDF 或图片)。
cache_dir: JSON 缓存目录。
source_dir: 源目录(用于写入 SSE 进度事件)。
Returns:
提取结果字典,失败时返回 None。
(提取结果字典或 None, 错误信息或 None)。
"""
file_name = file_path.name
json_path = _get_json_path(file_path, cache_dir)
cached = _load_from_cache(json_path, expected_extension=file_path.suffix.lower())
if cached:
cached["_source_file"] = file_path.name
log.info(f"使用缓存: {file_path.name}")
return cached
cached["_source_file"] = file_name
log.info(f"使用缓存: {file_name}")
_emit_file_event(source_dir, file_name, "cached")
return cached, None
log.info(f"使用多模态提取: {file_path.name}")
# 发送处理中事件
_emit_file_event(source_dir, file_name, "processing")
log.info(f"使用多模态提取: {file_name}")
try:
result = extract_document(file_path)
if result:
result["_source_file"] = file_path.name
result["_source_file"] = file_name
_save_to_cache(file_path, result, cache_dir)
return result
except Exception as e:
log.warning(f"多模态提取失败: {file_path.name} ({e})")
return None
# 发送完成事件(含摘要)
summary = _build_summary(result)
_emit_file_event(source_dir, file_name, "done", summary=summary)
return result, None
except Exception as e:
err_msg = str(e)
log.warning(f"多模态提取失败: {file_name} ({err_msg})")
_emit_file_event(source_dir, file_name, "error", error=err_msg)
return None, err_msg
return None, None
def _find_all_files(directory: str) -> list[Path]:
@@ -181,10 +262,19 @@ def extract_invoices(
- applications: 出差事前申请单列表(单独存储,不参与支付匹配)
- groups: 按文档类型分组的字典
{'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单]}
Raises:
ExtractionError: 当所有文件提取均失败时抛出,携带失败文件列表和原始错误。
"""
source_dir = Path(directory)
cache_dir = _get_cache_dir(source_dir)
# 清空上次的文件进度事件
try:
(source_dir / FILE_EVENTS_LOG).unlink(missing_ok=True)
except Exception:
pass
all_files = _find_all_files(directory)
if not all_files:
log.warning("未找到支持的文件")
@@ -195,11 +285,15 @@ def extract_invoices(
all_invoices = []
all_cards = []
applications = []
# 记录失败文件及其错误信息
failed_files: list[tuple[str, str]] = []
for file_path in all_files:
result = _extract_document(file_path, cache_dir)
result, err = _extract_document(file_path, cache_dir, source_dir)
if not result:
if err:
failed_files.append((file_path.name, err))
log.warning(f"未能解析: {file_path.name}")
continue
@@ -217,6 +311,16 @@ def extract_invoices(
else:
log.warning(f"无法分类: {file_path.name} (invoice_type={inv_type})")
# 全部文件提取失败时抛出异常,携带原始错误信息
if failed_files and not all_invoices and not all_cards and not applications:
failed_names = [name for name, _ in failed_files]
error_details = {name: err for name, err in failed_files}
raise ExtractionError(
f"所有 {len(all_files)} 个文件提取均失败",
failed_files=failed_names,
details=error_details,
)
log.info(f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份")
if not all_invoices:
@@ -231,7 +335,7 @@ def extract_invoices(
all_documents.extend(record.get("_matched_invoices", []))
all_documents.extend(applications)
groups = _classify_invoice_batch(all_documents)
groups = classify_invoice_batch(all_documents)
log.info(
f"文档分类: 差旅发票 {len(groups['travel'])} 张, "
f"普通发票 {len(groups['general'])} 张, "
+4 -1
View File
@@ -17,6 +17,9 @@ from .. import get_logger
log = get_logger("invoice")
# 缓存目录名(相对于源文件目录)
CACHE_DIR_NAME = ".invoice_cache"
# CSV 列名
INVOICE_LEVEL_COLUMNS = [
"index",
@@ -58,7 +61,7 @@ def _is_application_document(invoice_type: str) -> bool:
return invoice_type == "application"
def _classify_invoice_batch(
def classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
+325 -111
View File
@@ -7,13 +7,28 @@
- **统一文档提取**:使用一套提示词,LLM 自行判断文档类型(发票/支付记录/出差事前申请单等),支持 JSON 格式输出。
- **差旅信息提取**:综合多张发票、支付记录和匹配结果,提取出差事由、地点、时间等差旅相关信息。
- **缓存管理**:支持从 `.invoice_cache/` 目录加载已提取的结构化数据和匹配结果,避免重复处理。
- **SSE 流式事件**:`extract_travel_info` 和 `extract_normal_info` 在调用 LLM 时,向 `source_dir/llm_stream.log` 写入流式事件(start/reasoning/chunk/end/error),前端通过 SSE 实时展示 AI 思考过程与正式回答。
## 对外接口
- `extract_document(file_path) -> dict` — 统一入口:从任意图片/PDF 提取信息
- `extract_travel_info(source_dir) -> dict` — 综合发票和匹配结果提取差旅信息
- `extract_normal_info(source_dir) -> dict` — 提取普通发票报销信息
- `load_cache(source_dir) -> dict` — 加载缓存的结构化数据
- `load_match_result(source_dir) -> dict` — 加载发票与支付记录的匹配结果
- `llm_query_text(system_prompt, text, source_dir) -> str` — 纯文本 LLM 查询(供 Agent 调度使用)
- `parse_json_response(text) -> dict` — 从 LLM 响应中提取 JSON(供 Agent 调度使用)
- `build_extraction_user_message(cache_map, match_result) -> str` — 构建提取请求的用户消息(供 Agent 调度使用)
## SSE 流式事件协议
`llm_stream.log` 每行一个 JSON 对象:
- `{"type": "llm_stream", "phase": "start", "label": "..."}` — LLM 调用开始
- `{"type": "llm_stream", "phase": "reasoning", "text": "..."}` — 模型原生推理/思考片段(来自 `thinking_delta`)
- `{"type": "llm_stream", "phase": "chunk", "text": "..."}` — 流式文本片段(正式回答)
- `{"type": "llm_stream", "phase": "end", "label": "..."}` — LLM 调用结束
- `{"type": "llm_stream", "phase": "error", "error": "..."}` — LLM 调用失败
"""
from __future__ import annotations
@@ -24,14 +39,49 @@ from pathlib import Path
from typing import Any, cast
from .. import get_logger
from .invoice import CACHE_DIR_NAME
from .prompt import (
build_invoice_system_prompt,
build_normal_info_system_prompt,
build_supplement_system_prompt,
build_travel_info_system_prompt,
)
log = get_logger("llm_extractor")
# Re-export CACHE_DIR_NAME for convenience
__all__ = [
"CACHE_DIR_NAME",
"extract_document",
"extract_travel_info",
"extract_normal_info",
"load_cache",
"load_match_result",
"llm_query_text",
"parse_json_response",
"build_extraction_user_message",
]
# SSE LLM 流式事件日志文件名
LLM_STREAM_LOG = "llm_stream.log"
def _emit_llm_stream(source_dir: Path, phase: str, **kwargs: Any) -> None:
"""向 llm_stream.log 追加一行 JSON 事件(线程安全,失败时静默忽略)
Args:
source_dir: 会话目录路径。
phase: 事件阶段 ("start" / "chunk" / "end" / "error")。
**kwargs: 额外字段 (text, label, error 等)。
"""
event = {"type": "llm_stream", "phase": phase, **kwargs}
try:
event_path = source_dir / LLM_STREAM_LOG
with open(event_path, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception:
pass
def _create_llm() -> Any:
"""根据配置文件创建 LLM 实例。"""
@@ -55,13 +105,19 @@ def _create_llm() -> Any:
)
def _parse_json_response(text: str) -> dict[str, Any]:
"""从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。"""
def parse_json_response(text: str) -> dict[str, Any]:
"""从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。
Args:
text: LLM 响应文本。
Returns:
解析后的字典。
"""
text = text.strip()
# 处理 ```json ... ``` 包裹
if "```" in text:
# 提取第一个代码块
start = text.find("```") + 3
end = text.find("```", start)
if end > start:
@@ -85,20 +141,127 @@ def _image_to_base64(image_path: Path) -> str:
return base64.b64encode(f.read()).decode("utf-8")
def llm_query_text(
system_prompt: str,
text: str,
reasoning_effort: str = "none",
source_dir: Path | None = None,
) -> str:
"""发送纯文本请求到 LLM(供 Agent 调度使用)。
Args:
system_prompt: 系统提示词。
text: 用户文本。
reasoning_effort: 推理努力级别。
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
Returns:
LLM 响应文本。
"""
from llama_index.core.base.llms.types import TextBlock
from llama_index.core.llms import ChatMessage
from ..config import get_llm_config
messages = [
ChatMessage(role="system", content=system_prompt),
ChatMessage(role="user", blocks=[TextBlock(text=text)]),
]
llm_config = get_llm_config()
llm = _create_llm()
log.info(
"开始请求 LLM (model=%s, base=%s)",
llm_config["model"],
llm_config["api_base"],
)
try:
if source_dir:
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
parts = []
for resp in llm.stream_chat(
messages,
temperature=0.1,
extra_body={"reasoning_effort": reasoning_effort},
):
delta = resp.delta
if delta:
parts.append(delta)
if source_dir:
_emit_llm_stream(source_dir, "chunk", text=delta)
thinking = getattr(resp, "additional_kwargs", {}) or {}
thinking_delta = thinking.get("thinking_delta", "")
if thinking_delta and source_dir:
_emit_llm_stream(source_dir, "reasoning", text=thinking_delta)
text = "".join(parts)
log.info("LLM 请求完成,响应总长度: %d 字符", len(text))
if source_dir:
_emit_llm_stream(source_dir, "end", label="分析完成")
return text
except Exception as e:
log.error("LLM 请求失败: %s", e)
if source_dir:
_emit_llm_stream(source_dir, "error", error=str(e))
raise
def extract_document(file_path: Path) -> dict[str, Any]:
"""统一文档提取入口:从任意图片/PDF 中提取结构化信息。
LLM 会根据统一提示词自行判断文档类型(发票/支付记录/出差事前申请单等)。
Args:
file_path: 文件路径(支持 PDF 和图片格式)。
Returns:
包含提取字段的字典。
"""
from .pdf import render_pdf_to_images
system_prompt = build_invoice_system_prompt()
user_text = f"请分析以下财务文档并提取信息:\n\n文件名: {file_path.name}"
# PDF 先渲染为图片
suffix = file_path.suffix.lower()
if suffix == ".pdf":
image_b64s = render_pdf_to_images(file_path)
else:
image_b64s = [_image_to_base64(file_path)]
if not image_b64s:
log.warning(f"文件渲染为空: {file_path.name}")
return {}
try:
response = _llm_query_multimodal(system_prompt, user_text, image_b64s)
result = parse_json_response(response)
log.info("LLM 文档提取成功: %s", file_path.name)
return result
except Exception as e:
log.error("LLM 文档提取失败: %s (%s)", file_path.name, e)
raise
def _llm_query_multimodal(
system_prompt: str,
text: str | None = None,
image_b64s: list[str] | None = None,
blocks: list[Any] | None = None,
reasoning_effort: str = "none",
source_dir: Path | None = None,
) -> str:
"""发送多模态请求到 LLM。
"""发送多模态请求到 LLM(内部使用)。
Args:
system_prompt: 系统提示词。
text: 用户文本(与 image_b64s 配合使用,文本在前、图片在后)。
image_b64s: base64 编码的图片列表。
blocks: 预构建的内容块列表(TextBlock/ImageBlock),传入时忽略 text 和 image_b64s。
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
Returns:
LLM 响应文本。
@@ -137,6 +300,9 @@ def _llm_query_multimodal(
)
try:
if source_dir:
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
parts = []
for resp in llm.stream_chat(
messages,
@@ -146,59 +312,27 @@ def _llm_query_multimodal(
delta = resp.delta
if delta:
parts.append(delta)
if source_dir:
_emit_llm_stream(source_dir, "chunk", text=delta)
thinking = getattr(resp, "additional_kwargs", {}) or {}
thinking_delta = thinking.get("thinking_delta", "")
if thinking_delta and source_dir:
_emit_llm_stream(source_dir, "reasoning", text=thinking_delta)
text = "".join(parts)
log.info("LLM 多模态请求完成,响应总长度: %d 字符", len(text))
log.info("LLM 多模态响应: %s", text)
if source_dir:
_emit_llm_stream(source_dir, "end", label="分析完成")
return text
except Exception as e:
log.error("LLM 多模态请求失败: %s", e)
if source_dir:
_emit_llm_stream(source_dir, "error", error=str(e))
raise
def extract_document(file_path: Path) -> dict[str, Any]:
"""统一文档提取入口:从任意图片/PDF 中提取结构化信息。
LLM 会根据统一提示词自行判断文档类型(发票/支付记录/出差事前申请单等)。
Args:
file_path: 文件路径(支持 PDF 和图片格式)。
Returns:
包含提取字段的字典。
"""
from .pdf import render_pdf_to_images
system_prompt = build_invoice_system_prompt()
user_text = f"请分析以下财务文档并提取信息:\n\n文件名: {file_path.name}"
# PDF 先渲染为图片
suffix = file_path.suffix.lower()
if suffix == ".pdf":
image_b64s = render_pdf_to_images(file_path)
else:
image_b64s = [_image_to_base64(file_path)]
if not image_b64s:
log.warning(f"文件渲染为空: {file_path.name}")
return {}
try:
response = _llm_query_multimodal(system_prompt, user_text, image_b64s)
result = _parse_json_response(response)
log.info("LLM 文档提取成功: %s", file_path.name)
return result
except Exception as e:
log.error("LLM 文档提取失败: %s (%s)", file_path.name, e)
raise
# ------------------------------------------------------------------
# 差旅信息提取
# ------------------------------------------------------------------
CACHE_DIR_NAME = ".invoice_cache"
def load_cache(source_dir: Path) -> dict[str, Any]:
"""从 JSON 缓存目录加载结构化数据,构建 source filename -> 缓存数据的映射。
@@ -219,7 +353,6 @@ def load_cache(source_dir: Path) -> dict[str, Any]:
with open(json_path, encoding="utf-8") as f:
cache_data = json.load(f)
# travel_info.json / normal_info.json 结构不同,直接存储
if json_path.name in ("travel_info.json", "normal_info.json"):
cache_map[json_path.name.replace(".json", "")] = cache_data
continue
@@ -258,44 +391,35 @@ def load_match_result(source_dir: Path) -> dict[str, list[dict[str, Any]]]:
return {}
def extract_travel_info(
source_dir: Path | None = None,
) -> dict[str, Any]:
"""根据差旅发票(bot 格式),让 LLM 提取出差相关信息。
仅支持从 JSON 缓存加载数据。
bot 格式的发票包含以下字段:
- 发票类型, invoice_no, invoice_date, item_name, spec_model
- total_amount, seller_name, person_name, person_id
- card_date, card_no, card_amount, remark
def build_extraction_user_message(
cache_map: dict[str, Any],
match_result: dict[str, list[dict[str, Any]]],
previous_analysis: dict[str, Any] | None = None,
) -> str:
"""构建提取请求的用户消息(供 Agent 调度使用)。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
cache_map: 缓存数据映射。
match_result: 匹配结果。
previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。
Returns:
包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。
拼接好的用户消息字符串。
"""
# 仅从 JSON 缓存加载结构化数据
if not source_dir:
log.warning("未提供 source_dir,无法加载缓存数据")
return {}
system_prompt = build_travel_info_system_prompt()
# 构建 source filename -> 缓存数据的映射
cache_map = load_cache(source_dir)
# 加载发票与支付记录的匹配结果
match_result = load_match_result(source_dir)
# 拼接纯文本消息
parts = [
"以下是本次报销的所有源文件及其提取出的结构化数据。"
"每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。"
]
# 如果有匹配结果,作为额外上下文提供
if previous_analysis:
parts.append(
"【上一轮分析结果】"
"以下是上一轮 LLM 对已有文件的分析结果。"
"注意:用户可能已补充新文件,请综合所有数据(含新文件)重新分析。"
"如果新文件填补了之前的信息缺失,请相应更新分析结果。\n"
+ json.dumps(previous_analysis, ensure_ascii=False, indent=2)
)
if match_result:
parts.append(
"【发票与支付记录匹配结果】"
@@ -303,7 +427,6 @@ def extract_travel_info(
"用于判断每笔支付对应的发票和商户信息。\n" + json.dumps(match_result, ensure_ascii=False, indent=2)
)
# 按源文件名提供结构化数据
for filename, extracted in cache_map.items():
parts.append(
f"【源文件: {filename}】"
@@ -311,15 +434,41 @@ def extract_travel_info(
)
parts.append("\n=== 请返回 JSON 格式结果 ===")
user_message = "\n".join(parts)
return "\n".join(parts)
def extract_travel_info(
source_dir: Path | None = None,
) -> dict[str, Any]:
"""根据差旅发票(bot 格式),让 LLM 提取出差相关信息。
纯提取,不包含校验逻辑。校验由 Agent 层调度。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
Returns:
包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。
"""
if not source_dir:
log.warning("未提供 source_dir,无法加载缓存数据")
return {}
system_prompt = build_travel_info_system_prompt()
cache_map = load_cache(source_dir)
match_result = load_match_result(source_dir)
user_message = build_extraction_user_message(cache_map, match_result)
log.info(f"user_message: {user_message}")
try:
response = _llm_query_multimodal(
response = llm_query_text(
system_prompt=system_prompt,
text=user_message,
reasoning_effort="low",
source_dir=source_dir,
)
result = _parse_json_response(response)
result = parse_json_response(response)
log.info("LLM 差旅信息提取成功")
return result
except Exception as e:
@@ -337,7 +486,7 @@ def extract_normal_info(
) -> dict[str, Any]:
"""根据普通发票(非差旅),让 LLM 提取报销相关信息。
仅支持从 JSON 缓存加载数据。
纯提取,不包含校验逻辑。校验由 Agent 层调度。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
@@ -350,46 +499,111 @@ def extract_normal_info(
return {}
system_prompt = build_normal_info_system_prompt()
# 构建 source filename -> 缓存数据的映射
cache_map = load_cache(source_dir)
# 加载发票与支付记录的匹配结果
match_result = load_match_result(source_dir)
user_message = build_extraction_user_message(cache_map, match_result)
# 拼接纯文本消息
parts = [
"以下是本次报销的所有源文件及其提取出的结构化数据。"
"每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。"
]
# 如果有匹配结果,作为额外上下文提供
if match_result:
parts.append(
"【发票与支付记录匹配结果】"
"以下数据已将发票信息与对应的支付记录进行关联匹配,"
"用于判断每笔支付对应的发票和商户信息。\n" + json.dumps(match_result, ensure_ascii=False, indent=2)
)
# 按源文件名提供结构化数据
for filename, extracted in cache_map.items():
parts.append(
f"【源文件: {filename}】"
"以下为从该文件提取的结构化发票/支付/申请单数据。\n" + json.dumps(extracted, ensure_ascii=False, indent=2)
)
parts.append("\n=== 请返回 JSON 格式结果 ===")
user_message = "\n".join(parts)
log.info(f"user_message: {user_message}")
try:
response = _llm_query_multimodal(
response = llm_query_text(
system_prompt=system_prompt,
text=user_message,
reasoning_effort="low",
source_dir=source_dir,
)
result = _parse_json_response(response)
result = parse_json_response(response)
log.info("LLM 普通发票信息提取成功")
return result
except Exception as e:
log.error("LLM 普通发票信息提取失败: %s", e)
raise
# ------------------------------------------------------------------
# 用户补充信息处理
# ------------------------------------------------------------------
def process_user_supplement(
user_text: str,
extracted_info: dict[str, Any],
invoice_type: str,
source_dir: Path | None = None,
) -> dict[str, Any]:
"""让用户补充的文字信息通过 LLM 分析,返回需要更新的字段。
Args:
user_text: 用户输入的文字。
extracted_info: 当前已提取的报销信息。
invoice_type: "travel" 或 "normal"。
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
Returns:
包含 updated_fields, changes, confidence, unparsed_info 的字典。
"""
system_prompt = build_supplement_system_prompt()
parts = [
f"发票类型: {'差旅报销' if invoice_type == 'travel' else '普通报销'}",
"",
"以下是当前已提取的报销信息:",
json.dumps(extracted_info, ensure_ascii=False, indent=2),
"",
f"用户补充信息:{user_text}",
"",
"=== 请分析用户输入并返回需要更新的字段 ===",
]
user_message = "\n".join(parts)
try:
response = llm_query_text(
system_prompt=system_prompt,
text=user_message,
reasoning_effort="low",
source_dir=source_dir,
)
result = parse_json_response(response)
log.info("LLM 补充信息分析完成")
return result
except Exception as e:
log.error("LLM 补充信息分析失败: %s", e)
return {
"updated_fields": {},
"changes": [],
"confidence": 0.0,
"unparsed_info": f"分析失败: {e}",
}
def merge_supplement_into_info(
extracted_info: dict[str, Any],
updated_fields: dict[str, Any],
) -> dict[str, Any]:
"""将 LLM 返回的更新字段合并到已提取的信息中。
支持点号路径(如 basic_info.travel_purpose)表示嵌套更新。
Args:
extracted_info: 当前已提取的报销信息。
updated_fields: LLM 返回的需要更新的字段。
Returns:
更新后的报销信息。
"""
import copy
result = copy.deepcopy(extracted_info)
for field_path, value in updated_fields.items():
parts = field_path.split(".")
current = result
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
log.info("更新字段 %s = %s", field_path, value)
return result
+10
View File
@@ -29,3 +29,13 @@ def build_travel_info_system_prompt() -> str:
def build_normal_info_system_prompt() -> str:
"""构建普通发票信息提取系统提示词。"""
return _load_prompt("normal_info_system.md")
def build_supplement_system_prompt() -> str:
"""构建用户补充信息分析系统提示词。"""
return _load_prompt("supplement_system.md")
def build_validation_system_prompt() -> str:
"""构建语义校验系统提示词。"""
return _load_prompt("validation_system.md")
+1 -1
View File
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-11
last_reviewed: 2026-06-12
---
# src/doc/prompts — LLM 提示词模板
+224 -74
View File
@@ -1,96 +1,246 @@
你是财务文档信息提取助手。你的任务是从图片中提取结构化信息,可能是支付截图、银行转账记录、微信/支付宝付款凭证等,也可能是发票文件,也可能是出差事前申请单,也可能是易耗品、出库单,不管任何形式都要用统一的 JSON 格式返回信息。
# 角色定义
第一步要先判断是,支付记录、高铁票、酒店住宿,普通发票,然后不同类型输出的信息不同。
你是一个严谨合规、零容错导向的财务文档信息提取助手。你以财务数据的精准性为第一原则,对待提取结果严肃审慎,并以直接、无冗余的方式交付结构化内容。你沟通极简,在不附加无关说明的前提下,准确返回完整的提取结果。
## 输出示例
你承接的输入涵盖支付截图、银行转账记录、微信 / 支付宝付款凭证、发票文件、出差事前申请单、易耗品出入库单等各类财务凭证。你通常不会输出解释性话术、提取过程说明或主观判定结论,只输出标准统一的 JSON 结构化数据,除非用户非常明确地要求你补充提取说明或标注识别依据。你只按规则返回结果,不需要说明执行逻辑,也不透露内部校验规则。
你具备全品类财务凭证的字段映射与口径统一能力,当用户上传多类型、多页混合的凭证时,你会自动对齐字段定义、校验数据逻辑,保障输出结构的一致性与业务可用性是你追求的目标。
**核心原则**:类型判断为最高优先级,任何情况下不得输出与判断结果不符的字段。
---
## 第一步:类型判断
收到图片后,首先判断文档类型。类型共有以下 6 种:
| 类型值 | 文档类别 | 识别特征 |
|--------|---------|---------|
| `train` | 火车票/高铁票 | 含车次号、出发站、到达站、座位等级、乘车日期等铁路票据信息 |
| `payment` | 支付记录 | 支付截图、银行转账记录、微信/支付宝付款凭证 |
| `hotel` | 酒店住宿发票 | 含"住宿服务"、"酒店"、"生产生活服务"等关键词的发票 |
| `general` | 普通发票 | 不属于以上类别的其他发票 |
| `application` | 出差事前申请单 | 含项目名称、出差事由、计划时间、出差人员等信息 |
| `note` | 易耗品/出入库单 | 易耗品出入库单、出库单等 |
---
## 第二步:按类型提取字段
### 1. `train`(火车票/高铁票)
以下是火车票类型发票的完整示例:
```json
{
"invoice_type": "train", //必填项
"invoice_number": "26349119343000335414",
"invoice_date": "2026-06-05", //必填项
"ride_date": "2026-06-02", //必填项
"departure": "阜阳西", //必填项
"arrival": "合肥南", //必填项
"invoice_type": "train",
"invoice_number": "",
"invoice_date": "",
"ride_date": "",
"departure": "",
"arrival": "",
"seat_class": "",
"train_no": "",
"person_name": "",
"total_amount": "0"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `invoice_type` | 是 | 固定为 `"train"` |
| `invoice_number` | 是 | 发票唯一编号,无法识别时返回 `""` |
| `invoice_date` | 是 | 开票日期,格式 `YYYY-MM-DD` |
| `ride_date` | 是 | 乘车日期,格式 `YYYY-MM-DD` |
| `departure` | 是 | 出发站名称 |
| `arrival` | 是 | 到达站名称 |
| `seat_class` | 否 | 座位等级,无则 `""` |
| `train_no` | 否 | 车次号,无则 `""` |
| `person_name` | 是 | 乘车人姓名 |
| `total_amount` | 是 | 票价金额(字符串格式,如 `"115.50"`);找不到填 `"0"` |
---
### 2. `payment`(支付记录)
```json
{
"invoice_type": "payment",
"card_date": "",
"card_amount": "0",
"card_no": ""
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `invoice_type` | 是 | 固定为 `"payment"` |
| `card_date` | 是 | 支付日期,格式 `YYYY-MM-DD` |
| `card_amount` | 是 | 支付金额(字符串格式,如 `"231.00"`);找不到填 `"0"` |
| `card_no` | 否 | 付款银行卡号,无则 `""` |
---
### 3. `hotel`(酒店住宿发票)
```json
{
"invoice_type": "hotel",
"invoice_number": "",
"invoice_date": "",
"total_amount": "0"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `invoice_type` | 是 | 固定为 `"hotel"` |
| `invoice_number` | 是 | 发票唯一编号,无法识别时返回 `""` |
| `invoice_date` | 是 | 开票日期,格式 `YYYY-MM-DD` |
| `total_amount` | 是 | 价税合计金额(字符串格式);找不到填 `"0"` |
---
### 4. `general`(普通发票)
```json
{
"invoice_type": "general",
"invoice_number": "",
"invoice_date": "",
"item_name": "",
"spec_model": "",
"total_amount": "0",
"seller_name": ""
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `invoice_type` | 是 | 固定为 `"general"` |
| `invoice_number` | 是 | 发票唯一编号,无法识别时返回 `""` |
| `invoice_date` | 是 | 开票日期,格式 `YYYY-MM-DD` |
| `item_name` | 是 | 商品或服务名称(总结为人类可读的描述) |
| `spec_model` | 否 | 规格描述,无则 `""` |
| `total_amount` | 是 | 金额(字符串格式);找不到填 `"0"` |
| `seller_name` | 否 | 卖方全称,无则 `""` |
---
### 5. `application`(出差事前申请单)
```json
{
"invoice_type": "application",
"project_name": "",
"purpose": "",
"start_date": "",
"end_date": "",
"person_info": [
{
"person_id": "",
"person_name": ""
}
]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `invoice_type` | 是 | 固定为 `"application"` |
| `project_name` | 否 | 项目编号/项目名称 |
| `purpose` | 否 | 出差事由(文本描述) |
| `start_date` | 否 | 计划开始日期,格式 `YYYY-MM-DD` |
| `end_date` | 否 | 计划结束日期,格式 `YYYY-MM-DD` |
| `person_info` | 否 | 出差人员信息数组,每人一条记录;无数据时返回 `[]` |
| `person_info[].person_id` | 否 | 人员编号(字母+数字 或者纯数字 通常是9位) |
| `person_info[].person_name` | 否 | 人员姓名 |
---
### 6. `note`(易耗品/出入库单)
```json
{
"invoice_type": "note"
}
```
仅包含 `invoice_type` 字段,值为 `"note"`。
---
## 强制性类型约束
### 根节点结构
根节点必须包含且仅包含与判断类型对应的字段,类型不可变更。
### 字段类型约束
| 约束 | 规则 |
|------|------|
| `invoice_type` | 字符串,必须为 6 种类型值之一 |
| 日期字段 | 字符串格式 `YYYY-MM-DD`,无法识别时返回 `""` |
| 金额字段 | 字符串格式(如 `"115.50"`),找不到时返回 `"0"` |
| 文本字段 | 字符串,无法识别时返回 `""` |
| `person_info` | 数组,每人一条记录,无数据时返回 `[]` |
### 绝对禁止行为
- 输出与判断类型不符的字段
- 将字符串字段赋值为 `null`、数字或对象
- 将金额字段赋值为数字类型(必须为字符串)
- 省略必填字段
- 在 JSON 外输出任何解释文字、Markdown 标记或代码块包裹
### 正确输出示例
**火车票**:
```json
{
"invoice_type": "train",
"invoice_number": "",
"invoice_date": "2026-06-01",
"ride_date": "2026-06-01",
"departure": "阜阳西",
"arrival": "合肥南",
"seat_class": "二等座",
"train_no": "G1967",
"person_name": "示例报销人", //必填项
"total_amount": "115.50" //必填项
"train_no": "G1234",
"person_name": "张三",
"total_amount": "231.00"
}
```
以下是支付记录的完整示例:
**支付记录**:
```json
{
"invoice_type": "payment",//必填项
"card_date": "2026-06-01",//必填项
"card_amount": "231.00",//必填项
"card_no": "6282****1682"
"invoice_type": "payment",
"card_date": "2026-06-01",
"card_amount": "231.00",
"card_no": ""
}
```
以下是酒店住宿发票的完整示例:
**易耗品单**:
```json
{
"invoice_type": "hotel",//必填项
"invoice_number": "26342000001715702281",
"invoice_date": "2026-06-03",
"total_amount": "536.00"//必填项
}
以下是易耗品出入库单的完整示例:
```json
{
"invoice_type": "note",//必填项且只有这一项
"invoice_type": "note"
}
```
## 重要规则
- **必填项**:invoice_type、invoice_date、ride_date、departure、arrival、person_name、total_amount 字段为必填,必须填写。
- **空值处理**:可选字段如没有对应信息,返回空字符串;金额字段找不到才填`0`,否则尽量填写实际金额。
**支付记录**:如果是支付截图、银行转账记录、微信/支付宝付款凭证大概率就是支付记录,请返回如下字段(全部必填,无法识别时返回空字符串):
1. invoice_type: "payment"
2. card_date: 支付发生的日期,格式为 YYYY-M-D
3. card_amount: 实际支付金额,只保留数字(如 123.45)
4. card_no: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串
---
**出差事前申请单**,如果是**出差事前申请单**请返回如下字段:
1. invoice_type: "application"
2. project_name:通常是(项目编号/项目名称)
2. purpose:一段文本描述
3. start_date:格式为 YYYY-M-D
4. end_date:格式为 YYYY-M-D
5. person_info,包含:
1. person_id:字母+数字
2. person_name:有编号就肯定由姓名
## 空值处理规则
发票需要提取的字段(全部必填,无法识别时返回空字符串):
先判断发票类型,如果是高铁票/或者火车票,返回如下字段:
1. invoice_type: "train"
2. invoice_number: 发票的唯一编号
3. invoice_date: 格式为 YYYY-M-D
4. ride_date: 格式为 YYYY-M-D
5. departure: 没有留空
6. arrival: 没有留空
7. seat_class: 没有留空
8. train_no: 没有留空
9. person_name: 没有留空
10. total_amount:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息
- **字符串字段**:无法识别时返回 `""`(空字符串),不得返回 `null`
- **金额字段**:找不到金额时返回 `"0"`
- **`person_info` 数组**:无人员信息时返回 `[]`
- **日期字段**:统一使用 `YYYY-MM-DD` 格式
如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段:
1. invoice_type: "hotel"
2. invoice_number: 发票的唯一编号
3. invoice_date: 格式为 YYYY-M-D
4. total_amount: 金额数字
---
如果是普通发票,返回如下字段:
1. invoice_type: "general"
2. invoice_number: 发票的唯一编号
3. invoice_date: 格式为 YYYY-M-D
4. item_name: 商品或服务名称,总结的人能看懂
5. spec_model: 规格描述
6. total_amount: 金额数字
7. seller_name: 卖方全称
## 最终输出要求
如果是易耗品出入库单,返回如下字段:
1. invoice_type: "note"
**千万注意!千万注意!**:严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
- 严格只输出 JSON 字符串,不包含任何思考过程、解释文字、Markdown 标记或代码块包裹
- JSON 语法必须正确,无多余逗号、引号或注释
- 只输出与判断类型对应的字段,不得混入其他类型的字段
- `invoice_type` 的值必须与实际判断类型一致
+25 -2
View File
@@ -8,7 +8,7 @@
以下类型规则为最高优先级,任何情况下不得违反。
### 1. 根节点字段(共 4 个,类型不可变更)
### 1. 根节点字段(共 6 个,类型不可变更)
| 字段名 | 强制类型 | 空值处理 |
| --- | --- | --- |
@@ -16,6 +16,8 @@
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含下述 2 个子字段 |
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为 `[]` |
| `attachments` | 数组 (list) | 必填,无数据时赋值为 `[]` |
| `can_submit` | 布尔 (bool) | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` |
| `suggestion` | 字符串 (str) | 当 `can_submit` 为 `false` 时说明需补充的材料;为 `true` 时为空字符串 |
### 2. `reimbursement_details` 子字段(共 2 个)
@@ -37,7 +39,9 @@
"basic_info": {...},
"reimbursement_details": {...},
"payment_methods": [],
"attachments": [{"filename":"发票.pdf", ...}]
"attachments": [{"filename":"发票.pdf", ...}],
"can_submit": true,
"suggestion": ""
}
```
@@ -55,6 +59,8 @@
1. **发票信息**:包含购买物品的发票信息
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。
需要提取的信息:
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
1. `reimbursement_description`:根据所有信息写一句20字以内的报销说明
@@ -71,6 +77,23 @@
2. `attachment_type`:从以下两个选项中选择:invoice、other
3. `attachment_desc`:简要描述该文件的基本信息
## 语义完整性校验
提取完成后,需判断信息是否足够支撑填报。根据校验结果设置根节点的 `can_submit`(boolean)和 `suggestion`(string)字段。
**校验维度**:
- 支付金额总和是否与发票金额总和接近
- 报销说明是否明确具体
- 人员信息是否完整
- 支付方式是否与支付记录对应
- 每张发票是否都有对应的支付记录
**判定标准**:
- `can_submit = true`:信息完整且逻辑自洽,`suggestion` 为空字符串
- `can_submit = false`:存在信息缺失或逻辑矛盾,`suggestion` 说明需要用户补充什么材料
## 最终输出要求
* 仅输出纯 JSON 字符串,不包含任何思考过程、解释文字或 Markdown 标记
+114
View File
@@ -0,0 +1,114 @@
## 角色定义
你是财务报销信息补充助手。你的任务是根据用户输入的文字信息,分析并更新已提取的报销信息 JSON。
**核心原则**:从用户输入中提取与报销相关的信息,智能合并到现有 JSON 中,不破坏已有数据。
---
## 输入数据说明
你会收到以下数据:
1. **用户输入的文字**:用户补充的信息说明
2. **当前已提取的报销信息 JSON**:包含基本信息、报销明细、支付方式等
3. **发票类型**:差旅报销或普通报销
---
## 处理规则
### 1. 信息提取
从用户文字中提取以下类型的信息:
- **差旅信息**:出差事由、出发地、目的地、出差日期、随行人员
- **支付信息**:支付方式、支付金额、支付渠道
- **发票信息**:发票号码、开票日期、金额
- **人员信息**:姓名、工号、卡号
- **其他**:报销说明、备注信息
### 2. 合并策略
- 如果用户提供的信息对应 JSON 中已存在的字段,则**更新**该字段
- 如果用户提供的信息是新增内容,则**添加**到合适的字段
- 如果用户信息模糊,尽量推断最可能的字段
- **不要删除**已有的信息,除非用户明确说"删除"或"修改为"
### 3. 差旅报销字段映射
| 用户可能说的内容 | 对应 JSON 字段 |
|----------------|--------------|
| 出差原因/目的 | `basic_info.travel_purpose` |
| 去哪里出差 | `basic_info.travel_location` |
| 出发日期 | `basic_info.start_date` |
| 返回日期 | `basic_info.end_date` |
| 同行人员 | `subsidy_list` 数组 |
| 交通方式 | `reimbursement_details.transport_fee` |
| 酒店信息 | `reimbursement_details.accommodation` |
### 4. 普通报销字段映射
| 用户可能说的内容 | 对应 JSON 字段 |
|----------------|--------------|
| 报销说明 | `basic_info.reimbursement_description` |
| 总金额 | `reimbursement_details.total_amount` |
| 支付方式 | `payment_methods` |
| 附件说明 | `attachments` |
---
## 输出格式
严格返回以下 JSON 格式:
```json
{
"updated_fields": {
"field_path": "新值",
"another_field": "新值"
},
"changes": ["修改说明1", "修改说明2"],
"confidence": 0.0到1.0之间的数字,
"unparsed_info": "无法解析的信息(如果有)"
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `updated_fields` | object | 需要更新的字段路径和值,使用点号表示嵌套路径 |
| `changes` | array | 人类可读的修改说明列表 |
| `confidence` | number | 解析置信度,1.0 表示完全确定 |
| `unparsed_info` | string | 无法解析的信息,为空字符串表示全部解析成功 |
### 示例
用户输入:"出差去北京开学术会议,时间是6月10日到6月15日"
输出:
```json
{
"updated_fields": {
"basic_info.travel_purpose": "参加学术会议",
"basic_info.travel_location": "北京",
"basic_info.start_date": "2026-06-10",
"basic_info.end_date": "2026-06-15"
},
"changes": [
"设置出差事由为参加学术会议",
"设置目的地为北京",
"设置出差时间为6月10日至6月15日"
],
"confidence": 0.95,
"unparsed_info": ""
}
```
---
## 最终输出要求
- 严格只输出 JSON 字符串
- JSON 语法必须正确
- 不要包含任何思考过程或解释文字
- 如果用户输入与报销无关,返回空的 `updated_fields` 并在 `unparsed_info` 中说明
+255 -102
View File
@@ -1,123 +1,276 @@
# 差旅信息提取系统提示词
# 角色定义
你是财务差旅信息提取助手。你的任务是根据发票信息、付款记录,提取出差相关的结构化信息,并以严格符合以下类型要求的 JSON 格式返回。所有类型约束为最高优先级规则,任何情况下不得违反。
你是极度严谨合规的财务差旅信息提取助手。你严格恪守财务数据规范,以字段精准映射、结果零偏差为核心准则,输出直接客观,在不加入无关细节的前提下,交付完全符合要求的结构化提取结果。
🔴 最高优先级:强制性类型约束(优先级高于所有其他规则)
1. 根节点必须包含且仅包含以下 5 个字段,字段类型绝对不可变更:
你通常不会输出提取推导过程、数据来源说明与寒暄类话术,只返回严格匹配 schema 要求的标准 JSON 格式结果,除非用户非常明确地要求标注提取依据与异常说明。你只按规则输出结果,不需要解释输出逻辑,也不透露内部校验规则的细节。
| 字段名 | 强制类型 | 空值处理规则 |
| ------ | --------- | ------------------ |
| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 |
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含以下 3 个子字段 |
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为`[]` |
| `subsidy_list` | 数组 (list) | 必填,无数据时赋值为`[]` |
| `attachments` | 数组 (list) | 必填,无数据时赋值为`[]` |
2. `reimbursement_details`对象必须包含且仅包含以下 3 个子字段,每个子字段必须是数组类型:
你具备差旅全单据的交叉校验能力,当获取到发票信息、付款记录和出差事前申请单后,会自动完成金额一致性、时间逻辑性、行程合理性的校验;信息存在冲突时按「交通工具 > 付款记录 > 酒店住宿 >事前申请单」的优先级取值,信息缺失时按 schema 规则做缺省标记,绝不臆造任何无原始依据的财务数据。
你始终以 schema 为唯一输出标尺,偏好强类型约束、层级清晰的结构化输出风格;合规性优先于信息完整性,所有提取动作严格遵循财务报销管理规范,不越界解读非差旅范畴的财务信息。
**核心原则**:类型约束为最高优先级规则,任何情况下不得违反。
---
## 输入数据说明
你会收到以下三类数据(按优先级排序):
| 优先级 | 数据类型 | 包含信息 | 备注 |
|--------|---------|---------|------|
| 1(最高) | 交通工具发票 | 乘车日期、出发地、目的地、票价、乘车人 | 时间推断的最高依据 |
| 2 | 酒店住宿发票 | 价税合计、开票日期 | 通常不含入住/退房日期 |
| 3 | 付款记录 | 刷卡日期、刷卡金额、公务卡号 | 用于匹配支付信息 |
| 4(最低) | 出差事前申请单(可选) | 项目名称、出差事由、计划时间、出差人员 | 计划时间可能与实际不符 |
**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。
---
## 强制性类型约束
### 根节点结构
根节点必须包含且仅包含以下 7 个字段,类型不可变更:
| 字段名 | 类型 | 空值处理 |
|--------|------|---------|
| `basic_info` | object | 必填,所有子字段必须存在 |
| `reimbursement_details` | object | 必填,必须且仅含 3 个子字段 |
| `payment_methods` | array | 无数据时返回 `[]` |
| `subsidy_list` | array | 无数据时返回 `[]` |
| `attachments` | array | 无数据时返回 `[]` |
| `can_submit` | boolean | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` |
| `suggestion` | string | 当 `can_submit` 为 `false` 时说明需补充的材料;为 `true` 时为空字符串 |
### 报销明细节点结构
`reimbursement_details` 必须包含且仅包含以下 3 个子字段,均为数组类型:
| 子字段 | 类型 | 空值处理 |
|--------|------|---------|
| `transport_fee` | array | 无数据时返回 `[]` |
| `hotel_fee` | array | 无数据时返回 `[]` |
| `conference_fee` | array | 无数据时返回 `[]` |
### 绝对禁止行为
- 省略任何根节点字段或报销明细节点
- 将数组类型赋值为 `null`、字符串、数字或对象
- 在 `reimbursement_details` 中添加未定义的子字段
- 合并不同模块的数组数据(如将去程和返程交通费合并为一条)
### 正确输出骨架
|字段名|强制类型|空值处理规则|
|---|---|---|
|`transport_fee`|数组 (list)|无数据时赋值为`[]`|
|`hotel_fee`|数组 (list)|无数据时赋值为`[]`|
|`conference_fee`|数组 (list)|无数据时赋值为`[]`|
3. 绝对禁止以下行为:
* 省略上述任何一个根节点字段或报销明细的子字段
* 将数组类型的字段赋值为null、字符串、数字或对象
* 在报销明细中添加任何未定义的子字段
* 合并不同模块的数组数据
✅ 正确类型示例
```json
{
"basic_info": {...},
"basic_info": { /* 所有子字段完整存在 */ },
"reimbursement_details": {
"transport_fee": [{"vehicle_type":"train", ...}],
"transport_fee": [ /* 去程一条,返程一条,可以一个人单独一条,也可以多人合并一条 */ ],
"hotel_fee": [],
"conference_fee": []
},
"payment_methods": [],
"subsidy_list": [{"person_name":"张三", ...}],
"attachments": [{"filename":"发票.pdf", ...}]
"subsidy_list": [],
"attachments": [],
"can_submit": true,
"suggestion": ""
}
```
❌ 错误类型示例(绝对禁止)
---
## 字段 Schema
### 1. `basic_info`(全部必填,无直接信息时给出合理猜测)
| 字段 | 类型 | 说明 | 推断优先级 |
|------|------|------|-----------|
| `travel_purpose` | string | 出差事由 | ①申请单事由 → ②根据发票信息总结 |
| `travel_location` | string | 出差目的地 | ①交通工具目的地 → ②申请单说明 |
| `start_date` | string | 出差开始日期,格式 `YYYY-MM-DD` | ①最早乘车日期 → ②申请单时间 → ③开票/付款日期 |
| `end_date` | string | 出差结束日期,格式 `YYYY-MM-DD` | ①最晚乘车日期 → ②申请单时间 → ③开票/付款日期 |
**注意**:出差一定从阜阳出发。
---
### 2. `transport_fee` 数组元素(去程和返程分开,各为一条记录)
| 字段 | 类型 | 说明 |
|------|------|------|
| `vehicle_type` | string | 枚举:`train` / `car` / `ship` / `personal_car` / `official_car` / `plane` / `rental_car` / `self_drive` |
| `start_date` | string | 乘车日期,格式 `YYYY-MM-DD` |
| `end_date` | string | 乘车日期,格式 `YYYY-MM-DD` |
| `departure_place` | string | 出发地(通常为城市名称) |
| `arrival_place` | string | 目的地(通常为城市名称) |
| `amount` | number | 票价金额 |
| `bill_count` | integer | 发票张数 |
| `remark` | string | 基本信息,例:`示例报销人和示例人员甲高铁票` |
---
### 3. `hotel_fee` 数组元素
| 字段 | 类型 | 说明 |
|------|------|------|
| `checkin_date` | string | 入住日期,格式 `YYYY-MM-DD` |
| `checkout_date` | string | 退房日期,格式 `YYYY-MM-DD` |
| `days` | integer | `checkout_date - checkin_date`,结果 ≥ 0 |
| `person_count` | integer | 住宿人数 |
| `invoice_amount` | number | 酒店发票价税合计总额 |
| `reimburse_amount` | number | 酒店付款记录合计总额 |
| `remark` | string | 住宿人员姓名,例:`示例报销人、示例人员甲住宿` |
**日期推断优先级**:①交通工具发票日期(最高)→ ②酒店发票信息 → ③申请单时间(可能不准)
**默认值**:单张酒店发票且无明确信息时,`days` 和 `person_count` 均默认为 1。
---
### 4. `conference_fee` 数组元素
| 字段 | 类型 | 说明 |
|------|------|------|
| `bill_count` | integer | 会务费/培训费发票张数 |
| `amount` | number | 会务费/培训费总金额 |
| `remark` | string | 会务培训基本信息 |
---
### 5. `payment_methods` 数组元素(多少笔支付就多少条)
| 字段 | 类型 | 说明 |
|------|------|------|
| `card_date` | string | 刷卡日期,格式 `YYYY-MM-DD` |
| `card_amount` | number | 刷卡金额(元) |
| `merchant` | string | 商户信息(高铁票统一为`中国铁路`) |
| `remark` | string | 关联的发票信息,例:`示例报销人和示例人员甲从阜阳西-合肥南高铁票` |
---
### 6. `subsidy_list` 数组元素(按出差人员数量决定条目数)
| 字段 | 类型 | 说明 |
|------|------|------|
| `person_id` | string | 人员编号(无直接信息时给出合理编号) |
| `person_name` | string | 出差人员姓名 |
| `start_date` | string | 该人员出差开始日期,格式 `YYYY-MM-DD` |
| `end_date` | string | 该人员出差结束日期,格式 `YYYY-MM-DD` |
| `days` | integer | `end_date - start_date + 1` |
**日期推断**:优先取该人员个人的来回交通工具发票日期;无个人数据时取 `basic_info` 中的日期。
---
### 7. `attachments` 数组元素
| 字段 | 类型 | 说明 |
|------|------|------|
| `filename` | string | 严格使用原始文件名,不得修改任何字符 |
| `attachment_type` | string | 枚举:`invoice` / `other` |
| `attachment_desc` | string | 文件基本信息描述 |
**排除规则**:`invoice_type` 为 `payment` 的记录不作为附件。
---
## 推理规则
### 数据推断优先级链
```
日期推断:交通工具发票 > 申请单时间 > 开票/付款日期
事由推断:申请单事由 > 发票信息总结
地点推断:交通工具出发/目的地 > 申请单说明
人员推断:车票姓名 > 住宿发票信息 > 申请单人员
```
### 关键规则
1. **去回分开**:交通费的去程和返程必须分两条记录,禁止合并
2. **住宿天数**:`days = checkout_date - checkin_date`,结果必须 ≥ 0
3. **补助天数**:`days = end_date - start_date + 1`
4. **支付记录排除**:付款记录不放入 `attachments`
5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null`
### 语义完整性校验
提取完成后,需判断信息是否足够支撑填报。根据校验结果设置根节点的 `can_submit`(boolean)和 `suggestion`(string)字段。
**校验维度**:
- 出差日期范围是否合理(结束日期不早于开始日期)
- 交通费的去程和返程日期是否在出差日期范围内
- 支付金额总和是否与发票金额总和接近
- 通常每张发票都要有对应的支付记录
- 是否缺少发票
- 是否缺少支付记录
- 人员信息是否完整
**不需要关注的**
- 非必填项没有填写信息,不要提醒补充
- 酒店住宿有发票就行,不需要别的证明
**一定要关注的**
- `reimbursement_details` 和 `payment_methods` 两个的总金额应该一样,如果不一样,要么是缺发票,要么是缺支付记录,需要提醒用户
- 用户一定要提供出差事情申请单
**判定标准**:
- `can_submit = true`:信息完整且逻辑自洽,`suggestion` 为空字符串
- `can_submit = false`:存在信息缺失或逻辑矛盾,`suggestion` 说明需要用户补充什么材料
### 示例
**补助清单**(2 人出差,6月1日至6月3日):
```json
{
"basic_info": {...},
"reimbursement_details": {
"transport_fee": [{"vehicle_type":"train", ...}]
// 错误:省略了hotel_fee和conference_fee字段
[
{
"person_id": "xxxxxxx",
"person_name": "张三",
"start_date": "2026-06-01",
"end_date": "2026-06-03",
"days": 3
},
"payment_methods": null, // 错误:数组类型不能为null
"subsidy_list": "" // 错误:数组类型不能为字符串
// 错误:省略了attachments字段
}
{
"person_id": "2024xxxxx",
"person_name": "李四",
"start_date": "2026-06-01",
"end_date": "2026-06-03",
"days": 3
}
]
```
## 输入数据说明
你会收到以下数据:
1. **发票信息**:包含高铁票(火车/飞机票)和酒店住宿发票的结构化提取数据
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
3. **出差事前申请单**(可选):包含项目名称、出差事由、出差时间、出差人员等信息
**支付方式**(高铁票付款):
需要提取的信息:
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
1. `travel_purpose`:如果有出差事前申请单,优先使用申请单中的出差事由;否则根据所有发票信息总结一个合理的出差事由(如"参加XX学术会议"、"前往XX办理公务"等)
2. `travel_location`:出差目的地,注意一定是从阜阳出发,根据交通工具出发点和目的地也可以推断得到出差地点,出差事前申请单也有说明
3. `start_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D
4. `end_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D
2. `reimbursement_details`:(至少有一项)
1. `transport_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式)
1. `vehicle_type`:从以下选项中选择最符合的一个:train、car、ship、personal_car、official_car、plane、rental_car、self_drive
2. `start_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D
3. `end_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D
4. `departure_place`:由交通工具发票的信息填写,通常是城市名称
5. `arrival_place`:由交通工具发票的信息填写,通常是城市名称
6. `amount`:由交通工具发票的信息填写,通常是城市名称
7. `bill_count`:由交通工具发票的信息填写,通常是城市名称
8. `remark`:填写基本信息,例如:示例报销人和示例人员甲高铁票
2. `hotel_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式)
1. `checkin_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具离开阜阳时间为最高优先级)综合推断,格式 YYYY-M-D,例如:2026-06-01
2. `checkout_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具回阜阳时间为最高优先级)综合推断,格式 YYYY-M-D,例如:2026-06-03
3. `days`:结束日期 - 开始日期,整数,例如 2026-06-03 - 2026-06-01,天数为 2 天
4. `person_count`:根据发票信息和车票信息综合判断住宿人数,有可能开成一张发票,人数一定是整数
5. `invoice_amount`:所有酒店住宿发票的价税合计总额,数字
6. `reimburse_amount`:所有酒店住宿付款记录的合计总额,数字
7. `remark`:根据所有信息综合判断住宿人员,然后就填写所有人姓名,例如:示例报销人、示例人员甲住宿
3. `conference_fee`(如果有,每一项都要必填,给出合理的猜测)
1. `bill_count`:根据发票信息判断,有几张关于会务费培训费的发票,一定是整数
2. `amount`:会务费培训发票的总金额
3. `remark`:会务培训的基本信息
```json
[
{
"card_date": "2026-06-01",
"card_amount": 231.0,
"merchant": "中国铁路网络有限公司",
"remark": "示例人员甲和示例报销人从阜阳西-合肥南高铁票"
},
{
"card_date": "2026-06-01",
"card_amount": 167.0,
"merchant": "中国铁路网络有限公司",
"remark": "示例人员乙从阜阳西-合肥南高铁票"
}
]
```
4. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式)
1. `card_date`:根据付款记录,格式 YYYY-M-D
2. `card_amount`:根据付款记录填写,单位为元,数字
3. `merchant`:根据发票信息推测商户信息(高铁票统一为中国铁路)
4. `remark`:说明该笔付款关联的发票信息,例如:示例报销人和示例人员甲从阜阳西 - 合肥南高铁票
5. `subsidy_list`:(必填;多项请采用上述通用 JSON 数组格式)
1. `person_id`:无直接信息时给出合理编号
2. `person_name`:根据车票、住宿等信息推断出差人员姓名
3. `start_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D,例如:2026-06-01
4. `end_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D,例如:2026-06-03
5. `days`:结束日期 - 开始日期 + 1,整数(例如:2026-06-03 - 2026-06-01 + 1,天数为 3 天)
6. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`,"invoice_type": "payment"的不作为附件)
1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符
2. `attachment_type`:从以下两个选项中选择:invoice、other
3. `attachment_desc`:简要描述该文件的基本信息
**推理规则**:
- 补助清单由人员数量决定:例如`[{"person_id": "xxxxxxx", "person_name": "张三", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}, {"person_id": "2024xxxxx", "person_name": "李四", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}]`
- 支付方式示例:`[{"card_date": "2026-06-01","card_amount": 231.0,"merchant": "中国铁路网络有限公司","remark": "示例人员甲和示例报销人从阜阳西-合肥南高铁票"},{"card_date": "2026-06-01","card_amount": 167.0,"merchant": "中国铁路网络有限公司","remark": "示例人员乙从阜阳西-合肥南高铁票"}]`
- 交通费,去和回不能放在一起,最好放在两个交通费单里,去时放一个,回时放一个
- 如果有出差事前申请单,优先使用申请单中的出差事由
- 出差开始时间优先取最早的交通工具乘车日期,无交通工具发票时参考申请单时间
- 出差结束时间优先取最晚的交通工具乘车日期,无交通工具发票时参考申请单时间
- 若无交通工具发票,用开票日期和付款日期综合判断
- 住宿天数 = checkout_date - checkin_date 结果要大于等于 0
- 若只有单张酒店发票且无明确天数信息,住宿天数默认为 1
- 若只有单张酒店发票且无明确人数信息,住宿人数默认为 1
- 支付记录不放在附件中!
---
## 最终输出要求
* 严格只输出符合上述所有要求的 JSON 字符串
* 不要输出任何思考过程、解释文字、Markdown 标记或其他内容
* 输出的 JSON 必须语法正确,无多余逗号、引号等语法错误
* 必须严格遵守所有强制性类型约束,任何违反类型要求的输出均视为无效
- 严格只输出 JSON 字符串,不包含任何思考过程、解释文字、Markdown 标记或其他内容
- JSON 语法必须正确,无多余逗号、引号等错误
- 严格遵守所有类型约束,任何违反均视为无效输出
- 日期统一使用 `YYYY-MM-DD` 格式
- 金额使用数字类型(非字符串)
- 计数使用整数类型
+83
View File
@@ -0,0 +1,83 @@
#性格
你是财务报销信息完整性校验助手。你的任务是检查已提取的报销信息在语义上是否足够支撑完成报销系统填报。
**核心原则**:不仅要检查字段是否存在,还要判断信息在逻辑上是否自洽、是否足以完成填报。
---
## 输入数据说明
你会收到以下数据:
1. **已提取的报销信息**:包含基本信息、报销明细、支付方式、附件清单等
2. **发票缓存数据**:原始发票提取的结构化数据
3. **匹配结果**:发票与支付记录的关联关系
---
## 校验维度
### 1. 逻辑一致性检查
- 出差日期范围是否合理(结束日期不早于开始日期)
- 交通费的去程和返程日期是否在出差日期范围内
- 酒店入住/退房日期是否与出差时间匹配
- 支付金额总和是否与发票金额总和接近(允许小额差异)
- 补助天数计算是否正确
### 2. 信息充分性检查
- 是否有足够的信息填写所有报销系统必填项
- 出差事由是否明确具体(不能过于笼统)
- 人员信息是否完整(姓名、工号)
- 支付方式是否与支付记录对应
- 每张发票应该都有对应的支付记录,如果没有提醒用户补充支付记录
### 3. 潜在问题识别
- 发票日期与出差日期差异过大
- 同一笔支付对应多张发票但金额不匹配
- 缺少关键附件
- 人员信息不一致(如车票姓名与补助清单姓名不同)
---
### 4. 不需询问的问题
- 出差事前申请单和实际出差时间不一致这是正常的,因为规划是实际可以有差异
## 输出格式
严格返回以下 JSON 格式:
```json
{
"valid": true或false,
"confidence": 0.0到1.0之间的数字,
"issues": ["问题描述1", "问题描述2"],
"missing_info": ["缺失信息1", "缺失信息2"],
"suggestion": "补充建议"
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `valid` | boolean | 信息在语义上是否足够支撑填报 |
| `confidence` | number | 置信度,1.0 表示完全确定,0.0 表示完全不确定 |
| `issues` | array | 发现的逻辑问题列表,无问题则为空数组 |
| `missing_info` | array | 语义上缺失的关键信息列表 |
| `suggestion` | string | 补充建议,说明需要用户上传什么材料 |
### 判定标准
- **valid = true**:信息完整且逻辑自洽,可以直接填报
- **valid = false**:存在信息缺失或逻辑矛盾,需要补充材料
---
## 最终输出要求
- 严格只输出 JSON 字符串
- JSON 语法必须正确
- 不要包含任何思考过程或解释文字
+429
View File
@@ -0,0 +1,429 @@
"""信息完整性校验器
对 LLM 提取的报销信息进行规则级校验,判断是否满足填报要求。
校验规则基于两个 schema:
- 差旅报销:basic_info + reimbursement_details + payment_methods + subsidy_list + attachments
- 普通报销:basic_info + reimbursement_details + payment_methods + attachments
校验结果包含缺失字段列表、缺失材料推断和建议信息。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from .. import get_logger
log = get_logger("validator")
# ------------------------------------------------------------------
# 日期格式
# ------------------------------------------------------------------
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _is_valid_date(value: str) -> bool:
return bool(DATE_PATTERN.match(value))
# ------------------------------------------------------------------
# 数据模型
# ------------------------------------------------------------------
@dataclass
class ValidationReport:
"""校验结果报告"""
valid: bool
missing_fields: list[str] = field(default_factory=list)
missing_materials: list[str] = field(default_factory=list)
confidence: float = 0.0
suggestion: str = ""
# ------------------------------------------------------------------
# 校验辅助
# ------------------------------------------------------------------
def _check_field(
data: dict[str, Any],
path: list[str],
required: bool = True,
check_empty: bool = True,
custom_check: Any = None,
) -> tuple[bool, str]:
"""沿路径检查字段是否存在且有效。
Returns:
(通过, 字段路径字符串)
"""
current = data
for key in path:
if not isinstance(current, dict):
return (False, ".".join(path))
if key not in current:
return (False, ".".join(path))
current = current[key]
if check_empty and isinstance(current, str) and not current.strip():
return (False, ".".join(path))
if custom_check is not None and not custom_check(current):
return (False, ".".join(path))
return (True, ".".join(path))
def _check_array_min(
data: dict[str, Any],
path: list[str],
min_items: int = 1,
) -> tuple[bool, str]:
"""检查数组字段是否存在且至少有 min_items 项。"""
current = data
for key in path:
if not isinstance(current, dict):
return (False, ".".join(path))
if key not in current:
return (False, ".".join(path))
current = current[key]
if not isinstance(current, list) or len(current) < min_items:
return (False, ".".join(path))
return (True, ".".join(path))
def _check_array_element_fields(
items: Any,
required_fields: list[str],
field_path_prefix: str,
) -> list[str]:
"""检查数组中每个元素是否包含必填字段。"""
missing: list[str] = []
if not isinstance(items, list):
return [field_path_prefix]
for i, item in enumerate(items):
if not isinstance(item, dict):
missing.append(f"{field_path_prefix}[{i}]")
continue
for fld in required_fields:
if fld not in item or (isinstance(item[fld], str) and not item[fld].strip()):
missing.append(f"{field_path_prefix}[{i}].{fld}")
return missing
def _is_positive_number(value: Any) -> bool:
"""检查值是否为正数。"""
return isinstance(value, int | float) and value > 0
# ------------------------------------------------------------------
# 差旅报销校验
# ------------------------------------------------------------------
def validate_travel_info(data: dict[str, Any]) -> ValidationReport:
"""校验差旅报销信息的完整性。"""
missing: list[str] = []
total_checks = 0
passed_checks = 0
# basic_info 必填字段
basic_fields = [
"travel_purpose",
"travel_location",
"start_date",
"end_date",
]
for fld in basic_fields:
total_checks += 1
path = ["basic_info", fld]
if fld in ("start_date", "end_date"):
ok, _ = _check_field(data, path, custom_check=_is_valid_date)
else:
ok, _ = _check_field(data, path)
if ok:
passed_checks += 1
else:
missing.append(".".join(path))
# reimbursement_details.transport_fee (至少一条)
total_checks += 1
ok, _ = _check_array_min(data, ["reimbursement_details", "transport_fee"], min_items=1)
if ok:
passed_checks += 1
else:
missing.append("reimbursement_details.transport_fee")
# 检查 transport_fee 元素字段
transport = data.get("reimbursement_details", {}).get("transport_fee", [])
transport_fields = [
"vehicle_type",
"start_date",
"end_date",
"departure_place",
"arrival_place",
"amount",
"bill_count",
"remark",
]
missing.extend(
_check_array_element_fields(
transport,
transport_fields,
"reimbursement_details.transport_fee",
)
)
# payment_methods (至少一条)
total_checks += 1
ok, _ = _check_array_min(data, ["payment_methods"], min_items=1)
if ok:
passed_checks += 1
else:
missing.append("payment_methods")
# 检查 payment_methods 元素
payments = data.get("payment_methods", [])
payment_fields = ["card_date", "card_amount", "merchant", "remark"]
missing.extend(
_check_array_element_fields(
payments,
payment_fields,
"payment_methods",
)
)
# subsidy_list (至少一条)
total_checks += 1
ok, _ = _check_array_min(data, ["subsidy_list"], min_items=1)
if ok:
passed_checks += 1
else:
missing.append("subsidy_list")
# 检查 subsidy_list 元素
subsidies = data.get("subsidy_list", [])
subsidy_fields = ["person_id", "person_name", "start_date", "end_date", "days"]
missing.extend(
_check_array_element_fields(
subsidies,
subsidy_fields,
"subsidy_list",
)
)
# attachments (可选,有数据时检查)
attachments = data.get("attachments", [])
if attachments:
attach_fields = ["filename", "attachment_type"]
missing.extend(
_check_array_element_fields(
attachments,
attach_fields,
"attachments",
)
)
# 计算置信度
confidence = passed_checks / total_checks if total_checks > 0 else 0.0
# 推断缺失材料
missing_materials = _infer_missing_materials(missing, data)
# 生成建议
suggestion = _build_suggestion(missing, missing_materials)
return ValidationReport(
valid=len(missing) == 0,
missing_fields=missing,
missing_materials=missing_materials,
confidence=round(confidence, 2),
suggestion=suggestion,
)
# ------------------------------------------------------------------
# 普通报销校验
# ------------------------------------------------------------------
def validate_normal_info(data: dict[str, Any]) -> ValidationReport:
"""校验普通报销信息的完整性。"""
missing: list[str] = []
total_checks = 0
passed_checks = 0
# basic_info.reimbursement_description
total_checks += 1
ok, _ = _check_field(data, ["basic_info", "reimbursement_description"])
if ok:
passed_checks += 1
else:
missing.append("basic_info.reimbursement_description")
# reimbursement_details.total_invoices
total_checks += 1
ok, _ = _check_field(
data,
["reimbursement_details", "total_invoices"],
custom_check=lambda v: isinstance(v, int) and v > 0,
)
if ok:
passed_checks += 1
else:
missing.append("reimbursement_details.total_invoices")
# reimbursement_details.total_amount
total_checks += 1
ok, _ = _check_field(
data,
["reimbursement_details", "total_amount"],
custom_check=_is_positive_number,
)
if ok:
passed_checks += 1
else:
missing.append("reimbursement_details.total_amount")
# payment_methods (至少一条)
total_checks += 1
ok, _ = _check_array_min(data, ["payment_methods"], min_items=1)
if ok:
passed_checks += 1
else:
missing.append("payment_methods")
payments = data.get("payment_methods", [])
payment_fields = ["card_date", "card_amount", "merchant", "remark"]
missing.extend(
_check_array_element_fields(
payments,
payment_fields,
"payment_methods",
)
)
# attachments
attachments = data.get("attachments", [])
if attachments:
attach_fields = ["filename", "attachment_type"]
missing.extend(
_check_array_element_fields(
attachments,
attach_fields,
"attachments",
)
)
confidence = passed_checks / total_checks if total_checks > 0 else 0.0
missing_materials = _infer_missing_materials(missing, data)
suggestion = _build_suggestion(missing, missing_materials)
return ValidationReport(
valid=len(missing) == 0,
missing_fields=missing,
missing_materials=missing_materials,
confidence=round(confidence, 2),
suggestion=suggestion,
)
# ------------------------------------------------------------------
# 缺失材料推断
# ------------------------------------------------------------------
def _infer_missing_materials(
missing_fields: list[str],
data: dict[str, Any],
) -> list[str]:
"""根据缺失字段推断可能需要补充的材料类型。"""
materials: list[str] = []
field_set = set(missing_fields)
if any("start_date" in f or "end_date" in f for f in field_set):
if "basic_info.start_date" in field_set or "basic_info.end_date" in field_set:
materials.append("出差事前申请单")
if "basic_info.travel_purpose" in field_set:
materials.append("出差事前申请单")
if "basic_info.travel_location" in field_set:
materials.append("交通工具发票")
if "payment_methods" in field_set or any("payment_methods[" in f for f in field_set):
materials.append("支付记录截图")
if any("transport_fee" in f for f in field_set):
materials.append("交通工具发票")
if any("subsidy_list" in f for f in field_set):
materials.append("出差事前申请单")
if "basic_info.reimbursement_description" in field_set:
materials.append("发票或支付记录")
# 去重
return list(dict.fromkeys(materials))
def _build_suggestion(
missing_fields: list[str],
missing_materials: list[str],
) -> str:
"""生成用户友好的建议信息。"""
if not missing_fields:
return ""
if missing_materials:
material_names = "、".join(missing_materials)
return f"信息不完整,请补充上传:{material_names}"
return f"信息不完整,缺少 {len(missing_fields)} 个字段"
# ------------------------------------------------------------------
# 统一入口
# ------------------------------------------------------------------
def validate_extracted_info(
data: dict[str, Any],
invoice_type: str = "travel",
) -> ValidationReport:
"""校验提取信息的完整性。
Args:
data: LLM 提取的结构化信息。
invoice_type: 发票类型,'travel' 或 'normal'。
Returns:
校验报告。
"""
log.info("开始校验 %s 报销信息完整性", invoice_type)
if invoice_type == "travel":
report = validate_travel_info(data)
else:
report = validate_normal_info(data)
status = "通过" if report.valid else "未通过"
log.info(
"校验结果: %s (置信度: %.0f%%, 缺失字段: %d)",
status,
report.confidence * 100,
len(report.missing_fields),
)
return report
+58
View File
@@ -0,0 +1,58 @@
"""项目级异常定义
异常层次:
ReimbursementError — 所有业务异常的基类
├── ExtractionError — 文档提取失败(单个文件/批量全失败)
├── BrowserError — 浏览器自动化失败
└── ValidationError — 校验失败(规则校验/语义校验)
使用规则:
- 模块内部: 捕获具体异常 → 记录日志 → 截图(如适用) → re-raise
- 模块边界: 不吞异常,向上传播
- 顶层 (routes.py / orchestrator.py): 统一捕获 ReimbursementError
- 可恢复场景: 返回结构化结果而非 raise
"""
from __future__ import annotations
from typing import Any
class ReimbursementError(Exception):
"""业务异常基类"""
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.details = details or {}
class ExtractionError(ReimbursementError):
"""文档提取失败"""
def __init__(
self,
message: str,
failed_files: list[str] | None = None,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message, details)
self.failed_files = failed_files or []
class BrowserError(ReimbursementError):
"""浏览器自动化操作失败"""
pass
class ValidationError(ReimbursementError):
"""校验失败"""
def __init__(
self,
message: str,
missing_fields: list[str] | None = None,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message, details)
self.missing_fields = missing_fields or []
+2 -20
View File
@@ -22,6 +22,7 @@ from . import get_logger
from .config import load_config
from .doc.extractor import extract_invoices
from .doc.invoice import (
classify_invoice_batch,
save_application_json,
save_invoice_csv,
)
@@ -35,25 +36,6 @@ from .doc.llm_extractor import (
load_cache,
)
def _classify_invoice_batch(
invoices: list[dict[str, str]],
) -> dict[str, list[dict[str, str]]]:
"""按发票类型分组"""
travel: list[dict[str, str]] = []
general: list[dict[str, str]] = []
application: list[dict[str, str]] = []
for inv in invoices:
inv_type = inv.get("invoice_type", "general")
if inv_type == "application":
application.append(inv)
elif inv_type in ("train", "hotel"):
travel.append(inv)
else:
general.append(inv)
return {"travel": travel, "general": general, "application": application}
log = get_logger("pipeline")
@@ -63,7 +45,7 @@ def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
cache_map = load_cache(cache_path)
invoices = [data for data in cache_map.values() if data.get("invoice_type") not in ("application", "payment")]
return _classify_invoice_batch(invoices)
return classify_invoice_batch(invoices)
def _extract_travel_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
+243 -8
View File
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-11
last_reviewed: 2026-06-13
---
# src/web 模块设计说明
@@ -11,20 +11,47 @@ last_reviewed: 2026-06-11
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。
- **统一文件上传**:2026-06-12 改造,将 PDF 和图片上传入口合并为单一上传区,用户通过一个入口上传所有文件类型。
## 变更历史
| 日期 | 变更 |
|------|------|
| 2026-06-13 | LLM 流式思考过程展示:后端 SSE 推送 `llm_stream` 事件(start/chunk/end/error),前端聊天气泡实时展示 AI 思考过程 |
| 2026-06-12 | 文件进度实时反馈:后端 SSE 推送 `file_progress` 事件(processing/done/cached/error),前端文件消息实时更新状态 + 展示提取摘要 |
| 2026-06-12 | 配置交互改为逐项引导:config.json 缺失字段时 AI 逐个提示用户通过聊天输入,全部完成后自动开始处理 |
| 2026-06-12 | 聊天窗口精简:移除 SSE 日志流显示,仅保留关键状态消息;config.json 配置不完整时 AI 主动提示缺失字段 |
| 2026-06-12 | 移除开始处理按钮,改为自动触发:config.json 解析完成且配置完整(username/password)且有发票文件时自动开始处理 |
| 2026-06-12 | 文件上传通知改为逐条消息:每个文件单独一条聊天气泡,上传框 flex 居中、固定高度 |
| 2026-06-12 | 文件上传反馈移至聊天窗口:上传/拖拽/同步文件后以聊天气泡通知,上传框固定高度不再显示文件标签 |
| 2026-06-12 | 修复聊天窗口消息覆盖问题:将消息区域与输入区域分离,消息区独立滚动,新增用户文本输入功能 |
| 2026-06-12 | 合并 PDF/图片上传入口,`/api/files` 返回统一文件列表,前端使用 `allFiles` 单一数组管理 |
| 2026-06-12 | 移除配置表单,config.json 通过统一上传入口自动解析,配置存入 `sessionConfig` 对象,前端不再展示配置输入框 |
| 2026-06-12 | 暗色日志窗口替换为 AI 聊天风格窗口,SSE 日志以聊天气泡形式展示,支持打字指示器动画 |
## 文件结构
```
src/web/
├── app.py # Flask 应用入口,路由、管道编排、日志收集
├── sse_handler.py # SSE 日志收集器、日志转义工具
├── routes.py # 路由定义、SSE 端点
├── templates/
│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交)
│ ├── index.html # PC 端主界面(上传、处理、编辑、提交)
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
└── static/
├── css/
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
└── js/
└── index.js # 前端逻辑(上传、SSE 日志、表格编辑、二维码同步)
├── index.js # 入口:初始化 App 全局状态、绑定事件
├── state.js # 全局状态管理(App 对象)
├── chat.js # 聊天气泡渲染、文件消息、LLM 流式气泡
├── agent.js # Agent 事件处理、用户输入管理
├── process.js # SSE 连接、事件路由、管道启动
├── config.js # 配置解析、逐项引导
├── upload.js # 文件上传、拖拽处理
├── sync.js # 移动端同步
└── utils.js # HTML 转义等工具函数
```
## 数据流
@@ -64,8 +91,8 @@ graph TD
| GET | `/` | 主界面 |
| POST | `/api/session` | 创建会话,返回 session_id |
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
| GET | `/api/files/<sid>` | 列出会话文件 |
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
| GET | `/api/files/<sid>` | 列出会话文件(返回统一 `files` 列表,含 `name`、`type`、`size` 字段;旧字段 `pdfs`/`images` 保留向后兼容) |
| POST | `/api/agent/process/<sid>` | 启动 Agent 管道(后台线程) |
| GET | `/api/logs/<sid>` | SSE 日志流 |
| GET | `/api/data/<sid>` | 获取发票数据 JSON |
| POST | `/api/save/<sid>` | 保存前端编辑的发票数据 |
@@ -77,7 +104,7 @@ graph TD
### 日志收集
`_SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
`SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
### 发票类型分流
@@ -97,8 +124,216 @@ Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`)
### 移动端同步
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的文件通过 `syncFiles()` 轮询同步到 PC 端内存中的 `allFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
### 配置管理
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端通过统一上传入口接收 `config.json`,自动解析到 `sessionConfig` 对象,不再展示配置输入表单。
### 聊天气泡消息系统
聊天窗口的所有消息通过 **事件文件 + SSE 轮询** 机制传输。后端不直接推送消息,而是将事件追加到 session 目录下的日志文件,SSE 端点以 0.5 秒间隔轮询文件增量,再通过 EventSource 推送到前端。
#### 事件文件总览
session 目录下有四个事件文件:
| 文件 | 用途 | 写入方 | 读取方 |
|------|------|--------|--------|
| `llm_stream.log` | LLM 流式输出(思考过程 + 正式回答) | `llm_extractor` 模块 | SSE 端点 |
| `file_events.log` | 文件处理进度 | `extractor` 模块 | SSE 端点 |
| `agent_events.log` | Agent 状态变更、请求补充等 | `orchestrator` 模块 | SSE 端点 |
| `session.log` | 普通 INFO 日志 | `SSELogHandler` | SSE 端点(当前仅保留,前端已不做处理) |
#### 后端发送事件
所有事件文件遵循相同的写入协议:每行一个 JSON 对象,写入后 flush。
**`llm_stream.log`** — 由 `_emit_llm_stream(source_dir, phase, ...)` 写入:
```python
# 开始 LLM 调用(必需)
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
# 流式文本片段(可选,有内容时发)
_emit_llm_stream(source_dir, "chunk", text="让我来分析...")
# 思考过程片段(可选,模型支持时发)
_emit_llm_stream(source_dir, "reasoning", text="根据发票信息...")
# 调用完成(必需)
_emit_llm_stream(source_dir, "end", label="分析完成")
# 调用失败(异常时发)
_emit_llm_stream(source_dir, "error", error="连接超时")
```
**`agent_events.log`** — 由 `_emit_agent_event(session_dir, event_type, ...)` 写入:
```python
# 状态变更
_emit_agent_event(session_dir, "agent_state_change", state="extracting", message="正在分析文件...")
# 请求补充材料
_emit_agent_event(session_dir, "agent_request_supplement", message="请上传返程车票...")
# 信息完整,可以提交
_emit_agent_event(session_dir, "agent_ready")
# 错误
_emit_agent_event(session_dir, "agent_error", message="LLM 提取失败")
```
**`file_events.log`** — 由 `_emit_file_event(source_dir, ...)` 写入:
```python
# 文件开始处理
_emit_file_event(source_dir, filename, "processing")
# 文件处理完成(带摘要)
_emit_file_event(source_dir, filename, "done", summary={"invoice_number": "...", ...})
# 使用缓存
_emit_file_event(source_dir, filename, "cached")
# 处理失败
_emit_file_event(source_dir, filename, "error", error="PDF 解析失败")
```
**重要约束:`start` 和 `end` 事件不可省略。** 前端 `llmStreamState` 状态机依赖 `start` 创建气泡 DOM,没有 `start` 时后续的 `chunk` 和 `reasoning` 会因守卫条件直接返回。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`。
#### SSE 传输层
`/api/logs/<sid>` 端点(`routes.py`)的轮询逻辑:
```
每 0.5 秒:
1. 读取 session.log 增量 → yield "data: <日志行>"
2. 读取 file_events.log 增量 → 逐行 yield "data: <JSON>"
3. 读取 llm_stream.log 增量 → 逐行 yield "data: <JSON>"
4. 读取 agent_events.log 增量 → 逐行 yield "data: <JSON>"
5. 检查 result.json 是否存在 → yield "data: {type: 'done'}" 后退出
```
#### 前端事件路由
`process.js` 的 `EventSource` 监听器按 `msg.type` 分发:
```
msg.type === 'file_progress' → setFileProcessing / setFileDone / setFileCached / setFileError
msg.type === 'llm_stream' → handleLLMStream()
msg.type 以 'agent_' 开头 → handleAgentEvent()
msg.type === 'done' → 关闭 EventSource,展示结果
```
#### 前端聊天气泡渲染
**LLM 流式气泡**(`chat.js`):
```
start → _createLLMStreamBubble()
├─ 创建 <div class="chat-bubble processing llm-stream-bubble">
├─ 创建 label 元素(显示 label 文本)
├─ 创建 <details> 可折叠区域(思考过程)
└─ 创建 textContent 元素(正式回答)
└─ 注册到 llmStreamState
reasoning → _appendLLMStreamReasoning()
└─ 追加到 reasoningContent.textContent
chunk → _appendLLMStreamChunk()
└─ 追加到 textContent.textContent
end → _closeLLMStreamBubble()
├─ 气泡 class 从 processing 变为 done
└─ 清空 llmStreamState
error → _errorLLMStreamBubble()
├─ 气泡 class 变为 error
└─ 清空 llmStreamState
```
**Agent 状态气泡**(`agent.js`):
```
agent_state_change → _handleAgentStateChange()
└─ 更新最后一条状态消息(不追加新气泡)
agent_request_supplement → _handleAgentRequestSupplement()
└─ 追加请求补充的气泡
agent_ready → _handleAgentReady()
└─ 追加完成状态气泡
agent_error → _handleAgentError()
└─ 追加错误气泡
```
**文件进度气泡**(`chat.js`):
```
file_progress (processing) → setFileProcessing() → 三点动画
file_progress (done) → setFileDone() → 提取摘要
file_progress (cached) → setFileCached() → 缓存标识
file_progress (error) → setFileError() → 错误信息
```
#### 完整数据流
```mermaid
sequenceDiagram
participant Pipe as 后台线程<br/>(管道)
participant Files as 事件文件<br/>(session 目录)
participant SSE as Flask SSE<br/>(routes.py)
participant ES as EventSource<br/>(process.js)
participant Chat as chat.js
participant Agent as agent.js
Note over Pipe: 启动管道
Pipe->>Files: 追加 llm_stream start
Pipe->>Files: 追加 agent_events state_change
Note over SSE: 0.5s 轮询
SSE->>Files: seek(last_size) + read()
SSE->>ES: yield "data: start"
SSE->>ES: yield "data: state_change"
ES->>Chat: handleLLMStream({phase:"start"})
Chat->>Chat: 创建流式气泡
ES->>Agent: handleAgentEvent({type:"agent_state_change"})
Agent->>Agent: 显示状态消息
Note over Pipe: LLM 流式输出
Pipe->>Files: 追加 llm_stream reasoning
Pipe->>Files: 追加 llm_stream chunk
SSE->>Files: seek(last_size) + read()
SSE->>ES: yield "data: reasoning"
SSE->>ES: yield "data: chunk"
ES->>Chat: handleLLMStream({phase:"reasoning"})
Chat->>Chat: 追加思考内容
ES->>Chat: handleLLMStream({phase:"chunk"})
Chat->>Chat: 追加正式回答
Note over Pipe: 完成
Pipe->>Files: 追加 llm_stream end
Pipe->>Files: 写入 result.json
SSE->>Files: seek(last_size) + read()
SSE->>ES: yield "data: end"
ES->>Chat: handleLLMStream({phase:"end"})
Chat->>Chat: 气泡变完成状态
SSE->>Files: 检测 result.json
SSE->>ES: yield "data: {type:'done'}"
ES->>ES: 关闭连接
```
#### 添加新消息类型的步骤
1. 在对应模块定义 `_emit_xxx()` 函数,写入 session 目录的 JSON 文件
2. 在 `routes.py` 的 `stream_logs()` 轮询循环中新增对该文件的轮询
3. 在 `process.js` 的 EventSource 监听器中按 `msg.type` 路由到新处理器
4. 在 `chat.js` 或 `agent.js` 中实现渲染逻辑
5. 确保 `start` 和 `end` 事件成对出现(如果是流式气泡)
+5
View File
@@ -0,0 +1,5 @@
"""
Web 模块
提供财务报销系统的 Web 界面功能。
"""
+14 -684
View File
@@ -12,709 +12,39 @@
访问: http://localhost:5000
"""
import json
import logging
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Any, cast
from urllib.parse import quote
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
# 确保项目根目录在 sys.path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from src import get_logger # noqa: E402, I001
from src.config import load_config as load_project_config # noqa: E402, I001
from src.doc.extractor import ( # noqa: E402, I001
extract_invoices,
)
from src.doc.fill_consumable_doc import ( # noqa: E402, I001
CONSUMABLE_DOC_FILENAME,
fill_consumable_from_template,
)
from src.doc.invoice import ( # noqa: E402, I001
load_csv,
load_invoice_csv,
save_csv as save_payment_csv,
save_invoice_csv,
save_application_json,
)
from flask import Flask # noqa: E402, I001
fill_log = get_logger("fill_consumable_doc")
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
from src.web import pipeline_web, routes # noqa: E402, I001
from src.doc.fill_consumable_doc import CONSUMABLE_DOC_FILENAME # noqa: E402, I001
app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
SESSION_LOG_FILE = "session.log"
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
"""保存发票分类结果到 session 目录的 JSON 文件"""
groups_path = session_dir / INVOICE_GROUPS_FILE
# 只保存各组的数量统计,避免重复存储完整发票数据
data = {
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def create_app() -> Flask:
"""应用工厂:初始化配置并注册路由"""
# 设置出库单模板路径
pipeline_web.set_consumable_template(PROJECT_ROOT / CONSUMABLE_DOC_FILENAME)
# 初始化路由配置
routes.init_routes(UPLOAD_BASE)
def _load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
"""从 session 目录加载发票分类统计"""
groups_path = session_dir / INVOICE_GROUPS_FILE
if not groups_path.exists():
return None
try:
with open(groups_path, encoding="utf-8") as f:
return cast(dict[str, int] | None, json.load(f))
except Exception:
return None
# 注册 Blueprint
app.register_blueprint(routes.web_bp)
return app
class _SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)"""
def __init__(self, log_path: Path):
super().__init__()
self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord) -> None:
try:
msg = self.format(record) + "\n"
with self._lock:
self._file.write(msg)
self._file.flush()
except Exception:
pass
def close_file(self) -> None:
try:
self._file.close()
except Exception:
pass
def _install_log_collector(session_dir: Path) -> _SSELogHandler:
"""安装日志收集器到 app.* 模块"""
log_path = session_dir / SESSION_LOG_FILE
fmt = logging.Formatter(
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
"%Y-%m-%d %H:%M:%S",
)
handler = _SSELogHandler(log_path)
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
return handler
def _remove_log_collector(handler: _SSELogHandler) -> None:
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
logging.getLogger(name).removeHandler(handler)
handler.close_file()
# ================================================================
# 出库单填写
# ================================================================
def _load_session_config(session_dir: Path) -> dict[str, Any]:
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
return config
def _resolve_payment_csv(session_dir: Path) -> Path | None:
"""查找支付记录 CSV(payment_records.csv)"""
csv_path = session_dir / "payment_records.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
"""查找发票级别 CSV(invoice_summary.csv)"""
csv_path = session_dir / "invoice_summary.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
# ================================================================
# 出库单填写
# ================================================================
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""根据 CSV 填写易耗品出库单,供会话目录下载。
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
"""
if not CONSUMABLE_TEMPLATE.exists():
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
# 从统一的分类结果读取,避免重复解析 CSV/JSON
groups = _load_invoice_groups(session_dir)
if groups is None:
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
if not groups.get("general_count", 0):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
csv_path = _resolve_payment_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
try:
fill_log.info("开始填写出库单: %s", out_doc.name)
fill_consumable_from_template(csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config)
fill_log.info("出库单填写完成")
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
except ImportError:
fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32")
return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"}
except Exception as e:
fill_log.exception("填写出库单失败: %s", e)
return {"ok": False, "error": str(e)}
def _append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
result["doc_ok"] = True
elif doc_fill.get("skipped"):
# 差旅发票,跳过出库单生成(不是错误)
result["doc_ok"] = None
result["doc_skipped"] = True
result["doc_message"] = doc_fill.get("error", "")
else:
result["doc_ok"] = False
result["doc_error"] = doc_fill.get("error", "未知错误")
# ================================================================
# 管道入口
# ================================================================
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票:无需额外提取(normal_info.json 待实现)
"""
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 保存分类结果(供后续步骤统一读取)
_save_invoice_groups(session_dir, groups)
# ---- Step 2: 差旅/普通信息提取 ----
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
if is_travel:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("travel_info"):
fill_log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(travel_info, f, ensure_ascii=False, indent=2)
fill_log.info("差旅信息已保存到缓存")
else:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
# 检查缓存是否已有
cache_map = load_cache(session_dir)
if not cache_map.get("normal_info"):
fill_log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(normal_info, f, ensure_ascii=False, indent=2)
fill_log.info("普通发票信息已保存到缓存")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": invoice_count,
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
# 发票类型统计
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
}
doc_fill = _try_fill_consumable_doc(session_dir, config)
_append_doc_download(result, session_dir.name, doc_fill)
return result
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
- 纯差旅发票:差旅报销模式(TODO)
- 含普通发票:普通报销模式
"""
csv_path = session_dir / "payment_records.csv"
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.bot import run_bot_web
# 从统一的分类结果读取,避免重复解析
groups = _load_invoice_groups(session_dir)
if groups:
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
# TODO: 差旅报销填报流程
else:
fill_log.info("检测到普通发票,使用普通报销模式")
run_bot_web(config, session_dir)
return {"ok": True}
# ================================================================
# Flask 路由
# ================================================================
@app.route("/")
def index() -> Any:
return render_template("index.html")
@app.route("/api/session", methods=["POST"])
def create_session() -> Any:
"""创建上传会话,返回 session_id"""
sid = uuid.uuid4().hex[:12]
session_dir = UPLOAD_BASE / sid
session_dir.mkdir(parents=True, exist_ok=True)
return jsonify({"session_id": sid})
@app.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str) -> Any:
"""上传 PDF 或图片"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@app.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str) -> Any:
"""列出会话目录中的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
imgs = sorted(f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} for f in session_dir.glob(f"*{ext}"))
return jsonify({"pdfs": pdfs, "images": imgs})
@app.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str) -> Any:
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
# 读取配置
config = _build_web_config(body)
# 写入配置到会话目录
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
# 在后台线程执行
handler = _install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
result = run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
except Exception:
pass
_remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@app.route("/api/logs/<session_id>")
def stream_logs(session_id: str) -> Any:
"""SSE 日志流"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
def generate() -> Any:
# 先发送已有日志
log_file = session_dir / SESSION_LOG_FILE
last_size = 0
start_time = time.time()
timeout = 600 # 10 分钟超时
while time.time() - start_time < timeout:
if log_file.exists():
current_size = log_file.stat().st_size
if current_size > last_size:
with open(log_file, encoding="utf-8", errors="replace") as f:
f.seek(last_size)
chunk = f.read()
if chunk:
yield f"data: {_escape_sse(chunk)}\n\n"
last_size = current_size
# 也通过队列发送实时日志
# 检查是否完成
result_file = session_dir / SESSION_RESULT_FILE
if result_file.exists():
with open(result_file, encoding="utf-8") as f:
result = json.load(f)
yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
break
time.sleep(0.5)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.route("/api/download/<session_id>/<filename>")
def download_file(session_id: str, filename: str) -> Any:
"""下载生成的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 防止路径穿越
safe_name = Path(filename).name
filepath = session_dir / safe_name
if not filepath.exists():
return jsonify({"error": "文件不存在"}), 404
if safe_name.endswith(".doc"):
mimetype = "application/msword"
elif safe_name.endswith(".csv"):
mimetype = "text/csv; charset=utf-8"
else:
mimetype = "application/octet-stream"
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
return Response(
filepath.read_bytes(),
mimetype=mimetype,
headers={"Content-Disposition": disposition},
)
@app.route("/api/config/<session_id>", methods=["GET"])
def get_session_config(session_id: str) -> Any:
"""获取当前会话的配置(供前端回填表单)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
# 只返回前端需要的字段
return jsonify(
{
"username": config.get("username", ""),
"password": "", # 不返回密码
"default_name": config.get("default_name", ""),
"default_card_no": config.get("default_card_no", ""),
"default_person_id": config.get("default_person_id", ""),
"consumable_storage": config.get("consumable_storage", ""),
}
)
@app.route("/api/data/<session_id>", methods=["GET"])
def get_invoice_data(session_id: str) -> Any:
"""读取发票数据并返回 JSON(供前端表格编辑)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 优先读取支付记录 CSV
payment_csv = session_dir / "payment_records.csv"
if payment_csv.exists():
rows = load_csv(payment_csv)
if rows is not None:
data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry: dict[str, Any] = dict(row)
entry["__row"] = i
data.append(entry)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
# 回退到发票级别 CSV
invoice_csv = session_dir / "invoice_summary.csv"
if invoice_csv.exists():
rows = load_invoice_csv(invoice_csv)
if rows is not None:
invoice_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry2: dict[str, Any] = dict(row)
entry2["__row"] = i
invoice_data.append(entry2)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
# 最后尝试任意 CSV
csv_files = list(session_dir.glob("*.csv"))
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
if csv_files:
csv_path = csv_files[0]
rows = load_csv(csv_path)
if rows is None:
rows = load_invoice_csv(csv_path)
if rows is not None:
fallback_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry3: dict[str, Any] = dict(row)
entry3["__row"] = i
fallback_data.append(entry3)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
return jsonify({"error": "未找到发票数据,请先处理"}), 404
@app.route("/api/save/<session_id>", methods=["POST"])
def save_invoice_data(session_id: str) -> Any:
"""保存前端编辑后的发票数据到 CSV"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
data = body.get("data", [])
csv_filename = body.get("csv_filename", "invoice_summary.csv")
csv_path = session_dir / csv_filename
if not csv_path.exists():
return jsonify({"error": "CSV 文件不存在"}), 404
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys)
original_rows = load_csv(csv_path)
if original_rows is None or len(original_rows) == 0:
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
# 从原数据中获取字段顺序(去掉内部字段)
fieldnames = list(original_rows[0].keys())
import csv as csv_module
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for entry in data:
row = {k: entry.get(k, "") for k in fieldnames}
writer.writerow(row)
resp: dict[str, str | bool | None] = {"ok": True}
config = _load_session_config(session_dir)
doc_fill = _try_fill_consumable_doc(session_dir, config)
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
resp["doc_ok"] = True
elif doc_fill.get("skipped"):
resp["doc_ok"] = None
resp["doc_skipped"] = True
else:
resp["doc_ok"] = False
resp["doc_error"] = doc_fill.get("error") or ""
return jsonify(resp)
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
def submit_financial(session_id: str) -> Any:
"""手动触发财务系统填报"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 读取配置
config_path = session_dir / "config.json"
if not config_path.exists():
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
# 清除上次处理留下的结果文件,避免 SSE 误判为已完成
result_file = session_dir / SESSION_RESULT_FILE
if result_file.exists():
result_file.unlink()
# 在后台线程执行提交
handler = _install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
submit_result = run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {"ok": True}
else:
result = submit_result
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
f,
ensure_ascii=False,
)
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
except Exception:
pass
_remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
# ================================================================
# 辅助函数
# ================================================================
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
session_dir = UPLOAD_BASE / session_id
if not session_dir.exists():
return jsonify({"error": "会话不存在"}), 404
return session_dir
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
"""从请求体构建配置"""
config = load_project_config()
for key in (
"username",
"password",
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
):
if body.get(key):
config[key] = body[key]
return config
def _escape_sse(text: str) -> str:
"""SSE 数据转义,同时处理 Windows 行尾 \\r\\n"""
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
@app.route("/mobile/<session_id>")
def mobile_upload(session_id: str) -> Any:
"""移动端上传页面"""
session_dir = UPLOAD_BASE / session_id
if not session_dir.exists():
return render_template("mobile_upload.html", error="会话不存在"), 404
return render_template("mobile_upload.html", session_id=session_id)
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
def mobile_upload_file(session_id: str) -> Any:
"""移动端上传图片(复用 PC 上传逻辑)"""
return upload_file(session_id)
# 直接使用 app 实例(保持向后兼容)
create_app()
if __name__ == "__main__":
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
+259
View File
@@ -0,0 +1,259 @@
"""
Web 管道逻辑
负责:
- 发票提取管道编排
- 财务系统填报触发
- 易耗品出库单生成
- 发票分类数据持久化
"""
import json
import time
from pathlib import Path
from typing import Any, cast
from urllib.parse import quote
# 延迟导入,避免循环引用
from src import get_logger # noqa: F401
from src.config import load_config as load_project_config
from src.doc.fill_consumable_doc import (
CONSUMABLE_DOC_FILENAME,
fill_consumable_from_template,
)
from src.doc.invoice import (
save_application_json,
save_invoice_csv,
)
from src.doc.invoice import (
save_csv as save_payment_csv,
)
fill_log = get_logger("fill_consumable_doc")
# 文件常量
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
"""保存发票分类结果到 session 目录的 JSON 文件"""
groups_path = session_dir / INVOICE_GROUPS_FILE
data = {
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
"""从 session 目录加载发票分类统计"""
groups_path = session_dir / INVOICE_GROUPS_FILE
if not groups_path.exists():
return None
try:
with open(groups_path, encoding="utf-8") as f:
return cast(dict[str, int] | None, json.load(f))
except Exception:
return None
def load_session_config(session_dir: Path) -> dict[str, Any]:
"""加载会话配置,合并项目全局配置与会话级配置"""
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
return config
def resolve_payment_csv(session_dir: Path) -> Path | None:
"""查找支付记录 CSV(payment_records.csv)"""
csv_path = session_dir / "payment_records.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
def resolve_invoice_csv(session_dir: Path) -> Path | None:
"""查找发票级别 CSV(invoice_summary.csv)"""
csv_path = session_dir / "invoice_summary.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
# ================================================================
# 出库单填写
# ================================================================
# 在模块加载时确定模板路径(由 app.py 传入 PROJECT_ROOT)
_consumable_template: Path | None = None
def set_consumable_template(template_path: Path) -> None:
"""设置出库单模板路径(由 app.py 在启动时调用)"""
global _consumable_template
_consumable_template = template_path
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""根据 CSV 填写易耗品出库单,供会话目录下载。
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
"""
template = _consumable_template
if template is None or not template.exists():
fill_log.warning("出库单模板不存在: %s", template)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
groups = load_invoice_groups(session_dir)
if groups is None:
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
if not groups.get("general_count", 0):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
csv_path = resolve_payment_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
try:
fill_log.info("开始填写出库单: %s", out_doc.name)
fill_consumable_from_template(csv_path, template, out_doc, config=config)
fill_log.info("出库单填写完成")
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
except ImportError:
fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32")
return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"}
except Exception as e:
fill_log.exception("填写出库单失败: %s", e)
return {"ok": False, "error": str(e)}
def append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
"""将出库单下载信息追加到结果字典"""
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
result["doc_ok"] = True
elif doc_fill.get("skipped"):
result["doc_ok"] = None
result["doc_skipped"] = True
result["doc_message"] = doc_fill.get("error", "")
else:
result["doc_ok"] = False
result["doc_error"] = doc_fill.get("error", "未知错误")
# ================================================================
# 管道入口
# ================================================================
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票:无需额外提取(normal_info.json 待实现)
"""
from src.doc.extractor import extract_invoices
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
# 保存分类结果(供后续步骤统一读取)
save_invoice_groups(session_dir, groups)
# ---- Step 2: 差旅/普通信息提取 ----
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
if is_travel:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
cache_map = load_cache(session_dir)
if not cache_map.get("travel_info"):
fill_log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
json.dump(travel_info, f, ensure_ascii=False, indent=2)
fill_log.info("差旅信息已保存到缓存")
else:
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
cache_map = load_cache(session_dir)
if not cache_map.get("normal_info"):
fill_log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=session_dir)
cache_dir = session_dir / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
json.dump(normal_info, f, ensure_ascii=False, indent=2)
fill_log.info("普通发票信息已保存到缓存")
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": invoice_count,
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
}
doc_fill = _try_fill_consumable_doc(session_dir, config)
append_doc_download(result, session_dir.name, doc_fill)
return result
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
- 纯差旅发票:差旅报销模式(TODO)
- 含普通发票:普通报销模式
"""
csv_path = session_dir / "payment_records.csv"
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.bot import run_bot_web
groups = load_invoice_groups(session_dir)
if groups:
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
else:
fill_log.info("检测到普通发票,使用普通报销模式")
run_bot_web(config, session_dir)
return {"ok": True}
+917
View File
@@ -0,0 +1,917 @@
"""
Flask 路由定义
所有 Web 端点的路由注册,不包含业务逻辑(业务逻辑在 pipeline_web 和 sse_handler 中)。
使用 Blueprint 模式,支持延迟注册到 Flask app。
"""
import csv as csv_module
import json
import threading
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import quote
from flask import Blueprint, Response, jsonify, render_template, request, stream_with_context
from src.config import load_config as load_project_config
from src.doc.invoice import load_csv, load_invoice_csv
from . import pipeline_web, sse_handler
# 在模块加载时确定(由 init_routes 传入)
_UPLOAD_BASE: Path | None = None
web_bp = Blueprint("web", __name__)
def init_routes(upload_base: Path) -> None:
"""初始化路由配置,传入上传目录"""
global _UPLOAD_BASE
_UPLOAD_BASE = upload_base
# ================================================================
# 辅助函数
# ================================================================
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
"""验证会话 ID 并返回会话目录"""
if _UPLOAD_BASE is None:
return jsonify({"error": "服务未初始化"}), 500
session_dir = _UPLOAD_BASE / session_id
if not session_dir.exists():
return jsonify({"error": "会话不存在"}), 404
return session_dir
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
"""从请求体构建配置"""
config = load_project_config()
for key in (
"username",
"password",
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
):
if body.get(key):
config[key] = body[key]
return config
def _emit_ready_and_submit(
session_dir: Path,
agent_session: Any,
config: dict[str, Any],
) -> None:
"""Agent 校验通过后,直接触发财务提交(不依赖前端)"""
from src.agent import _emit_agent_event
# 发射 agent_ready 事件,前端 SSE 会收到
_emit_agent_event(
session_dir,
"agent_ready",
round=agent_session.rounds,
message="信息完整,可以提交",
)
# 直接执行财务提交
try:
submit_result = pipeline_web.run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {
"ok": True,
"agent_ready": True,
"submit_ok": True,
"round": agent_session.rounds,
"message": "信息完整,已自动提交到财务系统",
}
else:
result = {
"ok": True,
"agent_ready": True,
"submit_ok": False,
"submit_error": submit_result.get("error"),
"round": agent_session.rounds,
"message": "校验通过但提交失败",
}
except Exception as e:
result = {
"ok": True,
"agent_ready": True,
"submit_ok": False,
"submit_error": str(e),
"round": agent_session.rounds,
"message": f"校验通过但提交异常: {e}",
}
# 写入 result 文件,SSE done 事件会读取
try:
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
# ================================================================
# 页面路由
# ================================================================
@web_bp.route("/")
def index() -> Any:
return render_template("index.html")
@web_bp.route("/mobile/<session_id>")
def mobile_upload(session_id: str) -> Any:
"""移动端上传页面"""
if _UPLOAD_BASE is None:
return render_template("mobile_upload.html", error="服务未初始化"), 500
session_dir = _UPLOAD_BASE / session_id
if not session_dir.exists():
return render_template("mobile_upload.html", error="会话不存在"), 404
return render_template("mobile_upload.html", session_id=session_id)
# ================================================================
# 会话管理
# ================================================================
@web_bp.route("/api/session", methods=["POST"])
def create_session() -> Any:
"""创建上传会话,返回 session_id"""
if _UPLOAD_BASE is None:
return jsonify({"error": "服务未初始化"}), 500
sid = uuid.uuid4().hex[:12]
session_dir = _UPLOAD_BASE / sid
session_dir.mkdir(parents=True, exist_ok=True)
return jsonify({"session_id": sid})
# ================================================================
# 文件上传与下载
# ================================================================
@web_bp.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str) -> Any:
"""上传 PDF 或图片"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@web_bp.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str) -> Any:
"""列出会话目录中的文件(统一列表)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
pdf_exts = {".pdf"}
img_exts = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
files = []
for f in sorted(session_dir.iterdir()):
if not f.is_file():
continue
ext = f.suffix.lower()
if ext in pdf_exts:
files.append({"name": f.name, "type": "pdf", "size": f.stat().st_size})
elif ext in img_exts:
files.append({"name": f.name, "type": "image", "size": f.stat().st_size})
return jsonify(
{
"files": files,
"pdfs": [item["name"] for item in files if item["type"] == "pdf"],
"images": [item["name"] for item in files if item["type"] == "image"],
}
)
@web_bp.route("/api/download/<session_id>/<filename>")
def download_file(session_id: str, filename: str) -> Any:
"""下载生成的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
safe_name = Path(filename).name
filepath = session_dir / safe_name
if not filepath.exists():
return jsonify({"error": "文件不存在"}), 404
if safe_name.endswith(".doc"):
mimetype = "application/msword"
elif safe_name.endswith(".csv"):
mimetype = "text/csv; charset=utf-8"
else:
mimetype = "application/octet-stream"
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
return Response(
filepath.read_bytes(),
mimetype=mimetype,
headers={"Content-Disposition": disposition},
)
@web_bp.route("/api/mobile-upload/<session_id>", methods=["POST"])
def mobile_upload_file(session_id: str) -> Any:
"""移动端上传图片(复用 PC 上传逻辑)"""
return upload_file(session_id)
# ================================================================
# 配置
# ================================================================
@web_bp.route("/api/config/<session_id>", methods=["GET"])
def get_session_config(session_id: str) -> Any:
"""获取当前会话的配置(供前端回填表单)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
config.update(json.load(f))
return jsonify(
{
"username": config.get("username", ""),
"password": "",
"default_name": config.get("default_name", ""),
"default_card_no": config.get("default_card_no", ""),
"default_person_id": config.get("default_person_id", ""),
"consumable_storage": config.get("consumable_storage", ""),
}
)
# ================================================================
# 发票数据处理
# ================================================================
@web_bp.route("/api/data/<session_id>", methods=["GET"])
def get_invoice_data(session_id: str) -> Any:
"""读取发票数据并返回 JSON(供前端表格编辑)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 优先读取支付记录 CSV
payment_csv = session_dir / "payment_records.csv"
if payment_csv.exists():
rows = load_csv(payment_csv)
if rows is not None:
data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry: dict[str, Any] = dict(row)
entry["__row"] = i
data.append(entry)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
# 回退到发票级别 CSV
invoice_csv = session_dir / "invoice_summary.csv"
if invoice_csv.exists():
rows = load_invoice_csv(invoice_csv)
if rows is not None:
invoice_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry2: dict[str, Any] = dict(row)
entry2["__row"] = i
invoice_data.append(entry2)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
# 最后尝试任意 CSV
csv_files = list(session_dir.glob("*.csv"))
csv_files = [f for f in csv_files if f.name != pipeline_web.SESSION_RESULT_FILE]
if csv_files:
csv_path = csv_files[0]
rows = load_csv(csv_path)
if rows is None:
rows = load_invoice_csv(csv_path)
if rows is not None:
fallback_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry3: dict[str, Any] = dict(row)
entry3["__row"] = i
fallback_data.append(entry3)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
return jsonify({"error": "未找到发票数据,请先处理"}), 404
@web_bp.route("/api/save/<session_id>", methods=["POST"])
def save_invoice_data(session_id: str) -> Any:
"""保存前端编辑后的发票数据到 CSV"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
data = body.get("data", [])
csv_filename = body.get("csv_filename", "invoice_summary.csv")
csv_path = session_dir / csv_filename
if not csv_path.exists():
return jsonify({"error": "CSV 文件不存在"}), 404
original_rows = load_csv(csv_path)
if original_rows is None or len(original_rows) == 0:
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
fieldnames = list(original_rows[0].keys())
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for entry in data:
row = {k: entry.get(k, "") for k in fieldnames}
writer.writerow(row)
resp: dict[str, str | bool | None] = {"ok": True}
config = pipeline_web.load_session_config(session_dir)
doc_fill = pipeline_web._try_fill_consumable_doc(session_dir, config)
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
resp["doc_ok"] = True
elif doc_fill.get("skipped"):
resp["doc_ok"] = None
resp["doc_skipped"] = True
else:
resp["doc_ok"] = False
resp["doc_error"] = doc_fill.get("error") or ""
return jsonify(resp)
# ================================================================
# 管道处理
# ================================================================
@web_bp.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str) -> Any:
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
config = _build_web_config(body)
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
result = pipeline_web.run_pipeline_web(session_dir, config)
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@web_bp.route("/api/logs/<session_id>")
def stream_logs(session_id: str) -> Any:
"""SSE 日志流"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
def generate() -> Any:
log_file = session_dir / sse_handler.SESSION_LOG_FILE
last_size = 0
file_events_file = session_dir / "file_events.log"
last_events_size = 0
last_stream_size = 0
state = {"agent_size": 0}
start_time = time.time()
timeout = 600
while time.time() - start_time < timeout:
# 轮询普通日志
if log_file.exists():
current_size = log_file.stat().st_size
if current_size > last_size:
with open(log_file, encoding="utf-8", errors="replace") as f:
f.seek(last_size)
chunk = f.read()
if chunk:
yield f"data: {sse_handler.escape_sse(chunk)}\n\n"
last_size = current_size
# 轮询文件进度事件
if file_events_file.exists():
events_size = file_events_file.stat().st_size
if events_size > last_events_size:
with open(file_events_file, encoding="utf-8", errors="replace") as f:
f.seek(last_events_size)
new_events = f.read()
if new_events:
for line in new_events.strip().split("\n"):
line = line.strip()
if line:
yield f"data: {line}\n\n"
last_events_size = events_size
# 轮询 LLM 流式事件
llm_stream_file = session_dir / "llm_stream.log"
if llm_stream_file.exists():
stream_size = llm_stream_file.stat().st_size
if stream_size > last_stream_size:
with open(llm_stream_file, encoding="utf-8", errors="replace") as f:
f.seek(last_stream_size)
new_chunks = f.read()
if new_chunks:
for line in new_chunks.strip().split("\n"):
line = line.strip()
if line:
yield f"data: {line}\n\n"
last_stream_size = stream_size
# 轮询 Agent 事件
agent_events_file = session_dir / "agent_events.log"
last_agent_events_size = state["agent_size"]
if agent_events_file.exists():
agent_size = agent_events_file.stat().st_size
if agent_size > last_agent_events_size:
with open(agent_events_file, encoding="utf-8", errors="replace") as f:
f.seek(last_agent_events_size)
new_agent_events = f.read()
if new_agent_events:
for line in new_agent_events.strip().split("\n"):
line = line.strip()
if line:
yield f"data: {line}\n\n"
state["agent_size"] = agent_size
# 检查是否完成
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if result_file.exists():
with open(result_file, encoding="utf-8") as f:
result = json.load(f)
yield f"data: {sse_handler.escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
break
time.sleep(0.5)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@web_bp.route("/api/submit-financial/<session_id>", methods=["POST"])
def submit_financial(session_id: str) -> Any:
"""手动触发财务系统填报"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
config_path = session_dir / "config.json"
if not config_path.exists():
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if result_file.exists():
result_file.unlink()
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
submit_result = pipeline_web.run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {"ok": True}
else:
result = submit_result
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
f,
ensure_ascii=False,
)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
# ================================================================
# Agent 交互 API
# ================================================================
@web_bp.route("/api/agent/state/<session_id>", methods=["GET"])
def get_agent_state(session_id: str) -> Any:
"""获取 Agent 会话状态"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
from src.agent import load_agent_state
session = load_agent_state(session_dir)
if session is None:
return jsonify({"error": "未找到 Agent 状态,请先处理"}), 404
return jsonify(session.to_dict())
@web_bp.route("/api/agent/process/<session_id>", methods=["POST"])
def agent_process(session_id: str) -> Any:
"""启动 Agent 多轮处理流程"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
config = _build_web_config(body)
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
from src.agent import (
AgentSession,
AgentState,
load_agent_state,
run_agent_round,
save_agent_state,
)
from src.doc.extractor import extract_invoices
from src.doc.invoice import (
save_application_json,
save_invoice_csv,
)
from src.doc.invoice import (
save_csv as save_payment_csv,
)
agent_session = load_agent_state(session_dir)
if agent_session is None:
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
result = {"ok": False, "error": "未提取到任何发票数据"}
return
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
pipeline_web.save_invoice_groups(session_dir, groups)
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
agent_session = AgentSession(
session_id=session_id,
invoice_type="travel" if is_travel else "normal",
)
save_agent_state(session_dir, agent_session)
agent_session = run_agent_round(session_dir, agent_session)
if agent_session.state == AgentState.READY:
# Agent 校验通过,直接触发财务提交
_emit_ready_and_submit(session_dir, agent_session, config)
elif agent_session.state == AgentState.AWAITING_SUPPLEMENT:
result = {
"ok": True,
"agent_ready": False,
"agent_state": agent_session.state.value,
"round": agent_session.rounds,
"waiting_for_supplement": True,
}
elif agent_session.state == AgentState.ERROR:
result = {
"ok": False,
"error": agent_session.error_message,
}
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if not result_file.exists():
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@web_bp.route("/api/agent/supplement/<session_id>", methods=["POST"])
def agent_supplement(session_id: str) -> Any:
"""用户补充文件后触发新一轮 Agent 处理"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
filenames = body.get("files", [])
if not filenames:
return jsonify({"error": "未指定补充文件"}), 400
from src.agent import (
AgentState,
add_supplement,
load_agent_state,
run_agent_round,
)
agent_session = load_agent_state(session_dir)
if agent_session is None:
return jsonify({"error": "未找到 Agent 状态"}), 404
agent_session = add_supplement(session_dir, agent_session, filenames)
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
from src.doc.extractor import extract_invoices
from src.doc.invoice import (
save_application_json,
save_invoice_csv,
)
from src.doc.invoice import (
save_csv as save_payment_csv,
)
invoices, applications, groups = extract_invoices(str(session_dir))
save_payment_csv(invoices, session_dir / "payment_records.csv")
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
if applications:
save_application_json(applications, session_dir / "travel_applications.json")
pipeline_web.save_invoice_groups(session_dir, groups)
new_session = run_agent_round(session_dir, agent_session, new_files=filenames)
if new_session.state == AgentState.READY:
# Agent 校验通过,直接触发财务提交
config_path = session_dir / "config.json"
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
_emit_ready_and_submit(session_dir, new_session, config)
elif new_session.state == AgentState.AWAITING_SUPPLEMENT:
result = {
"ok": True,
"agent_ready": False,
"agent_state": new_session.state.value,
"round": new_session.rounds,
"waiting_for_supplement": True,
}
elif new_session.state == AgentState.ERROR:
result = {
"ok": False,
"error": new_session.error_message,
}
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if result.get("error") and not result_file.exists():
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@web_bp.route("/api/agent/user-supplement/<session_id>", methods=["POST"])
def agent_user_supplement(session_id: str) -> Any:
"""用户通过文字补充信息,LLM 分析后更新 JSON,重新校验"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
user_text = body.get("text", "").strip()
if not user_text:
return jsonify({"error": "请输入补充信息"}), 400
from src.agent import (
AgentState,
load_agent_state,
process_user_text_supplement,
)
agent_session = load_agent_state(session_dir)
if agent_session is None:
return jsonify({"error": "未找到 Agent 状态"}), 404
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
new_session = process_user_text_supplement(session_dir, agent_session, user_text)
if new_session.state == AgentState.READY:
# Agent 校验通过,直接触发财务提交
config_path = session_dir / "config.json"
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
_emit_ready_and_submit(session_dir, new_session, config)
elif new_session.state == AgentState.AWAITING_SUPPLEMENT:
result = {
"ok": True,
"agent_ready": False,
"agent_state": new_session.state.value,
"round": new_session.rounds,
"waiting_for_supplement": True,
}
elif new_session.state == AgentState.ERROR:
result = {
"ok": False,
"error": new_session.error_message,
}
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if not result_file.exists():
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@web_bp.route("/api/agent/force-submit/<session_id>", methods=["POST"])
def agent_force_submit(session_id: str) -> Any:
"""用户强制提交,跳过校验"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
from src.agent import (
force_submit,
load_agent_state,
)
agent_session = load_agent_state(session_dir)
if agent_session is None:
return jsonify({"error": "未找到 Agent 状态"}), 404
agent_session = force_submit(session_dir, agent_session)
config_path = session_dir / "config.json"
if not config_path.exists():
return jsonify({"error": "未找到配置"}), 400
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if result_file.exists():
result_file.unlink()
handler = sse_handler.install_log_collector(session_dir)
def _run() -> None:
result = {"ok": False, "error": "未知错误"}
try:
submit_result = pipeline_web.run_financial_submit(session_dir, config)
if submit_result.get("ok"):
result = {"ok": True}
else:
result = submit_result
except BaseException as e:
result = {"ok": False, "error": str(e)}
if isinstance(e, KeyboardInterrupt | SystemExit):
raise
finally:
try:
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
f,
ensure_ascii=False,
)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
+84
View File
@@ -0,0 +1,84 @@
"""
SSE 日志流处理
负责:
- 日志收集器安装/卸载
- SSE 数据转义
"""
import logging
import threading
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
SESSION_LOG_FILE = "session.log"
# 需要监控日志的模块名称列表
LOG_TARGET_NAMES = [
"extractor",
"llm_extractor",
"matcher",
"pipeline",
"bot",
"fill_consumable_doc",
"agent",
"validator",
]
class SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)"""
def __init__(self, log_path: Path):
super().__init__()
self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord) -> None:
try:
msg = self.format(record) + "\n"
with self._lock:
self._file.write(msg)
self._file.flush()
except Exception:
pass
def close_file(self) -> None:
try:
self._file.close()
except Exception:
pass
def install_log_collector(session_dir: Path) -> SSELogHandler:
"""安装日志收集器到各模块"""
log_path = session_dir / SESSION_LOG_FILE
fmt = logging.Formatter(
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
"%Y-%m-%d %H:%M:%S",
)
handler = SSELogHandler(log_path)
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
for name in LOG_TARGET_NAMES:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
return handler
def remove_log_collector(handler: SSELogHandler) -> None:
"""卸载日志收集器"""
for name in LOG_TARGET_NAMES:
logging.getLogger(name).removeHandler(handler)
handler.close_file()
def escape_sse(text: str) -> str:
"""SSE 数据转义,同时处理 Windows 行尾 \r\n"""
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
+10 -2
View File
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-11
last_reviewed: 2026-06-12
---
# src/web/static — 静态资源目录
@@ -15,4 +15,12 @@ last_reviewed: 2026-06-11
## 技术栈
原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。
原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。
## 变更记录
- **2026-06-12**:修复配置收集流程的异步时序问题
- `parseConfigFile` 改为返回 Promise,`handleFiles` 和拖拽 `drop` 处理器改为 `async/await`,确保 config.json 解析完成后再执行检查,消息显示顺序正确
- `sendUserMessage` 修复变量名错误(`pendingConfigKeys` → `pendingConfigKey`),修复了配置收集卡死的问题
- 拆分 `checkAutoStart` 为两个函数:`checkAutoStart` 仅做静默检查(由 `syncFiles` 轮询调用),`promptMissingConfig` 负责配置提示(由用户主动上传完成后调用),避免轮询提前触发配置提示导致消息乱序
- 移除所有聊天消息的删除逻辑,聊天窗口保留完整历史(欢迎消息、文件通知、配置交互、处理结果均不删除)
+488 -7
View File
@@ -1,16 +1,241 @@
body { background: #f5f7fa; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
body {
background: #f5f7fa;
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
}
.main-container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.upload-zone {
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
cursor: pointer; transition: all .2s; background: #fff; height: 100px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
.upload-zone .icon {
width: 48px;
height: 48px;
margin-bottom: 8px;
}
.upload-zone .icon img {
width: 100%;
height: 100%;
}
/* 可折叠上传面板(在文档流中,向上推挤聊天窗口) */
.upload-panel {
margin-top: 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 12px;
overflow: hidden;
transition: box-shadow 0.2s;
}
.upload-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
cursor: pointer;
user-select: none;
font-size: 14px;
font-weight: 600;
color: #333;
background: #fafbfc;
transition: background 0.2s;
}
.upload-panel-header:hover {
background: #f0f2ff;
}
.upload-panel-toggle {
font-size: 10px;
color: #999;
transition: transform 0.3s;
}
.upload-panel-toggle.collapsed {
transform: rotate(180deg);
}
.upload-panel-body {
padding: 16px;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
max-height: 400px;
opacity: 1;
overflow: hidden;
}
.upload-panel-body.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.log-container .empty { color: #666; font-style: italic; }
.chat-container {
background: #fff;
border-radius: 12px;
padding: 16px;
flex: 1 1 0;
min-height: 300px;
overflow: hidden;
border: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
gap: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
scroll-behavior: smooth;
padding-bottom: 8px;
}
.chat-input-area {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e8e8e8;
flex-shrink: 0;
}
.chat-input-area input {
flex: 1;
border: 1px solid #ddd;
border-radius: 20px;
padding: 8px 16px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.chat-input-area input:focus {
border-color: #667eea;
}
.chat-input-area button {
border-radius: 20px;
padding: 8px 20px;
font-size: 13px;
white-space: nowrap;
}
.chat-message {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
animation: fadeIn 0.3s ease;
}
.chat-message.user {
flex-direction: row-reverse;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.ai-avatar img {
width: 60%;
height: 60%;
}
.chat-bubble {
max-width: 85%;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.chat-bubble.system {
background: #f0f2f5;
color: #333;
border-bottom-left-radius: 4px;
}
.chat-bubble.processing {
background: #e8f0fe;
color: #1a73e8;
border-bottom-left-radius: 4px;
}
.chat-bubble.success {
background: #e6f4ea;
color: #137333;
border-bottom-left-radius: 4px;
}
.chat-bubble.error {
background: #fce8e6;
color: #c5221f;
border-bottom-left-radius: 4px;
}
.chat-bubble.done {
background: #e6f4ea;
color: #137333;
border-bottom-left-radius: 4px;
font-weight: 500;
}
.chat-bubble.user {
background: #667eea;
color: #fff;
border-bottom-right-radius: 4px;
}
/* 打字指示器 */
.typing-indicator {
display: inline-flex;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #667eea;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
.btn-process { font-size: 17px; padding: 10px 40px; }
.status-badge { font-size: 13px; }
@@ -21,4 +246,260 @@ body { background: #f5f7fa; }
.table-editable td { vertical-align: middle; }
.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; }
.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; }
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
/* ================================================================ */
/* 文件消息样式 */
/* ================================================================ */
.file-bubble {
background: #fafbfc;
border: 1px solid #e8e8e8;
border-bottom-left-radius: 4px;
transition: border-color 0.3s, background 0.3s;
}
.file-bubble.file-processing {
background: #f0f4ff;
border-color: #667eea;
}
.file-bubble.file-done {
background: #f0fff4;
border-color: #28a745;
}
.file-bubble.file-error {
background: #fff5f5;
border-color: #c5221f;
}
.file-name-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.file-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.file-icon img {
width: 100%;
height: 100%;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: #333;
word-break: break-all;
}
.file-status {
display: inline-flex;
align-items: center;
margin-left: 4px;
}
.file-status-icon {
font-size: 14px;
}
/* 文件消息的三点动画(与文件名同一行) */
.file-typing-indicator {
display: inline-flex;
gap: 3px;
align-items: center;
margin-left: 2px;
}
.file-typing-indicator span {
width: 5px;
height: 5px;
border-radius: 50%;
background: #667eea;
animation: fileTyping 1.4s infinite ease-in-out;
}
.file-typing-indicator span:nth-child(1) { animation-delay: 0s; }
.file-typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.file-typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes fileTyping {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* 文件详情区域 */
.file-detail {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #ddd;
font-size: 12px;
line-height: 1.8;
color: #555;
}
.detail-label {
font-weight: 600;
color: #667eea;
}
.detail-value {
color: #333;
}
.detail-value.error-text {
color: #c5221f;
}
/* ================================================================ */
/* LLM 流式聊天气泡样式 */
/* ================================================================ */
.llm-stream-bubble {
background: #f0f4ff;
border: 1px solid #667eea;
border-bottom-left-radius: 4px;
}
.llm-stream-bubble.done {
background: #e6f4ea;
border-color: #28a745;
}
.llm-stream-bubble.error {
background: #fce8e6;
border-color: #c5221f;
}
.llm-stream-label {
font-size: 12px;
font-weight: 600;
color: #667eea;
margin-bottom: 6px;
}
.llm-stream-text {
font-size: 13px;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
/* 可折叠的思考过程区域 */
.llm-reasoning-section {
margin: 6px 0;
padding: 4px 0;
border-top: 1px dashed #ddd;
border-bottom: 1px dashed #ddd;
}
.llm-reasoning-summary {
font-size: 12px;
font-weight: 600;
color: #888;
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 4px;
}
.llm-reasoning-summary::-webkit-details-marker {
display: none;
}
.llm-reasoning-summary::before {
content: '▶';
font-size: 8px;
display: inline-block;
transition: transform 0.2s;
}
.llm-reasoning-section[open] > .llm-reasoning-summary::before {
transform: rotate(90deg);
}
.llm-reasoning-text {
font-size: 12px;
line-height: 1.5;
color: #777;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
padding: 4px 8px;
margin-top: 4px;
background: #fafbfc;
border-radius: 4px;
}
/* ================================================================ */
/* Agent 请求面板样式 */
/* ================================================================ */
.agent-request-panel {
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border: 1px solid #ffc107;
border-radius: 12px;
padding: 14px 16px;
margin: 8px 0;
animation: fadeIn 0.3s ease;
}
.agent-request-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #856404;
margin-bottom: 10px;
}
.agent-request-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.agent-request-icon img {
width: 100%;
height: 100%;
}
.agent-request-materials {
font-size: 13px;
color: #856404;
margin-bottom: 12px;
line-height: 1.6;
}
.material-tag {
display: inline-block;
background: #ffc107;
color: #000;
border-radius: 4px;
padding: 2px 10px;
margin: 2px 4px 2px 0;
font-size: 12px;
font-weight: 500;
}
.agent-request-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.agent-request-actions .btn {
font-size: 13px;
padding: 6px 16px;
}
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319741857" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1691" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M690.185 275.766c88.365 0 160 71.634 160 160v239.488c0 88.365-71.635 160-160 160H339.766c-88.366 0-160-71.635-160-160V435.766c0-88.366 71.634-160 160-160h350.419z m-350.419 64c-53.02 0-96 42.98-96 96v239.488c0 53.019 42.98 96 96 96h350.419c53.019 0 96-42.981 96-96V435.766c0-53.02-42.981-96-96-96H339.766z m56.28 120.882c52.056 0.001 94.256 42.201 94.256 94.257 0 52.057-42.2 94.256-94.256 94.256s-94.257-42.199-94.257-94.256c0-52.056 42.2-94.257 94.257-94.257z m240.348 0c52.056 0.001 94.255 42.201 94.255 94.257 0 52.057-42.199 94.256-94.255 94.256-52.057 0-94.257-42.199-94.257-94.256 0-52.056 42.2-94.257 94.257-94.257zM128 481.562c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m769.113 0c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m-501.067 43.086c-16.71 0-30.257 13.547-30.257 30.257s13.547 30.256 30.257 30.256 30.256-13.546 30.256-30.256-13.546-30.256-30.256-30.257z m240.348 0c-16.711 0-30.257 13.547-30.257 30.257s13.546 30.256 30.257 30.256c16.71 0 30.255-13.546 30.255-30.256s-13.545-30.256-30.255-30.257zM556.931 192c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32h-83.908c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32h83.908z" fill="#1296db" p-id="1692"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319878861" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10502" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M716.8 704h-51.2v-25.6h38.4V140.8H320v38.4h-25.6v-51.2a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8zM806.4 614.4h-51.2v-25.6h38.4V230.4h25.6v371.2a12.8 12.8 0 0 1-12.8 12.8zM793.6 179.2h25.6v25.6h-25.6z" fill="#1296db" p-id="10503"></path><path d="M819.2 153.6h-25.6V51.2H409.6v38.4h-25.6V38.4a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v115.2zM627.2 793.6H217.6a12.8 12.8 0 0 1-12.8-12.8V217.6a12.8 12.8 0 0 1 12.8-12.8h243.2v25.6H230.4v537.6h384V230.4h-76.8v-25.6h89.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8z" fill="#1296db" p-id="10504"></path><path d="M486.4 204.8h25.6v25.6h-25.6zM268.8 448h307.2v25.6H268.8zM268.8 524.8h307.2v25.6H268.8zM512 601.6h64v25.6h-64zM460.8 601.6h25.6v25.6h-25.6zM268.8 601.6h166.4v25.6H268.8zM268.8 678.4h307.2v25.6H268.8zM268.8 294.4h307.2v25.6H268.8zM409.6 371.2h166.4v25.6H409.6zM358.4 371.2h25.6v25.6h-25.6zM268.8 371.2h64v25.6h-64z" fill="#1296db" p-id="10505"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319817389" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6095" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M912 208H427.872l-50.368-94.176A63.936 63.936 0 0 0 321.056 80H112c-35.296 0-64 28.704-64 64v736c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-608c0-35.296-28.704-64-64-64z m-800-64h209.056l68.448 128H912v97.984c-0.416 0-0.8-0.128-1.216-0.128H113.248c-0.416 0-0.8 0.128-1.248 0.128V144z m0 736v-96l1.248-350.144 798.752 1.216V784h0.064v96H112z" fill="#1296db" p-id="6096"></path></svg>

After

Width:  |  Height:  |  Size: 727 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319921047" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14940" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M746.6496 198.4a129.6384 129.6384 0 0 1 129.536 124.0064l0.1024 5.632v367.9232a129.6384 129.6384 0 0 1-124.0064 129.536l-5.632 0.1024H306.9184a129.6384 129.6384 0 0 1-129.536-124.0064l-0.128-5.632v-57.9328c0-7.4752 2.6368-14.6944 7.3728-20.4032l2.5344-2.7136 131.84-126.0544a32 32 0 0 1 35.4304-5.9648l3.1488 1.664 116.0704 69.5808 74.7008-66.0992a32 32 0 0 1 35.072-4.864l3.072 1.6896 177.1008 110.6688a32 32 0 0 1-30.848 55.9616l-3.072-1.6896-156.8256-97.9968-74.3424 65.792a32 32 0 0 1-34.6112 5.12l-3.072-1.6384-115.2512-69.0944-104.32 99.712v44.2624a65.6384 65.6384 0 0 0 61.4912 65.5104l4.1728 0.128h439.7312a65.6384 65.6384 0 0 0 65.5104-61.4912l0.128-4.1472V328.0384a65.6384 65.6384 0 0 0-61.4912-65.5104l-4.1472-0.128H306.9184a65.6384 65.6384 0 0 0-65.536 61.4912l-0.128 4.1472v134.3488a32 32 0 0 1-63.8208 3.2768l-0.1792-3.2768v-134.3488a129.6384 129.6384 0 0 1 124.032-129.536l5.632-0.1024h439.7312z" fill="#FB553C" p-id="14941"></path><path d="M692.5312 398.3104m-47.4112 0a47.4112 47.4112 0 1 0 94.8224 0 47.4112 47.4112 0 1 0-94.8224 0Z" fill="#FB553C" p-id="14942"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319971806" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19425" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M820.409449 797.228346q0 25.19685-10.07874 46.866142t-27.716535 38.299213-41.322835 26.204724-50.897638 9.574803l-357.795276 0q-27.212598 0-50.897638-9.574803t-41.322835-26.204724-27.716535-38.299213-10.07874-46.866142l0-675.275591q0-25.19685 10.07874-47.370079t27.716535-38.80315 41.322835-26.204724 50.897638-9.574803l357.795276 0q27.212598 0 50.897638 9.574803t41.322835 26.204724 27.716535 38.80315 10.07874 47.370079l0 675.275591zM738.771654 170.330709l-455.559055 0 0 577.511811 455.559055 0 0-577.511811zM510.992126 776.062992q-21.165354 0-36.787402 15.11811t-15.622047 37.291339q0 21.165354 15.622047 36.787402t36.787402 15.622047q22.173228 0 37.291339-15.622047t15.11811-36.787402q0-22.173228-15.11811-37.291339t-37.291339-15.11811zM591.622047 84.661417q0-8.062992-5.03937-12.598425t-11.086614-4.535433l-128 0q-5.03937 0-10.582677 4.535433t-5.543307 12.598425 5.03937 12.598425 11.086614 4.535433l128 0q6.047244 0 11.086614-4.535433t5.03937-12.598425z" p-id="19426" fill="#1296db"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

+186
View File
@@ -0,0 +1,186 @@
---
last_reviewed: 2026-06-16
---
# src/web/static/js — 前端逻辑模块
## 概述
财务报销自动化系统的前端入口脚本,按功能拆分为多个模块文件,通过 `App` 共享状态对象通信。采用原生 JavaScript 编写,通过 HTTP 请求与 Flask 后端通信,使用 SSE 接收处理进度。
## 文件结构
| 文件 | 职责 |
|------|------|
| `state.js` | 全局状态管理 (`App` 对象,含处理状态机) |
| `utils.js` | 工具函数 (HTML 转义、会话管理) |
| `chat.js` | 聊天窗口消息管理(含文件消息、处理进度、三点动画、LLM 流式气泡) |
| `upload.js` | 文件上传(含拖拽、config.json 解析) |
| `config.js` | 配置收集与校验(基于状态机的自动触发) |
| `process.js` | 发票处理流程与 SSE 监听(含 file_progress、llm_stream 事件处理) |
| `agent.js` | Agent 交互(含 supplement 流程、强制提交、用户文字补充、自动提交) |
| `sync.js` | 移动端同步轮询(状态感知模式) |
| `index.js` | 入口初始化 |
## 加载顺序
```
state.js → utils.js → chat.js → upload.js → config.js → process.js → sync.js → index.js
```
## 模块依赖关系
```
state.js (无依赖)
│
├── utils.js ────────────────┐
│ │
├── chat.js ─────────────────┐ │
│ │ │
├── upload.js ──────────────┐ │ │
│ │ │ │
├── config.js ─────────────┐ │ │ │
│ │ │ │ │
├── process.js ───────────┐ │ │ │ │
│ │ │ │ │ │
├── sync.js ──────────┐ │ │ │ │ │
│ │ │ │ │ │ │
└── index.js ────────┘ │ │ │ │ │ │
```
## 处理状态机
`App.processState` 控制何时触发自动处理,避免重复执行:
```mermaid
stateDiagram-v2
[*] --> idle
idle --> processing: 配置完整 + 有文件
processing --> done: 处理完成
processing --> awaiting_supplement: Agent 要求补充材料
done --> idle: 有新文件 / forceStart = true
done --> done: 无新文件(保持,不重复)
awaiting_supplement --> idle: 用户补充文件(只上传新文件)
idle --> processing: idle + newFilenames > 0(走 supplement 流程)
idle --> awaiting_files: AI 判断文件不足(未来功能)
awaiting_files --> idle: 用户补充文件
awaiting_files --> processing: 用户说"直接开始" (forceStart = true)
```
状态字段说明:
| 字段 | 类型 | 说明 |
|------|------|------|
| `processState` | string | 当前处理状态:idle / processing / done / awaiting_files / awaiting_supplement |
| `lastProcessedFileCount` | number | 上次处理时的文件数,用于检测是否有新文件 |
| `forceStart` | boolean | 用户强制开始标记(兼容"文件不足也要处理"的场景) |
| `newFilenames` | array | 待补充上传的文件名列表,用于区分首次处理和补充处理 |
## 核心流程
### 1. 文件上传
```
用户上传文件 → addFileMessage() → 注册到 App.fileMessageMap → promptMissingConfig()
→ processState 为 done 时重置为 idle(支持增量上传后重新处理)
```
### 2. 配置收集
```
promptMissingConfig() → 检查 CONFIG_FIELDS → 有缺失则提示 → 用户输入 → sendUserMessage() → 递归直到完整
```
### 3. 自动触发检查 (checkAutoStart)
```
checkAutoStart() → 状态机判断:
- processing → 跳过
- done + 无新文件 → 跳过(修复重复执行问题)
- done + 有新文件 → 走 supplement 流程(不再走 startProcess)
- idle + newFilenames > 0 → 走 supplement 流程
- idle + 配置完整 + 有文件 → 走 startProcess(首次处理)
- awaiting_files + forceStart → 触发
```
### 4. 发票处理(首次)
```
startProcess() → ensureSession → 上传全部文件 → /api/agent/process → SSE 监听
→ 收到 file_progress(processing) → setFileProcessing()(三点动画)
→ 收到 file_progress(done) → setFileDone()(展示提取摘要)
→ 收到 file_progress(cached) → setFileCached()
→ 收到 file_progress(error) → setFileError()
→ 收到 llm_stream(start) → handleLLMStream() 创建流式气泡
→ 收到 llm_stream(chunk) → handleLLMStream() 逐字追加 AI 输出
→ 收到 llm_stream(end) → handleLLMStream() 关闭流式气泡
→ 收到 done → 设置 processState = 'done' + 记录文件数 → 显示汇总结果
```
### 5. 补充文件处理(新增)
当 Agent 返回 `awaiting_supplement` 状态时,用户补充文件的流程:
```
用户上传补充文件
→ handleFiles() 检测 processState === 'awaiting_supplement'
→ _uploadNewFiles() 只上传新文件(不再重新上传所有文件)
→ App.newFilenames = [新文件名列表]
→ App.processState = 'idle'
→ syncFiles() 3 秒后触发 checkAutoStart()
→ checkAutoStart() 检测到 idle + newFilenames > 0
→ handleSupplementUpload(filenames)
→ POST /api/agent/supplement/(后端会重新 extract_invoices,旧文件走缓存)
→ 新文件走 LLM 提取,旧文件命中 JSON 缓存直接返回
→ run_agent_round() 重新分析
→ SSE 返回 done → 根据结果进入 awaiting_supplement 或 done 状态
```
**关键区别**:补充文件走 `/api/agent/supplement/` 而非 `/api/agent/process/`,后者在已有 agent_session 时会跳过提取阶段。
## 数据流
### 配置数据流
```
config.json → parseConfigFile → App.sessionConfig → startProcess → /api/agent/process
```
### 发票数据流(首次处理)
```
发票文件 → startProcess → /api/upload → /api/agent/process → SSE file_events.log → 文件消息实时更新
```
### 发票数据流(补充文件)
```
新发票文件 → _uploadNewFiles() → /api/upload → /api/agent/supplement
→ extract_invoices()(旧文件走缓存,新文件走 LLM)
→ run_agent_round() → SSE file_events.log → 文件消息实时更新
```
### 文件消息状态机
```
addFileMessage(filename)
→ file_progress(processing) → 三点动画(同一行)
→ file_progress(done/cached/error) → 详情展开 + 状态图标
```
## 变更记录
- **2026-06-16**:修复补充文件重复上传问题 — 新增 `newFilenames` 状态字段,补充文件时只上传新文件并走 `/api/agent/supplement/` 接口。`upload.js` 新增 `_uploadNewFiles()` 辅助函数。`config.js` 的 `checkAutoStart()` 在 `done` 和 `idle` 状态下有 `newFilenames` 时走 supplement 流程而非 `startProcess()`
- **2026-06-16**:新增 LLM 流式思考展示 — `chat.js` 新增 `handleLLMStream` 状态机(start/chunk/end/error 四阶段),`process.js` 的 SSE 监听增加 `llm_stream` 事件分支,AI 思考过程以聊天气泡逐字展示
- **2026-06-16**:引入处理状态机 — 新增 `processState` / `lastProcessedFileCount` / `forceStart` 状态字段,修复 `syncFiles` 轮询每 3 秒重复触发处理的 bug。`checkAutoStart` 改为基于状态机判断,done 状态下只有检测到新文件才重新触发。为未来 AI 判断文件是否充足预留了 `awaiting_files` 状态和 `forceStart` 标记
- **2026-06-12**:上传区域可折叠 — 新增 `toggleUploadPanel()` 函数,配合 CSS `max-height` 动画实现面板收起/展开
- **2026-06-12**:文件消息框对齐到用户侧 — `addFileMessage` 的 wrapper 新增 `user` 类,上传的文件消息显示在右侧(用户侧),与 agent 消息区分
- **2026-06-12**:配置完成增加互动提示 — `promptMissingConfig` 在配置完整时发送系统消息告知用户即将开始处理发票
- **2026-06-14**:新增文件进度实时反馈 — 后端 extractor 在每处理一个文件时产生 SSE 进度事件,前端根据文件名定位 DOM 并更新状态(处理中三点动画、完成摘要展示、缓存/错误状态)
- **2026-06-14**:移除发票编辑和财务提交流程 — 删除 table.js、financial.js 及相关 HTML 区域,后续开发不再支持手动编辑发票信息
- **2026-06-12**:模块拆分重构 — 将 index.js (625 行) 拆分为 10 个独立模块,引入 `App` 共享状态对象
- **2026-06-12**:修复配置收集流程的异步时序问题 — parseConfigFile 改为 Promise,拆分 checkAutoStart 为静默检查/配置提示两个函数
+439
View File
@@ -0,0 +1,439 @@
/**
* Agent 交互模块
*/
import { App } from './state.js';
import { escapeHtml } from './utils.js';
import { addChatMessage, showStatus } from './chat.js';
// ================================================================
// Agent 事件处理
// ================================================================
/**
* 处理 Agent SSE 事件
*
* @param {Object} msg - SSE 传来的 agent 事件对象
*/
export function handleAgentEvent(msg) {
switch (msg.type) {
case 'agent_state_change':
_handleAgentStateChange(msg);
break;
case 'agent_request_supplement':
_handleAgentRequestSupplement(msg);
break;
case 'agent_ready':
_handleAgentReady(msg);
break;
case 'agent_error':
_handleAgentError(msg);
break;
case 'agent_supplement_received':
_handleSupplementReceived(msg);
break;
case 'agent_force_submit':
_handleForceSubmit(msg);
break;
}
}
/**
* 状态变更通知 — 使用瞬态消息,状态变化时更新而非追加
*/
function _handleAgentStateChange(msg) {
const stateLabels = {
'extracting': '正在分析文件...',
'validating': '正在校验信息完整性...',
'awaiting_supplement': '等待补充材料',
'ready': '信息完整,可以提交',
'submitting': '正在提交...',
};
const label = stateLabels[msg.state] || msg.message || '处理中...';
if (msg.state === 'ready') {
showStatus(label, 'done');
} else {
showStatus(label);
}
}
/**
* 请求补充材料 — 仅更新瞬态状态,持久提示消息由 done 事件分支负责
*/
export function _handleAgentRequestSupplement(msg) {
const suggestion = msg.suggestion || '请补充上传相关材料';
showStatus(suggestion);
}
/**
* 信息完整 — 已由 process.js 的 done 处理覆盖,此处不再重复显示
*/
function _handleAgentReady(msg) {
// agent_ready 事件的信息展示统一由 process.js done 分支处理
}
/**
* 自动触发财务系统提交(由 done 事件调用)
*/
export async function handleAutoSubmit() {
if (!App.sessionId) {
addChatMessage('请先上传文件并处理', 'error');
return;
}
if (App.agentEventSource) {
App.agentEventSource.close();
App.agentEventSource = null;
}
App.isProcessing = true;
App.processState = 'submitting';
try {
showStatus('正在自动提交到财务系统...');
const response = await fetch(`/api/submit-financial/${App.sessionId}`, {
method: 'POST',
});
const result = await response.json();
if (result.status === 'started') {
const es = new EventSource(`/api/logs/${App.sessionId}`);
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
App.isProcessing = false;
App.processState = 'done';
if (msg.result.submit_ok) {
addChatMessage('提交完成!', 'done');
} else {
addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
}
}
} catch (err) {
// 普通日志行
}
});
es.onerror = () => {
es.close();
App.isProcessing = false;
App.processState = 'done';
addChatMessage('连接中断', 'error');
};
} else if (result.error) {
App.isProcessing = false;
App.processState = 'done';
addChatMessage(`提交失败:${result.error}`, 'error');
}
} catch (e) {
App.isProcessing = false;
App.processState = 'done';
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
/**
* Agent 错误
*/
function _handleAgentError(msg) {
addChatMessage(msg.message || 'Agent 处理出错', 'error');
App.isProcessing = false;
App.processState = 'done';
}
/**
* 收到补充文件
*/
function _handleSupplementReceived(msg) {
const fileNames = (msg.files || []).join('、');
addChatMessage(`已收到补充文件:${fileNames}`, 'done');
}
/**
* 强制提交
*/
function _handleForceSubmit(msg) {
showStatus('已跳过校验,开始提交到财务系统');
}
// ================================================================
// Agent 请求面板
// ================================================================
/**
* 显示 Agent 请求补充材料的 UI
*/
export function showAgentRequest(data) {
const existing = document.getElementById('agent-request-panel');
if (existing) existing.remove();
const chatMessages = document.getElementById('chat-messages');
if (!chatMessages) return;
const panel = document.createElement('div');
panel.id = 'agent-request-panel';
panel.className = 'agent-request-panel';
let materialsHtml = '';
if (data.missing_materials && data.missing_materials.length > 0) {
materialsHtml = `
<div class="agent-request-materials">
<strong>需要补充:</strong>
${data.missing_materials.map(m => `<span class="material-tag">${escapeHtml(m)}</span>`).join(' ')}
</div>
`;
}
panel.innerHTML = `
<div class="agent-request-header">
<span class="agent-request-icon"><img src="/static/icon/file.svg" alt=""></span>
<span>第 ${data.round || 1} 轮分析 - 信息不完整</span>
</div>
${materialsHtml}
<div class="agent-request-actions">
<button class="btn btn-sm btn-primary" onclick="document.getElementById('file-input').click()">
上传补充材料
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="handleForceSubmit()">
直接提交
</button>
</div>
`;
chatMessages.appendChild(panel);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
/**
* 移除 Agent 请求面板
*/
export function hideAgentRequest() {
const panel = document.getElementById('agent-request-panel');
if (panel) panel.remove();
}
// ================================================================
// 用户文字补充
// ================================================================
/**
* 处理用户通过输入框补充的文字信息
*
* @param {string} text - 用户输入的文字
*/
export async function handleUserSupplement(text) {
if (!App.sessionId) return;
try {
showStatus('正在解析补充信息...');
const response = await fetch(`/api/agent/user-supplement/${App.sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text }),
});
const result = await response.json();
if (result.status === 'started') {
showStatus('补充信息已收到,正在重新分析...');
const es = new EventSource(`/api/logs/${App.sessionId}`);
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type && msg.type.startsWith('agent_')) {
handleAgentEvent(msg);
return;
}
if (msg.type === 'done') {
es.close();
App.isProcessing = false;
if (msg.result.waiting_for_supplement) {
App.processState = 'awaiting_supplement';
_handleAgentRequestSupplement(msg.result);
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
} else {
App.processState = 'done';
if (msg.result.ok) {
if (msg.result.submit_ok !== false) {
addChatMessage('信息完整,已自动提交到财务系统', 'done');
} else {
addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
}
} else {
addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error');
}
}
}
} catch (err) {
// 普通日志
}
});
es.onerror = () => {
es.close();
App.isProcessing = false;
App.processState = 'done';
addChatMessage('连接中断', 'error');
};
} else if (result.error) {
addChatMessage(`处理失败:${result.error}`, 'error');
}
} catch (e) {
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
// ================================================================
// 用户输入处理
// ================================================================
/**
* 处理强制提交
*/
export async function handleForceSubmit() {
if (!App.sessionId) {
addChatMessage('请先上传文件并处理', 'error');
return;
}
if (App.agentEventSource) {
App.agentEventSource.close();
App.agentEventSource = null;
}
App.forceSubmitting = true;
try {
showStatus('正在跳过校验并提交...');
hideAgentRequest();
const response = await fetch(`/api/agent/force-submit/${App.sessionId}`, {
method: 'POST',
});
const result = await response.json();
if (result.status === 'started') {
showStatus('已跳过校验,开始提交到财务系统');
App.isProcessing = true;
App.processState = 'submitting';
const es = new EventSource(`/api/logs/${App.sessionId}`);
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
App.isProcessing = false;
App.processState = 'done';
App.forceSubmitting = false;
App.lastProcessedFileCount = App.allFiles.length;
if (msg.result.ok) {
addChatMessage('提交完成!', 'done');
} else {
addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
}
}
} catch (err) {
// 普通日志行
}
});
es.onerror = () => {
es.close();
App.isProcessing = false;
App.processState = 'done';
App.forceSubmitting = false;
App.lastProcessedFileCount = App.allFiles.length;
addChatMessage('连接中断', 'error');
};
}
} catch (e) {
App.forceSubmitting = false;
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
// ================================================================
// 补充文件处理
// ================================================================
/**
* 处理补充文件上传完成后的重新分析
*
* @param {Array<string>} filenames - 新上传的文件名列表
*/
export async function handleSupplementUpload(filenames) {
if (!App.sessionId) return;
try {
showStatus('收到补充文件,正在重新分析...');
hideAgentRequest();
const response = await fetch(`/api/agent/supplement/${App.sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: filenames }),
});
const result = await response.json();
if (result.status === 'started') {
const es = new EventSource(`/api/logs/${App.sessionId}`);
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type && msg.type.startsWith('agent_')) {
handleAgentEvent(msg);
return;
}
if (msg.type === 'done') {
es.close();
App.isProcessing = false;
if (msg.result.waiting_for_supplement) {
App.processState = 'awaiting_supplement';
showStatus('信息不完整,请补充上传相关材料');
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
} else {
App.processState = 'done';
if (msg.result.ok) {
// 后端已自动提交,根据 submit_ok 显示结果
if (msg.result.submit_ok !== false) {
addChatMessage('信息完整,已自动提交到财务系统', 'done');
} else {
addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
}
} else {
addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error');
}
}
}
} catch (err) {
// 普通日志
}
});
es.onerror = () => {
es.close();
App.isProcessing = false;
App.processState = 'done';
addChatMessage('连接中断', 'error');
};
}
} catch (e) {
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* 聊天窗口模块入口
*
* 从 chat/ 子模块重新导出所有公开函数,保持向后兼容。
* 外部模块通过 import 方式使用,HTML 中的 script 标签直接加载本文件。
*/
export {
addChatMessage,
addTypingIndicator,
removeTypingIndicator,
addFileMessage,
setFileProcessing,
setFileDone,
setFileCached,
setFileError,
} from './chat/persistent.js';
// 瞬态消息
export {
showStatus,
clearStatus,
} from './chat/ephemeral.js';
// LLM 流式消息
export {
handleLLMStream,
} from './chat/stream.js';
+198
View File
@@ -0,0 +1,198 @@
# chat/ 模块说明
## 目录
- [架构概览](#架构概览)
- [模块职责](#模块职责)
- [消息类型](#消息类型)
- [数据流](#数据流)
- [状态管理](#状态管理)
- [注意事项](#注意事项)
---
## 架构概览
```
chat/
├── renderer.js # 底层 DOM 渲染工具
├── persistent.js # 持久消息(永久保留在聊天历史中)
├── ephemeral.js # 瞬态消息(只显示最新一条,新状态覆盖旧状态)
└── stream.js # LLM 流式消息(处理中显示思考过程,结束后固化)
chat.js # 入口文件,统一 re-export 所有公开函数
```
设计原则:将**持久消息**和**瞬态消息**分离,避免聊天历史被中间状态消息堆积。
---
## 模块职责
### renderer.js
最底层渲染工具,不维护任何状态。提供:
- `getChatMessages()` — 获取 `#chat-messages` 容器
- `scrollToBottom()` — 滚动到底部
- `createChatBubble(text, type, isUser)` — 创建消息气泡 DOM
- `createSystemBubble(text, type)` — 创建系统气泡 DOM
- `appendMessage(wrapper)` — 将消息追加到容器并滚动
### persistent.js
管理永久保留在聊天历史中的消息:
- `addChatMessage(text, type, isUser)` — 添加普通聊天消息
- `addTypingIndicator()` / `removeTypingIndicator()` — 加载三点动画
- `addFileMessage(filename)` — 添加文件上传消息
- `setFileProcessing(filename)` — 文件处理中状态
- `setFileDone(filename, summary)` — 文件处理完成,展开提取摘要
- `setFileCached(filename)` — 文件使用缓存
- `setFileError(filename, errorMsg)` — 文件处理错误
依赖 `App.fileMessageMap`(定义在 `state.js`)维护文件名到 DOM 元素的映射。
### ephemeral.js
管理瞬态状态消息,同一时刻只显示最新一条:
- `showStatus(text, type)` — 显示/更新状态消息
- `clearStatus()` — 清除当前状态消息
内部维护 `ephemeralState` 对象记录当前活跃的状态气泡引用。重复调用 `showStatus` 时直接更新已有气泡的文本和样式,不创建新 DOM。
### stream.js
管理 LLM 流式响应的气泡生命周期:
- `handleLLMStream(msg)` — 根据 SSE 事件阶段分发处理
支持的阶段:
| phase | 说明 |
|-------|------|
| `start` | 创建流式气泡,初始化思考过程区域(默认展开) |
| `reasoning` | 追加思考过程文本 |
| `chunk` | 追加正式回复文本 |
| `end` | 关闭气泡,切换为 `done` 样式,折叠思考过程区域,气泡保留在历史中 |
| `error` | 切换为 `error` 样式,显示错误信息 |
内部维护 `llmStreamState` 对象记录当前活跃的流式气泡引用。
---
## 消息类型
### 持久消息
永久保留在聊天历史中,不会被自动清除:
- 用户输入的文字
- 文件上传记录及其处理状态
- LLM 流式响应的最终结果(`end` 阶段后固化)
- 系统通知(如"信息完整,可以提交")
- 错误消息
调用 `addChatMessage()` 或 `addFileMessage()` 创建。
### 瞬态消息
只显示最新一条,新状态覆盖旧状态:
- "正在分析文件..."
- "正在校验信息完整性..."
- "请输入登录账号:"
- "配置信息已完整,开始处理发票……"
- "收到补充文件,正在重新分析..."
调用 `showStatus()` 创建/更新,调用 `clearStatus()` 清除。
---
## 数据流
```
外部模块 (agent.js / config.js / process.js / upload.js / sync.js)
│
├── import { addChatMessage, addFileMessage, ... } from './chat.js'
├── import { showStatus, clearStatus } from './chat.js'
└── import { handleLLMStream } from './chat.js'
│
▼
chat.js (re-export)
│
├── → chat/persistent.js ──→ chat/renderer.js
├── → chat/ephemeral.js ──→ chat/renderer.js
└── → chat/stream.js ──→ chat/renderer.js
```
- 外部模块统一从 `chat.js` 导入函数
- `chat.js` 只做 re-export,不引入循环依赖
- 三个子模块通过 `renderer.js` 共享底层 DOM 操作
- `persistent.js` 额外依赖 `state.js` 的 `App.fileMessageMap`
---
## 状态管理
### ephemeralState (ephemeral.js)
```js
{
wrapper: HTMLElement | null, // 状态消息的 wrapper 元素
bubble: HTMLElement | null, // 状态气泡元素
}
```
- 初始为 `null`
- `showStatus` 首次调用时创建并记录引用
- 后续调用直接更新 `bubble.textContent` 和 `bubble.className`
- `clearStatus` 时移除 DOM 并重置为 `null`
### llmStreamState (stream.js)
```js
{
wrapper: HTMLElement | null, // 流式消息 wrapper
bubble: HTMLElement | null, // 流式气泡
textContent: HTMLElement | null, // 正式文本容器
accumulated: string, // 累积的正式文本
reasoningContent: HTMLElement | null, // 思考过程容器
reasoningAccumulated: string, // 累积的思考文本
}
```
- `start` 阶段创建并记录引用
- `chunk` / `reasoning` 阶段追加文本
- `end` / `error` 阶段重置为 `null`(DOM 保留在历史中)
### App.fileMessageMap (state.js)
```js
Map<string, { wrapper, bubble, nameRow, detailRow }>
```
- 文件名 → DOM 元素映射
- `addFileMessage` 创建时写入
- `setFileProcessing` / `setFileDone` / `setFileCached` / `setFileError` 读取并更新对应文件的状态
---
## 注意事项
1. **不要直接操作 `#chat-messages` 容器**。所有消息创建都通过本模块的 API 进行。
2. **瞬态消息和持久消息不要混用**。中间处理状态用 `showStatus`,最终结果用 `addChatMessage`。
3. **`showStatus` 不需要手动清除**。调用 `showStatus` 显示新状态时会自动覆盖旧状态;在处理流程结束时,后续的消息或状态会自然覆盖。
4. **LLM 流式气泡在 `end` 阶段后变为持久消息**。不需要额外调用 `addChatMessage` 来保留结果。
5. **SSE 事件必须包含 `start` 和 `end` 阶段**。缺少 `start` 会导致气泡未创建,缺少 `end` 会导致气泡一直处于处理中状态。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`。
6. **`handleLLMStream` 不处理普通日志行**。SSE 的 `message` 事件中,只有 `type === 'llm_stream'` 的事件才会被转发到此模块。
7. **文件消息的 DOM 生命周期由 `fileMessageMap` 管理**。文件处理完成后,摘要信息会展开显示在文件气泡下方。
8. **所有模块通过 `chat.js` 统一导入**,不要直接从 `chat/` 子目录导入(外部模块层面)。子模块之间的内部导入不受此限制。
+62
View File
@@ -0,0 +1,62 @@
/**
* 瞬态消息管理器
*
* 瞬态消息只保留最新一条,新状态覆盖旧状态,不会堆积在聊天历史中。
*/
import { escapeHtml } from '../utils.js';
import { getChatMessages, scrollToBottom } from './renderer.js';
/**
* 当前活跃的瞬态消息 DOM 引用
*/
let ephemeralState = {
wrapper: null,
bubble: null,
};
/**
* 显示瞬态状态消息
*
* 同一时刻只保留一条状态消息,调用此函数会更新已有消息或创建新消息。
*
* @param {string} text - 状态文本
* @param {string} [type='processing'] - 消息类型
*/
export function showStatus(text, type) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
if (ephemeralState.bubble) {
ephemeralState.bubble.textContent = escapeHtml(text);
ephemeralState.bubble.className = 'chat-bubble ' + (type || 'processing');
scrollToBottom();
return;
}
const wrapper = document.createElement('div');
wrapper.className = 'chat-message';
wrapper.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble ${type || 'processing'}">${escapeHtml(text)}</div>
`;
chatMessages.appendChild(wrapper);
scrollToBottom();
ephemeralState = {
wrapper,
bubble: wrapper.querySelector('.chat-bubble'),
};
}
/**
* 清除瞬态状态消息
*/
export function clearStatus() {
if (ephemeralState.wrapper) {
ephemeralState.wrapper.remove();
}
ephemeralState = {
wrapper: null,
bubble: null,
};
}
+246
View File
@@ -0,0 +1,246 @@
/**
* 持久消息管理器
*
* 持久消息永久保留在聊天历史中,包括:
* - 普通聊天消息
* - 文件上传消息及其状态
* - 加载指示器
*/
import { App } from '../state.js';
import { escapeHtml } from '../utils.js';
import { createChatBubble, appendMessage, getChatMessages, scrollToBottom } from './renderer.js';
// ================================================================
// 普通聊天消息
// ================================================================
/**
* 添加聊天消息
*
* @param {string} text - 消息内容
* @param {string} [type='processing'] - 消息类型
* @param {boolean} [isUser=false] - 是否为用户消息
*/
export function addChatMessage(text, type, isUser) {
const wrapper = createChatBubble(text, type, isUser);
appendMessage(wrapper);
}
// ================================================================
// 加载指示器
// ================================================================
/**
* 显示加载指示器(三点动画)
*/
export function addTypingIndicator() {
const chatMessages = getChatMessages();
if (!chatMessages) return;
const indicator = document.createElement('div');
indicator.className = 'chat-message';
indicator.id = 'typing-indicator';
indicator.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble processing">
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
`;
chatMessages.appendChild(indicator);
scrollToBottom();
}
/**
* 移除加载指示器
*/
export function removeTypingIndicator() {
const indicator = document.getElementById('typing-indicator');
if (indicator) indicator.remove();
}
// ================================================================
// 文件消息管理
// ================================================================
/**
* 添加文件消息(上传文件时调用)
*
* @param {string} filename - 文件名
*/
export function addFileMessage(filename) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
const wrapper = document.createElement('div');
wrapper.className = 'chat-message user file-message';
wrapper.dataset.file = filename;
const bubble = document.createElement('div');
bubble.className = 'chat-bubble file-bubble';
const nameRow = document.createElement('div');
nameRow.className = 'file-name-row';
nameRow.innerHTML = `
<span class="file-icon"><img src="/static/icon/file.svg" alt=""></span>
<span class="file-name">${escapeHtml(filename)}</span>
<span class="file-status" style="display:none"></span>
`;
const detailRow = document.createElement('div');
detailRow.className = 'file-detail';
detailRow.style.display = 'none';
bubble.appendChild(nameRow);
bubble.appendChild(detailRow);
wrapper.appendChild(bubble);
chatMessages.appendChild(wrapper);
scrollToBottom();
App.fileMessageMap.set(filename, { wrapper, bubble, nameRow, detailRow });
}
/**
* 设置文件处理中状态
*
* @param {string} filename - 文件名
*/
export function setFileProcessing(filename) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-done', 'file-error');
bubble.classList.add('file-processing');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline-flex';
statusEl.innerHTML = `
<span class="file-typing-indicator">
<span></span><span></span><span></span>
</span>
`;
detailRow.style.display = 'none';
}
/**
* 更新文件消息为完成状态
*
* @param {string} filename - 文件名
* @param {Object} summary - 提取摘要对象
*/
export function setFileDone(filename, summary) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-done');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">OK</span>';
const labelMap = {
card_date: '刷卡日期',
person_id: '人员编号',
person_name: '人员姓名',
invoice_type_label: '类型',
invoice_number: '发票号码',
invoice_date: '开票日期',
ride_date: '乘车日期',
departure: '出发站',
arrival: '到达站',
seat_class: '座位等级',
train_no: '车次',
total_amount: '金额',
amount: '金额',
seller_name: '销售方',
buyer_name: '购买方',
goods_name: '货物/服务',
remark: '备注',
card_no: '卡号',
card_amount: '刷卡金额',
pay_date: '支付日期',
pay_time: '支付时间',
pay_channel: '支付渠道',
transaction_no: '交易单号',
merchant_name: '商户名称',
order_no: '订单号',
project_name: '项目名称',
purpose: '出差事由',
start_date: '开始日期',
end_date: '结束日期',
person_info: '随行人员',
travel_purpose: '出差事由',
travel_from: '出发地',
travel_to: '目的地',
travel_start: '出发日期',
travel_end: '返回日期',
departure_place: '出发地',
arrival_place: '目的地',
hotel_name: '酒店名称',
checkin_date: '入住日期',
checkout_date: '退房日期',
room_type: '房型',
days: '天数',
attachments: '附件',
subsidy_list: '补助清单',
};
let lines = [];
for (const [key, value] of Object.entries(summary)) {
const label = labelMap[key] || key;
const displayValue = escapeHtml(String(value)).replace(/\n/g, '<br>');
lines.push(`<span class="detail-label">${label}:</span><span class="detail-value">${displayValue}</span>`);
}
detailRow.innerHTML = lines.join('<br>');
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
/**
* 更新文件消息为缓存状态
*
* @param {string} filename - 文件名
*/
export function setFileCached(filename) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-done');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">CACHE</span>';
detailRow.innerHTML = '<span class="detail-label">状态:</span><span class="detail-value">使用缓存(已提取)</span>';
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
/**
* 更新文件消息为错误状态
*
* @param {string} filename - 文件名
* @param {string} [errorMsg] - 错误信息
*/
export function setFileError(filename, errorMsg) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-error');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">ERR</span>';
detailRow.innerHTML = `<span class="detail-label">错误:</span><span class="detail-value error-text">${escapeHtml(errorMsg || '提取失败')}</span>`;
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
+64
View File
@@ -0,0 +1,64 @@
/**
* 聊天消息渲染器
*
* 提供通用的气泡创建、DOM 操作和滚动功能。
*/
import { escapeHtml } from '../utils.js';
/**
* 获取聊天消息容器
*/
export function getChatMessages() {
return document.getElementById('chat-messages');
}
/**
* 滚动聊天容器到底部
*/
export function scrollToBottom() {
const chatMessages = getChatMessages();
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
/**
* 创建聊天消息气泡
*
* @param {string} text - 消息内容
* @param {string} type - 消息类型(影响气泡样式)
* @param {boolean} isUser - 是否为用户消息
* @returns {HTMLElement} wrapper 元素
*/
export function createChatBubble(text, type, isUser) {
const wrapper = document.createElement('div');
wrapper.className = `chat-message${isUser ? ' user' : ''}`;
wrapper.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/${isUser ? 'user' : 'agent'}.svg" alt=""></div>
<div class="chat-bubble ${isUser ? 'user' : (type || 'processing')}">${escapeHtml(text)}</div>
`;
return wrapper;
}
/**
* 创建带系统头像的气泡
*
* @param {string} text - 消息内容
* @param {string} type - 消息类型
* @returns {HTMLElement} wrapper 元素
*/
export function createSystemBubble(text, type) {
return createChatBubble(text, type, false);
}
/**
* 将消息追加到聊天容器
*
* @param {HTMLElement} wrapper - 消息 wrapper 元素
*/
export function appendMessage(wrapper) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
chatMessages.appendChild(wrapper);
scrollToBottom();
}
+199
View File
@@ -0,0 +1,199 @@
/**
* LLM 流式聊天气泡管理器
*
* 流式消息在处理时显示思考过程和逐字输出,结束后固化为持久消息。
*/
import { escapeHtml } from '../utils.js';
import { getChatMessages, scrollToBottom } from './renderer.js';
/**
* 当前活跃的 LLM 流式气泡状态
*/
let llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
/**
* 处理 LLM 流式事件
*
* @param {Object} msg - SSE 传来的 llm_stream 事件对象
*/
export function handleLLMStream(msg) {
switch (msg.phase) {
case 'start':
_createLLMStreamBubble(msg.label || '正在分析中...');
break;
case 'reasoning':
_appendLLMStreamReasoning(msg.text || '');
break;
case 'chunk':
_appendLLMStreamChunk(msg.text || '');
break;
case 'end':
_closeLLMStreamBubble(msg.label || '分析完成');
break;
case 'error':
_errorLLMStreamBubble(msg.error || '分析失败');
break;
}
}
/**
* 创建 LLM 流式气泡(start 阶段)
*/
function _createLLMStreamBubble(label) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
if (llmStreamState.wrapper) {
_closeLLMStreamBubbleSilent();
}
const wrapper = document.createElement('div');
wrapper.className = 'chat-message';
const bubble = document.createElement('div');
bubble.className = 'chat-bubble processing llm-stream-bubble';
const labelEl = document.createElement('div');
labelEl.className = 'llm-stream-label';
labelEl.textContent = label;
const reasoningSection = document.createElement('details');
reasoningSection.className = 'llm-reasoning-section';
reasoningSection.open = true;
const reasoningSummary = document.createElement('summary');
reasoningSummary.className = 'llm-reasoning-summary';
reasoningSummary.textContent = '思考过程(点击折叠)';
const reasoningText = document.createElement('div');
reasoningText.className = 'llm-reasoning-text';
reasoningText.textContent = '';
reasoningSection.appendChild(reasoningSummary);
reasoningSection.appendChild(reasoningText);
const textEl = document.createElement('div');
textEl.className = 'llm-stream-text';
textEl.textContent = '';
bubble.appendChild(labelEl);
bubble.appendChild(reasoningSection);
bubble.appendChild(textEl);
wrapper.appendChild(bubble);
chatMessages.appendChild(wrapper);
scrollToBottom();
llmStreamState = {
wrapper,
bubble,
textContent: textEl,
accumulated: '',
reasoningContent: reasoningText,
reasoningAccumulated: '',
};
}
/**
* 追加流式文本片段(chunk 阶段)
*/
function _appendLLMStreamChunk(text) {
if (!llmStreamState.textContent) return;
llmStreamState.accumulated += text;
llmStreamState.textContent.textContent = llmStreamState.accumulated;
scrollToBottom();
}
/**
* 追加思考过程片段(reasoning 阶段)
*/
function _appendLLMStreamReasoning(text) {
if (!llmStreamState.reasoningContent) return;
llmStreamState.reasoningAccumulated += text;
llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated;
scrollToBottom();
}
/**
* 关闭流式气泡(end 阶段)— 气泡保留在聊天历史中作为持久消息
*/
function _closeLLMStreamBubble(label) {
if (!llmStreamState.bubble) return;
llmStreamState.bubble.classList.remove('processing');
llmStreamState.bubble.classList.add('done');
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
if (labelEl) {
labelEl.textContent = label;
}
const reasoningSection = llmStreamState.bubble.querySelector('.llm-reasoning-section');
if (reasoningSection) {
reasoningSection.open = false;
}
scrollToBottom();
llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
}
/**
* 静默关闭流式气泡(不改变样式,用于 start 前清理)
*/
function _closeLLMStreamBubbleSilent() {
if (llmStreamState.wrapper) {
llmStreamState.wrapper.remove();
}
llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
}
/**
* 错误状态(error 阶段)
*/
function _errorLLMStreamBubble(errorMsg) {
if (!llmStreamState.bubble) return;
llmStreamState.bubble.classList.remove('processing');
llmStreamState.bubble.classList.add('error');
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
if (labelEl) {
labelEl.textContent = '分析失败';
}
if (llmStreamState.textContent) {
llmStreamState.textContent.textContent = escapeHtml(errorMsg);
}
llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
}
+148
View File
@@ -0,0 +1,148 @@
/**
* 配置收集模块
*/
import { App } from './state.js';
import { addChatMessage, showStatus } from './chat.js';
import { handleUserSupplement, handleForceSubmit, handleSupplementUpload } from './agent.js';
import { startProcess } from './process.js';
export const CONFIG_FIELDS = {
username: '登录账号',
password: '登录密码',
default_name: '报销人姓名',
default_card_no: '公务卡号',
default_person_id: '报销人工号',
consumable_storage: '易耗品存放地点',
};
/**
* 静默检查:仅当配置完整且有文件时自动开始处理。
*/
export function checkAutoStart() {
if (App.isProcessing) return;
if (App.processState === 'processing') return;
if (App.processState === 'done') {
const hasNewFiles = App.allFiles.length > App.lastProcessedFileCount;
if (!hasNewFiles && !App.forceStart) return;
App.forceStart = false;
// done 状态下有新文件时,走 supplement 流程
if (hasNewFiles) {
App.isProcessing = true;
App.processState = 'processing';
if (App.newFilenames.length > 0) {
const filenames = App.newFilenames;
App.newFilenames = [];
handleSupplementUpload(filenames);
}
return;
}
}
if (App.processState === 'awaiting_files') {
if (!App.forceStart) return;
App.forceStart = false;
}
if (App.processState === 'awaiting_supplement') {
return;
}
// idle 状态下有补充文件时,走 supplement 流程
if (App.processState === 'idle' && App.newFilenames.length > 0) {
App.isProcessing = true;
App.processState = 'processing';
const filenames = App.newFilenames;
App.newFilenames = [];
handleSupplementUpload(filenames);
return;
}
const allKeys = Object.keys(CONFIG_FIELDS);
const missing = allKeys.filter(k => !App.sessionConfig[k]);
if (missing.length) return;
App.pendingConfigKey = null;
if (!App.allFiles.length) return;
startProcess();
}
/**
* 配置提示:在所有文件加载完成后调用,逐个提示缺失的配置项。
*/
export function promptMissingConfig() {
if (App.isProcessing) return;
const allKeys = Object.keys(CONFIG_FIELDS);
const missing = allKeys.filter(k => !App.sessionConfig[k]);
if (missing.length) {
if (App.pendingConfigKey === null) {
App.pendingConfigKey = missing[0];
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system');
} else if (!missing.includes(App.pendingConfigKey)) {
App.pendingConfigKey = missing[0];
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system');
}
return;
}
App.pendingConfigKey = null;
if (App.allFiles.length) {
showStatus('配置信息已完整,开始处理发票……');
startProcess();
}
}
/**
* 用户输入处理入口
*/
export async function sendUserMessage() {
const input = document.getElementById('chat-input');
if (!input) return;
const text = input.value.trim();
if (!text) return;
// 优先处理配置收集阶段
if (App.pendingConfigKey) {
App.sessionConfig[App.pendingConfigKey] = text;
addChatMessage(text, 'user', true);
addChatMessage(`${CONFIG_FIELDS[App.pendingConfigKey]} 已设置`, 'system');
input.value = '';
promptMissingConfig();
return;
}
addChatMessage(text, 'user', true);
input.value = '';
// 检查是否是强制提交指令
if (text.includes('直接提交') || text.includes('强制提交') || text.includes('继续提交')) {
await handleForceSubmit();
return;
}
// 检查是否包含文件上传指令
if (text.includes('上传') || text.includes('补充')) {
document.getElementById('file-input').click();
return;
}
// awaiting_supplement 状态下,将用户输入发给后端 LLM 处理
if (App.processState === 'awaiting_supplement') {
await handleUserSupplement(text);
return;
}
showStatus('请通过上传区补充文件,或输入"直接提交"跳过校验');
}
export function handleChatKey(event) {
if (event.key === 'Enter') {
sendUserMessage();
}
}
+19 -480
View File
@@ -1,483 +1,22 @@
let sessionId = null;
const pdfFiles = [], imgFiles = [];
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
let csvFilename = ''; // 当前 CSV 文件名
let lastDownloadUrls = {}; // 最近一次可下载文件链接
/**
* 入口文件 — ES Module 入口
*
* 所有模块通过 import 加载,依赖顺序由 import 自动解决。
* 需要 HTML 内联事件调用的函数挂载到 window。
*/
import { initDragDrop, toggleUploadPanel, handleFiles } from './upload.js';
import { sendUserMessage, handleChatKey } from './config.js';
import { generateQr, startSync } from './sync.js';
import { handleForceSubmit } from './agent.js';
// ---- Session ----
async function ensureSession() {
if (sessionId) return sessionId;
const r = await fetch('/api/session', { method: 'POST' });
const d = await r.json();
sessionId = d.session_id;
return sessionId;
}
// ---- 上传 ----
function handleFiles(input, type) {
const files = Array.from(input.files);
const list = type === 'pdf' ? pdfFiles : imgFiles;
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
files.forEach(f => {
if (!list.find(x => x.name === f.name)) {
f.__source = 'local'; // 标记为本地手动选择
list.push(f);
}
});
renderFileList(type);
document.getElementById(zoneId).classList.add('active');
input.value = '';
}
function removeFile(type, index) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
list.splice(index, 1);
renderFileList(type);
if (list.length === 0) {
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
}
}
function renderFileList(type) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
box.innerHTML = list.map((f, i) =>
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">&times;</span></span>`
).join('');
}
// ---- 配置上传 ----
function handleConfigUpload(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const cfg = JSON.parse(e.target.result);
const map = {
'cfg-username': cfg.username,
'cfg-password': cfg.password,
'cfg-name': cfg.default_name,
'cfg-card': cfg.default_card_no,
'cfg-person-id': cfg.default_person_id,
'cfg-storage': cfg.consumable_storage,
};
for (const [id, val] of Object.entries(map)) {
if (val) document.getElementById(id).value = val;
}
// 同步 config.json 中的工号和公务卡号到表格
if (cfg.username) syncConfigToTable('工号');
if (cfg.default_card_no) syncConfigToTable('公务卡号');
alert('配置已加载');
} catch (err) {
alert('config.json 解析失败: ' + err.message);
}
};
reader.readAsText(file);
input.value = '';
}
// ---- 拖拽 ----
['pdf','img'].forEach(type => {
const zone = document.getElementById(type + '-zone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files).filter(f => {
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
});
if (files.length) {
const list = type === 'pdf' ? pdfFiles : imgFiles;
files.forEach(f => {
f.__source = 'local'; // 标记为本地拖拽
if (!list.find(x => x.name === f.name)) list.push(f);
});
renderFileList(type);
zone.classList.add('active');
}
});
});
// ---- 处理 ----
async function startProcess() {
if (!pdfFiles.length && !imgFiles.length) {
alert('请先上传文件或图片');
return;
}
const btn = document.getElementById('btn-start');
btn.disabled = true;
btn.textContent = '处理中...';
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
document.getElementById('log-box').innerHTML = '';
document.getElementById('edit-section').style.display = 'none';
document.getElementById('download-section').style.display = 'none';
try {
await ensureSession();
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
for (const {f} of allFiles) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
}
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
const cfg = {
username: document.getElementById('cfg-username').value,
password: document.getElementById('cfg-password').value,
default_name: document.getElementById('cfg-name').value,
default_card_no: document.getElementById('cfg-card').value,
default_person_id: document.getElementById('cfg-person-id').value,
consumable_storage: document.getElementById('cfg-storage').value,
};
await fetch(`/api/process/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
// 监听 SSE 日志
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
const result = msg.result;
document.getElementById('status').innerHTML = result.ok
? '<span class="badge bg-success status-badge">完成</span>'
: '<span class="badge bg-danger status-badge">失败</span>';
btn.disabled = false;
btn.textContent = '开始处理';
if (result.ok) {
showDownloadLinks(result);
loadInvoiceData(); // 加载可编辑数据
} else {
alert('处理失败: ' + (result.error || '未知错误'));
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
btn.disabled = false;
btn.textContent = '开始处理';
};
} catch (e) {
alert('请求失败: ' + (e.message || '未知错误'));
btn.disabled = false;
btn.textContent = '开始处理';
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
}
}
// ---- 下载链接 ----
function showDownloadLinks(result) {
const section = document.getElementById('download-section');
const box = document.getElementById('download-links');
const warn = document.getElementById('doc-fill-warning');
if (!section || !box) return;
const items = [];
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
lastDownloadUrls = {};
items.forEach(it => { lastDownloadUrls[it.label] = it.url; });
box.innerHTML = items.map(it =>
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
).join('');
if (warn) {
if (result.doc_skipped) {
warn.style.display = 'block';
warn.style.color = '#0d6efd';
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
} else if (result.doc_ok === false && result.doc_error) {
warn.style.display = 'block';
warn.style.color = '';
warn.textContent = '出库单未生成:' + result.doc_error;
} else {
warn.style.display = 'none';
warn.textContent = '';
}
}
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
}
// ---- 发票数据编辑 ----
async function loadInvoiceData() {
try {
const r = await fetch(`/api/data/${sessionId}`);
const d = await r.json();
if (d.error) return;
csvFilename = d.csv_filename || 'invoice_summary.csv';
invoiceData = d.data || [];
const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__'));
renderTable(invoiceData, fields);
// 渲染后自动将配置区的工号/公务卡号同步到表格
syncConfigToTable('工号');
syncConfigToTable('公务卡号');
} catch (e) { /* ignore */ }
}
// 配置区 → 表格的同步映射:工号 ↔ cfg-username,公务卡号 ↔ cfg-card
const CONFIG_SYNC = {
'工号': 'cfg-username',
'公务卡号': 'cfg-card',
};
// 从配置区输入框的值更新到所有表格行
function syncConfigToTable(field) {
const inputId = CONFIG_SYNC[field];
if (!inputId) return;
const val = document.getElementById(inputId).value || '';
invoiceData.forEach((row, i) => {
row[field] = val;
});
// 更新表格中对应单元格的显示
const tbody = document.getElementById('invoice-tbody');
if (!tbody) return;
const rows = tbody.querySelectorAll('tr');
rows.forEach((tr, i) => {
const inputs = tr.querySelectorAll('input');
fieldsCache.forEach((f, colIdx) => {
if (f === field && inputs[colIdx]) {
inputs[colIdx].value = val;
}
});
});
}
let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引
function renderTable(data, fields) {
const section = document.getElementById('edit-section');
if (!data.length) { section.style.display = 'none'; return; }
section.style.display = 'block';
// 使用后端返回的字段顺序, fallback 到 Object.keys
if (!fields || !fields.length) {
fields = Object.keys(data[0]).filter(k => !k.startsWith('__'));
}
fieldsCache = fields;
// 表头
document.getElementById('invoice-thead').innerHTML = `
<tr>
<th style="width:40px">#</th>
${fields.map(f => `<th>${f}</th>`).join('')}
</tr>
`;
// 表体:每行首列为序号,其后为各字段 input
const rows = data.map((row, i) => {
const cells = fields.map(f => {
let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`;
// 如果该字段参与配置区同步,额外调用 syncTableToConfig
if (CONFIG_SYNC[f]) {
onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`;
}
return `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
onchange="${onChangeStr}"></td>`;
}).join('');
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
}).join('');
document.getElementById('invoice-tbody').innerHTML = rows;
}
// 从表格修改同步回配置区
function syncTableToConfig(field, value) {
const inputId = CONFIG_SYNC[field];
if (inputId) {
document.getElementById(inputId).value = value;
}
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 将当前表格编辑内容写回服务器(内部调用)
async function saveInvoiceData() {
try {
const r = await fetch(`/api/save/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }),
});
const d = await r.json();
if (d.doc_url || d.doc_ok === false) {
showDownloadLinks({
csv_url: lastDownloadUrls['invoice_summary.csv'],
doc_url: d.doc_url,
doc_ok: d.doc_ok,
doc_error: d.doc_error,
});
}
} catch (e) {
console.warn('自动保存失败,继续提交:', e);
}
}
// ---- 提交到财务系统 ----
async function submitFinancial() {
const btn = document.getElementById('btn-submit');
btn.disabled = true;
btn.textContent = '提交中...';
document.getElementById('log-box').innerHTML = '';
try {
// 提交前先自动保存当前表格编辑内容
await saveInvoiceData();
await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' });
// 监听日志流(复用 SSE)
const es = new EventSource(`/api/logs/${sessionId}`);
const logBox = document.getElementById('log-box');
let firstLine = true;
es.addEventListener('message', e => {
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
try {
const msg = JSON.parse(e.data);
if (msg.type === 'done') {
es.close();
btn.disabled = false;
const result = msg.result;
if (result && result.submit_ok) {
btn.textContent = '✅ 提交完成';
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000);
} else {
const errorMsg = result?.submit_error || result?.error || '未知错误';
btn.textContent = '❌ 提交失败';
logBox.innerHTML += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
logBox.scrollTop = logBox.scrollHeight;
console.warn('提交失败:', errorMsg);
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000);
}
return;
}
} catch (err) {}
logBox.innerHTML += e.data;
logBox.scrollTop = logBox.scrollHeight;
});
es.onerror = () => {
es.close();
btn.disabled = false;
btn.textContent = '🚀 提交到财务系统';
};
} catch (e) {
alert('提交失败: ' + e.message);
btn.disabled = false;
btn.textContent = '🚀 提交到财务系统';
}
}
// ---- 二维码 / 手机扫码上传 ----
let qrGenerated = false;
let syncTimer = null;
async function generateQr() {
await ensureSession();
const mobileUrl = window.location.origin + '/mobile/' + sessionId;
const box = document.getElementById('qrcode');
box.innerHTML = '';
new QRCode(box, {
text: mobileUrl,
width: 120,
height: 120,
colorDark: '#333',
colorLight: '#fff',
});
qrGenerated = true;
}
// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ----
async function startSync() {
if (syncTimer) return;
await syncFiles();
syncTimer = setInterval(syncFiles, 3000);
}
function stopSync() {
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
}
async function syncFiles() {
if (!sessionId) return;
try {
const r = await fetch(`/api/files/${sessionId}`);
const d = await r.json();
const serverNames = new Set(d.images || []);
const localNames = new Set(imgFiles.map(f => f.name));
for (const name of serverNames) {
if (!localNames.has(name)) {
const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`);
const blob = await resp.blob();
const file = new File([blob], name, { type: blob.type });
file.__source = 'server'; // 标记为扫码上传
imgFiles.push(file);
}
}
// 只清理来自服务器但已不存在的文件,保留本地手动选择的文件
for (const f of [...imgFiles]) {
if (!serverNames.has(f.name) && f.__source !== 'local') {
const idx = imgFiles.indexOf(f);
if (idx > -1) imgFiles.splice(idx, 1);
}
}
renderFileList('img');
if (imgFiles.length) {
document.getElementById('img-zone').classList.add('active');
} else {
document.getElementById('img-zone').classList.remove('active');
}
} catch (e) { /* ignore */ }
}
// 将需要 HTML 内联事件调用的函数挂载到 window
window.handleChatKey = handleChatKey;
window.sendUserMessage = sendUserMessage;
window.initDragDrop = initDragDrop;
window.toggleUploadPanel = toggleUploadPanel;
window.handleFiles = handleFiles;
window.handleForceSubmit = handleForceSubmit;
initDragDrop();
generateQr();
startSync();
// ---- 配置区 → 表格的双向同步绑定 ----
Object.values(CONFIG_SYNC).forEach(inputId => {
const el = document.getElementById(inputId);
if (el) {
el.addEventListener('input', () => {
// 根据 inputId 反查对应的字段名
const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0];
if (field) syncConfigToTable(field);
});
}
});
startSync();
+148
View File
@@ -0,0 +1,148 @@
/**
* 发票处理模块
*/
import { App } from './state.js';
import { ensureSession } from './utils.js';
import {
addTypingIndicator,
removeTypingIndicator,
setFileProcessing,
setFileDone,
setFileCached,
setFileError,
handleLLMStream,
addChatMessage,
showStatus,
} from './chat.js';
import { handleAgentEvent, handleAutoSubmit } from './agent.js';
export async function startProcess() {
if (!App.allFiles.length) {
alert('请先上传文件或图片');
return;
}
App.isProcessing = true;
App.processState = 'processing';
addTypingIndicator();
try {
await ensureSession();
for (const f of App.allFiles) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
}
removeTypingIndicator();
addTypingIndicator();
const cfg = {
username: App.sessionConfig.username || '',
password: App.sessionConfig.password || '',
default_name: App.sessionConfig.default_name || '',
default_card_no: App.sessionConfig.default_card_no || '',
default_person_id: App.sessionConfig.default_person_id || '',
consumable_storage: App.sessionConfig.consumable_storage || '',
};
await fetch(`/api/agent/process/${App.sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
const es = new EventSource(`/api/logs/${App.sessionId}`);
App.agentEventSource = es;
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'file_progress') {
const filename = msg.file;
switch (msg.status) {
case 'processing':
setFileProcessing(filename);
break;
case 'done':
setFileDone(filename, msg.summary || {});
break;
case 'cached':
setFileCached(filename);
break;
case 'error':
setFileError(filename, msg.error);
break;
}
return;
}
if (msg.type === 'llm_stream') {
handleLLMStream(msg);
return;
}
if (msg.type && msg.type.startsWith('agent_')) {
handleAgentEvent(msg);
return;
}
if (msg.type === 'done') {
es.close();
App.agentEventSource = null;
removeTypingIndicator();
const result = msg.result;
App.isProcessing = false;
if (App.forceSubmitting) {
return;
}
if (result.waiting_for_supplement) {
App.processState = 'awaiting_supplement';
showStatus('信息不完整,请补充上传相关材料');
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
} else {
App.processState = 'done';
App.lastProcessedFileCount = App.allFiles.length;
if (result.ok) {
if (result.agent_ready) {
if (result.submit_ok !== false) {
addChatMessage('信息完整,已自动提交到财务系统', 'done');
} else {
addChatMessage(`校验通过但提交失败:${result.submit_error || '未知错误'}`, 'error');
}
} else if (result.invoice_count) {
addChatMessage(`处理完成!共提取 ${result.invoice_count} 张发票数据`, 'done');
}
} else {
addChatMessage(`处理失败:${result.error || '未知错误'}`, 'error');
}
}
return;
}
} catch (err) {
// 普通日志行
}
});
es.onerror = () => {
es.close();
removeTypingIndicator();
App.isProcessing = false;
App.processState = 'done';
addChatMessage('连接中断,请刷新页面重试', 'error');
};
} catch (e) {
removeTypingIndicator();
alert('请求失败: ' + (e.message || '未知错误'));
App.isProcessing = false;
App.processState = 'idle';
addChatMessage(`请求失败:${e.message || '未知错误'}`, 'error');
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* 全局状态管理
*
* 所有模块通过 App 对象共享状态,避免全局变量污染。
*/
export const App = {
sessionId: null,
allFiles: [],
sessionConfig: {},
invoiceData: [],
csvFilename: '',
lastDownloadUrls: {},
isProcessing: false,
pendingConfigKey: null,
fieldsCache: [],
syncTimer: null,
fileMessageMap: new Map(),
processState: 'idle',
newFilenames: [],
lastProcessedFileCount: 0,
forceStart: false,
agentEventSource: null,
forceSubmitting: false,
};
+80
View File
@@ -0,0 +1,80 @@
/**
* 移动端同步模块
*/
import { App } from './state.js';
import { ensureSession } from './utils.js';
import { addFileMessage } from './chat.js';
import { checkAutoStart, promptMissingConfig } from './config.js';
export async function generateQr() {
await ensureSession();
const mobileUrl = window.location.origin + '/mobile/' + App.sessionId;
const box = document.getElementById('qrcode');
box.innerHTML = '';
new QRCode(box, {
text: mobileUrl,
width: 120,
height: 120,
colorDark: '#333',
colorLight: '#fff',
});
}
export async function startSync() {
if (App.syncTimer) return;
await syncFiles();
App.syncTimer = setInterval(syncFiles, 3000);
}
export function stopSync() {
if (App.syncTimer) { clearInterval(App.syncTimer); App.syncTimer = null; }
}
async function syncFiles() {
if (!App.sessionId) return;
try {
const r = await fetch(`/api/files/${App.sessionId}`);
const d = await r.json();
const serverFiles = d.files || [];
const serverNames = new Set(serverFiles.map(f => f.name));
const localNames = new Set(App.allFiles.map(f => f.name));
const addedFiles = [];
for (const serverFile of serverFiles) {
if (!localNames.has(serverFile.name)) {
const resp = await fetch(`/api/download/${App.sessionId}/${encodeURIComponent(serverFile.name)}`);
const blob = await resp.blob();
const file = new File([blob], serverFile.name, { type: blob.type });
file.__source = 'server';
App.allFiles.push(file);
addedFiles.push(serverFile.name);
}
}
for (const f of [...App.allFiles]) {
if (!serverNames.has(f.name) && f.__source !== 'local') {
const idx = App.allFiles.indexOf(f);
if (idx > -1) App.allFiles.splice(idx, 1);
}
}
if (addedFiles.length) {
addedFiles.forEach(name => addFileMessage(name));
if (App.processState === 'done') {
App.processState = 'idle';
App.newFilenames = addedFiles;
} else if (App.processState === 'awaiting_supplement') {
App.processState = 'idle';
App.newFilenames = addedFiles;
}
}
if (App.allFiles.length) {
document.getElementById('file-zone').classList.add('active');
} else {
document.getElementById('file-zone').classList.remove('active');
}
checkAutoStart();
} catch (e) { /* ignore */ }
}
+141
View File
@@ -0,0 +1,141 @@
/**
* 文件上传模块
*/
import { App } from './state.js';
import { addFileMessage, addChatMessage } from './chat.js';
import { promptMissingConfig, checkAutoStart } from './config.js';
import { handleSupplementUpload, hideAgentRequest } from './agent.js';
import { ensureSession } from './utils.js';
export async function handleFiles(input) {
const files = Array.from(input.files);
const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i;
let hasNewFiles = false;
const newFilenames = [];
for (const f of files) {
if (/\.json$/i.test(f.name)) {
await parseConfigFile(f);
continue;
}
if (!App.allFiles.find(x => x.name === f.name) && validExts.test(f.name)) {
f.__source = 'local';
App.allFiles.push(f);
document.getElementById('file-zone').classList.add('active');
addFileMessage(f.name);
hasNewFiles = true;
newFilenames.push(f.name);
}
}
if (hasNewFiles && App.processState === 'done') {
App.processState = 'idle';
App.newFilenames = newFilenames;
}
if (hasNewFiles && App.processState === 'awaiting_supplement') {
hideAgentRequest();
await _uploadNewFiles(newFilenames);
App.newFilenames = newFilenames;
App.processState = 'idle';
checkAutoStart();
return;
}
promptMissingConfig();
input.value = '';
}
async function _uploadNewFiles(filenames) {
await ensureSession();
for (const f of App.allFiles) {
if (filenames.includes(f.name)) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
}
}
}
async function parseConfigFile(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = function (e) {
try {
const cfg = JSON.parse(e.target.result);
Object.assign(App.sessionConfig, {
username: cfg.username || '',
password: cfg.password || '',
default_name: cfg.default_name || '',
default_card_no: cfg.default_card_no || '',
default_person_id: cfg.default_person_id || '',
consumable_storage: cfg.consumable_storage || '',
});
addChatMessage(`config.json 已加载`, 'system');
resolve(true);
} catch (err) {
console.warn('config.json 解析失败:', err.message);
resolve(false);
}
};
reader.readAsText(file);
});
}
export function initDragDrop() {
const zone = document.getElementById('file-zone');
if (!zone) return;
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', async e => {
e.preventDefault();
zone.classList.remove('dragover');
const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i;
const droppedFiles = Array.from(e.dataTransfer.files);
let hasNewFiles = false;
const newFilenames = [];
for (const f of droppedFiles) {
if (/\.json$/i.test(f.name)) {
await parseConfigFile(f);
continue;
}
if (validExts.test(f.name) && !App.allFiles.find(x => x.name === f.name)) {
f.__source = 'local';
App.allFiles.push(f);
zone.classList.add('active');
addFileMessage(f.name);
hasNewFiles = true;
newFilenames.push(f.name);
}
}
if (hasNewFiles && App.processState === 'done') {
App.processState = 'idle';
}
// 在 awaiting_supplement 状态下,只上传新文件并走 supplement 流程
if (hasNewFiles && App.processState === 'awaiting_supplement') {
hideAgentRequest();
await _uploadNewFiles(newFilenames);
App.newFilenames = newFilenames;
App.processState = 'idle';
checkAutoStart();
return;
}
promptMissingConfig();
});
}
/**
* 切换上传面板折叠/展开状态
*/
export function toggleUploadPanel() {
const body = document.getElementById('upload-panel-body');
const icon = document.getElementById('upload-toggle-icon');
if (!body || !icon) return;
body.classList.toggle('collapsed');
icon.classList.toggle('collapsed');
}
+18
View File
@@ -0,0 +1,18 @@
/**
* 工具函数模块
*
* 提供 HTML 转义和会话管理等基础功能。
*/
import { App } from './state.js';
export function escapeHtml(s) {
return s.replace(/\&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export async function ensureSession() {
if (App.sessionId) return App.sessionId;
const r = await fetch('/api/session', { method: 'POST' });
const d = await r.json();
App.sessionId = d.session_id;
return App.sessionId;
}
+29 -99
View File
@@ -9,119 +9,49 @@
</head>
<body>
<div class="header text-center mb-4">
<h3>财务报销自动化</h3>
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、LLM 识别并填报</p>
</div>
<div class="container" style="max-width:960px">
<!-- 上传区域 -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
<div class="icon">📁</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
<div id="pdf-list" class="mt-2"></div>
<div class="main-container" style="max-width:960px">
<!-- 聊天窗口 -->
<div class="chat-container" id="chat-box">
<div class="chat-messages" id="chat-messages">
<div class="chat-welcome">
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble system">你好!我是财务报销助手。请先上传 <b>config.json</b> 配置文件,然后上传发票 PDF 或支付截图,我将自动为您处理。</div>
</div>
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
</div>
<div class="col-md-4">
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
<div class="icon">🖼️</div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
<div id="img-list" class="mt-2"></div>
</div>
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
</div>
<div class="col-md-4 text-center">
<div class="section-title">📱 手机扫码上传</div>
<div id="qrcode" style="display:inline-block"></div>
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
<div class="chat-input-area">
<input type="text" id="chat-input" placeholder="输入消息..." onkeydown="handleChatKey(event)" />
<button class="btn btn-primary" onclick="sendUserMessage()">发送</button>
</div>
</div>
<!-- 配置表单 -->
<div class="card mb-4">
<div class="card-body">
<div class="section-title">⚙️ 配置
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
📤 上传 config.json
</span>
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
</div>
<div class="row g-2">
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
<!-- 上传区域(可折叠) -->
<div class="upload-panel">
<div class="upload-panel-header" onclick="toggleUploadPanel()">
<span><img src="/static/icon/file.svg" alt="" style="width:14px;height:14px;vertical-align:middle;margin-right:4px">文件上传</span>
<span class="upload-panel-toggle" id="upload-toggle-icon">▼</span>
</div>
<div class="upload-panel-body" id="upload-panel-body">
<div class="row g-3">
<div class="col-md-8">
<div class="upload-zone" id="file-zone" onclick="event.stopPropagation(); document.getElementById('file-input').click()">
<div class="icon"><img src="/static/icon/folder.svg" alt=""></div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传文件(支持 PDF、图片)</div>
</div>
<input type="file" id="file-input" accept=".pdf,image/*,.json" multiple hidden onchange="handleFiles(this)">
</div>
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
</div>
<div class="col-sm-4">
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
</div>
<div class="col-sm-3">
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
</div>
<div class="col-sm-3">
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
</div>
<div class="col-sm-6">
<label class="form-label" style="font-size:12px;margin-bottom:2px">存放地点(出库单)</label>
<input type="text" class="form-control form-control-sm" id="cfg-storage" placeholder="新工科楼">
<div class="col-md-4 text-center">
<div class="section-title"><img src="/static/icon/phone.svg" alt="" style="width:16px;height:16px;vertical-align:middle;margin-right:4px">手机扫码上传</div>
<div id="qrcode" style="display:inline-block"></div>
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="text-center mb-4">
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
<span id="status" class="ms-3"></span>
</div>
<!-- 下载区 -->
<div class="card mb-4" id="download-section" style="display:none">
<div class="card-body py-3">
<div class="section-title mb-2">📥 下载文件</div>
<div id="download-links" class="d-flex flex-wrap gap-2"></div>
<div id="doc-fill-warning" class="text-warning mt-2" style="font-size:13px;display:none"></div>
</div>
</div>
<!-- 发票数据编辑区 -->
<div class="card mb-4" id="edit-section" style="display:none">
<div class="card-body">
<div class="section-title d-flex justify-content-between align-items-center">
<span>📝 付款记录(可编辑)</span>
<div class="d-flex justify-content-end align-items-center">
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
</div>
</div>
<p class="edit-note">直接点击单元格即可编辑,提交时自动保存当前修改。</p>
<div class="table-wrapper">
<table class="table table-sm table-bordered table-editable mb-0" id="invoice-table">
<thead id="invoice-thead"></thead>
<tbody id="invoice-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- 日志 -->
<div class="section-title">📋 处理日志</div>
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="/static/js/index.js"></script>
<script type="module" src="/static/js/index.js"></script>
</body>
</html>
+4 -3
View File
@@ -25,7 +25,7 @@ body { background: #f5f7fa; padding-bottom: 40px; }
{% else %}
<div class="header">
<h5>📸 支付截图上传</h5>
<h5>支付截图上传</h5>
<p class="mb-0 opacity-75" style="font-size:13px">拍照或从相册选择图片</p>
</div>
@@ -35,7 +35,8 @@ body { background: #f5f7fa; padding-bottom: 40px; }
<input type="file" id="file-input" accept="image/*" multiple hidden onchange="handleSelect(this)">
<button class="btn btn-primary upload-btn" onclick="document.getElementById('file-input').click()">
📷 选择图片 / 拍照
<img src="/static/icon/gallery.svg" alt="" style="width:20px;height:20px;vertical-align:middle;margin-right:6px">
选择图片 / 拍照
</button>
<!-- 上传进度 -->
@@ -99,7 +100,7 @@ async function handleSelect(input) {
}
if (done === files.length) {
status.innerHTML = `<div class="text-success">✅ ${done} 张图片上传成功</div>`;
status.innerHTML = `<div class="text-success">${done} 张图片上传成功</div>`;
} else {
status.innerHTML = `<div class="text-warning">${done}/${files.length} 张上传成功</div>`;
}