Compare commits

5 Commits

Author SHA1 Message Date
wandering
5c35bd06d6 差旅报销和普通报销都已经完成 2026-06-12 12:08:27 +08:00
wandering
7aa8ca86f2 日常报销和差旅报销都可以走通 2026-06-12 12:06:06 +08:00
wandering
87c7b2d5be 完成差旅发票录入流程 2026-06-11 19:22:34 +08:00
wandering
263d542903 chore: 从版本控制中移除 uploads 上传文件 2026-06-09 16:30:03 +08:00
wandering
1a8e06f228 重构项目为LLM 驱动 2026-06-09 16:25:20 +08:00
130 changed files with 4493 additions and 12934 deletions

View File

@@ -1,74 +0,0 @@
---
last_reviewed: 2026-06-13
---
# llm_query_text 缺少 start/end 事件导致前端不显示思考过程
## 错误现象
- 前端只显示 "正在分析文件..." 的文字提示(来自 agent 事件的 `agent_state_change`
- LLM 返回的思考过程气泡和正式回答气泡都不显示
- 校验流程的气泡正常显示,但提取流程的气泡缺失
- 后端日志正常LLM 请求成功返回
## 触发条件
- `llm_query_text()``_llm_query_multimodal()` 被 Agent 调度调用
- `source_dir` 参数已传入(启用 SSE 流式事件)
- 函数内部只发了 `chunk``reasoning` 事件,缺少 `start``end`
## 原因
前端 `chat.js``handleLLMStream` 状态机:
```
start -> _createLLMStreamBubble() // 创建聊天气泡 DOM
reasoning -> _appendLLMStreamReasoning() // 往气泡追加思考内容
chunk -> _appendLLMStreamChunk() // 往气泡追加正式回答
end -> _closeLLMStreamBubble() // 关闭气泡,切换完成样式
```
`_appendLLMStreamReasoning``_appendLLMStreamChunk` 的入口守卫:
```js
if (!llmStreamState.reasoningContent) return;
if (!llmStreamState.textContent) return;
```
没有 `start` 事件,`llmStreamState` 就一直是初始空值,后续所有 `reasoning``chunk` 事件都会被静默丢弃。
**为什么校验流程正常?** 因为 `validate_semantic_completeness()` 在调用 `llm_query_text` 之前,自己手动发了 `start``end` 事件,绕过了这个问题。
## 修复方法
`llm_query_text``_llm_query_multimodal``source_dir` 非空时,必须在流式循环前后发送完整的事件序列:
```python
# 循环前
if source_dir:
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
try:
for resp in llm.stream_chat(...):
# ... chunk / reasoning ...
# 循环后
if source_dir:
_emit_llm_stream(source_dir, "end", label="分析完成")
except Exception as e:
if source_dir:
_emit_llm_stream(source_dir, "error", error=str(e))
```
## 防回归要点
修改 `llm_query_text``_llm_query_multimodal` 时,检查事件发送是否完整:
| 阶段 | 事件 | 必需性 |
|------|------|--------|
| 循环前 | `start` | 必需(前端创建气泡) |
| 循环中 | `chunk` | 可选(无内容时不发) |
| 循环中 | `reasoning` | 可选(模型不支持时不发) |
| 成功 | `end` | 必需(前端切换完成样式) |
| 失败 | `error` | 必需 |
删除 `start``end` 会导致前端气泡丢失,是高频回归点。

View File

@@ -1,59 +0,0 @@
---
last_reviewed: 2026-06-13
---
# 闭包内复用外层变量名导致 UnboundLocalError
## 错误现象
- 补充材料提交后,提取函数正常执行完成
- 日志停在 `travel_applications.json` 保存处,后续没有任何 Agent 处理日志
- 没有报错、没有异常堆栈,看起来像"停止"
- `result.json` 里实际记录了 `{"ok": false, "error": "local variable 'agent_session' referenced before assignment"}`
## 触发条件
- 在 Flask 路由中用 `threading.Thread` 启动后台任务
- 外层作用域已有一个变量(如 `agent_session`
- 闭包 `_run()` 内对同名变量既读又写:`agent_session = run_agent_round(session_dir, agent_session, ...)`
## 原因
Python 变量作用域规则:**只要函数体内有任何对某标识符的赋值,该标识符在整个函数内都被视为局部变量**。
```python
agent_session = add_supplement(session_dir, agent_session, filenames) # 外层变量
def _run() -> None:
# ...
agent_session = run_agent_round( # 赋值 -> 整个 _run 内 agent_session 是局部变量
session_dir, agent_session, # 读局部变量,但此时还未赋值 -> UnboundLocalError
new_files=filenames,
)
```
`run_agent_round` 调用时,`agent_session` 作为参数被求值,但此时它还是未初始化的局部变量,触发 `UnboundLocalError`。该异常被 `except BaseException` 捕获后写入 result.json没有在日志中输出所以表现为"静默停止"。
## 修复方法
闭包内使用不同名称接收返回值:
```python
# 修复前
agent_session = run_agent_round(session_dir, agent_session, new_files=filenames)
# 修复后
new_session = run_agent_round(session_dir, agent_session, new_files=filenames)
```
后续对返回值的引用统一改为 `new_session`
## 防回归要点
| 场景 | 风险 | 检查方法 |
|------|------|----------|
| 在闭包/嵌套函数内赋值与外层同名的变量 | UnboundLocalError | ruff F823 规则 |
| `except BaseException` 吞掉异常且不打日志 | 静默失败,难以排查 | 至少记录 `log.exception` |
| 用 `# noqa: F823` 压制警告而不修复 | 问题持续存在 | noqa 只应用于确认安全的场景 |
**核心原则**:在闭包内需要接收外层变量的返回值时,始终使用不同的变量名。不要依赖 `nonlocal` 来修复合法性问题——换名字更简单、更安全。

View File

@@ -1,60 +0,0 @@
---
last_reviewed: 2026-06-15
---
# 补充材料提交后 SSE 立即读到旧 result.json 导致前端无消息
## 错误现象
- 第二次补充材料提交后,前端没有任何消息显示
- 状态栏不更新,聊天区无新增消息
- 后台日志显示处理正常完成LLM 提取、校验、Bot 提交均成功)
- 前端像是"卡住"了一样,没有报错也没有反馈
## 触发条件
1. 第一轮处理或第一轮补充材料完成,`result.json` 已写入 session 目录
2. 用户再次补充材料,触发新一轮处理
3. 前端创建新的 SSE 连接到 `/api/logs/<session_id>`
4. SSE 端点轮询时立即检测到旧的 `result.json`,直接发射 `done` 事件并关闭连接
5. 前端断开 SSE但后台线程仍在执行新任务
## 根因
`_run_agent_task` 在每次任务启动时只清理了 `llm_stream.log`,未清理 `result.json``agent_events.log`
```python
# 修复前 - 只清理了 llm_stream.log
try:
(session_dir / "llm_stream.log").unlink(missing_ok=True)
except Exception:
pass
```
SSE 端点 (`/api/logs/<session_id>`) 在 `generate()` 中轮询检查 `result.json` 是否存在,一旦存在就发射 `done` 事件并 `break` 退出循环。旧的 `result.json` 未被清理,导致 SSE 连接在任务实际开始前就结束了。
## 修复
`_run_agent_task` 开头统一清理三个残留文件:
```python
# 修复后 - 同时清理三个残留文件
for fname in ("llm_stream.log", "agent_events.log", pipeline_web.SESSION_RESULT_FILE):
try:
(session_dir / fname).unlink(missing_ok=True)
except Exception:
pass
```
- `llm_stream.log` — LLM 流式日志
- `agent_events.log` — Agent 事件日志(避免旧事件被重放)
- `result.json` — 处理结果文件(避免 SSE 立即读到旧结果)
## 影响范围
所有使用 `_run_agent_task` 的端点均受影响:
- `/api/process/<session_id>` — 初始处理
- `/api/agent/supplement/<session_id>` — 补充文件
- `/api/agent/user-supplement/<session_id>` — 文字补充
- `/api/agent/force-submit/<session_id>` — 强制提交
- `/api/submit-financial/<session_id>` — 手动财务提交

View File

@@ -1,657 +0,0 @@
# 系统事件流全景图
> 最后更新: 2026-06-15
> 用途: 排查 SSE 事件问题、提交流程中断、状态不一致等 Bug
---
## 一、核心概念
### 1.1 前后端状态映射
| 前端 `App.processState` | 后端 `AgentState` | 含义 |
|---|---|---|
| `idle` | `IDLE` | 初始状态,等待用户操作 |
| `processing` | `EXTRACTING` | LLM 正在分析文件 |
| `awaiting_supplement` | `AWAITING_SUPPLEMENT` | 信息不完整,等待用户补充 |
| `submitting` | `SUBMITTING` | 正在提交到财务系统 |
| `ready` | `READY` | 信息完整,可以提交 |
| `submitting` | `SUBMITTING` | 正在提交到财务系统 |
| `done` | `DONE` / `ERROR` | 流程结束(成功或失败) |
### 1.2 通信机制
```mermaid
sequenceDiagram
participant F as 前端
participant S as SSE连接
participant B as 后端线程
F->>B: POST /api/agent/process/:sid
B-->>F: {status: "started"}
F->>S: GET /api/logs/:sid (SSE长连接)
S-->>F: message: file_progress (轮询 file_events.log)
S-->>F: message: llm_stream (轮询 llm_stream.log)
S-->>F: message: agent_* (轮询 agent_events.log)
S-->>F: message: done (检测到 result.json)
S->>S: 连接关闭
```
**关键约束**
- 后端所有处理接口均返回 `{status: "started"}`,实际工作在 daemon 线程中执行
- SSE 通过每 0.5 秒轮询 4 个日志文件实现(非原生 SSE是长轮询模拟
- `result.json` 的原子写入:先写 `.tmp`,再 `replace()` 重命名
- SSE 超时600 秒后自动断开
### 1.3 操作信号点清单
每个 API 操作涉及的信号文件生命周期如下。**新增或修改信号文件时必须同步更新此清单**。
| 序号 | 操作 | API 端点 | 线程启动时清理 | 一次写入且不被清理 | 轮次结束时写入 |
|---|---|---|---|---|---|
| 1 | 初始处理 | `POST /api/agent/process/:sid` | `llm_stream.log`, `agent_events.log`, `result.json` | `file_events.log`, `session.log` | `result.json` |
| 2 | 补充文件 | `POST /api/agent/supplement/:sid` | `llm_stream.log`, `agent_events.log`, `result.json` | `file_events.log`, `session.log` | `result.json` |
| 3 | 文字补充 | `POST /api/agent/user-supplement/:sid` | `llm_stream.log`, `agent_events.log`, `result.json` | `file_events.log`, `session.log` | `result.json` |
| 4 | 强制提交 | `POST /api/agent/force-submit/:sid` | `llm_stream.log`, `agent_events.log`, `result.json` | `file_events.log`, `session.log` | `result.json` |
| 5 | 手动财务提交 | `POST /api/submit-financial/:sid` | `llm_stream.log`, `agent_events.log`, `result.json` | `file_events.log`, `session.log` | `result.json` |
**信号点说明**
| 信号文件 | 读/写方 | 生命周期 | 作用 |
|---|---|---|---|
| `result.json` | 后端线程写入SSE 端点读取 | 每轮开始时删除,`finally` 块中原子写入 | SSE 检测到该文件即发射 `done` 事件并断开连接 |
| `agent_events.log` | Agent 调度器追加写入SSE 端点读取 | 每轮开始时删除Agent 运行时持续追加 | 传递 agent 状态变化事件给前端 |
| `llm_stream.log` | LLM 回调追加写入SSE 端点读取 | 每轮开始时删除LLM 运行时持续追加 | 传递 LLM 流式输出给前端 |
| `file_events.log` | `pipeline_web` 追加写入SSE 端点读取 | 会话内持续追加,不删除 | 传递文件处理进度给前端 |
| `session.log` | `sse_handler` 追加写入SSE 端点读取 | 会话内持续追加,不删除 | 传递普通日志行给前端 |
### 1.4 关键约束(修改代码前必读)
**约束 1`result.json` 必须在每轮线程启动时删除**
SSE 端点通过检测 `result.json` 是否存在来判断任务是否完成。如果上一轮的 `result.json` 残留SSE 会立即读到旧数据并发射 `done` 事件,导致前端断开连接,新任务的消息无法送达。
- 实现位置:`_run_agent_task()``try` 块开头
- 删除时机:在 `install_log_collector()` 之后、`task_fn()` 执行之前
- 写入位置:`finally` 块中统一写入(唯一写入点)
- 写入规则:`finally` 始终执行原子写入,不再有条件判断
- `_emit_ready_and_submit` 只返回 result 字典,不写入文件
**约束 2`result.json` 的写入必须使用 `finally` 块**
无论任务成功或失败SSE 端点都需要 `result.json` 来发送 `done` 事件。如果仅在成功路径写入,异常时 SSE 会一直轮询直到 600 秒超时,前端无反馈。
**约束 3SSE 新建连接时,当前文件偏移必须从 0 开始**
`_run_agent_task` 在启动时删除 `llm_stream.log``agent_events.log`,确保 SSE 重新建立连接后从 0 偏移开始读取。如果文件不被删除,旧的事件会被重复发送给前端。
**约束 4前端 SSE 连接的生命周期**
- 前端在每次 POST 请求返回 `{status: "started"}` 后立即创建新的 SSE 连接
- 收到 `done` 事件后关闭连接
- 旧的连接引用必须清理(`agent.js` 中的 `agentEventSource`
- 如果前端在 POST 之前就创建了 SSE 连接,会读到旧数据
---
## 二、场景一:用户提交材料 → LLM 分析完整 → 直接提交
### 2.1 时序图
```mermaid
sequenceDiagram
participant F as 前端
participant S as SSE连接
participant B as 后端线程
participant A as Agent调度器
F->>F: startProcess()
F->>B: POST /api/agent/process/:sid
B-->>F: {status: "started"}
F->>S: GET /api/logs/:sid
Note over B,A: 后台线程启动
B->>A: extract_invoices()
S-->>F: file_progress (processing/done)
Note over S: 轮询 file_events.log
S-->>F: llm_stream (start/chunk/end)
Note over S: 轮询 llm_stream.log
S-->>F: agent_state_change (state=extracting)
Note over S: 轮询 agent_events.log
Note over A: _do_extraction_with_validation()<br/>LLM提取 → validator校验<br/>最多3次重试
S-->>F: agent_state_change (校验通过/未通过)
Note over A: can_submit == true
A->>A: state → READY
A->>A: _emit_agent_event (agent_ready)
A->>A: _emit_ready_and_submit()
A->>A: run_financial_submit()
S-->>F: agent_ready
Note over B: 写入 result.json
S-->>F: done (携带 result)
Note over S: 检测到 result.json
F->>F: es.close()
F->>F: App.processState = 'done'
F->>F: addChatMessage(成功)
Note over B: remove_log_collector
```
### 2.2 事件流清单
| 序号 | 事件类型 | 来源文件 | 触发时机 | 前端处理 |
|---|---|---|---|---|
| 1 | `file_progress` | `file_events.log` | 每个文件处理开始/完成 | 更新文件状态 UI |
| 2 | `llm_stream` | `llm_stream.log` | LLM 流式输出 | 显示聊天气泡 |
| 3 | `agent_state_change` | `agent_events.log` | 状态变为 `extracting` | 显示瞬态状态提示 |
| 4 | `agent_state_change` | `agent_events.log` | 校验通过/未通过 | 更新瞬态状态 |
| 5 | `agent_ready` | `agent_events.log` | 双重校验通过 | 由 `done` 事件统一处理 |
| 6 | `done` | SSE 检测到 `result.json` | 流程结束 | 根据 `result` 判断终态 |
### 2.3 result.json 结构(成功路径)
```json
{
"ok": true,
"agent_ready": true,
"submit_ok": true,
"round": 1,
"message": "信息完整,已自动提交到财务系统"
}
```
---
## 三、场景二:用户提交材料 → 需补充 → 用户上传文件
### 3.1 时序图
```mermaid
sequenceDiagram
participant F as 前端
participant S as SSE连接
participant B as 后端线程
participant A as Agent调度器
Note over F,A: 阶段1: 初次分析
F->>B: POST /api/agent/process/:sid
B-->>F: {status: "started"}
F->>S: GET /api/logs/:sid
S-->>F: agent_state_change (state=extracting)
Note over A: can_submit == false
A->>A: state → AWAITING_SUPPLEMENT
S-->>F: agent_request_supplement
Note over B: 写入 result.json<br/>(waiting_for_supplement=true)
S-->>F: done
F->>F: es.close()
F->>F: App.processState = 'awaiting_supplement'
F->>F: showStatus('请补充')
F->>F: showAgentRequest()
Note over B: remove_log_collector
Note over F,A: 阶段2: 用户上传补充文件
F->>F: 用户点击"上传补充材料"
F->>F: 文件上传完成
F->>F: handleSupplementUpload(newFilenames)
F->>B: POST /api/agent/supplement/:sid<br/>{files: [...]}
B-->>F: {status: "started"}
F->>S: GET /api/logs/:sid
Note over B: 新后台线程启动
A->>A: add_supplement()<br/>(记录文件名, 发射收到事件)
S-->>F: agent_supplement_received
A->>A: extract_invoices()<br/>(重新提取所有文件)
A->>A: run_agent_round(new_files=[...])
Note over A: 加载上一轮结果作为<br/>previous_analysis
S-->>F: agent_state_change (state=extracting)
Note over A: LLM提取 → 校验循环
alt 分支A: 补充后仍不完整
S-->>F: agent_request_supplement
S-->>F: done (waiting=true)
F->>F: es.close()
F->>F: App.processState = 'awaiting_supplement'
else 分支B: 补充后完整
Note over A: can_submit == true
A->>A: state → READY
A->>A: _emit_ready_and_submit()
S-->>F: agent_ready
S-->>F: done (submit_ok=true)
F->>F: es.close()
F->>F: App.processState = 'done'
F->>F: addChatMessage(成功)
end
```
### 3.2 事件流清单(补充文件路径)
| 序号 | 事件类型 | 来源文件 | 触发时机 | 前端处理 |
|---|---|---|---|---|
| 1 | `agent_supplement_received` | `agent_events.log` | 收到补充文件列表 | 显示"已收到补充文件" |
| 2 | `agent_state_change` | `agent_events.log` | 开始重新分析 | 显示瞬态状态 |
| 3 | `agent_request_supplement` | `agent_events.log` | 仍不完整 | 更新补充请求面板 |
| 4 | `agent_ready` | `agent_events.log` | 校验通过 | 由 `done` 统一处理 |
| 5 | `done` | SSE 检测到 `result.json` | 流程结束 | 判断终态 |
### 3.3 result.json 结构(需补充)
```json
{
"ok": true,
"agent_ready": false,
"agent_state": "awaiting_supplement",
"round": 1,
"waiting_for_supplement": true
}
```
---
## 四、场景三:用户提交材料 → 需补充 → 用户通过对话提供信息
### 4.1 时序图
```mermaid
sequenceDiagram
participant F as 前端
participant S as SSE连接
participant B as 后端线程
participant A as Agent调度器
Note over F,A: 阶段1: 初次分析
F->>B: POST /api/agent/process/:sid
B-->>F: {status: "started"}
F->>S: GET /api/logs/:sid
S-->>F: agent_request_supplement
S-->>F: done (waiting=true)
F->>F: es.close()
F->>F: App.processState = 'awaiting_supplement'
Note over B: remove_log_collector
Note over F,A: 阶段2: 用户输入文字
F->>F: 用户在输入框输入文字
F->>F: handleUserSupplement()
F->>B: POST /api/agent/user-supplement/:sid<br/>{text: "..."}
B-->>F: {status: "started"}
F->>S: GET /api/logs/:sid
Note over B: 新后台线程启动
S-->>F: agent_supplement_received
A->>A: process_user_text_supplement()
A->>A: process_user_supplement()<br/>(LLM解析用户文字)
S-->>F: llm_stream (解析过程)
A->>A: merge_supplement_into_info()<br/>(合并到 extracted_info)
Note over A: 保存到缓存文件
A->>A: run_agent_round()<br/>(重新校验)
S-->>F: agent_state_change<br/>(state=extracting, 正在重新校验)
alt 分支A: 补充后仍不完整
S-->>F: agent_request_supplement
S-->>F: done (waiting=true)
F->>F: es.close()
F->>F: App.processState = 'awaiting_supplement'
else 分支B: 补充后完整
Note over A: can_submit == true
A->>A: state → READY
A->>A: _emit_ready_and_submit()
S-->>F: agent_ready
S-->>F: done (submit_ok=true)
F->>F: es.close()
F->>F: App.processState = 'done'
F->>F: addChatMessage(成功)
end
```
### 4.2 事件流清单(文字补充路径)
| 序号 | 事件类型 | 来源文件 | 触发时机 | 前端处理 |
|---|---|---|---|---|
| 1 | `agent_supplement_received` | `agent_events.log` | 收到用户文字 | 显示"已收到补充" |
| 2 | `llm_stream` | `llm_stream.log` | LLM 解析用户文字 | 显示解析过程 |
| 3 | `agent_state_change` | `agent_events.log` | 开始重新校验 | 显示"正在重新校验" |
| 4 | `agent_request_supplement` | `agent_events.log` | 仍不完整 | 更新补充请求 |
| 5 | `agent_ready` | `agent_events.log` | 校验通过 | 由 `done` 统一处理 |
| 6 | `done` | SSE 检测到 `result.json` | 流程结束 | 判断终态 |
---
## 五、强制提交流程
### 5.1 时序图
```mermaid
sequenceDiagram
participant F as 前端
participant S as SSE连接
participant B as 后端线程
F->>F: handleForceSubmit()
F->>F: App.forceSubmitting = true
F->>B: POST /api/agent/force-submit/:sid
B-->>F: {status: "started"}
F->>S: GET /api/logs/:sid
Note over B: 后台线程启动
B->>B: force_submit()<br/>(state → READY)
S-->>F: agent_force_submit
B->>B: run_financial_submit()
Note over B: 写入 result.json
S-->>F: done
F->>F: es.close()
F->>F: App.forceSubmitting = false
F->>F: App.processState = 'done'
```
---
## 六、错误处理路径
### 6.1 错误场景和事件
| 错误场景 | 后端行为 | 发射事件 | 前端表现 |
|---|---|---|---|
| LLM 提取异常 | `state → ERROR` | `agent_error` | 聊天显示错误,`processState → 'done'` |
| 规则校验 3 次失败 | 返回最后一次结果,继续语义判断 | `agent_state_change` | 依赖 `can_submit` 字段决定 |
| 轮次超限 (5 轮) | `state → ERROR` | `agent_max_rounds` | 聊天显示错误,可强制提交 |
| 财务提交失败 | `result.submit_ok = false` | 无独立事件 | `done` 事件携带错误信息 |
| SSE 连接中断 | 无 | `es.onerror` 触发 | 显示"连接中断" |
| 超时 (600s) | SSE 轮询循环退出 | 连接自然断开 | 连接断开 |
### 6.2 agent_error 事件结构
```json
{
"type": "agent_error",
"message": "LLM 提取失败: ..."
}
```
### 6.3 agent_max_rounds 事件结构
```json
{
"type": "agent_max_rounds",
"message": "已达到最大轮次 (5),请检查信息或强制提交"
}
```
---
## 七、状态机完整图
### 7.1 后端 AgentState 状态机
```mermaid
stateDiagram-v2
[*] --> IDLE
IDLE --> EXTRACTING: POST /api/agent/process\nPOST /api/agent/supplement\nPOST /api/agent/user-supplement
EXTRACTING --> READY: can_submit == true
EXTRACTING --> AWAITING_SUPPLEMENT: can_submit == false
EXTRACTING --> ERROR: 异常 / 轮次超限
READY --> SUBMITTING: _emit_ready_and_submit()
SUBMITTING --> DONE: 财务提交完成
AWAITING_SUPPLEMENT --> EXTRACTING: 用户补充文件/文字
READY: 准备提交\n(终态保护)
SUBMITTING: 财务提交中\n(终态保护)
DONE: 终态\n(终态保护)
ERROR: 错误状态\n(可强制提交)
note right of EXTRACTING
LLM 提取 + validator 校验\n最多 3 次重试
end note
```
### 7.2 前端 processState 状态机
```mermaid
stateDiagram-v2
[*] --> idle
idle --> processing: startProcess()
processing --> awaiting_supplement: done事件\nresult.waiting_for_supplement
processing --> done: done事件\nresult.ok
awaiting_supplement --> processing: 补充文件或文字
awaiting_supplement --> submitting: 强制提交
submitting --> done: done事件
done: 流程结束
idle: 初始状态
processing: 处理中
awaiting_supplement: 等待补充
submitting: 提交中
```
---
## 八、SSE 事件类型完整参考
### 8.1 Agent 事件 (agent_events.log)
| 事件类型 | 数据结构 | 触发条件 |
|---|---|---|
| `agent_state_change` | `{type, state, round, attempt, message}` | 状态切换 |
| `agent_ready` | `{type, round, message}` | 双重校验通过 |
| `agent_request_supplement` | `{type, round, missing_fields, missing_materials, semantic_issues, suggestion}` | 校验未通过 |
| `agent_supplement_received` | `{type, files}` | 收到用户补充 |
| `agent_force_submit` | `{type, message}` | 用户强制提交 |
| `agent_error` | `{type, message}` | 提取失败 |
| `agent_max_rounds` | `{type, message}` | 达到最大轮次 |
### 8.2 文件进度事件 (file_events.log)
| 事件类型 | 数据结构 | 触发条件 |
|---|---|---|
| `file_progress` | `{type, file, status, summary?, error?}` | 文件处理状态变更 |
`status` 取值: `processing` / `done` / `cached` / `error`
### 8.3 LLM 流式事件 (llm_stream.log)
| 事件类型 | 数据结构 | 触发条件 |
|---|---|---|
| `llm_stream` | `{type, phase, text?}` | LLM 输出流 |
`phase` 取值: `start` / `reasoning` / `chunk` / `end` / `error`
### 8.4 完成事件 (SSE 直接发送)
| 事件类型 | 数据结构 | 触发条件 |
|---|---|---|
| `done` | `{type, result: {...}}` | `result.json` 出现 |
---
## 九、常见问题排查清单
### 9.1 SSE 事件丢失
**症状**: 前端没有收到预期的 agent 事件
**排查步骤**:
1. 检查 `agent_events.log` 是否存在、是否有内容
2. 检查 SSE 连接是否建立成功(浏览器 Network 面板)
3. 确认 `sse_handler.install_log_collector()` 是否被调用
4. 确认 `remove_log_collector()` 是否过早调用
### 9.2 提交流程中断
**症状**: 流程在某个中间状态卡住,没有 `done` 事件
**排查步骤**:
1. 检查 `result.json` 是否被写入
2. 检查后台线程是否异常退出(查看 `session.log`
3. 确认 `finally` 块中的 `result.json` 写入逻辑是否执行
4. 检查是否触发了 600 秒超时
### 9.3 状态不一致
**症状**: 前端 `processState` 和后端 `AgentState` 不匹配
**排查步骤**:
1. 对比 `agent_events.log` 中的状态变更序列
2. 检查前端是否正确处理了 `done` 事件
3. 确认 SSE 连接是否在适当时机关闭和重建
4. 检查 `App.agentEventSource` 引用是否正确清理
### 9.4 补充流程不触发
**症状**: 用户上传补充文件或输入文字后,没有重新分析
**排查步骤**:
1. 确认 `processState` 是否为 `awaiting_supplement`
2. 检查补充 API 是否返回 `{status: "started"}`
3. 检查新 SSE 连接是否成功建立
4. 确认 `add_supplement()``process_user_text_supplement()` 是否被调用
### 9.5 补充材料后前端无任何消息(`result.json` 残留问题)
**症状**: 第二轮及之后的补充材料提交后,前端完全没有任何消息显示,状态栏不更新,聊天区无新增消息。后台日志显示处理正常完成。
**根因**: `_run_agent_task` 在每轮启动时未清理上一轮的 `result.json`。SSE 端点轮询时立即检测到旧的 `result.json`,直接发射 `done` 事件并关闭连接,前端断开后无法接收新任务的消息。
**排查步骤**:
1. 检查 session 目录中 `result.json` 的修改时间 — 如果早于当前轮次开始时间,说明是残留文件
2. 检查浏览器 Network 面板中 SSE 连接 — 是否在建立后立即收到 `done` 事件
3. 确认 `_run_agent_task` 是否在启动时清理了 `result.json`
**修复**: 在 `_run_agent_task``try` 块开头同时清理 `llm_stream.log``agent_events.log``result.json` 三个文件。
**详细记录**: 参见 `.agents/docs/error-experience/2026-06-15-补充材料SSE立即读到旧result.json导致前端无消息.md`
---
## 十、关键文件索引
| 文件 | 职责 |
|---|---|
| `src/web/static/js/process.js` | 主提交流程入口SSE 事件分发 |
| `src/web/static/js/agent.js` | Agent 事件处理,补充/强制提交逻辑 |
| `src/web/static/js/state.js` | 全局状态管理 |
| `src/web/routes.py` | 后端路由,后台线程启动 |
| `src/agent/orchestrator.py` | Agent 调度器,状态机,校验循环 |
| `src/web/sse_handler.py` | SSE 日志收集器 |
| `src/web/pipeline_web.py` | 发票提取管道,财务提交 |
---
## 十一、文件生命周期与操作信号点
### 11.1 单轮处理的完整文件生命周期
```mermaid
sequenceDiagram
participant API as 路由层
participant RT as _run_agent_task
participant TF as task_fn
participant SS as _emit_ready_and_submit
participant SSE as SSE 端点
Note over API: 1. 创建 handler
API->>API: install_log_collector(session_dir)
Note over API: 创建 SSE 日志收集器<br/>随即开始写入 session.log
API->>RT: threading.Thread(target=_run_agent_task)
Note over RT: 2. 清理残留文件
RT->>RT: unlink(llm_stream.log)
RT->>RT: unlink(agent_events.log)
RT->>RT: unlink(result.json)
Note over RT: 3. 执行任务
RT->>TF: task_fn(session_dir, config)
Note over TF: 执行期间各个文件由对应模块写入:
TF-->>TF: file_events.log (pipeline_web)
TF-->>TF: llm_stream.log (LLM 回调)
TF-->>TF: agent_events.log (Agent 调度器)
TF-->>RT: 返回 (agent_session, 占位 result)
alt 成功路径 (READY)
RT->>SS: _emit_ready_and_submit()
Note over SS: 发射 agent_ready 事件<br/>执行财务提交<br/>返回 result 字典
SS-->>RT: result 字典
Note over RT: result = {...}
else 需补充路径 (AWAITING_SUPPLEMENT)
Note over RT: result = {waiting_for_supplement: true}
else 异常路径
Note over RT: result = {ok: false, error: ...}
end
Note over RT: 4. finally 块 — 唯一写入点
RT->>RT: 原子写入 result.json (.tmp → replace)
RT->>RT: remove_log_collector(handler)
Note over SSE: 5. SSE 端点检测
SSE->>SSE: 轮询检测到 result.json
SSE-->>SSE: 发射 done 事件
SSE->>SSE: break 退出轮询
```
### 11.2 各阶段信号文件状态
| 阶段 | `result.json` | `llm_stream.log` | `agent_events.log` | `file_events.log` | `session.log` |
|---|---|---|---|---|---|
| 会话创建 | 不存在 | 不存在 | 不存在 | 不存在 | 不存在 |
| `install_log_collector` 后 | 不存在 | 不存在 | 不存在 | 不存在 | 开始写入 |
| `_run_agent_task` 清理后 | 已删除 | 已删除 | 已删除 | 保持 | 保持 |
| 文件提取中 | 不存在 | 不存在 | 不存在 | 持续追加 | 持续追加 |
| LLM 提取中 | 不存在 | 持续追加 | 持续追加 | 保持 | 持续追加 |
| 校验中 | 不存在 | 保持 | 持续追加 | 保持 | 持续追加 |
| 任务完成 (READY) | 已写入 | 保持 | 保持 | 保持 | 保持 |
| 任务完成 (需补充) | 已写入 | 保持 | 保持 | 保持 | 保持 |
| 任务异常 | 已写入 | 保持 | 保持 | 保持 | 保持 |
| SSE done 事件后 | 保持 | 保持 | 保持 | 保持 | 保持 |
### 11.3 新增信号文件检查清单
当需要在系统中新增一个信号文件(如 `submit_progress.log`)时,必须检查以下事项:
1. **写入方**:哪个模块负责写入?写入时机是什么?
2. **读取方**SSE 端点是否需要轮询?前端是否需要处理?
3. **清理时机**:是否需要在 `_run_agent_task` 中清理?如果不需要,为什么?
4. **原子性**:写入是否需要 `.tmp` + `replace` 模式?
5. **轮询偏移**SSE 端点是否需要跟踪该文件的读取偏移?
6. **更新本文档**:在 1.3 操作信号点清单中新增一行,在 11.2 文件状态表中新增一列
7. **更新 `_run_agent_task`**:如果需要清理,在清理循环中添加文件名
8. **更新前端**:在 `sse.js``agent.js` 中添加对应的事件处理器

View File

@@ -1,401 +0,0 @@
# 项目架构全景图
> 最后更新: 2026-06-15
> 用途: 理解项目整体结构、模块职责、依赖关系和数据流
---
## 一、分层架构总览
```
src/
├── agent/ Agent 调度层(协调提取-校验-修正循环,状态机管理)
├── core/ 核心业务层(纯逻辑,零框架依赖)
├── infra/ 基础设施层浏览器、文档、LLM 提示词)
├── web/ Web 界面层Flask + SSE
├── pipeline.py CLI 流程编排
├── pipeline_core.py CLI/Web 公共管道逻辑
├── main.py CLI 入口
├── config.py 配置加载
└── exceptions.py 异常定义
```
### 依赖方向
```mermaid
graph TD
classDef entry fill:#e8eaf6,stroke:#3f51b5,color:#1a237e
classDef orchestrate fill:#e0f2f1,stroke:#00897b,color:#004d40
classDef agent fill:#fff8e1,stroke:#ff8f00,color:#3e2723
classDef core fill:#e3f2fd,stroke:#1565c0,color:#0d47a1
classDef infra fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
subgraph 入口层
CLI["main.py"]:::entry
WEB["web/app.py"]:::entry
end
subgraph 编排层
PIPE["pipeline.py"]:::orchestrate
PIPE_WEB["web/pipeline_web.py"]:::orchestrate
PIPE_CORE["pipeline_core.py"]:::orchestrate
end
subgraph Agent调度层
AGENT["agent/orchestrator.py"]:::agent
SESSION["agent/session.py"]:::agent
EVENTS["agent/events.py"]:::agent
end
subgraph 核心业务层
EXTRACT["core/extraction/"]:::core
MATCH["core/matching/"]:::core
VALID["core/validation/"]:::core
end
subgraph 基础设施层
BROWSER["infra/browser/"]:::infra
DOCS["infra/documents/"]:::infra
LLM["infra/llm/"]:::infra
end
CLI --> PIPE
WEB --> PIPE_WEB
PIPE --> PIPE_CORE
PIPE --> EXTRACT
PIPE --> BROWSER
PIPE_WEB --> AGENT
PIPE_WEB --> PIPE_CORE
PIPE_WEB --> EXTRACT
AGENT --> EXTRACT
AGENT --> VALID
AGENT --> LLM
AGENT --> PIPE_CORE
EXTRACT --> MATCH
EXTRACT --> DOCS
EXTRACT --> LLM
MATCH --> DOCS
BROWSER --> DOCS
```
**关键约束**
- `infra` 不依赖 `core``agent`,只提供工具能力
- `core` 零外部依赖,不依赖 Flask、Playwright 等框架
- `agent` 依赖 `core``infra`,作为调度中枢编排各模块
- 所有跨层调用均通过 `__init__.py` 导出的稳定接口
---
## 二、模块清单
### 2.1 Agent 调度层 (`src/agent/`)
| 文件 | 职责 |
|------|------|
| `coordinator.py` | 核心协调逻辑:提取-校验-修正循环(最多 3 次重试)、用户补充处理、强制提交 |
| `session.py` | 会话状态:`AgentState` 枚举、`AgentSession` 数据类、状态持久化(原子写入) |
| `events.py` | SSE 事件发射:事件去重、事件日志追加、事件读取 |
| `orchestrator.py` | 兼容层:从子模块重新导出所有符号,保持旧导入路径可用 |
**对外接口**`AgentSession`, `AgentState`, `run_agent_round()`, `force_submit()`, `add_supplement()`, `process_user_text_supplement()`, `load_agent_state()`, `save_agent_state()`
### 2.2 核心业务层 (`src/core/`)
| 子模块 | 职责 | 对外接口 |
|------|------|------|
| `extraction/extractor.py` | 编排入口:扫描目录 → 逐文件提取 → 分类 → 金额匹配 | `extract_invoices()`, `extract_document()` |
| `extraction/llm_extractor.py` | LLM 多模态提取核心:统一文档提取、差旅/普通信息提取、缓存管理、SSE 流式事件 | `llm_query_text()`, `extract_travel_info()`, `extract_normal_info()`, `load_cache()` |
| `matching/matcher.py` | 发票与支付记录按金额匹配(一对一 / 一对多贪心,相对容差 3% | `match_invoices_to_cards()` |
| `validation/validator.py` | 声明式规则校验引擎,规则从 JSON 配置文件加载 | `validate_extracted_info()`, `ValidationReport` |
### 2.3 基础设施层 (`src/infra/`)
| 子模块 | 职责 | 对外接口 |
|------|------|------|
| `browser/base.py` | `BaseBot` 基类Playwright 浏览器生命周期、登录、导航、截图 | 内部基类 |
| `browser/travel.py` | 差旅报销填报:基本信息 → 明细 → 支付 → 补助 → 附件上传 | 内部流程 |
| `browser/normal.py` | 普通报销填报:基本信息 → 总明细 → 支付 → 附件上传 | 内部流程 |
| `browser/__init__.py` | 浏览器入口:类型路由和流程调度 | `run_bot()`, `run_bot_web()` |
| `documents/invoice.py` | 发票数据模型、CSV/JSON 读写、发票分类 | `load_csv()`, `save_csv()`, `save_invoice_csv()`, `classify_invoice_batch()` |
| `documents/pdf.py` | PDF 渲染为图片PyMuPDF | `render_pdf_to_images()` |
| `documents/consumable.py` | 易耗品出库单填写CSV → Word 模板 | `fill_consumable_doc()` |
| `llm/prompt.py` | LLM 提示词加载 | `build_invoice_system_prompt()`, `build_travel_info_system_prompt()`, `build_normal_info_system_prompt()` |
### 2.4 Web 界面层 (`src/web/`)
| 文件/目录 | 职责 |
|------|------|
| `app.py` | Flask 应用入口,注册蓝图和模板 |
| `routes.py` | 路由定义会话管理、文件上传、配置、SSE 日志流、Agent 交互 API |
| `pipeline_web.py` | Web 管道逻辑:发票提取 + 出库单生成 + 财务提交 |
| `sse_handler.py` | SSE 日志收集器、日志转义、文件轮询 |
| `templates/` | `index.html`PC 端主界面)、`mobile_upload.html`(移动端上传) |
| `static/js/` | 前端逻辑(按加载顺序):`state.js``utils.js``chat.js``upload.js``config.js``process.js``sync.js``index.js` |
---
## 三、CLI 模式数据流
```mermaid
graph TD
CLI_ENTRY["main.py --step all"] --> PIPE["pipeline.py run_pipeline()"]
subgraph Step1["Step 1: 发票提取"]
PIPE --> EXT["core/extraction/extractor.py extract_invoices()"]
EXT --> DOC["逐文件提取"]
DOC --> LLM["LLM 多模态识别 (infra/llm)"]
LLM --> CLASS["分类: train/hotel/general/payment/application"]
CLASS --> MATCH["core/matching/matcher.py 金额匹配"]
MATCH --> SAVE["infra/documents/ CSV/JSON 保存"]
end
subgraph Step2["Step 2: 信息提取"]
SAVE --> TYPE{"判断报销类型"}
TYPE -->|差旅| TRAVEL["提取差旅信息 → travel_info.json"]
TYPE -->|普通| NORMAL["提取普通发票信息 → normal_info.json"]
end
subgraph Step3["Step 3: 浏览器填报"]
TRAVEL --> BOT["infra/browser/ 填报"]
NORMAL --> BOT
BOT -->|差旅| BOT_T["browser/travel.py"]
BOT -->|普通| BOT_N["browser/normal.py"]
end
```
**关键文件输出**
| 文件 | 来源 | 说明 |
|------|------|------|
| `payment_records.csv` | Step 1 | 支付记录级别(每笔刷卡记录一行) |
| `invoice_summary.csv` | Step 1 | 发票级别(每张发票一行) |
| `travel_applications.json` | Step 1 | 出差事前申请单 |
| `invoice_groups.json` | Step 1 | 发票分类结果 |
| `travel_info.json` | Step 2 | 差旅信息:交通/住宿明细、补贴、附件清单 |
| `normal_info.json` | Step 2 | 普通发票信息:报销说明、发票总数、总金额、附件清单 |
---
## 四、Web 模式数据流
```mermaid
sequenceDiagram
participant F as 前端 (浏览器)
participant API as routes.py
participant PW as pipeline_web.py
participant AG as agent/orchestrator.py
participant EX as core/extraction/
participant VA as core/validation/
participant SSE as SSE 轮询
F->>API: POST /api/session → 创建 session
F->>API: POST /api/upload/:sid → 上传文件
F->>API: POST /api/agent/process/:sid
API-->>F: {status: "started"}
F->>SSE: GET /api/logs/:sid (SSE 长连接)
Note over API: 后台 daemon 线程启动
API->>PW: extract_invoices(session_dir)
PW->>EX: 发票提取 + 分类 + 匹配
EX-->>PW: payment_records, applications, groups
API->>AG: run_agent_round(session_dir, session)
loop 校验-修正循环 (最多 3 次)
AG->>EX: llm_query_text() 提取信息
AG->>VA: validate_extracted_info() 规则校验
alt 校验失败
AG->>AG: 构建修正提示
end
end
AG-->>API: session (READY 或 AWAITING_SUPPLEMENT)
SSE-->>F: file_progress, llm_stream, agent_state_change, agent_ready/agent_request_supplement
API->>API: 写入 result.json
SSE-->>F: done (携带 result)
F->>F: 关闭 SSE, 展示结果
```
### Web 模式特有的 Agent 调度
CLI 模式中 `pipeline.py` 直接调用 `extract_invoices()``infra/browser/`,不经过 Agent 层。
Web 模式中 `routes.py` 启动后台线程,调用 `agent/orchestrator.py` 作为调度中枢:
```
run_agent_round()
├── 1. load_cache() — 检查缓存
├── 2. _do_extraction_with_validation() — 提取-校验-修正循环
│ ├── llm_query_text() — LLM 提取结构化信息
│ ├── validate_extracted_info() — 规则校验
│ └── 校验失败 → 构建修正提示 → 再次调用 LLM (最多 3 次)
├── 3. 判断 can_submit 字段
│ ├── true → READY → 自动触发财务提交
│ └── false → AWAITING_SUPPLEMENT → 等待用户补充
├── 4. 用户补充处理
│ ├── add_supplement() — 记录补充文件
│ └── process_user_text_supplement() — LLM 解析文字补充
└── 5. save_agent_state() — 持久化状态
```
---
## 五、Agent 状态机
```mermaid
stateDiagram-v2
[*] --> IDLE: 会话创建
IDLE --> EXTRACTING: POST /api/agent/process
IDLE --> EXTRACTING: POST /api/agent/supplement
IDLE --> EXTRACTING: POST /api/agent/user-supplement
EXTRACTING --> READY: can_submit == true
EXTRACTING --> AWAITING_SUPPLEMENT: can_submit == false
EXTRACTING --> ERROR: 异常 / 轮次超限
READY --> SUBMITTING: _emit_ready_and_submit()
SUBMITTING --> DONE: 财务提交完成
AWAITING_SUPPLEMENT --> EXTRACTING: 用户补充文件/文字
AWAITING_SUPPLEMENT --> READY: 用户强制提交
note right of EXTRACTING
LLM 提取 + validator 校验
最多 3 次重试
end note
```
### 终态保护
以下状态为终态,再次触发 `run_agent_round()` 会被跳过:
- `DONE` — 提交完成
- `SUBMITTING` — 提交中
- `READY` — 准备提交
### 轮次保护
默认最多 5 轮(`AgentSession.max_rounds`),超限后进入 `ERROR` 状态,用户可选择强制提交。
---
## 六、SSE 事件通信机制
```mermaid
graph LR
subgraph 后端写入
AGENT[agent/orchestrator.py] -->|追加写入| AE[agent_events.log]
LLM[LLM 回调] -->|追加写入| LS[llm_stream.log]
PW[pipeline_web.py] -->|追加写入| FE[file_events.log]
SH[sse_handler.py] -->|追加写入| SL[session.log]
RT[_run_agent_task] -->|finally 原子写入| RJ[result.json]
end
subgraph SSE 轮询 (0.5s)
POLL[SSE 端点] -->|读取| AE
POLL -->|读取| LS
POLL -->|读取| FE
POLL -->|读取| SL
POLL -->|检测| RJ
end
POLL -->|event: agent_*| FRONT[前端 agent.js]
POLL -->|event: llm_stream| FRONT
POLL -->|event: file_progress| FRONT
POLL -->|event: done| FRONT
```
### 信号文件生命周期
| 阶段 | `result.json` | `llm_stream.log` | `agent_events.log` | `file_events.log` | `session.log` |
|------|:--:|:--:|:--:|:--:|:--:|
| 会话创建 | 不存在 | 不存在 | 不存在 | 不存在 | 不存在 |
| 后台线程启动 | 已删除 | 已删除 | 已删除 | 保持 | 保持 |
| 文件提取中 | 不存在 | 不存在 | 不存在 | 持续追加 | 持续追加 |
| LLM 提取中 | 不存在 | 持续追加 | 持续追加 | 保持 | 持续追加 |
| 校验中 | 不存在 | 保持 | 持续追加 | 保持 | 持续追加 |
| 任务完成 | 已写入 | 保持 | 保持 | 保持 | 保持 |
| SSE done 事件 | 保持 | 保持 | 保持 | 保持 | 保持 |
---
## 七、发票类型路由
```mermaid
graph TD
INPUT["上传文件 (PDF/图片)"] --> EXT["LLM 多模态识别"]
EXT --> TYPE{"invoice_type?"}
TYPE -->|train| TRAVEL["差旅报销流程"]
TYPE -->|hotel| TRAVEL
TYPE -->|general| NORMAL["普通报销流程"]
TYPE -->|payment| MATCH["参与金额匹配"]
TYPE -->|application| APP["存储为 JSON"]
TRAVEL --> TRAVEL_INFO["提取差旅信息<br/>travel_info.json"]
TRAVEL_INFO --> TRAVEL_BOT["browser/travel.py<br/>填报差旅报销单"]
NORMAL --> NORMAL_INFO["提取普通发票信息<br/>normal_info.json"]
NORMAL_INFO --> NORMAL_BOT["browser/normal.py<br/>填报普通报销单"]
NORMAL_INFO --> CONSUMABLE["生成易耗品出库单<br/>(仅普通报销)"]
MATCH --> MERGE["合并到对应发票组"]
style TRAVEL fill:#cfe2ff,stroke:#0d6efd
style NORMAL fill:#f8d7da,stroke:#dc3545
style MATCH fill:#d1e7dd,stroke:#198754
style APP fill:#fff3cd,stroke:#ffc107
```
| 发票类型 | `invoice_type` | 报销流程 | 生成出库单 |
|----------|---------------|---------|:--:|
| 高铁票/火车票 | `train` | 差旅报销 | 否 |
| 酒店住宿 | `hotel` | 差旅报销 | 否 |
| 普通发票 | `general` | 普通报销 | 是 |
| 支付记录 | `payment` | 参与匹配 | 否 |
| 出差申请单 | `application` | 单独存储 | 否 |
> 差旅发票和普通发票不支持混报,混合时系统按普通报销处理。
---
## 八、设计原则
| 原则 | 说明 |
|------|------|
| **Agent 是调度中枢** | 校验-修正循环由 Agent 编排,不内嵌在 `llm_extractor` 中 |
| **模块职责单一** | `llm_extractor` 只管提取,`validator` 只管校验Agent 负责编排 |
| **core 零外部依赖** | 不依赖 Flask、Playwright 等框架 |
| **infra 不依赖业务** | 基础设施层只提供工具能力,不包含业务逻辑 |
| **缓存优先** | 信息提取优先读取 `.invoice_cache`,避免重复调用 LLM |
| **轮次保护** | 默认 5 轮上限,校验-修正循环最多重试 3 次 |
| **终态保护** | `DONE`/`SUBMITTING`/`READY` 状态下不再重复处理 |
| **容错降级** | 规则校验 3 次重试后返回最佳结果,不阻断流程 |
| **原子写入** | 状态文件先写 `.tmp``rename()`,防止读取不完整数据 |
---
## 九、关键文件索引
| 文件 | 职责 |
|------|------|
| `src/main.py` | CLI 入口 |
| `src/web/app.py` | Web 入口 |
| `src/pipeline.py` | CLI 流程编排 |
| `src/pipeline_core.py` | CLI/Web 公共管道逻辑 |
| `src/web/pipeline_web.py` | Web 管道逻辑 + 财务提交 |
| `src/web/routes.py` | Web 路由 + 后台线程启动 |
| `src/agent/coordinator.py` | Agent 核心协调逻辑 |
| `src/agent/session.py` | 会话状态定义与持久化 |
| `src/agent/events.py` | SSE 事件发射 |
| `src/core/extraction/extractor.py` | 发票提取编排入口 |
| `src/core/extraction/llm_extractor.py` | LLM 多模态提取核心 |
| `src/core/matching/matcher.py` | 金额匹配 |
| `src/core/validation/validator.py` | 声明式规则校验 |
| `src/infra/browser/base.py` | 浏览器自动化基类 |
| `src/infra/documents/invoice.py` | 发票数据模型 |
| `src/web/sse_handler.py` | SSE 日志收集器 |
| `src/web/static/js/process.js` | 前端主提交流程 |
| `src/web/static/js/agent.js` | 前端 Agent 交互处理 |
| `config.json` | 项目配置 |

View File

@@ -1,20 +0,0 @@
---
last_reviewed: 2026-06-15
---
# .agents/docs/plans — 实施方案与工作交接
存放项目实施方案、架构分析报告、重构计划等规划类文档。
## 文件
| 文件 | 说明 |
|------|------|
| `架构分析-2026-06-15.md` | 项目架构分析与重构建议(模块拆分、分层设计、接口契约) |
## 用途
- 架构决策记录
- 重构实施方案
- 工作交接说明
- 技术选型论证

View File

@@ -1,225 +0,0 @@
# 项目架构分析与重构建议
## 一、当前架构总览
```
src/
├── main.py # CLI 入口
├── pipeline.py # CLI 管道编排
├── pipeline_core.py # CLI/Web 公共管道逻辑
├── config.py # 配置加载
├── exceptions.py # 异常定义
├── doc/ # 文档处理模块(职责过重)
│ ├── extractor.py # 发票提取编排
│ ├── llm_extractor.py # LLM 提取核心
│ ├── invoice.py # 发票数据模型 + CSV 工具
│ ├── matcher.py # 发票匹配逻辑
│ ├── validator.py # 信息校验规则
│ ├── prompt.py # 提示词加载
│ ├── pdf.py # PDF 渲染
│ ├── fill_consumable_doc.py # 出库单填写
│ └── prompts/ # LLM 提示词模板
├── agent/ # Agent 调度模块
│ └── orchestrator.py # 校验-修正循环调度
├── bot/ # 浏览器自动化模块
│ ├── base.py # 浏览器基类
│ ├── travel.py # 差旅填报
│ └── normal.py # 普通报销填报
└── web/ # Web 界面模块
├── app.py # Flask 应用
├── routes.py # 路由定义
├── pipeline_web.py # Web 管道逻辑(与 pipeline_core 重复)
├── sse_handler.py # SSE 日志流处理
└── static/templates/ # 前端资源
```
---
## 二、问题分析
### 2.1 职责不清(高耦合)
| 问题 | 位置 | 说明 |
|------|------|------|
| **doc 模块职责过重** | `src/doc/` | 同时负责提取、匹配、校验、提示词、PDF渲染、出库单填写、CSV操作 |
| **Web 层重复逻辑** | `pipeline_web.py` vs `pipeline_core.py` | 两者的 `is_travel_invoice``extract_and_cache_*` 逻辑重复 |
| **提示词与校验耦合** | `validator.py` | 校验规则直接引用提示词相关函数,缺乏分层 |
| **bot 模块位置** | `src/bot/` | 浏览器自动化属于基础设施,却被放在 src 根目录而非独立模块 |
### 2.2 逻辑混乱
1. **`src/doc/validator.py`** 的问题:
- 校验规则(`TRAVEL_VALIDATION_RULES`)硬编码在模块中,修改需改代码
- `FieldRule``ArrayRule` 类与校验逻辑紧耦合
- 数组元素字段支持简单格式和详细格式两种配置,增加了理解成本
2. **`src/doc/prompt.py`** 的问题:
- 简单的文件读取包装,但调用方分散
- `build_invoice_system_prompt()``build_travel_info_system_prompt()` 分别调用,但结构相似
3. **`src/agent/orchestrator.py`** 的问题:
- 校验循环与提取逻辑混合在 `_do_extraction_with_validation`
- SSE 事件发射逻辑(`_emit_agent_event`)与业务逻辑混杂
- 状态机转换逻辑分散
### 2.3 分层不合理
```
当前分层(按目录):
main.py → pipeline.py → doc/ + bot/
pipeline_web.py → web/
建议分层(按职责):
应用层: main.py, pipeline.py, pipeline_web.py
业务层: agent/orchestrator.py, doc/validator.py, doc/matcher.py
提取层: doc/extractor.py, doc/llm_extractor.py
基础设施层: bot/, web/, doc/pdf.py, doc/fill_consumable_doc.py
```
---
## 三、重构建议
### 3.1 目录重组
```
src/
├── main.py # CLI 入口
├── config.py # 配置加载
├── exceptions.py # 异常定义
├── apps/ # 应用层(管道编排)
│ ├── cli/ # CLI 应用
│ │ └── pipeline.py
│ └── web/ # Web 应用
│ ├── app.py
│ ├── routes.py
│ ├── pipeline.py # Web 专用管道
│ └── sse.py
├── core/ # 核心业务逻辑
│ ├── agent/ # Agent 调度
│ │ ├── orchestrator.py
│ │ └── session.py
│ ├── validation/ # 校验模块
│ │ ├── validator.py
│ │ └── rules/ # 校验规则(可配置化)
│ ├── matching/ # 匹配模块
│ │ └── matcher.py
│ └── extraction/ # 提取模块
│ ├── extractor.py
│ └── llm.py
├── infra/ # 基础设施层
│ ├── browser/ # 浏览器自动化
│ │ ├── base.py
│ │ ├── travel.py
│ │ └── normal.py
│ ├── documents/ # 文档处理
│ │ ├── invoice.py
│ │ ├── pdf.py
│ │ └── consumable.py
│ └── llm/ # LLM 接口
│ └── prompts/ # 提示词模板
└── shared/ # 共享工具
├── logging.py
└── cache.py
```
### 3.2 关键重构点
#### 3.2.1 doc 模块拆分
| 职责 | 建议移动位置 |
|------|-------------|
| `validator.py` | `core/validation/` |
| `matcher.py` | `core/matching/` |
| `llm_extractor.py` | `core/extraction/` |
| `extractor.py` | `core/extraction/` |
| `invoice.py` | `infra/documents/` |
| `pdf.py` | `infra/documents/` |
| `fill_consumable_doc.py` | `infra/documents/` |
| `prompt.py` + `prompts/` | `infra/llm/` |
#### 3.2.2 消除重复逻辑
**问题**: `pipeline_web.py``pipeline_core.py` 都有相似逻辑:
- `is_travel_invoice()`
- `extract_and_cache_travel_info()`
- `extract_and_cache_normal_info()`
**建议**: 将这些公共逻辑统一到 `core/pipeline/` 目录,两个入口调用同一模块。
#### 3.2.3 Validator 重构
**当前问题**:
- 校验规则硬编码
- `FieldRule``ArrayRule` 类过于复杂
**建议**:
- 将校验规则外部化为 JSON/YAML 配置文件
- 简化 `FieldRule` 为单一数据结构
- 统一顶层字段和数组元素字段的校验方式
#### 3.2.4 Agent 拆分
**当前问题**:
- `orchestrator.py` 包含状态机、SSE 事件、校验循环、提取逻辑
**建议**:
```
agent/
├── session.py # 状态机定义 + 会话数据模型
├── coordinator.py # 校验-修正循环
├── events.py # SSE 事件发射
└── orchestrator.py # 总调度入口
```
### 3.3 接口契约强化
| 模块 | 依赖关系 | 接口契约 |
|------|----------|----------|
| `core/extraction` | 被 `apps/*` 调用 | 返回 `(payment_records, applications, groups)` |
| `core/validation` | 被 `agent/*` 调用 | `validate(info, rules) -> ValidationReport` |
| `core/matching` | 被 `extraction` 调用 | `match(invoices, cards) -> List[Dict]` |
| `infra/browser` | 被 `apps/*` 调用 | `run(bot, info) -> None` |
| `infra/llm` | 被 `core/extraction` 调用 | `extract_document(file) -> dict` |
---
## 四、优先重构顺序
### 第一阶段(降低耦合)
1.`doc/` 拆分为 `core/` + `infra/`
2. 消除 `pipeline_web.py``pipeline_core.py` 的重复逻辑
3.`bot/` 移动到 `infra/browser/`
### 第二阶段(职责清晰化)
4. 拆分 `agent/orchestrator.py` 为多个模块
5. 外部化 `validator.py` 的校验规则为配置文件
6. 统一 SSE 事件处理接口
### 第三阶段(可维护性)
7. 完善 `__init__.py` 的接口导出
8. 添加模块间依赖注入机制
9. 建立跨模块调用规范
---
## 五、当前项目优点
1. **日志规范**: 统一的 `get_logger()` 方式,全局日志管理
2. **异常体系**: 清晰的 `ReimbursementError` 异常层次
3. **SSE 事件协议**: 良好的实时反馈机制
4. **缓存设计**: `llm_extractor.py` 的缓存加载逻辑完善
5. **声明式校验**: `validator.py` 的规则配置思路正确
---
*生成时间: 2026-06-15*

View File

@@ -1,107 +0,0 @@
---
name: clean-git-history
description: >-
Remove sensitive files and directories from Git commit history using git-filter-repo.
Use when the user wants to remove secrets, credentials, uploaded files, or any sensitive data
that was accidentally committed to Git history. Also use when the user mentions cleaning
Git history, removing leaked files, or scrubbing sensitive information from repositories.
---
# Clean Git History
Remove sensitive files from Git history using `git-filter-repo`. This is a destructive operation that rewrites commit history.
## Prerequisites
Install `git-filter-repo` if not already available:
```powershell
python -m pip install git-filter-repo
```
## Safety Checklist
Before proceeding, verify:
- [ ] Local source code is intact (`git log --oneline` shows expected commits)
- [ ] Remote repository is accessible (`git fetch origin` succeeds)
- [ ] Sensitive files are identified in history (`git log --all --pretty=format: --name-only | Select-String "pattern"`)
## Step-by-Step Workflow
### 1. Identify Sensitive Files
Check what sensitive paths exist in history:
```powershell
git log --all --pretty=format: --name-only | Select-String "\.env|uploads/|images/|scripts/data/|logs/" | Sort-Object -Unique
```
### 2. Clean One Path at a Time
Remove each sensitive path separately, verifying after each step:
```powershell
# Remove .env from history
python -m git_filter_repo --path .env --invert-paths --force
# Remove uploads directory from history
python -m git_filter_repo --path src/web/uploads/ --invert-paths --force
# Remove images directory from history
python -m git_filter_repo --path images/ --invert-paths --force
```
**Critical**: Always use `--invert-paths` to exclude files. Without it, `--path` keeps only those files and deletes everything else.
### 3. Verify Cleanup
Confirm sensitive files are gone:
```powershell
git log --all --pretty=format: --name-only | Select-String "\.env|uploads/|images/" | Sort-Object -Unique
```
Result should be empty.
### 4. Restore Remote and Push
`git-filter-repo` removes the origin remote. Re-add and force push:
```powershell
# Re-add remote (replace with actual URL)
git remote add origin <remote-url>
# Force push cleaned history
git push --force origin <branch-name>
```
If multiple branches exist, push each one:
```powershell
git push --force origin master
git push --force origin feature/table
```
### 5. Final Verification
Verify remote history is clean:
```powershell
git fetch origin
git log --all --pretty=format: --name-only | Select-String "\.env|uploads/|images/" | Sort-Object -Unique
```
## Common Pitfalls
| Mistake | Consequence | Fix |
|---------|-------------|-----|
| Missing `--invert-paths` | Deletes all files except the listed ones | Restore from remote: `git reset --hard origin/<branch>` |
| Wrong Python environment | `No module named git_filter_repo` | Use `python -m pip install git-filter-repo` in current environment |
| Forgetting to restore remote | Cannot push changes | Re-add remote with `git remote add origin <url>` |
## Post-Cleanup Actions
- Rotate any secrets that were exposed in history
- Update `.gitignore` to prevent re-committing sensitive files
- Notify team members to re-clone the repository (old clones still contain sensitive history)

BIN
.coverage

Binary file not shown.

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.6
rev: v0.9.6
hooks:
- id: ruff
args: [--fix]

View File

@@ -1,96 +1,19 @@
---
description:
alwaysApply: true
last_reviewed: 2026-06-09
---
---
last_reviewed: 2026-07-02
---
# AGENTS 索引
# AGENTS — 项目操作指南
本文件为 Agent 提供高信号量的项目操作知识,避免重复探索。
本文件是规则的入口。详细策略文本位于 `.agents/docs/standards/*.md`
## 文档边界
* **禁止使用表情文字**输出任何内容
* `docs/` 目录存放面向开源用户、外部贡献者的公开文档。
* `.agents/` 目录存放维护规范、实施方案、经验总结等内部资料
* 每个文件夹下都有 `README.md` 说明该文件夹的作用和重要信息。
* `docs/` 目录专门存放面向开源用户、外部贡献者的项目公开文档及说明文件
* 维护规范、实施方案、经验总结、拉取请求佐证材料与各类内部记录资料,均统一放置在 `.agents/` 目录下,避免内部自动化流程相关内容混入公开文档目录
* 每个文件夹下都有一个 `README.md` 文件用来交代这个文件夹的作用以及重要的信息
## 开发命令(必须使用 uv
## 标准目录
项目使用 `uv` 管理依赖,所有包版本锁定在 `uv.lock` 中。
| 操作 | Makefile (跨平台) | tasks.py (Windows) |
|------|-------------------|---------------------|
| 安装依赖 + pre-commit | `make install` | `python tasks.py install` |
| 代码检查lint+format+typecheck+deptry | `make check` | `python tasks.py check` |
| 运行测试(含覆盖率报告) | `make test` | `python tasks.py test` |
| 运行 CLI 全流程 | `make run` | `python tasks.py run` |
| 清理缓存和虚拟环境 | `make clean` | `python tasks.py clean` |
**注意:** `tasks.py` 中的 `check` 命令使用 `&&` 连接Windows PowerShell 不支持 `&&`,但 `tasks.py` 内部已处理为单行字符串。
## 代码质量工具链(执行顺序)
1. **Ruff lint**`uv run ruff check .` (select: E, F, W, I, N, UP, B; ignore: E501)
2. **Ruff format**`uv run ruff format --check .` (line-length: 120)
3. **MyPy strict mode**`uv run mypy src/main.py` (strict=true, warn_return_any, ignore_missing_imports)
4. **deptry**`uv run deptry .` (检测未声明、未使用、过时依赖)
### pre-commit 钩子(仅 Ruff
`.pre-commit-config.yaml` 配置了两个 hook
- `ruff --fix` — lint 并自动修复
- `ruff-format` — 格式化
**注意:** MyPy 和 deptry **不在** pre-commit 中,需要手动运行 `make check`
## 项目架构Agent 调度模式)
核心入口:`src/agent/orchestrator.py` — Agent 是负责调度的中枢,协调以下模块:
- `extraction/extractor.py` — 文件扫描 → LLM 多模态提取 → JSON 缓存
- `matching/matcher.py` — 支付记录与发票金额匹配
- `validation/validator.py` — 声明式校验器(规则配置与引擎分离)
- `infra/browser/travel.py` / `normal.py` — 浏览器自动化填报
### 数据流关键产物
| 文件 | 生成阶段 | 作用 |
|------|---------|------|
| `.invoice_cache/*.json` | extractor 提取 | 单张发票/支付记录的结构化数据 |
| `match_result.json` | matcher 匹配 | 支付截图与发票的关联关系 |
| `travel_info.json` / `normal_info.json` | LLM 综合提取 | 差旅/普通报销所需的全部结构化数据 |
| `invoice_summary.csv` | extractor 提取 | 普通发票汇总(用于生成易耗品出库单) |
### 缓存机制
CLI 模式:`scripts/data/.invoice_cache/`
Web 模式:`src/web/uploads/<session_id>/.invoice_cache/`
缓存文件与源文件同名(如 `发票1.pdf``.invoice_cache/发票1.json`),后续步骤均从缓存读取。删除缓存后下次处理会重新提取。
## 重要约束
* **Windows-only**:易耗品出库单填写依赖 Microsoft Word + COM (`pywin32`),仅 Windows 可用
* **浏览器自动化**:使用 Playwright填报时会打开 Chromium请勿手动干扰
* **敏感信息**`scripts/config.json` 含登录凭据,勿提交到公开仓库
* **发票类型区分**:差旅发票(高铁票/酒店住宿)不生成易耗品出库单,走差旅报销流程;普通发票生成出库单
## Web 服务
```bash
uv run python src/web/app.py
# 访问 http://localhost:5000
```
Web 端浏览器填报以无头模式运行。会话产物存放在 `src/web/uploads/<session_id>/`,每次上传生成独立会话。
## 测试
```bash
make test # pytest + coverage report (term-missing)
```
测试目录:`tests/`,配置在 `pyproject.toml` 中 (`testpaths = ["tests"]`, `pythonpath = ["."]`)。
* 标准文档元数据:`.agents/docs/standards/README.md`
* 调试规范:`.agents/docs/standards/调试规范.md`
* 复利式工程实践:`.agents/docs/standards/复利式工程实践.md`

128
README.md
View File

@@ -18,40 +18,20 @@
├── src/
│ ├── __init__.py # 包初始化 / 日志器
│ ├── config.py # 配置加载
│ ├── exceptions.py # 异常定义
│ ├── bot.py # 浏览器自动填报
│ ├── pipeline.py # CLI 流程编排
│ ├── pipeline_core.py # CLI/Web 公共管道逻辑
│ ├── main.py # CLI 入口
│ ├── agent/ # Agent 调度模块
│ │ ├── orchestrator.py # 总调度入口
│ │ ├── coordinator.py # 校验-修正循环
│ │ ├── session.py # 状态机与会话数据
│ │ ── events.py # SSE 事件发射
│ ├── core/ # 核心业务逻辑
│ │ ├── extraction/ # 信息提取
│ │ │ ├── extractor.py # 编排入口:串联文件扫描 → 提取 → 分类
│ │ │ └── llm_extractor.py # LLM 多模态信息提取
│ ├── matching/ # 金额匹配
│ └── matcher.py # 支付记录与发票关联
│ │ └── validation/ # 校验模块
│ │ └── validator.py # 声明式校验器
│ ├── infra/ # 基础设施层
│ │ ├── browser/ # 浏览器自动化
│ │ │ ├── base.py # BaseBot 基类
│ │ │ ├── travel.py # 差旅报销填报流程
│ │ │ └── normal.py # 普通报销填报流程
│ │ ├── documents/ # 文档处理
│ │ │ ├── invoice.py # 发票数据模型 + CSV 工具
│ │ │ ├── pdf.py # PDF 图片渲染
│ │ │ └── consumable.py # 易耗品出库单填写Word COM
│ │ └── llm/ # LLM 接口
│ │ ├── prompt.py # 提示词加载
│ │ └── prompts/ # 提示词模板文件
│ └── web/ # Web 界面模块
│ ├── app.py # Flask 应用入口
│ ├── routes.py # 路由定义
│ ├── pipeline_web.py # Web 管道逻辑
│ ├── sse_handler.py # SSE 日志流处理
│ ├── doc/ # 文档处理模块
│ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类
│ │ ├── pdf.py # PDF 图片渲染PyMuPDF供多模态 LLM 使用)
│ │ ├── llm_extractor.py # LLM 信息提取
│ │ ── matcher.py # 数据匹配与校验
│ ├── invoice.py # 发票类型常量、分类逻辑、CSV 读写工具
│ │ ├── fill_consumable_doc.py # 将 CSV 填入易耗品出库单Word COM
│ │ ├── prompt.py # LLM 提示词模板
│ │ └── prompts/ # 提示词模板文件
└── web/
├── app.py # Web 服务入口
│ ├── templates/
│ │ ├── index.html # PC 端主页
│ │ └── mobile_upload.html # 移动端扫码上传
@@ -68,66 +48,6 @@
└── *.pdf / *.jpg / *.png # 发票 PDF 或图片CLI 模式,放在 scripts/data/
```
## 声明式校验器
`src/core/validation/validator.py` 采用**规则配置与校验引擎分离**的设计模式,支持声明式定义校验规则:
### 设计特点
| 特性 | 说明 |
|------|------|
| **声明式配置** | 校验规则以数据结构形式定义,无需编写代码 |
| **统一路径定位** | 使用 `path` 统一定位字段,如 `["basic_info", "travel_purpose"]` |
| **自定义校验函数** | 支持为字段定义自定义校验逻辑(日期格式、正数检查等) |
| **数组元素校验** | 支持校验数组字段的最小元素数量及每个元素的必填字段 |
| **向后兼容** | 支持简单格式 `["field1", "field2"]` 和详细格式 `{"path": [...], "custom_check": ...}` |
### 规则配置示例
```python
# 差旅报销校验规则
TRAVEL_VALIDATION_RULES = {
"fields": [
{"path": ["basic_info", "travel_purpose"], "description": "出差事由"},
{"path": ["basic_info", "start_date"], "custom_check": _is_valid_date},
],
"arrays": [
{
"path": ["payment_methods"],
"min_items": 1, # 至少1条支付记录
"element_fields": [
{"path": ["card_date"], "description": "刷卡日期"},
{"path": ["card_amount"], "custom_check": _is_positive_number},
],
},
],
}
```
### 校验规则类型
| 规则类型 | 用途 | 关键字段 |
|----------|------|----------|
| `fields` | 顶层单值字段校验 | `path`, `custom_check`, `check_empty` |
| `arrays` | 数组字段校验 | `path`, `min_items`, `element_fields` |
### 内置校验函数
- `_is_valid_date(value)` — 检查日期格式是否为 `YYYY-MM-DD`
- `_is_positive_number(value)` — 检查值是否为正数
### 扩展自定义校验
```python
# 定义自定义校验函数
def check_vehicle_type(value):
valid_types = ["飞机", "火车", "汽车", "打车"]
return isinstance(value, str) and value.strip() in valid_types
# 在规则中使用
{"path": ["vehicle_type"], "custom_check": check_vehicle_type}
```
## 数据流
```mermaid
@@ -153,21 +73,21 @@ flowchart TB
MatchResult --> NormalLLM
NormalLLM --> NormalInfo[(normal_info.json)]
TravelInfo -->|差旅基本信息| Bot_T[infra/browser/travel.py<br/>差旅填报流程]
TravelInfo -->|差旅基本信息| Bot_T[bot/travel.py<br/>差旅填报流程]
TravelInfo -->|报销明细| Bot_T
TravelInfo -->|支付方式| Bot_T
TravelInfo -->|补助清单| Bot_T
TravelInfo -->|附件清单| Bot_T
Bot_T --> Submit_T[差旅报销提交]
NormalInfo -->|报销说明| Bot_G[infra/browser/normal.py<br/>普通填报流程]
NormalInfo -->|报销说明| Bot_G[bot/normal.py<br/>普通填报流程]
NormalInfo -->|发票总数/金额| Bot_G
NormalInfo -->|支付方式| Bot_G
NormalInfo -->|附件清单| Bot_G
Bot_G --> Submit_G[普通报销提交]
General --> CSV[(invoice_summary.csv)]
CSV --> Fill[consumable.py]
CSV --> Fill[fill_consumable_doc]
Fill --> Doc[易耗品、出库单.doc]
```
@@ -183,14 +103,14 @@ flowchart TB
### bot 模块架构
`infra/browser/` 包负责浏览器自动化填报,仅接收已提取的信息并执行填报操作,不承担信息提取职责:
`bot/` 包负责浏览器自动化填报,仅接收已提取的信息并执行填报操作,不承担信息提取职责:
| 模块 | 职责 |
|------|------|
| `infra/browser/base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图 |
| `infra/browser/travel.py` | 差旅填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `infra/browser/normal.py` | 普通填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
| `infra/browser/__init__.py` | 入口函数:`run_bot()` / `run_bot_web()`,负责类型判断和流程路由 |
| `bot/base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图 |
| `bot/travel.py` | 差旅填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `bot/normal.py` | 普通填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
| `bot/__init__.py` | 入口函数:`run_bot()` / `run_bot_web()`,负责类型判断和流程路由 |
## 环境要求
@@ -280,10 +200,10 @@ uv run python src/main.py -u 工号 -p 密码
需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**
```bash
uv run python -m src.infra.documents.consumable
uv run python -m src.infra.documents.consumable --csv invoice_summary.csv --doc "易耗品、出库单.doc"
uv run python -m src.infra.documents.consumable --config scripts/config.json # 指定配置文件
uv run python -m src.infra.documents.consumable --no-backup # 不生成 .doc.bak 备份
uv run python -m src.doc.fill_consumable_doc
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
uv run python -m src.doc.fill_consumable_doc --config scripts/config.json # 指定配置文件
uv run python -m src.doc.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份
```
填写规则概要:

View File

@@ -1,24 +0,0 @@
---
last_reviewed: 2026-06-15
---
# config — 配置文件目录
## 文件
| 文件 | 说明 |
|------|------|
| `validation_rules.json` | 声明式校验规则配置:定义差旅和普通报销的必填字段、数组元素校验规则和自定义校验函数 |
## validation_rules.json 结构
```json
{
"version": "1.0",
"custom_checks": { ... },
"travel": { "fields": [...], "arrays": [...] },
"normal": { "fields": [...], "arrays": [...] }
}
```
校验引擎 `src/core/validation/validator.py` 在启动时读取此文件,若文件不存在则使用内置默认规则。

View File

@@ -1,135 +0,0 @@
{
"version": "1.0",
"custom_checks": {
"is_valid_date": "检查日期格式是否为 YYYY-MM-DD",
"is_positive_number": "检查是否为正数(整数或浮点数)",
"is_positive_integer": "检查是否为正整数"
},
"travel": {
"description": "差旅报销校验规则",
"fields": [
{
"path": ["basic_info", "travel_purpose"],
"required": true,
"check_empty": true,
"description": "出差事由"
},
{
"path": ["basic_info", "travel_location"],
"required": true,
"check_empty": true,
"description": "出差地点"
},
{
"path": ["basic_info", "start_date"],
"required": true,
"check_empty": true,
"custom_check": "is_valid_date",
"description": "出差开始日期"
},
{
"path": ["basic_info", "end_date"],
"required": true,
"check_empty": true,
"custom_check": "is_valid_date",
"description": "出差结束日期"
}
],
"arrays": [
{
"path": ["reimbursement_details", "transport_fee"],
"min_items": 1,
"description": "交通费用明细",
"element_fields": [
{"path": ["vehicle_type"], "required": true, "check_empty": true, "description": "交通工具类型"},
{"path": ["start_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "出发日期"},
{"path": ["end_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "到达日期"},
{"path": ["departure_place"], "required": true, "check_empty": true, "description": "出发地"},
{"path": ["arrival_place"], "required": true, "check_empty": true, "description": "目的地"},
{"path": ["amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "金额"},
{"path": ["bill_count"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "票据张数"},
{"path": ["remark"], "required": true, "check_empty": false, "description": "备注说明"}
]
},
{
"path": ["payment_methods"],
"min_items": 1,
"description": "支付方式记录",
"element_fields": [
{"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"},
{"path": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"},
{"path": ["merchant"], "required": true, "check_empty": true, "description": "商户名称"},
{"path": ["remark"], "required": true, "check_empty": false, "description": "备注"}
]
},
{
"path": ["subsidy_list"],
"min_items": 1,
"description": "补助清单",
"element_fields": [
{"path": ["person_id"], "required": true, "check_empty": true, "description": "人员工号"},
{"path": ["person_name"], "required": true, "check_empty": true, "description": "人员姓名"},
{"path": ["start_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "补助开始日期"},
{"path": ["end_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "补助结束日期"},
{"path": ["days"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "补助天数"}
]
},
{
"path": ["attachments"],
"min_items": 0,
"description": "附件列表",
"element_fields": [
{"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"},
{"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"}
]
}
]
},
"normal": {
"description": "普通报销校验规则",
"fields": [
{
"path": ["basic_info", "reimbursement_description"],
"required": true,
"check_empty": true,
"description": "报销事由"
},
{
"path": ["reimbursement_details", "total_invoices"],
"required": true,
"check_empty": true,
"custom_check": "is_positive_integer",
"description": "发票总数"
},
{
"path": ["reimbursement_details", "total_amount"],
"required": true,
"check_empty": true,
"custom_check": "is_positive_number",
"description": "总金额"
}
],
"arrays": [
{
"path": ["payment_methods"],
"min_items": 1,
"description": "支付方式记录",
"element_fields": [
{"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"},
{"path": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"},
{"path": ["merchant"], "required": true, "check_empty": true, "description": "商户名称"},
{"path": ["remark"], "required": true, "check_empty": false, "description": "备注"}
]
},
{
"path": ["attachments"],
"min_items": 0,
"description": "附件列表",
"element_fields": [
{"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"},
{"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"}
]
}
]
}
}

View File

@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-13
last_reviewed: 2026-06-09
---
# 财务报销自动化 — API 文档
@@ -12,30 +12,17 @@ last_reviewed: 2026-06-13
| # | 方法 | 路径 | 说明 |
|---|------|------|------|
| 1 | GET | `/` | PC 端主页 |
| 2 | GET | `/mobile/<session_id>` | 移动端上传页面 |
| 3 | POST | `/api/session` | 创建会话 |
| 4 | POST | `/api/upload/<session_id>` | 上传文件PDF/图片) |
| 5 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
| 6 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
| 7 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
| 8 | GET | `/api/config/<session_id>` | 获取会话配置 |
| 9 | GET | `/api/data/<session_id>` | 获取发票数据JSON |
| 10 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
| 11 | POST | `/api/process/<session_id>` | 启动管道处理(仅发票提取,不自动提交) |
| 12 | GET | `/api/logs/<session_id>` | SSE 日志流 |
| 13 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
| **14** | **GET** | **`/api/agent/state/<session_id>`** | **获取 Agent 会话状态** |
| **15** | **POST** | **`/api/agent/process/<session_id>`** | **启动 Agent 多轮处理(主入口)** |
| **16** | **POST** | **`/api/agent/supplement/<session_id>`** | **补充文件后重新分析** |
| **17** | **POST** | **`/api/agent/user-supplement/<session_id>`** | **通过文字补充信息** |
| **18** | **POST** | **`/api/agent/force-submit/<session_id>`** | **强制提交,跳过校验** |
> 加粗条目为 Agent 多轮校验流程新增接口。
### 入口选择建议
- **推荐使用** `/api/agent/process`完整流程包含发票提取、LLM 信息校验、自动提交财务系统。
- **仅发票提取** `/api/process`:跳过 Agent 校验,只做文档解析和发票分类。适合调试发票提取本身,或仅需导出 CSV 的场景。
| 2 | POST | `/api/session` | 创建会话 |
| 3 | POST | `/api/upload/<session_id>` | 上传文件PDF/图片) |
| 4 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
| 5 | POST | `/api/process/<session_id>` | 启动处理(提取+LLM识别+出库单) |
| 6 | GET | `/api/logs/<session_id>` | SSE 日志流 |
| 7 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
| 8 | GET | `/api/data/<session_id>` | 获取发票数据JSON |
| 9 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
| 10 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
| 11 | GET | `/mobile/<session_id>` | 移动端上传页面 |
| 12 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
---
@@ -43,7 +30,7 @@ last_reviewed: 2026-06-13
- 调用 `POST /api/session` 获得 `session_id`
- 该会话下所有文件存放在 `src/web/uploads/<session_id>/`
- 典型产物:`invoice_summary.csv``payment_records.csv``易耗品、出库单.doc``config.json``session.log``result.json``agent_state.json``agent_events.log``file_events.log``llm_stream.log`
- 典型产物:`invoice_summary.csv``易耗品、出库单.doc``config.json``session.log``result.json`
---
@@ -100,10 +87,6 @@ GET /api/files/<session_id>
```json
{
"files": [
{ "name": "1. 电容一批.pdf", "type": "pdf", "size": 12345 },
{ "name": "payment_01.jpg", "type": "image", "size": 67890 }
],
"pdfs": ["1. 电容一批.pdf"],
"images": ["payment_01.jpg"]
}
@@ -113,7 +96,130 @@ GET /api/files/<session_id>
---
### 4. 下载文件
### 5. 启动管道处理
```
POST /api/process/<session_id>
Content-Type: application/json
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 否 | 财务系统工号 |
| password | string | 否 | 登录密码 |
| default_name | string | 否 | 默认报销人姓名 |
| default_card_no | string | 否 | 默认公务卡号 |
| default_person_id | string | 否 | 默认人员编号 |
| consumable_storage | string | 否 | 出库单存放地点;未填则用 `config.json` 中的值 |
**处理内容:**
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
2. 对支付截图多模态 LLM 识别,回填刷卡字段
3. 根据发票类型自动分类:差旅发票(高铁票/酒店住宿)不生成出库单;普通发票从模板复制并自动填写
配置会写入 `src/web/uploads/<session_id>/config.json`
**响应(立即):**
```json
{ "status": "started" }
```
处理在后台线程执行,进度与结果通过 `GET /api/logs/<session_id>`SSE获取。
**SSE 完成时 `result` 示例(成功):**
```json
{
"ok": true,
"elapsed": "45.2s",
"invoice_count": 4,
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
"travel_count": 2,
"general_count": 2,
"doc_url": "/api/download/<session_id>/%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc",
"doc_ok": true
}
```
**纯差旅发票(跳过出库单生成):**
```json
{
"ok": true,
"invoice_count": 3,
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
"travel_count": 3,
"general_count": 0,
"doc_ok": null,
"doc_skipped": true,
"doc_message": "差旅发票无需生成易耗品出库单"
}
```
**出库单生成失败时CSV 等仍可能成功):**
```json
{
"ok": true,
"invoice_count": 4,
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
"travel_count": 2,
"general_count": 2,
"doc_ok": false,
"doc_error": "服务器未安装 pywin32无法生成 Word 出库单"
}
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `travel_count` | int | 差旅发票数量(高铁票/酒店住宿) |
| `general_count` | int | 普通发票数量 |
| `doc_ok` | bool/null | `true`=成功,`false`=失败,`null`=已跳过(纯差旅发票) |
| `doc_skipped` | bool | 是否因纯差旅发票而跳过出库单生成 |
| `doc_message` | string | 跳过时的提示信息 |
---
### 6. SSE 日志流
```
GET /api/logs/<session_id>
Accept: text/event-stream
```
**日志行格式:**
```
data: 2026-05-26 12:00:01 [INFO ] extractor: 正在提取发票...
```
**结束消息:**
```json
{
"type": "done",
"result": { }
}
```
`result` 结构取决于触发来源:
| 来源 | 典型字段 |
|------|----------|
| `/api/process` | `ok`, `elapsed`, `invoice_count`, `csv_url`, `travel_count`, `general_count`, `doc_url`, `doc_ok`, `doc_skipped`, `doc_error` |
| `/api/submit-financial` | `ok`, `submit_ok`, `submit_error` |
> SSE 超时时间为 10 分钟600 秒)。
---
### 7. 下载文件
```
GET /api/download/<session_id>/<filename>
@@ -131,12 +237,8 @@ GET /api/download/<session_id>/<filename>
| 文件名 | 说明 |
|--------|------|
| `invoice_summary.csv` | 发票汇总 |
| `payment_records.csv` | 支付记录 |
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
| `易耗品、出库单.doc` | 自动填写的出库单 |
| `travel_applications.json` | 差旅申请信息 |
| `result.json` | 处理结果 |
| `agent_state.json` | Agent 会话状态 |
**错误:**
@@ -148,56 +250,6 @@ HTTP `404`。`filename` 仅允许会话目录内的文件名(防止路径穿
---
### 5. 移动端上传页面
```
GET /mobile/<session_id>
```
返回移动端 HTML 页面。
---
### 6. 移动端上传图片
```
POST /api/mobile-upload/<session_id>
Content-Type: multipart/form-data
```
| 字段 | 类型 | 说明 |
|------|------|------|
| file | File | 图片文件 |
逻辑与 `POST /api/upload/<session_id>` 相同。
---
### 7. 获取会话配置
```
GET /api/config/<session_id>
```
获取当前会话的配置,供前端回填表单。优先读取会话目录下的 `config.json`,未找到则使用项目全局配置。
**响应:**
```json
{
"username": "",
"password": "",
"default_name": "",
"default_card_no": "",
"default_person_id": "",
"consumable_storage": ""
}
```
注意:`password` 字段始终返回空字符串。
---
### 8. 获取发票数据
```
@@ -208,7 +260,7 @@ GET /api/data/<session_id>
```json
{
"csv_filename": "payment_records.csv",
"csv_filename": "invoice_summary.csv",
"fields": [
"序号", "发票号码", "开票日期", "项目名称", "规格型号",
"价税合计", "销售方名称", "人员姓名", "刷卡日期",
@@ -225,8 +277,6 @@ GET /api/data/<session_id>
}
```
读取优先级:`payment_records.csv``invoice_summary.csv` → 任意 `.csv` 文件。
- `fields`:列顺序
- `data[].__row`:内部行索引(保存时不需要提交,服务端按数组顺序写回)
@@ -300,82 +350,7 @@ Content-Type: application/json
---
### 10. 启动管道处理(仅发票提取)
```
POST /api/process/<session_id>
Content-Type: application/json
```
> 此接口仅执行发票提取和 LLM 识别,**不会**触发 Agent 多轮校验,也**不会**自动提交到财务系统。如需完整的 Agent 校验流程,请使用 `/api/agent/process`。
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 否 | 财务系统工号 |
| password | string | 否 | 登录密码 |
| default_name | string | 否 | 默认报销人姓名 |
| default_card_no | string | 否 | 默认公务卡号 |
| default_person_id | string | 否 | 默认人员编号 |
| consumable_storage | string | 否 | 出库单存放地点 |
**处理内容:**
1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv`
2. 对支付截图多模态 LLM 识别,回填刷卡字段
3. 根据发票类型自动分类:差旅发票(高铁票/酒店住宿)不生成出库单;普通发票从模板复制并自动填写
配置会写入 `src/web/uploads/<session_id>/config.json`
**响应(立即):**
```json
{ "status": "started" }
```
处理在后台线程执行,进度与结果通过 `GET /api/logs/<session_id>`SSE获取。
**SSE 完成时 `result` 示例(成功):**
```json
{
"ok": true,
"elapsed": "45.2s",
"invoice_count": 4,
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
"travel_count": 2,
"general_count": 2,
"doc_url": "/api/download/<session_id>/%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc",
"doc_ok": true
}
```
---
### 11. SSE 日志流
```
GET /api/logs/<session_id>
Accept: text/event-stream
```
每 0.5 秒轮询 4 个日志文件,通过文件 size 增量检测新内容:
| 文件 | 内容 |
|------|------|
| `session.log` | 普通日志extractor、llm_extractor、matcher、pipeline、bot、agent、validator 等模块) |
| `file_events.log` | 文件处理进度事件 |
| `llm_stream.log` | LLM 流式输出 |
| `agent_events.log` | Agent 调度事件 |
检测到 `result.json` 存在时,读取后发送 `done` 事件并断开连接。
**SSE 超时:** 900 秒。
---
### 12. 提交到财务系统
### 10. 提交到财务系统
```
POST /api/submit-financial/<session_id>
@@ -383,12 +358,13 @@ POST /api/submit-financial/<session_id>
**前置条件:**
- 会话目录存在 `config.json`,否则返回 `400`
- 存在可用的发票 CSV通常为 `invoice_summary.csv``payment_records.csv`
- 会话目录存在 `config.json`(由 `/api/process` 写入),否则返回 `400`
- 存在可用的发票 CSV通常为 `invoice_summary.csv`
**说明:**
- 前端一般在提交前调用 `/api/save` 保存表格修改
- 本接口**不会**自动执行发票提取或 LLM 识别
- 根据发票类型选择填报模式:纯差旅发票走差旅报销流程,含普通发票走普通报销流程
**响应(立即):**
@@ -413,323 +389,28 @@ POST /api/submit-financial/<session_id>
---
## Agent 多轮校验流程
Agent 是系统的调度中枢,负责编排信息提取、规则校验、补充材料请求的完整流程。推荐使用 `/api/agent/process` 作为主入口。
### Agent 状态机
```mermaid
stateDiagram-v2
[*] --> IDLE
IDLE --> EXTRACTING: 启动处理
EXTRACTING --> READY: can_submit == true
EXTRACTING --> AWAITING_SUPPLEMENT: can_submit == false
EXTRACTING --> ERROR: 异常 / 轮次超限
READY --> SUBMITTING: _emit_ready_and_submit()
SUBMITTING --> DONE: 财务提交完成
AWAITING_SUPPLEMENT --> EXTRACTING: 用户补充文件/文字
AWAITING_SUPPLEMENT --> READY: 用户强制提交
note right of EXTRACTING
LLM 提取 + validator 校验\n最多 3 次重试
end note
```
### 13. 获取 Agent 会话状态
### 11. 移动端上传页面
```
GET /api/agent/state/<session_id>
GET /mobile/<session_id>
```
**响应:**
```json
{
"session_id": "a1b2c3d4e5f6",
"state": "extracting",
"rounds": 1,
"max_rounds": 5,
"invoice_type": "travel",
"extracted_info": { ... },
"validation_reports": [ ... ],
"user_supplements": [ ... ],
"error_message": ""
}
```
**状态值:**
| 状态 | 含义 |
|------|------|
| `idle` | 初始状态 |
| `extracting` | LLM 正在分析文件 |
| `awaiting_supplement` | 信息不完整,等待用户补充 |
| `ready` | 信息完整,可以提交 |
| `submitting` | 正在提交到财务系统 |
| `done` | 流程结束 |
| `error` | 出错 |
返回移动端 HTML 页面。扫码上传的图片与 PC 端共用同一会话目录PC 通过轮询 `GET /api/files/<session_id>` 同步文件列表。
---
### 14. 启动 Agent 多轮处理(主入口)
### 12. 移动端上传图片
```
POST /api/agent/process/<session_id>
Content-Type: application/json
POST /api/mobile-upload/<session_id>
Content-Type: multipart/form-data
```
**请求体:**
| 字段 | 类型 | 说明 |
|------|------|------|
| file | File | 图片文件 |
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 否 | 财务系统工号 |
| password | string | 否 | 登录密码 |
| default_name | string | 否 | 默认报销人姓名 |
| default_card_no | string | 否 | 默认公务卡号 |
| default_person_id | string | 否 | 默认人员编号 |
| consumable_storage | string | 否 | 出库单存放地点 |
**处理流程:**
1. 发票提取(同 `/api/process`
2. Agent 调度 LLM 分析提取结果
3. validator 规则校验(最多 3 次校验-修正循环)
4. LLM 语义判断信息完整性(`can_submit` 字段)
5. 校验通过 → 自动提交到财务系统
6. 校验未通过 → 等待用户补充材料
**响应(立即):**
```json
{ "status": "started" }
```
**SSE done 事件 - 信息完整(成功提交):**
```json
{
"type": "done",
"result": {
"ok": true,
"agent_ready": true,
"submit_ok": true,
"round": 1,
"message": "信息完整,已自动提交到财务系统"
}
}
```
**SSE done 事件 - 信息完整但提交失败:**
```json
{
"type": "done",
"result": {
"ok": true,
"agent_ready": true,
"submit_ok": false,
"submit_error": "提交失败原因",
"round": 1,
"message": "校验通过但提交失败"
}
}
```
**SSE done 事件 - 需补充材料:**
```json
{
"type": "done",
"result": {
"ok": true,
"agent_ready": false,
"agent_state": "awaiting_supplement",
"round": 1,
"waiting_for_supplement": true
}
}
```
**SSE done 事件 - 处理失败:**
```json
{
"type": "done",
"result": {
"ok": false,
"error": "未提取到任何发票数据"
}
}
```
---
### 15. 补充文件后重新分析
```
POST /api/agent/supplement/<session_id>
Content-Type: application/json
```
在 Agent 请求补充材料后,用户上传新文件并调用此接口触发重新分析。
**请求体:**
```json
{
"files": ["补充材料1.pdf", "补充材料2.jpg"]
}
```
**处理流程:**
1. 记录补充文件
2. 重新提取所有发票(包含新文件)
3. 加载上一轮分析结果作为历史上下文
4. 重新执行 Agent 校验
**响应(立即):**
```json
{ "status": "started" }
```
**SSE done 事件:** 同上(可能仍需补充或校验通过自动提交)。
**错误:**
```json
{ "error": "未找到 Agent 状态" }
```
HTTP `404`(未先调用 `/api/agent/process` 或 Agent 状态已丢失)。
---
### 16. 通过文字补充信息
```
POST /api/agent/user-supplement/<session_id>
Content-Type: application/json
```
用户通过对话方式提供补充信息LLM 解析后更新已提取的信息并重新校验。
**请求体:**
```json
{
"text": "报销人是张三,公务卡号是 6228480402564890001"
}
```
**处理流程:**
1. LLM 分析用户文字,提取需要更新的字段
2. 合并到已提取的信息中
3. 保存到缓存
4. 重新执行 Agent 校验
**响应(立即):**
```json
{ "status": "started" }
```
**SSE done 事件:** 同上(可能仍需补充或校验通过自动提交)。
**错误:**
```json
{ "error": "请输入补充信息" }
```
HTTP `400`(文本为空)。
---
### 17. 强制提交,跳过校验
```
POST /api/agent/force-submit/<session_id>
```
当 Agent 校验未通过或出错时,用户可选择强制提交,跳过所有校验直接提交到财务系统。
**处理流程:**
1. 将 Agent 状态设为 `READY`
2. 执行财务提交
**响应(立即):**
```json
{ "status": "started" }
```
**SSE done 事件:**
```json
{
"type": "done",
"result": {
"ok": true,
"submit_ok": true,
"submit_error": null
}
}
```
---
## SSE 事件类型
### Agent 事件
通过 `agent_events.log` 轮询推送:
| 事件类型 | 数据结构 | 触发条件 |
|---|---|---|
| `agent_state_change` | `{type, state, round, attempt, message}` | 状态切换 |
| `agent_ready` | `{type, round, message}` | 双重校验通过 |
| `agent_request_supplement` | `{type, round, missing_fields, missing_materials, semantic_issues, suggestion}` | 校验未通过 |
| `agent_supplement_received` | `{type, files}` | 收到用户补充 |
| `agent_force_submit` | `{type, message}` | 用户强制提交 |
| `agent_extract_status` | `{type, state, round, attempt, message}` | 校验-修正循环中的每次尝试结果 |
| `agent_error` | `{type, message}` | 提取失败 |
| `agent_max_rounds` | `{type, message}` | 达到最大轮次 |
### 文件进度事件
通过 `file_events.log` 轮询推送:
| 事件类型 | 数据结构 | 触发条件 |
|---|---|---|
| `file_progress` | `{type, file, status, summary?, error?}` | 文件处理状态变更 |
`status` 取值: `processing` / `done` / `cached` / `error`
### LLM 流式事件
通过 `llm_stream.log` 轮询推送:
| 事件类型 | 数据结构 | 触发条件 |
|---|---|---|
| `llm_stream` | `{type, phase, text?}` | LLM 输出流 |
`phase` 取值: `start` / `reasoning` / `chunk` / `end` / `error`
### 完成事件
SSE 检测到 `result.json` 后直接发送:
```json
{
"type": "done",
"result": { ... }
}
```
逻辑与 `POST /api/upload/<session_id>` 相同。
---
@@ -737,9 +418,9 @@ SSE 检测到 `result.json` 后直接发送:
| HTTP | 场景 |
|------|------|
| 400 | 参数缺失、未找到配置、文本为空等 |
| 404 | `session_id` 不存在、文件不存在、Agent 状态丢失 |
| 500 | CSV 读取失败、服务未初始化等内部错误 |
| 400 | 参数缺失、未找到配置等 |
| 404 | `session_id` 不存在、文件不存在 |
| 500 | CSV 读取失败等内部错误 |
统一错误体:
@@ -766,81 +447,38 @@ SSE 检测到 `result.json` 后直接发送:
```mermaid
sequenceDiagram
participant F as
participant S as SSE连接
participant B as 后端线程
participant A as Agent调度器
participant PC as PC
participant Server as Server
participant Mobile as 移动端
participant Word as Word COM
F->>B: POST /api/session
B-->>F: session_id
PC->>Server: POST /api/session
Server-->>PC: session_id
F->>B: POST /api/upload/{sid} (多次)
F->>B: POST /api/agent/process/{sid}
B-->>F: {status: "started"}
F->>S: GET /api/logs/{sid}
Note over B,A: 后台线程启动
B->>A: extract_invoices()
S-->>F: file_progress (processing/done)
S-->>F: llm_stream (start/chunk/end)
S-->>F: agent_state_change (extracting)
Note over A: LLM 提取 + validator 校验<br/>最多 3 次重试
alt 信息完整
A->>A: state → READY
A->>A: _emit_ready_and_submit()
S-->>F: agent_ready
S-->>F: done (submit_ok=true)
F->>F: es.close()
else 信息不完整
A->>A: state → AWAITING_SUPPLEMENT
S-->>F: agent_request_supplement
S-->>F: done (waiting_for_supplement=true)
F->>F: es.close()
F->>B: 上传补充文件或输入文字
alt 文件补充
F->>B: POST /api/agent/supplement/{sid}
else 文字补充
F->>B: POST /api/agent/user-supplement/{sid}
end
B-->>F: {status: "started"}
F->>S: GET /api/logs/{sid}
Note over A: 重新分析 + 校验
alt 仍不完整
S-->>F: agent_request_supplement
S-->>F: done (waiting=true)
F->>F: es.close()
Note over F: 可继续补充或强制提交
else 完整
A->>A: state → READY
A->>A: _emit_ready_and_submit()
S-->>F: agent_ready
S-->>F: done (submit_ok=true)
F->>F: es.close()
end
alt 强制提交
F->>B: POST /api/agent/force-submit/{sid}
B-->>F: {status: "started"}
F->>S: GET /api/logs/{sid}
S-->>F: agent_force_submit
S-->>F: done
F->>F: es.close()
end
PC->>Server: POST /api/upload/{sid}
PC->>Server: POST /api/process/{sid}
Note over Server: PDF 提取 + LLM 识别 + 写 config.json
alt 含普通发票
Server->>Word: 从模板复制并填写出库单
else 纯差旅发票
Note over Server: 跳过出库单生成
end
Server-->>PC: SSE done (csv_url, doc_url, ...)
F->>B: GET /api/data/{sid}
B-->>F: fields + data
F->>B: POST /api/save/{sid}
Note over B: 更新 CSV重新生成出库单
B-->>F: doc_url
PC->>Server: GET /api/data/{sid}
Server-->>PC: fields + data
PC->>Server: POST /api/save/{sid}
Note over Server: 更新 CSV重新生成出库单
Server-->>PC: doc_url
F->>B: GET /api/download/{sid}/易耗品、出库单.doc
PC->>Server: GET /api/download/{sid}/易耗品、出库单.doc
PC->>Server: POST /api/submit-financial/{sid}
Note over Server: Playwright 浏览器填报
Server-->>PC: SSE done (submit_ok)
Mobile->>Server: POST /api/mobile-upload/{sid}
PC->>Server: GET /api/files/{sid} (轮询)
```
---
@@ -850,7 +488,7 @@ sequenceDiagram
不经过 Web、在本地直接填写出库单
```bash
uv run python -m src.infra.documents.consumable --csv invoice_summary.csv --doc "易耗品、出库单.doc"
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
```
详见 [README.md](./README.md)。

View File

@@ -143,7 +143,7 @@ uv run python src/web/app.py
### 4.7 提交到财务系统
确认数据无误后,点击"提交到财务系统"按钮,系统自动:
确认数据无误后,点击"🚀 提交到财务系统"按钮,系统自动:
1. 登录信息门户
2. 进入财务系统
@@ -427,7 +427,7 @@ PC 端生成二维码指向移动端上传页面,手机端上传的图片通
### 单独填写出库单
```bash
uv run python -m src.infra.documents.consumable --csv invoice_summary.csv --doc "易耗品、出库单.doc"
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
```
### 分步执行管道

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
images/debug_error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
images/debug_item_total.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
images/debug_step3_done.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
images/debug_step5_done.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
images/debug_step6_done.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -9,8 +9,7 @@ dependencies = [
"PyMuPDF>=1.24",
"pywin32>=306",
"llama-index>=0.12.0",
"llama-index-llms-openai-like>=0.7.2",
"python-dotenv>=1.0",
"llama-index-llms-openai-like==0.7.2",
]
[dependency-groups]
@@ -38,6 +37,10 @@ warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
ignore_errors = true
[tool.deptry]
ignore_notebooks = true

View File

@@ -1,20 +1,21 @@
---
last_reviewed: 2026-06-15
last_reviewed: 2026-06-11
---
# scripts — 试脚本与数据目录
# scripts — 试脚本目录
## 子目录
存放用于测试各模块功能的独立脚本,可直接运行。
| 目录 | 说明 |
|------|------|
| `data/` | CLI 模式的数据目录:发票源文件、`config.json``.invoice_cache` 缓存 |
## 脚本
## 脚本清单
| 文件 | 说明 |
|------|------|
| `debug_stream_fields.py` | 诊断 stream_chat 返回对象的字段结构 |
| `test_application_extract.py` | 测试出差申请单提取 |
| `test_multimodal.py` | 测试多模态 LLM 识别 |
| `test_travel_info.py` | 测试差旅信息提取 |
| `test_multimodal.py` | 测试 PDF 多模态提取完整链路PDF 渲染 + LLM 提取) |
| `test_travel_info.py` | 测试差旅信息提取函数(数据从 `data/.invoice_cache` 缓存加载) |
## 运行方式
```bash
uv run python scripts/test_multimodal.py
uv run python scripts/test_travel_info.py
```

View File

@@ -1,69 +0,0 @@
"""诊断脚本:检查 stream_chat 返回对象的字段结构"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
# 加载 .env
from dotenv import load_dotenv # noqa: E402
from llama_index.core.llms import ChatMessage # noqa: E402
from src.config import get_llm_config # noqa: E402
from src.core.extraction import _create_llm # noqa: E402
load_dotenv(Path(__file__).parent / ".env")
llm_config = get_llm_config()
print(f"LLM config: model={llm_config['model']}, api_base={llm_config['api_base']}")
llm = _create_llm()
messages = [
ChatMessage(role="system", content="你是一个助手"),
ChatMessage(role="user", content="1+1 等于几?"),
]
print("\n=== 检查 stream_chat 返回对象的字段 ===")
count = 0
try:
for resp in llm.stream_chat(messages, temperature=0.1):
count += 1
if count <= 3:
print(f"\n--- chunk #{count} ---")
print(f" type: {type(resp).__name__}")
print(f" delta: {repr(resp.delta)[:200]}")
if hasattr(resp, "additional_kwargs") and resp.additional_kwargs:
print(f" additional_kwargs keys: {list(resp.additional_kwargs.keys())}")
for k, v in resp.additional_kwargs.items():
val_preview = str(v)[:200]
print(f" additional_kwargs['{k}']: {val_preview}")
if hasattr(resp, "raw"):
raw = resp.raw
if isinstance(raw, dict):
print(f" raw keys: {list(raw.keys())}")
for k, v in raw.items():
val_preview = str(v)[:200]
print(f" raw['{k}']: {val_preview}")
else:
print(f" raw type: {type(raw)}")
if hasattr(resp, "message") and resp.message:
msg = resp.message
print(f" message type: {type(msg).__name__}")
if hasattr(msg, "additional_kwargs") and msg.additional_kwargs:
print(f" message.additional_kwargs keys: {list(msg.additional_kwargs.keys())}")
for k, v in msg.additional_kwargs.items():
val_preview = str(v)[:200]
print(f" message.additional_kwargs['{k}']: {val_preview}")
if hasattr(msg, "reasoning_content"):
rc = msg.reasoning_content
print(f" message.reasoning_content: {repr(rc)[:200]}")
elif count == 4:
print("\n... (更多 chunk 省略)")
if count >= 10:
break
print(f"\n=== 共收到 {count} 个 chunk ===")
except Exception as e:
print(f"\n错误: {e}")
import traceback
traceback.print_exc()

View File

@@ -1,208 +0,0 @@
#!/usr/bin/env python3
"""单独测试事前申请单的信息提取功能
用于调试 LLM 对事前申请单的提取准确率。
用法:
# 测试项目根目录的事前申请单.pdf
python scripts/test_application_extract.py
# 测试指定文件
python scripts/test_application_extract.py --file path/to/file.pdf
# 测试 scripts/data 目录下的事前申请单
python scripts/test_application_extract.py --dir scripts/data
"""
import argparse
import io
import json
import sys
from pathlib import Path
# 加载 .env 环境变量(在导入 src 模块之前)
from dotenv import load_dotenv
ROOT = Path(__file__).resolve().parent.parent
load_dotenv(ROOT / ".env")
# Windows 终端强制 UTF-8
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.path.insert(0, str(ROOT)) # noqa: E402
from src.core.extraction import extract_document # noqa: E402
def test_single_file(file_path: Path) -> None:
"""测试单个文件的提取效果"""
print("=" * 60)
print(f"测试文件: {file_path.name}")
print("=" * 60)
if not file_path.exists():
print(f"文件不存在: {file_path}")
return
try:
# 调用统一提取接口
result = extract_document(file_path)
if not result:
print("[ERROR] 提取返回空结果")
return
# 检查类型判断
inv_type = result.get("invoice_type", "")
print(f"\n[类型判断] invoice_type = {inv_type}")
if inv_type != "application":
print(f"[WARNING] 类型判断错误!期望 'application',实际得到 '{inv_type}'")
else:
print("[OK] 类型判断正确")
# 展示提取结果
print("\n[提取结果]")
print(json.dumps(result, ensure_ascii=False, indent=2))
# 字段完整性检查
print("\n[字段检查]")
expected_fields = {
"invoice_type": "类型标识",
"project_name": "项目名称",
"purpose": "出差事由",
"start_date": "开始日期",
"end_date": "结束日期",
"person_info": "人员信息",
}
for field, desc in expected_fields.items():
value = result.get(field)
if value is None:
print(f" [MISSING] {desc} ({field}) - 字段缺失")
elif value == "" or value == []:
print(f" [EMPTY] {desc} ({field}) - 字段为空")
else:
print(f" [OK] {desc} ({field})")
# 日期格式检查
for date_field in ["start_date", "end_date"]:
date_value = result.get(date_field, "")
if date_value and len(date_value) == 10:
try:
parts = date_value.split("-")
if len(parts) == 3:
int(parts[0]) # year
int(parts[1]) # month
int(parts[2]) # day
print(f" [OK] {date_field} 格式正确 (YYYY-MM-DD)")
except (ValueError, IndexError):
print(f" [ERROR] {date_field} 格式错误: {date_value}")
elif date_value:
print(f" [ERROR] {date_field} 格式错误: {date_value}")
# 人员信息结构检查
person_info = result.get("person_info")
if person_info:
if isinstance(person_info, list):
print(f"\n[人员信息] 共 {len(person_info)}")
for i, person in enumerate(person_info, 1):
pid = person.get("person_id", "")
pname = person.get("person_name", "")
print(f" 人员 {i}: {pname} ({pid})")
elif isinstance(person_info, dict):
print("\n[人员信息] 单人格式")
print(f" 姓名: {person_info.get('person_name', '')}")
print(f" 编号: {person_info.get('person_id', '')}")
except Exception as e:
print(f"\n[EXCEPTION] 提取失败: {e}")
import traceback
traceback.print_exc()
def test_cache_comparison(file_path: Path) -> None:
"""对比原始提取和缓存结果"""
cache_dir = file_path.parent / ".invoice_cache"
cache_file = cache_dir / f"{file_path.stem}{file_path.suffix}.json"
if not cache_file.exists():
print(f"\n[INFO] 无缓存文件对比: {cache_file}")
return
print("\n" + "=" * 60)
print("[缓存对比]")
print("=" * 60)
try:
with open(cache_file, encoding="utf-8") as f:
cache_data = json.load(f)
cached_result = cache_data.get("extracted_data", {})
print("\n[缓存数据]")
print(json.dumps(cached_result, ensure_ascii=False, indent=2))
# 对比关键字段
print("\n[字段对比]")
for key in ["invoice_type", "project_name", "purpose", "start_date", "end_date"]:
cached_value = cached_result.get(key, "<缺失>")
print(f" {key}: {cached_value}")
except Exception as e:
print(f"[ERROR] 读取缓存失败: {e}")
def main() -> None:
parser = argparse.ArgumentParser(description="测试事前申请单信息提取")
parser.add_argument(
"--file",
type=str,
default=None,
help="要测试的文件路径 (默认: 扫描 scripts/data 目录)",
)
parser.add_argument(
"--dir",
type=str,
default=str(ROOT / "scripts" / "data"),
help="扫描目录下所有事前申请单文件 (默认: scripts/data)",
)
parser.add_argument(
"--no-cache-compare",
action="store_true",
help="不执行缓存对比",
)
args = parser.parse_args()
if args.dir:
# 目录模式扫描所有PDF文件
dir_path = Path(args.dir)
if not dir_path.exists():
print(f"目录不存在: {dir_path}")
sys.exit(1)
pdf_files = sorted(dir_path.glob("*.pdf"))
if not pdf_files:
print(f"目录下没有找到PDF文件: {dir_path}")
sys.exit(1)
print(f"发现 {len(pdf_files)} 个PDF文件开始逐个测试...\n")
for pdf in pdf_files:
if "事前申请" in pdf.name or "申请单" in pdf.name:
test_single_file(pdf)
if not args.no_cache_compare:
test_cache_comparison(pdf)
print()
else:
# 单文件模式
file_path = Path(args.file)
test_single_file(file_path)
if not args.no_cache_compare:
test_cache_comparison(file_path)
print("\n测试完成!")
if __name__ == "__main__":
main()

View File

@@ -15,8 +15,8 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="repla
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) # noqa: E402
from src.core.extraction import extract_document # noqa: E402
from src.infra.documents.pdf import render_pdf_to_images # noqa: E402
from src.doc.llm_extractor import extract_document # noqa: E402
from src.doc.pdf import render_pdf_to_images # noqa: E402
def test_render() -> None:

View File

@@ -15,7 +15,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) # noqa: E402
from src.core.extraction import extract_travel_info # noqa: E402
from src.doc.llm_extractor import extract_travel_info # noqa: E402
def main() -> None:

View File

@@ -1,77 +1,74 @@
---
last_reviewed: 2026-06-15
last_reviewed: 2026-06-12
---
# src — 主源码目录
包含财务报销自动化系统的全部源码模块。
## 架构分层
```
src/
├── agent/ Agent 调度层(协调提取-校验-修正循环)
├── core/ 核心业务层(提取、匹配、校验)
├── infra/ 基础设施层浏览器、文档、LLM 提示词)
├── web/ Web 界面层Flask + SSE
├── pipeline.py CLI 流程编排
├── pipeline_core.py CLI/Web 公共管道逻辑
├── main.py CLI 入口
├── config.py 配置加载
└── exceptions.py 异常定义
```
包含财务报销自动化系统的核心模块。
## 模块清单
| 文件/目录 | 说明 |
|-----------|------|
| `agent/` | Agent 调度:校验-修正循环、状态机管理、SSE 事件发射 |
| `core/` | 核心业务逻辑:信息提取、金额匹配、信息校验 |
| `infra/` | 基础设施浏览器自动填报、文档处理、LLM 提示词管理 |
| `web/` | Web 界面Flask 应用、SSE 日志流、可编辑表格、移动端上传、会话隔离 |
| `pipeline.py` | CLI 流程编排:串联提取 → 类型判断 → 信息提取 → 浏览器填报 |
| `pipeline_core.py` | CLI/Web 公共管道逻辑:发票类型判断、缓存提取 |
| `main.py` | CLI 入口:`--step` 分步执行、`-u/-p` 覆盖凭据 |
| `config.py` | 配置加载:`config.json` + 环境变量 |
| `exceptions.py` | 异常层次定义 |
| `__init__.py` | 包初始化:提供 `get_logger()` 日志工厂(支持终端 + 文件双输出,按日期自动分文件) |
| `config.py` | 配置加载:从 `config.json` 读取用户凭据和默认值从环境变量读取服务端配置SSO 地址、LLM 参数) |
| `pipeline.py` | 流程编排:串联发票提取 → 类型判断 → 差旅/普通信息提取 → 浏览器填报,支持分步执行 |
| `main.py` | CLI 入口:支持 `--step` 分步执行、`-u/-p` 覆盖凭据、`--cache-dir` 指定缓存目录 |
| `bot/` | 浏览器自动化Playwright 驱动的财务系统填报机器人(仅负责接收信息并填报 |
| `doc/` | 文档处理模块PDF 渲染、LLM 提取、支付匹配、发票分类、出库单生成 |
| `web/` | Web 界面模块Flask 应用、SSE 日志、可编辑表格、移动端上传、会话隔离 |
## 数据流
```mermaid
graph TD
A[CLI/Web 入口] --> B["pipeline.py (编排)"]
B --> C["core/extraction/extractor.py (统一提取入口)"]
C --> D["infra/documents/pdf.py (PDF 渲染为图片)"]
C --> E["core/extraction/llm_extractor.py (多模态 LLM 识别)"]
B --> C["doc/extractor.py (统一提取入口)"]
C --> D["doc/pdf.py (PDF 渲染为图片)"]
C --> E["doc/llm_extractor.py (多模态 LLM 识别)"]
E --> F["发票 invoice_type=train/hotel/general"]
E --> G["支付记录 invoice_type=payment"]
E --> H["出差事前申请单 invoice_type=application"]
C --> I["core/matching/matcher.py (发票与支付记录按金额匹配)"]
C --> I["doc/matcher.py (发票与支付记录按金额匹配)"]
I --> J["一对一匹配 发票数 == 刷卡数"]
I --> K["一对多匹配 贪心算法 相对容差 3%"]
C --> L["infra/documents/invoice.py (CSV/JSON 读写)"]
C --> L["doc/invoice.py (CSV/JSON 读写)"]
L --> M["payment_records.csv (支付记录级别)"]
L --> N["invoice_summary.csv (发票级别)"]
L --> O["travel_applications.json (出差申请单)"]
B --> R{"判断报销类型"}
R -->|差旅| T["core/extraction/llm_extractor.py (差旅信息提取)"]
R -->|普通| V["core/extraction/llm_extractor.py (普通发票信息提取)"]
R -->|差旅| T["doc/llm_extractor.py (差旅信息提取)"]
R -->|普通| V["doc/llm_extractor.py (普通发票信息提取)"]
T --> W["travel_info.json (差旅信息: 交通/住宿明细、补贴、附件清单)"]
V --> X["normal_info.json (普通发票信息: 报销说明、发票总数、总金额、支付方式、附件清单)"]
W --> P["infra/browser/ (浏览器填报 - 仅接收信息并填报)"]
W --> P["bot/ (浏览器填报 - 仅接收信息并填报)"]
X --> P
P --> Q["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
P --> S["普通模式: 基本信息 → 录入明细 → 支付信息 → 上传附件"]
```
## 子模块文档
**数据流变更2026-06-11** 差旅信息提取从 `bot.py` 提升到 `pipeline.py` 编排层。在发票提取和匹配完成后立即判断报销类型,差旅发票调用 LLM 提取 `travel_info.json`,普通发票调用 LLM 提取 `normal_info.json`。Bot 仅负责接收信息并填报,不再承担信息提取职责。
| 目录 | 文档 |
|------|------|
| `agent/` | [`agent/README.md`](agent/README.md) |
| `core/` | [`core/README.md`](core/README.md) |
| `infra/` | [`infra/README.md`](infra/README.md) |
| `web/` | [`web/README.md`](web/README.md) |
## 文档处理子模块 (`doc/`)
详见 [`doc/README.md`](doc/README.md)
核心能力:
- **统一文档提取**LLM 自行判断文档类型(发票/支付记录/出差事前申请单),无需正则回退
- **JSON 缓存**:提取结果缓存于 `.invoice_cache/`,避免重复处理
- **金额匹配**:支持一对多匹配,相对容差 3%,未匹配发票单独列为记录
- **差旅信息提取**:综合发票、支付记录和匹配结果,提取出差事由、地点、时间等
- **普通发票信息提取**:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单
- **出库单生成**:将 CSV 数据填入 Word 模板pywin32 COM仅 Windows
## Web 界面子模块 (`web/`)
详见 [`web/README.md`](web/README.md)
核心能力:
- **会话隔离**:每次上传生成独立 `session_id`,文件/日志/配置/结果各自隔离
- **移动端同步**PC 端生成二维码指向 `/mobile/<sid>`,跨设备协作上传
- **可编辑表格**:前端加载 CSV 数据,支持在线编辑后保存
## 启动方式

View File

@@ -1,242 +0,0 @@
---
## 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 次重试后返回最佳结果

View File

@@ -1,32 +0,0 @@
"""Agent 模块
提供多轮对话协调、规则校验和语义校验能力。
入口:
- `orchestrator.run_agent_round()` — 执行一轮 Agent 处理
- `orchestrator.force_submit()` — 用户强制提交
- `orchestrator.add_supplement()` — 记录用户补充的文件
- `orchestrator.load_agent_state()` / `save_agent_state()` — 状态持久化
"""
from .orchestrator import (
AgentSession,
AgentState,
add_supplement,
force_submit,
load_agent_state,
process_user_text_supplement,
run_agent_round,
save_agent_state,
)
__all__ = [
"AgentSession",
"AgentState",
"add_supplement",
"force_submit",
"load_agent_state",
"process_user_text_supplement",
"run_agent_round",
"save_agent_state",
]

View File

@@ -1,410 +0,0 @@
"""Agent 协调器
作为调度中枢,编排信息提取、规则校验的完整流程。
校验-修正循环由 Agent 层调度:
1. Agent 调用 LLM 提取信息
2. Agent 调用 validator.py 校验
3. 校验失败则构建修正提示,再次调用 LLM
4. 重复直到校验通过或达到最大重试次数
5. LLM 在输出中包含 can_submit 和 suggestion 字段,用于判断信息完整性
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .. import get_logger
from ..core.extraction import (
build_extraction_user_message,
llm_query_text,
load_cache,
load_match_result,
merge_supplement_into_info,
parse_json_response,
process_user_supplement,
)
from ..core.validation import validate_extracted_info
from ..infra.llm import (
build_normal_info_system_prompt,
build_travel_info_system_prompt,
)
from ..pipeline_core import save_cache_info
from .events import emit_agent_event
from .session import AgentSession, AgentState, save_agent_state
log = get_logger("agent.coordinator")
# 规则校验-修正循环的最大重试次数
MAX_VALIDATION_RETRIES = 3
# ------------------------------------------------------------------
# 辅助:构建修正提示
# ------------------------------------------------------------------
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,
cache_map: 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 = cache_map or 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_extract_status",
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_extract_status",
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.is_terminal():
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
# 判断是否为补充文件场景:有新文件传入时,加载上一轮分析结果作为上下文
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:
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
should_reanalyze = not cache_map.get(info_key) or is_supplement
if should_reanalyze:
session.extracted_info = _do_extraction_with_validation(
session_dir, session, previous_analysis=previous_analysis, cache_map=cache_map
)
# 提取后立即写入缓存,后续步骤依赖此数据
save_cache_info(session_dir, info_key, session.extracted_info)
else:
session.extracted_info = cache_map[info_key]
emit_agent_event(
session_dir,
"agent_state_change",
state=session.state,
round=session.rounds,
message="使用缓存数据,无需重新分析",
)
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 会话。
"""
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: 保存到缓存
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
save_cache_info(session_dir, info_key, session.extracted_info)
log.info("已更新 %s", info_key)
# 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

View File

@@ -1,75 +0,0 @@
"""Agent 事件系统
负责 SSE 事件的发射和管理,用于实时通知前端状态变化。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .. import get_logger
log = get_logger("agent.events")
AGENT_EVENT_LOG = "agent_events.log"
# 去重守卫:记录每个 session 上一次发射的事件类型,防止连续重复发射
# key: str(session_dir), value: 上一次的 event_type
_last_event_type: dict[str, str] = {}
def emit_agent_event(session_dir: Path, event_type: str, **kwargs: Any) -> None:
"""向 agent_events.log 追加一行 JSON 事件。
同一 session 连续发射相同 event_type 时直接抛出 RuntimeError
强制调用方修复重复发射的代码,而非静默掩盖。
注意agent_state_change 会在同一轮提取中多次发射不同消息
"正在分析""校验通过""校验失败,请求修正"),这是合法行为。
去重守卫仅检查 event_type 字符串是否完全相同,不检查 kwargs。
因此不要在同一个 event_type 下连续发射不同消息,应使用不同的事件类型。
"""
session_key = str(session_dir)
prev = _last_event_type.get(session_key)
if prev == event_type:
raise RuntimeError(
f"事件重复发射: session={session_dir.name!r}, event_type={event_type!r}"
f"请检查调用链,确保每个事件类型只发射一次。"
)
_last_event_type[session_key] = event_type
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 clear_event_history(session_dir: Path) -> None:
"""清除指定会话的事件历史。"""
session_key = str(session_dir)
_last_event_type.pop(session_key, None)
event_path = session_dir / AGENT_EVENT_LOG
if event_path.exists():
event_path.unlink()
def read_events(session_dir: Path) -> list[dict[str, Any]]:
"""读取指定会话的所有事件记录。"""
event_path = session_dir / AGENT_EVENT_LOG
if not event_path.exists():
return []
events = []
try:
with open(event_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
events.append(json.loads(line))
except Exception as e:
log.warning("读取事件日志失败: %s", e)
return events

View File

@@ -1,46 +0,0 @@
"""Agent 协调器(兼容层)
此文件为向后兼容而保留,所有功能已迁移到以下子模块:
- session.py: 会话状态定义和持久化
- events.py: SSE 事件发射系统
- coordinator.py: 核心协调逻辑
原有导入路径保持可用。
"""
# 从新模块重新导出所有符号,保持向后兼容
from .coordinator import (
add_supplement,
force_submit,
process_user_text_supplement,
run_agent_round,
)
from .events import emit_agent_event as _emit_agent_event
from .session import (
AgentSession,
AgentState,
load_agent_state,
save_agent_state,
)
# 为旧代码提供兼容的私有函数
_emit_agent_event = _emit_agent_event
# 常量保持不变
MAX_VALIDATION_RETRIES = 3
AGENT_STATE_FILE = "agent_state.json"
AGENT_EVENT_LOG = "agent_events.log"
__all__ = [
"AgentSession",
"AgentState",
"save_agent_state",
"load_agent_state",
"run_agent_round",
"force_submit",
"add_supplement",
"process_user_text_supplement",
"MAX_VALIDATION_RETRIES",
"AGENT_STATE_FILE",
"AGENT_EVENT_LOG",
]

View File

@@ -1,101 +0,0 @@
"""Agent 会话管理
负责会话状态的定义、序列化和持久化。
"""
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
log = get_logger("agent.session")
# ------------------------------------------------------------------
# 状态枚举
# ------------------------------------------------------------------
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)
def is_terminal(self) -> bool:
"""判断会话是否处于终态(已提交或已完成)"""
return self.state in (AgentState.DONE, AgentState.SUBMITTING, AgentState.READY)
# ------------------------------------------------------------------
# 持久化
# ------------------------------------------------------------------
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
def create_agent_session(session_id: str, invoice_type: str = "travel") -> AgentSession:
"""创建新的 Agent 会话。"""
return AgentSession(
session_id=session_id,
invoice_type=invoice_type,
)

33
src/bot/README.md Normal file
View File

@@ -0,0 +1,33 @@
---
last_reviewed: 2026-06-12
---
# bot — 浏览器自动化填报模块
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
## 模块清单
| 文件 | 说明 |
|------|------|
| `__init__.py` | 对外入口:`run_bot()``run_bot_web()`,负责类型判断和流程路由 |
| `base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图、日期格式化 |
| `travel.py` | 差旅报销填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `normal.py` | 普通发票报销填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
## 架构设计
```
run_bot(config, travel_info, normal_info)
├── 创建 BaseBot启动浏览器登录门户
├── travel_info 存在 → travel.run(bot, travel_info)
└── normal_info 存在 → normal.run(bot, normal_info)
```
- **`BaseBot`** 只保留公共操作launch、login、navigate、create_new_form、close、screenshot
- **差旅/普通流程** 作为独立函数接受 `bot: BaseBot` 参数,符合函数式编程偏好
- **`__init__.py`** 仅做路由分发,不包含具体填报逻辑
## 变更历史
- **2026-06-12**:从 `bot.py` 单文件重构为 `bot/` 包,分离差旅和普通报销逻辑

93
src/bot/__init__.py Normal file
View File

@@ -0,0 +1,93 @@
"""
浏览器自动化填报
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
对外接口:
run_bot(config, travel_info, normal_info) 启动浏览器并执行填报流程
run_bot_web(config, work_dir) Web 模式填报(从缓存加载信息)
"""
from pathlib import Path
from typing import Any
from .. import get_logger
from .base import BaseBot
log = get_logger("bot")
def run_bot(
config: dict[str, Any],
headless: bool = False,
work_dir: Path | None = None,
travel_info: dict[str, Any] | None = None,
normal_info: dict[str, Any] | None = None,
) -> None:
"""执行完整的浏览器填报流程,根据发票类型自动路由
Args:
config: 配置字典。
headless: 是否无头模式。
work_dir: 工作目录。
travel_info: 差旅信息(由 pipeline 层提前提取并传入,非差旅时传 None
normal_info: 普通发票信息(由 pipeline 层提前提取并传入,非普通时传 None
"""
if not config["username"] or not config["password"]:
raise ValueError("缺少用户名或密码")
if not work_dir:
raise ValueError("缺少工作目录")
bot = BaseBot(config, headless=headless)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
if travel_info is not None:
log.info("处理差旅发票...")
bot.navigate_to_reimburse(page_key="travel_page")
bot.create_new_form()
from . import travel
travel.run(bot, travel_info)
elif normal_info is not None:
log.info("处理普通发票...")
bot.navigate_to_reimburse(page_key="reimburse_page")
bot.create_new_form()
from . import normal
normal.run(bot, normal_info)
else:
raise ValueError("缺少差旅信息travel_info和普通发票信息normal_info无法继续填报")
except Exception as e:
log.error(f"操作失败: {e}")
try:
bot._screenshot("error")
except Exception:
pass
raise
finally:
bot.close()
def run_bot_web(config: dict[str, Any], work_dir: Path) -> None:
"""Web 模式填报 — headless附件从指定目录读取
Web 端的信息提取由 app.py 的管道负责,此处从缓存加载。
"""
from ..doc.llm_extractor import load_cache
cache_map = load_cache(work_dir)
travel_info = cache_map.get("travel_info")
normal_info = cache_map.get("normal_info")
run_bot(
config,
headless=True,
work_dir=work_dir,
travel_info=travel_info,
normal_info=normal_info,
)

View File

@@ -1,4 +1,5 @@
"""浏览器自动化填报 — 公共基类
"""
浏览器自动化填报 公共基类
提供浏览器生命周期管理登录导航截图等公共操作
"""
@@ -6,7 +7,7 @@
from pathlib import Path
from typing import Any
from ... import get_logger
from .. import get_logger
log = get_logger("bot")

View File

@@ -1,4 +1,5 @@
"""普通报销填报流程
"""
普通报销填报流程
负责普通发票报销的完整填报步骤
基本信息 总明细 支付方式 附件上传
@@ -6,7 +7,7 @@
from typing import Any
from ... import get_logger
from .. import get_logger
from .base import BaseBot, format_date
log = get_logger("bot.normal")
@@ -56,26 +57,22 @@ def run(
def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") -> None:
"""填写基本信息"""
try:
bot.page.fill("#EXPENEXPLAIN", description)
bot.page.click("#PROJECTCODE", timeout=10000)
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()
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")
bot.page.click("#saveAndNext", timeout=5000)
bot.page.wait_for_timeout(2000)
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
bot._screenshot("step3_done")
# ------------------------------------------------------------------
@@ -126,8 +123,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_person_id"])
bot.page.fill("#accountname2", bot.config["default_name"])
bot.page.fill("#personid2", bot.config["default_name"])
bot.page.fill("#accountname2", bot.config["default_person_id"])
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)))

View File

@@ -1,4 +1,5 @@
"""差旅报销填报流程
"""
差旅报销填报流程
负责差旅报销的完整填报步骤
基本信息 差旅明细 支付方式 补助清单 附件上传
@@ -6,7 +7,7 @@
from typing import Any
from ... import get_logger
from .. import get_logger
from .base import BaseBot, format_date
log = get_logger("bot.travel")
@@ -31,8 +32,8 @@ def run(bot: BaseBot, travel_info: dict[str, Any]) -> None:
fill_travel_info(bot, basic_info)
log.info("填写差旅报销明细...")
details = travel_info["reimbursement_details"]
add_travel_items(bot, details)
travel_items = travel_info["reimbursement_details"]
add_travel_items(bot, travel_items)
log.info("填写差旅报销支付方式...")
payment_info = travel_info["payment_methods"]
@@ -73,10 +74,9 @@ 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 as e:
log.error(f"填写基本信息失败: {e}")
except Exception:
log.error("填写基本信息失败")
bot._screenshot("travel_basic_error")
raise
# ------------------------------------------------------------------
@@ -84,14 +84,8 @@ def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None:
# ------------------------------------------------------------------
def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
"""录入差旅报销明细
Args:
bot: 已启动的 BaseBot 实例
details: 报销明细字典travel_info["reimbursement_details"]包含
transport_feehotel_feeconference_fee 等子字段
"""
def add_travel_items(bot: BaseBot, travel_items: dict[str, Any]) -> None:
"""录入差旅报销明细"""
vehicle_map = {
"火车": "01",
"汽车": "02",
@@ -104,7 +98,7 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
}
try:
traffic_info = details.get("transport_fee") or []
traffic_info = travel_items.get("transport_fee") or []
for item in traffic_info:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="增加明细"', timeout=5000)
@@ -131,7 +125,7 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
hotel_info = details.get("hotel_fee") or []
hotel_info = travel_items.get("hotel_fee") or []
for item in hotel_info:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="增加明细"', timeout=5000)
@@ -156,7 +150,7 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
bot.page.click("#detailAdd", timeout=3000)
bot.page.wait_for_timeout(1000)
conference_info = details.get("conference_fee") or []
conference_info = travel_items.get("conference_fee") or []
for item in conference_info:
bot.page.click("#insertDetail", timeout=5000)
bot._wait_for('text="增加明细"', timeout=5000)
@@ -196,8 +190,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_person_id"])
bot.page.fill("#accountname2", bot.config["default_name"])
bot.page.fill("#personid2", bot.config["default_name"])
bot.page.fill("#accountname2", bot.config["default_person_id"])
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"]))
@@ -230,9 +224,6 @@ def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> Non
bot._wait_for('text="增加补助清单"', timeout=5000)
bot.page.click("#jzg3", timeout=5000)
bot.page.wait_for_timeout(500)
# 注意:此处使用直接索引而非 .get(),是故意的设计。
# LLM 必须返回 person_name 和 person_id 字段,若缺失则说明数据质量有问题,
# 应当立即报错终止流程,而非静默跳过。
if info["person_name"] and info["person_name"] != "":
bot.page.fill("#seacher", info["person_name"])
elif info["person_id"] and info["person_id"] != "":
@@ -253,8 +244,6 @@ def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> Non
bot.page.fill("#enddate1", format_date(info["end_date"]))
bot.page.fill("#trafficdays1", str(info["days"]))
bot.page.fill("#fooddays1", str(info["days"]))
# 补助标准硬编码:交通补助 80 元/天,伙食补助 100 元/天。
# 此为阜阳师范大学现行标准,如需适配其他单位,可改为从 config.json 读取。
bot.page.fill("#trafficnorm1", str(80))
bot.page.fill("#foodnorm1", str(100))
trafficmoney = int(info["days"]) * 80
@@ -293,7 +282,7 @@ def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]
bot.page.select_option("#fjlx", "1")
else:
bot.page.select_option("#fjlx", "2")
bot.page.fill("#fpsmxx", info.get("attachment_desc", ""))
bot.page.fill("#fpsmxx", info["attachment_desc"])
if attachment_file and attachment_file.exists():
bot.page.set_input_files("#file", str(attachment_file))
bot.page.wait_for_timeout(1000)
@@ -303,5 +292,4 @@ def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]
log.error(f"差旅附件上传失败: {e}")
bot._screenshot("travel_attachment_error")
raise
bot._screenshot("travel_attachment_done")

View File

@@ -7,39 +7,12 @@
import json
import os
from pathlib import Path
from typing import Any
_CONFIG_PATH = Path(__file__).parent.parent.parent / "scripts" / "data" / "config.json"
# 会话级配置允许覆盖的用户相关字段白名单(含密码)
SESSION_CONFIG_KEYS = frozenset(
{
"username",
"password",
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
}
)
# 前端安全的配置字段白名单(不含密码)
SAFE_CONFIG_KEYS = frozenset(
{
"username",
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
}
)
# 模块级配置缓存
_config_cache: dict[str, str | Path] | None = None
def _read_config() -> dict[str, str | Path]:
"""读取并合并配置,缺失字段使用默认值"""
def load_config() -> dict[str, str | Path]:
"""加载并合并配置,缺失字段使用默认值"""
raw = {}
if _CONFIG_PATH.exists():
with open(_CONFIG_PATH, encoding="utf-8") as f:
@@ -63,37 +36,6 @@ def _read_config() -> dict[str, str | Path]:
}
def load_config() -> dict[str, str | Path]:
"""加载并合并配置,使用模块级缓存避免重复读取文件"""
global _config_cache
if _config_cache is None:
_config_cache = _read_config()
return dict(_config_cache)
def clear_config_cache() -> None:
"""清除配置缓存(测试或配置变更时调用)"""
global _config_cache
_config_cache = None
def load_session_config(session_dir: Path) -> dict[str, Any]:
"""加载会话配置,合并项目全局配置与会话级配置
仅允许覆盖用户相关字段(白名单),防止用户上传的 config.json
覆盖 sso_login_url、portal_url 等系统级配置。
"""
config = load_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
session_cfg = json.load(f)
for key in SESSION_CONFIG_KEYS:
if key in session_cfg:
config[key] = session_cfg[key]
return config
def get_llm_config() -> dict[str, str]:
"""加载 LLM 配置,优先从环境变量读取,缺失字段使用默认值"""
return {

View File

@@ -1,21 +0,0 @@
---
last_reviewed: 2026-06-15
---
# src/core — 核心业务逻辑
项目的核心业务层,负责信息提取、金额匹配和信息校验。此层不依赖 Web 框架或浏览器自动化等基础设施。
## 子模块
| 目录 | 说明 |
|------|------|
| `extraction/` | 文档信息提取PDF/图片 → LLM 多模态识别 → 结构化数据 |
| `matching/` | 发票与支付记录按金额匹配(一对一 / 一对多) |
| `validation/` | 声明式信息完整性校验,规则从 JSON 配置文件加载 |
## 设计原则
- **零外部依赖**:不依赖 Flask、Playwright 等框架
- **接口契约**:每个子模块通过 `__init__.py` 导出稳定的对外接口
- **错误传播**:明确的异常层次,便于上层统一处理

View File

@@ -1,4 +0,0 @@
"""核心业务逻辑模块
提供信息提取、规则校验和发票匹配功能。
"""

View File

@@ -1,29 +0,0 @@
---
last_reviewed: 2026-06-15
---
# src/core/extraction — 信息提取
从 PDF 发票和图片中提取结构化数据,是系统数据流的起点。
## 文件
| 文件 | 职责 |
|------|------|
| `extractor.py` | 编排入口:扫描目录 → 逐文件提取 → 分类(发票/支付记录/申请单)→ 金额匹配 |
| `llm_extractor.py` | LLM 多模态提取核心:统一文档提取、差旅/普通信息提取、缓存管理、SSE 流式事件 |
## 对外接口
| 函数 | 说明 |
|------|------|
| `extract_invoices(directory)` | 统一提取入口,返回 `(payment_records, applications, groups)` |
| `extract_document(file_path)` | 从单个图片/PDF 提取信息 |
| `extract_travel_info(source_dir)` | 综合发票和匹配结果提取差旅信息 |
| `extract_normal_info(source_dir)` | 提取普通发票报销信息 |
| `load_cache(source_dir)` | 加载缓存的结构化数据 |
| `llm_query_text(...)` | 纯文本 LLM 查询(供 Agent 调度使用) |
## 缓存机制
提取结果缓存在 `.invoice_cache/` 目录中,文件名与源文件同名(`发票1.pdf``.invoice_cache/发票1.json`),避免重复调用 LLM。

View File

@@ -1,42 +0,0 @@
"""信息提取模块
提供发票/文档结构化提取、LLM 辅助提取等功能。
"""
from .extractor import (
EXTRACTION_PARALLEL_COUNT,
FILE_EVENTS_LOG,
SUPPORTED_EXTENSIONS,
extract_invoices,
)
from .llm_extractor import (
CACHE_DIR_NAME,
build_extraction_user_message,
extract_document,
extract_normal_info,
extract_travel_info,
llm_query_text,
load_cache,
load_match_result,
merge_supplement_into_info,
parse_json_response,
process_user_supplement,
)
__all__ = [
"CACHE_DIR_NAME",
"build_extraction_user_message",
"extract_document",
"extract_normal_info",
"extract_travel_info",
"load_cache",
"load_match_result",
"llm_query_text",
"merge_supplement_into_info",
"parse_json_response",
"process_user_supplement",
"EXTRACTION_PARALLEL_COUNT",
"FILE_EVENTS_LOG",
"SUPPORTED_EXTENSIONS",
"extract_invoices",
]

View File

@@ -1,358 +0,0 @@
"""发票提取编排
统一扫描目录下所有文件PDF + 图片),通过 LLM 提取结构化数据,
根据 LLM 返回的「invoice_type」字段自动分类为发票/支付记录/出差事前申请单。
对外接口:
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
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
from ... import get_logger
from ...core.matching import match_invoices_to_cards
from ...exceptions import ExtractionError
from ...infra.documents.invoice import CACHE_DIR_NAME, classify_invoice_batch
from .llm_extractor import extract_document
log = get_logger("extractor")
# SSE 文件进度事件日志文件名
FILE_EVENTS_LOG = "file_events.log"
# 并行提取文件数,可通过环境变量 EXTRACTION_PARALLEL_COUNT 配置
EXTRACTION_PARALLEL_COUNT = int(os.environ.get("EXTRACTION_PARALLEL_COUNT", "3"))
# 支持的文件扩展名
SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".webp"}
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
def _get_cache_dir(source_dir: Path) -> Path:
"""获取缓存目录路径"""
cache_dir = source_dir / CACHE_DIR_NAME
cache_dir.mkdir(exist_ok=True)
return cache_dir
def _get_json_path(file_path: Path, cache_dir: Path) -> Path:
"""根据文件路径生成对应的 JSON 缓存路径(包含后缀名以区分同名的 PDF/图片)"""
return cache_dir / f"{file_path.stem}{file_path.suffix}.json"
def _save_to_cache(file_path: Path, extracted_data: dict[str, Any], cache_dir: Path) -> Path:
"""将提取结果保存到 JSON 缓存文件,并记录源文件路径和后缀名"""
json_path = _get_json_path(file_path, cache_dir)
cache_data = {
"source_file": str(file_path),
"source_filename": file_path.name,
"source_extension": file_path.suffix.lower(),
"extracted_data": extracted_data,
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
log.info(f"提取结果已缓存: {json_path.name}")
return json_path
def _load_from_cache(json_path: Path, expected_extension: str | None = None) -> dict[str, Any] | None:
"""从 JSON 缓存文件加载提取结果,可选校验后缀名一致性"""
if not json_path.exists():
return None
try:
with open(json_path, encoding="utf-8") as f:
cache_data: dict[str, Any] = json.load(f)
# 校验后缀名是否一致,防止同名不同后缀的文件误命中缓存
if expected_extension and cache_data.get("source_extension", "").lower() != expected_extension.lower():
return None
result: dict[str, Any] | None = cache_data.get("extracted_data")
return result
except Exception as e:
log.warning(f"读取缓存失败 {json_path.name}: {e}")
return 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」字段自动分类
- "payment" -> 支付记录
- "application" -> 申请单
- 有 "invoice_number" -> 发票
- 其他 -> 无法识别
Args:
file_path: 文件路径PDF 或图片)。
cache_dir: JSON 缓存目录。
source_dir: 源目录(用于写入 SSE 进度事件)。
Returns:
(提取结果字典或 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_name
log.info(f"使用缓存: {file_name}")
_emit_file_event(source_dir, file_name, "cached")
return cached, None
# 发送处理中事件
_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_name
_save_to_cache(file_path, result, cache_dir)
# 发送完成事件(含摘要)
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]:
"""扫描目录下所有支持的文件PDF + 图片)"""
dir_path = Path(directory)
files = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS]
return sorted(files)
def _save_match_result(payment_records: list[dict[str, Any]], cache_dir: Path) -> None:
"""将发票与支付记录的匹配结果保存到缓存。"""
match_data: dict[str, list[dict[str, Any]]] = {}
for record in payment_records:
card_source = record.get("_source_file", "")
matched = record.get("_matched_invoices", [])
card_amount = record.get("card_amount", "")
if matched:
invoice_list = [
{
"file": inv.get("_source_file", ""),
"type": inv.get("invoice_type", ""),
"amount": inv.get("total_amount", inv.get("total_amount", "")),
}
for inv in matched
]
if card_source:
match_data[f"{card_source}{card_amount})"] = invoice_list
else:
key = "__unmatched__"
if key not in match_data:
match_data[key] = []
match_data[key].extend(invoice_list)
if not match_data:
return
match_path = cache_dir / "match_result.json"
with open(match_path, "w", encoding="utf-8") as f:
json.dump(match_data, f, ensure_ascii=False, indent=2)
log.info(f"匹配结果已缓存: {match_path.name}")
def extract_invoices(
directory: str = ".",
) -> tuple[list[dict[str, str]], list[dict[str, str]], dict[str, list[dict[str, str]]]]:
"""扫描目录下所有文件,提取信息并匹配支付记录
统一使用 LLM 提取根据返回的「invoice_type」自动分类
- 发票(有 invoice_number-> 参与金额匹配
- 支付记录invoice_type="payment"-> 参与金额匹配
- 出差事前申请单 -> 单独存储,不参与匹配
Returns:
(payment_records, applications, groups):
- payment_records: 支付记录列表(仅包含真实发票,不含申请单)
- 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("未找到支持的文件")
return [], [], {"travel": [], "general": [], "application": []}
log.info(f"发现 {len(all_files)} 个文件")
all_invoices = []
all_cards = []
applications = []
# 记录失败文件及其错误信息
failed_files: list[tuple[str, str]] = []
max_workers = max(1, EXTRACTION_PARALLEL_COUNT)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_file = {executor.submit(_extract_document, fp, cache_dir, source_dir): fp for fp in all_files}
for future in as_completed(future_to_file):
file_path = future_to_file[future]
try:
result, err = future.result()
except Exception as e:
err_msg = str(e)
failed_files.append((file_path.name, err_msg))
log.warning(f"未能解析: {file_path.name} ({err_msg})")
continue
if not result:
if err:
failed_files.append((file_path.name, err))
log.warning(f"未能解析: {file_path.name}")
continue
inv_type = result.get("invoice_type", "")
if inv_type == "application":
applications.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif inv_type == "payment":
all_cards.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif result.get("invoice_number"):
all_invoices.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
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:
log.warning("未成功解析任何发票")
payment_records = match_invoices_to_cards(all_invoices, all_cards)
_save_match_result(payment_records, cache_dir)
# 构建分类
all_documents: list[dict[str, str]] = []
for record in payment_records:
all_documents.extend(record.get("_matched_invoices", []))
all_documents.extend(applications)
groups = classify_invoice_batch(all_documents)
log.info(
f"文档分类: 差旅发票 {len(groups['travel'])} 张, "
f"普通发票 {len(groups['general'])} 张, "
f"出差事前申请单 {len(groups['application'])}"
)
return payment_records, applications, groups

View File

@@ -1,591 +0,0 @@
"""LLM 信息提取
使用 LLM 从 PDF 文本/图片、支付截图中提取结构化数据。
## 功能模块
- **统一文档提取**使用一套提示词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
import base64
import json
from pathlib import Path
from typing import Any, cast
from ... import get_logger
from ...infra.documents.invoice import CACHE_DIR_NAME
from ...infra.llm.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 实例。"""
try:
from llama_index.llms.openai_like import OpenAILike
except ImportError:
log.error("缺少 llama-index-llms-openai-like请执行: uv pip install llama-index-llms-openai-like")
raise
from ...config import get_llm_config
llm_config = get_llm_config()
return OpenAILike(
model=llm_config["model"],
api_base=llm_config["api_base"],
api_key=llm_config.get("api_key", "lm-studio"),
temperature=0.1,
max_tokens=65535,
request_timeout=600.0,
is_chat_model=True,
)
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:
text = text[start:end].strip()
# 去掉可能的前缀 (如 "json")
if text.lower().startswith("json"):
text = text[4:].strip()
return cast(dict[str, Any], json.loads(text))
# ------------------------------------------------------------------
# 统一文档提取(多模态,直接传图片给 LLM
# ------------------------------------------------------------------
def _image_to_base64(image_path: Path) -> str:
"""将图片文件读取为 base64 字符串。"""
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def _stream_llm_response(
llm: Any,
messages: list[Any],
source_dir: Path | None,
reasoning_effort: str,
log_label: str = "LLM",
) -> str:
"""流式调用 LLM 并写入 SSE 事件(供 llm_query_text 和 _llm_query_multimodal 共用)。
Args:
llm: LLM 实例。
messages: 消息列表。
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
reasoning_effort: 推理努力级别。
log_label: 日志标签(用于区分"纯文本""多模态")。
Returns:
LLM 响应文本。
"""
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("%s请求完成,响应总长度: %d 字符", log_label, len(text))
if source_dir:
_emit_llm_stream(source_dir, "end", label="分析完成")
return text
except Exception as e:
log.error("%s请求失败: %s", log_label, e)
if source_dir:
_emit_llm_stream(source_dir, "error", error=str(e))
raise
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"],
)
return _stream_llm_response(llm, messages, source_dir, reasoning_effort, log_label="LLM")
def extract_document(file_path: Path) -> dict[str, Any]:
"""统一文档提取入口:从任意图片/PDF 中提取结构化信息。
LLM 会根据统一提示词自行判断文档类型(发票/支付记录/出差事前申请单等)。
Args:
file_path: 文件路径(支持 PDF 和图片格式)。
Returns:
包含提取字段的字典。
"""
from ...infra.documents.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内部使用
Args:
system_prompt: 系统提示词。
text: 用户文本(与 image_b64s 配合使用,文本在前、图片在后)。
image_b64s: base64 编码的图片列表。
blocks: 预构建的内容块列表TextBlock/ImageBlock传入时忽略 text 和 image_b64s。
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
Returns:
LLM 响应文本。
"""
from llama_index.core.base.llms.types import ImageBlock, TextBlock
from llama_index.core.llms import ChatMessage
from ...config import get_llm_config
if blocks is not None:
final_blocks = blocks
else:
text = text or ""
image_b64s = image_b64s or []
final_blocks = [TextBlock(text=text)]
for img_b64 in image_b64s:
final_blocks.append(
ImageBlock(
url=f"data:image/jpeg;base64,{img_b64}",
detail="high",
)
)
messages = [
ChatMessage(role="system", content=system_prompt),
ChatMessage(role="user", blocks=final_blocks),
]
llm_config = get_llm_config()
llm = _create_llm()
log.info(
"开始请求 LLM 多模态 (model=%s, base=%s, blocks=%d)",
llm_config["model"],
llm_config["api_base"],
len(final_blocks),
)
return _stream_llm_response(llm, messages, source_dir, reasoning_effort, log_label="LLM多模态")
def load_cache(source_dir: Path) -> dict[str, Any]:
"""从 JSON 缓存目录加载结构化数据,构建 source filename -> 缓存数据的映射。
Args:
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
Returns:
{source_filename: extracted_data} 字典。
额外包含 "travel_info" 键(如果 travel_info.json 存在)。
"""
cache_map: dict[str, Any] = {}
cache_dir = source_dir / CACHE_DIR_NAME
if not cache_dir.exists():
return cache_map
for json_path in sorted(cache_dir.glob("*.json")):
try:
with open(json_path, encoding="utf-8") as f:
cache_data = json.load(f)
if json_path.name in ("travel_info.json", "normal_info.json"):
cache_map[json_path.name.replace(".json", "")] = cache_data
continue
extracted = cache_data.get("extracted_data", {})
src_file = extracted.get("_source_file", "")
if src_file:
cache_map[src_file] = extracted
except Exception as e:
log.warning(f"读取缓存失败 {json_path.name}: {e}")
return cache_map
def load_match_result(source_dir: Path) -> dict[str, list[dict[str, Any]]]:
"""从 JSON 缓存目录加载发票与支付记录的匹配结果。
Args:
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
Returns:
{支付记录源文件 (含金额): [发票信息列表]} 字典。
每个发票信息包含 file, type, amount 字段。
"""
cache_dir = source_dir / CACHE_DIR_NAME
match_path = cache_dir / "match_result.json"
if not match_path.exists():
return {}
try:
with open(match_path, encoding="utf-8") as f:
result: dict[str, list[dict[str, Any]]] = json.load(f)
return result
except Exception as e:
log.warning(f"读取匹配结果缓存失败: {e}")
return {}
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:
cache_map: 缓存数据映射。
match_result: 匹配结果。
previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。
Returns:
拼接好的用户消息字符串。
"""
parts = [
"以下是本次报销的所有源文件及其提取出的结构化数据。"
"每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。"
]
if previous_analysis:
parts.append(
"【上一轮分析结果】"
"以下是上一轮 LLM 对已有文件的分析结果。"
"注意:用户可能已补充新文件,请综合所有数据(含新文件)重新分析。"
"如果新文件填补了之前的信息缺失,请相应更新分析结果。\n"
+ json.dumps(previous_analysis, ensure_ascii=False, indent=2)
)
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 格式结果 ===")
return "\n".join(parts)
def _extract_info(
source_dir: Path | None,
system_prompt: str,
info_type: str,
) -> dict[str, Any]:
"""通用的信息提取函数:加载缓存、构建消息、调用 LLM 并解析 JSON。
extract_travel_info 和 extract_normal_info 的公共实现。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
system_prompt: 系统提示词。
info_type: 信息类型标签("差旅""普通发票"),用于日志。
Returns:
LLM 提取的结构化信息字典。
"""
if not source_dir:
log.warning("未提供 source_dir无法加载缓存数据")
return {}
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("开始构建%s信息提取请求,缓存条目: %d, 匹配结果: %d", info_type, len(cache_map), len(match_result))
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 %s信息提取成功", info_type)
return result
except Exception as e:
log.error("LLM %s信息提取失败: %s", info_type, e)
raise
def extract_travel_info(
source_dir: Path | None = None,
) -> dict[str, Any]:
"""根据差旅发票bot 格式),让 LLM 提取出差相关信息。
纯提取,不包含校验逻辑。校验由 Agent 层调度。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
Returns:
包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。
"""
return _extract_info(source_dir, build_travel_info_system_prompt(), "差旅")
def extract_normal_info(
source_dir: Path | None = None,
) -> dict[str, Any]:
"""根据普通发票(非差旅),让 LLM 提取报销相关信息。
纯提取,不包含校验逻辑。校验由 Agent 层调度。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
Returns:
包含报销说明、发票总数、总金额、支付方式、附件清单等字段的字典。
"""
return _extract_info(source_dir, build_normal_info_system_prompt(), "普通发票")
# ------------------------------------------------------------------
# 用户补充信息处理
# ------------------------------------------------------------------
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

View File

@@ -1,27 +0,0 @@
---
last_reviewed: 2026-06-15
---
# src/core/matching — 金额匹配
将提取到的发票数据与支付记录(刷卡截图)按金额进行匹配。
## 文件
| 文件 | 职责 |
|------|------|
| `matcher.py` | 匹配引擎:一对一匹配、一对多贪心匹配、未匹配发票处理 |
## 匹配策略
| 场景 | 策略 |
|------|------|
| 发票数 == 支付记录数 | 一对一匹配:按金额降序配对,相对容差内即匹配 |
| 发票数 > 支付记录数 | 一对多匹配:贪心算法凑金额,相对容差 3% |
| 文件名匹配 | 最高优先级:文件名(不含后缀)一致时直接匹配 |
| 未匹配发票 | 单独列为一条支付记录,`remark` 标记为 `"unmatched"` |
## 业务约束
- 发票总金额 >= 支付总金额
- 输出以支付记录为主键的结果列表

View File

@@ -1,8 +0,0 @@
"""匹配模块
提供发票与支付记录的金额匹配功能。
"""
from .matcher import match_invoices_to_cards
__all__ = ["match_invoices_to_cards"]

View File

@@ -1,34 +0,0 @@
---
last_reviewed: 2026-06-15
---
# src/core/validation — 信息校验
对 LLM 提取的报销信息进行声明式规则校验,判断是否满足填报要求。
## 文件
| 文件 | 职责 |
|------|------|
| `validator.py` | 校验引擎:加载 JSON 规则配置 → 遍历字段/数组 → 输出校验报告 |
## 设计特点
- **规则与引擎分离**:校验规则存储在 `config/validation_rules.json`,引擎只负责执行
- **统一路径定位**:使用 `path` 列表定位嵌套字段,如 `["basic_info", "travel_purpose"]`
- **自定义校验**:支持 `custom_check` 函数(日期格式、正数检查等)
- **数组元素校验**:支持 `min_items` 最小数量 + 每个元素的必填字段
## 校验规则类型
| 类型 | 用途 | 配置项 |
|------|------|--------|
| `fields` | 顶层单值字段 | `path`, `required`, `custom_check`, `check_empty` |
| `arrays` | 数组字段 | `path`, `min_items`, `element_fields` |
## 对外接口
| 函数 | 说明 |
|------|------|
| `validate(info, invoice_type)` | 执行校验,返回 `ValidationReport` |
| `get_missing_fields(report)` | 提取缺失字段列表 |

View File

@@ -1,28 +0,0 @@
"""校验模块
提供报销信息的规则级校验功能。
"""
from .validator import (
ArrayRule,
FieldRule,
ValidationReport,
ValidationRules,
get_validation_rules,
reload_validation_rules,
validate_extracted_info,
validate_normal_info,
validate_travel_info,
)
__all__ = [
"validate_extracted_info",
"validate_travel_info",
"validate_normal_info",
"ValidationReport",
"FieldRule",
"ArrayRule",
"ValidationRules",
"get_validation_rules",
"reload_validation_rules",
]

View File

@@ -1,537 +0,0 @@
"""信息完整性校验器
对 LLM 提取的报销信息进行规则级校验,判断是否满足填报要求。
校验规则从 JSON 配置文件加载,支持声明式配置。
设计理念:
使用声明式规则配置,将校验规则与校验逻辑分离,提高可读性和可维护性。
"""
from __future__ import annotations
import json
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, TypedDict
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:
"""检查日期格式是否为 YYYY-MM-DD。"""
return bool(DATE_PATTERN.match(value))
def _is_positive_number(value: Any) -> bool:
"""检查值是否为正数(整数或浮点数)。"""
return isinstance(value, int | float) and value > 0
def _is_positive_integer(value: Any) -> bool:
"""检查值是否为正整数。"""
return isinstance(value, int) and value > 0
# 自定义校验函数注册表
_CUSTOM_CHECKS: dict[str, Callable[[Any], bool]] = {
"is_valid_date": _is_valid_date,
"is_positive_number": _is_positive_number,
"is_positive_integer": _is_positive_integer,
}
# ------------------------------------------------------------------
# 规则定义
# ------------------------------------------------------------------
class FieldRule(TypedDict, total=False):
"""字段校验规则(统一使用 path 定位)"""
path: list[str] # 字段路径(统一定位方式)
required: bool = True # 是否必填(默认必填)
check_empty: bool = True # 是否检查空字符串(默认检查)
custom_check: str | Callable[[Any], bool] | None = None # 自定义校验函数(名称或函数)
description: str = "" # 字段描述(用于生成友好提示)
class ArrayRule(TypedDict, total=False):
"""数组校验规则"""
path: list[str] # 数组路径
min_items: int = 1 # 最小元素数量
element_fields: list[str | FieldRule] = [] # 元素字段规则
description: str = "" # 数组描述
class ValidationRules(TypedDict):
"""校验规则集合"""
fields: list[FieldRule] # 字段规则列表
arrays: list[ArrayRule] # 数组规则列表
class ValidationConfig(TypedDict):
"""校验配置结构"""
version: str
custom_checks: dict[str, str]
travel: ValidationRules
normal: ValidationRules
# ------------------------------------------------------------------
# 配置加载
# ------------------------------------------------------------------
_CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / "validation_rules.json"
_cached_rules: ValidationConfig | None = None
def _load_validation_config() -> ValidationConfig:
"""加载校验规则配置文件。"""
global _cached_rules
if _cached_rules is not None:
return _cached_rules
if not _CONFIG_PATH.exists():
log.warning("校验规则配置文件不存在: %s,使用内置默认规则", _CONFIG_PATH)
return _load_default_rules()
try:
with open(_CONFIG_PATH, encoding="utf-8") as f:
config = json.load(f)
_cached_rules = _resolve_custom_checks(config)
log.info("校验规则配置加载成功")
return _cached_rules
except Exception as e:
log.error("加载校验规则配置失败: %s,使用内置默认规则", e)
return _load_default_rules()
def _resolve_custom_checks(config: dict[str, Any]) -> ValidationConfig:
"""解析配置中的自定义校验函数名称,替换为实际函数引用。"""
def resolve_rule(rule: dict[str, Any]) -> dict[str, Any]:
if "custom_check" in rule and isinstance(rule["custom_check"], str):
check_name = rule["custom_check"]
if check_name in _CUSTOM_CHECKS:
rule["custom_check"] = _CUSTOM_CHECKS[check_name]
else:
log.warning("未知的自定义校验函数: %s", check_name)
rule["custom_check"] = None
return rule
# 解析 travel 规则的 fields
for field_rule in config.get("travel", {}).get("fields", []):
resolve_rule(field_rule)
# 解析 element_fields
for array_rule in config.get("travel", {}).get("arrays", []):
for elem_field in array_rule.get("element_fields", []):
if isinstance(elem_field, dict):
resolve_rule(elem_field)
# 解析 normal 规则的 fields
for field_rule in config.get("normal", {}).get("fields", []):
resolve_rule(field_rule)
# 解析 element_fields
for array_rule in config.get("normal", {}).get("arrays", []):
for elem_field in array_rule.get("element_fields", []):
if isinstance(elem_field, dict):
resolve_rule(elem_field)
return config # type: ignore[return-value]
def _load_default_rules() -> ValidationConfig:
"""返回内置的默认校验规则(当配置文件不存在时使用)。"""
return {
"version": "1.0",
"custom_checks": {},
"travel": {
"fields": [
{"path": ["basic_info", "travel_purpose"], "description": "出差事由"},
{"path": ["basic_info", "travel_location"], "description": "出差地点"},
{"path": ["basic_info", "start_date"], "custom_check": _is_valid_date, "description": "出差开始日期"},
{"path": ["basic_info", "end_date"], "custom_check": _is_valid_date, "description": "出差结束日期"},
],
"arrays": [
{
"path": ["reimbursement_details", "transport_fee"],
"min_items": 1,
"element_fields": [
{"path": ["vehicle_type"], "description": "交通工具类型"},
{"path": ["start_date"], "custom_check": _is_valid_date, "description": "出发日期"},
{"path": ["end_date"], "custom_check": _is_valid_date, "description": "到达日期"},
{"path": ["departure_place"], "description": "出发地"},
{"path": ["arrival_place"], "description": "目的地"},
{"path": ["amount"], "custom_check": _is_positive_number, "description": "金额"},
{"path": ["bill_count"], "custom_check": _is_positive_integer, "description": "票据张数"},
{"path": ["remark"], "check_empty": False, "description": "备注说明"},
],
"description": "交通费用明细",
},
{
"path": ["payment_methods"],
"min_items": 1,
"element_fields": [
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
{"path": ["merchant"], "description": "商户名称"},
{"path": ["remark"], "check_empty": False, "description": "备注"},
],
"description": "支付方式记录",
},
{
"path": ["subsidy_list"],
"min_items": 1,
"element_fields": [
{"path": ["person_id"], "description": "人员工号"},
{"path": ["person_name"], "description": "人员姓名"},
{"path": ["start_date"], "custom_check": _is_valid_date, "description": "补助开始日期"},
{"path": ["end_date"], "custom_check": _is_valid_date, "description": "补助结束日期"},
{"path": ["days"], "custom_check": _is_positive_integer, "description": "补助天数"},
],
"description": "补助清单",
},
{
"path": ["attachments"],
"min_items": 0,
"element_fields": [
{"path": ["filename"], "description": "文件名"},
{"path": ["attachment_type"], "description": "附件类型"},
],
"description": "附件列表",
},
],
},
"normal": {
"fields": [
{"path": ["basic_info", "reimbursement_description"], "description": "报销事由"},
{
"path": ["reimbursement_details", "total_invoices"],
"custom_check": _is_positive_integer,
"description": "发票总数",
},
{
"path": ["reimbursement_details", "total_amount"],
"custom_check": _is_positive_number,
"description": "总金额",
},
],
"arrays": [
{
"path": ["payment_methods"],
"min_items": 1,
"element_fields": [
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
{"path": ["merchant"], "description": "商户名称"},
{"path": ["remark"], "check_empty": False, "description": "备注"},
],
"description": "支付方式记录",
},
{
"path": ["attachments"],
"min_items": 0,
"element_fields": [
{"path": ["filename"], "description": "文件名"},
{"path": ["attachment_type"], "description": "附件类型"},
],
"description": "附件列表",
},
],
},
}
def get_validation_rules(invoice_type: str) -> ValidationRules:
"""获取指定发票类型的校验规则。
Args:
invoice_type: 发票类型,'travel''normal'
Returns:
对应的校验规则。
"""
config = _load_validation_config()
return config.get(invoice_type, config.get("travel", {})) # type: ignore[return-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],
rule: FieldRule,
) -> tuple[bool, str]:
"""根据字段规则检查字段。"""
path = rule["path"]
check_empty = rule.get("check_empty", True)
custom_check = rule.get("custom_check")
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(
data: dict[str, Any],
rule: ArrayRule,
) -> tuple[list[str], int, int]:
"""根据数组规则检查数组。
Returns:
(缺失字段列表, 总检查数, 通过检查数)
"""
missing: list[str] = []
path = rule["path"]
min_items = rule.get("min_items", 1)
element_fields = rule.get("element_fields", [])
path_str = ".".join(path)
total_checks = 1 # 数组存在性和最小数量检查
passed_checks = 0
# 遍历路径获取数组
current = data
for key in path:
if not isinstance(current, dict) or key not in current:
return ([path_str], total_checks, passed_checks)
current = current[key]
# 检查数组是否满足最小数量要求
if not isinstance(current, list) or len(current) < min_items:
return ([path_str], total_checks, passed_checks)
passed_checks += 1 # 数组检查通过
# 检查数组元素的字段
if element_fields:
for i, item in enumerate(current):
if not isinstance(item, dict):
missing.append(f"{path_str}[{i}]")
total_checks += len(element_fields)
continue
for field_rule in element_fields:
total_checks += 1
# 支持两种格式:简单字符串格式 和 详细规则格式
if isinstance(field_rule, str):
field_rule_dict: FieldRule = {"path": [field_rule]}
else:
field_rule_dict = field_rule
# 复用 _check_field 函数检查元素字段
ok, _ = _check_field(item, field_rule_dict)
if ok:
passed_checks += 1
else:
field_path_str = ".".join(field_rule_dict["path"])
missing.append(f"{path_str}[{i}].{field_path_str}")
return missing, total_checks, passed_checks
def _validate_with_rules(data: dict[str, Any], rules: ValidationRules) -> tuple[list[str], int, int]:
"""使用规则配置进行校验。"""
missing: list[str] = []
total_checks = 0
passed_checks = 0
# 校验字段规则
for rule in rules.get("fields", []):
total_checks += 1
ok, field_path = _check_field(data, rule)
if ok:
passed_checks += 1
else:
missing.append(field_path)
# 校验数组规则
for rule in rules.get("arrays", []):
array_missing, array_total, array_passed = _check_array(data, rule)
total_checks += array_total
passed_checks += array_passed
missing.extend(array_missing)
return missing, total_checks, passed_checks
# ------------------------------------------------------------------
# 校验入口
# ------------------------------------------------------------------
def validate_travel_info(data: dict[str, Any]) -> ValidationReport:
"""校验差旅报销信息的完整性。"""
rules = get_validation_rules("travel")
missing, total_checks, passed_checks = _validate_with_rules(data, rules)
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:
"""校验普通报销信息的完整性。"""
rules = get_validation_rules("normal")
missing, total_checks, passed_checks = _validate_with_rules(data, rules)
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
def reload_validation_rules() -> None:
"""重新加载校验规则配置(用于运行时热更新)。"""
global _cached_rules
_cached_rules = None
_load_validation_config()
log.info("校验规则已重新加载")

60
src/doc/README.md Normal file
View File

@@ -0,0 +1,60 @@
---
last_reviewed: 2026-06-11
---
# src/doc — 文档处理模块
负责发票信息提取、基于 LLM 的支付截图信息识别、差旅/普通报销信息提取、以及将数据填入 Word 出库单模板。
## 模块清单
| 文件 | 作用 |
| ------------------------ | -------------------------------------------------- |
| `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
[发票列表 + 匹配结果] → llm_extractor.py
差旅发票 → extract_travel_info() → travel_info.json
普通发票 → extract_normal_info() → normal_info.json
```
## 依赖说明
- **PyMuPDF (pymupdf)** — PDF 图片渲染
- **pywin32** — Word COM 自动化(仅 Windows
- **llama-index** — LLM 信息提取
## 注意事项
- `fill_consumable_doc.py` 依赖 Microsoft Word + COM仅 Windows 可用
- LLM 提取不会覆盖 CSV 中已有非空字段
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
- LLM 提取失败时直接报错,无正则回退
## 变更说明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`:普通发票信息提取的系统提示词

4
src/doc/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""文档处理模块
包含发票提取、LLM 信息提取、出库单填写等功能。
"""

241
src/doc/extractor.py Normal file
View File

@@ -0,0 +1,241 @@
"""发票提取编排
统一扫描目录下所有文件PDF + 图片),通过 LLM 提取结构化数据,
根据 LLM 返回的「invoice_type」字段自动分类为发票/支付记录/出差事前申请单。
对外接口:
extract_invoices(directory) -> tuple[list[dict], list[dict], dict]
"""
import json
from pathlib import Path
from typing import Any
from .. import get_logger
from .llm_extractor import extract_document
from .matcher import match_invoices_to_cards
log = get_logger("extractor")
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}
# JSON 缓存目录(相对于源文件目录)
CACHE_DIR_NAME = ".invoice_cache"
# 支持的文件扩展名
SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".bmp"}
def _get_cache_dir(source_dir: Path) -> Path:
"""获取缓存目录路径"""
cache_dir = source_dir / CACHE_DIR_NAME
cache_dir.mkdir(exist_ok=True)
return cache_dir
def _get_json_path(file_path: Path, cache_dir: Path) -> Path:
"""根据文件路径生成对应的 JSON 缓存路径(包含后缀名以区分同名的 PDF/图片)"""
return cache_dir / f"{file_path.stem}{file_path.suffix}.json"
def _save_to_cache(file_path: Path, extracted_data: dict[str, Any], cache_dir: Path) -> Path:
"""将提取结果保存到 JSON 缓存文件,并记录源文件路径和后缀名"""
json_path = _get_json_path(file_path, cache_dir)
cache_data = {
"source_file": str(file_path),
"source_filename": file_path.name,
"source_extension": file_path.suffix.lower(),
"extracted_data": extracted_data,
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
log.info(f"提取结果已缓存: {json_path.name}")
return json_path
def _load_from_cache(json_path: Path, expected_extension: str | None = None) -> dict[str, Any] | None:
"""从 JSON 缓存文件加载提取结果,可选校验后缀名一致性"""
if not json_path.exists():
return None
try:
with open(json_path, encoding="utf-8") as f:
cache_data: dict[str, Any] = json.load(f)
# 校验后缀名是否一致,防止同名不同后缀的文件误命中缓存
if expected_extension and cache_data.get("source_extension", "").lower() != expected_extension.lower():
return None
result: dict[str, Any] | None = cache_data.get("extracted_data")
return result
except Exception as e:
log.warning(f"读取缓存失败 {json_path.name}: {e}")
return None
def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None:
"""提取单个文件的结构化信息,优先使用缓存。
根据 LLM 返回的「invoice_type」字段自动分类
- "payment" -> 支付记录
- "application" -> 申请单
- 有 "invoice_number" -> 发票
- 其他 -> 无法识别
Args:
file_path: 文件路径PDF 或图片)。
cache_dir: JSON 缓存目录。
Returns:
提取结果字典,失败时返回 None。
"""
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
log.info(f"使用多模态提取: {file_path.name}")
try:
result = extract_document(file_path)
if result:
result["_source_file"] = file_path.name
_save_to_cache(file_path, result, cache_dir)
return result
except Exception as e:
log.warning(f"多模态提取失败: {file_path.name} ({e})")
return None
def _find_all_files(directory: str) -> list[Path]:
"""扫描目录下所有支持的文件PDF + 图片)"""
dir_path = Path(directory)
files = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS]
return sorted(files)
def _save_match_result(payment_records: list[dict[str, Any]], cache_dir: Path) -> None:
"""将发票与支付记录的匹配结果保存到缓存。"""
match_data: dict[str, list[dict[str, Any]]] = {}
for record in payment_records:
card_source = record.get("_source_file", "")
matched = record.get("_matched_invoices", [])
card_amount = record.get("card_amount", "")
if matched:
invoice_list = [
{
"file": inv.get("_source_file", ""),
"type": inv.get("invoice_type", ""),
"amount": inv.get("total_amount", inv.get("total_amount", "")),
}
for inv in matched
]
if card_source:
match_data[f"{card_source}{card_amount})"] = invoice_list
else:
key = "__unmatched__"
if key not in match_data:
match_data[key] = []
match_data[key].extend(invoice_list)
if not match_data:
return
match_path = cache_dir / "match_result.json"
with open(match_path, "w", encoding="utf-8") as f:
json.dump(match_data, f, ensure_ascii=False, indent=2)
log.info(f"匹配结果已缓存: {match_path.name}")
def extract_invoices(
directory: str = ".",
) -> tuple[list[dict[str, str]], list[dict[str, str]], dict[str, list[dict[str, str]]]]:
"""扫描目录下所有文件,提取信息并匹配支付记录
统一使用 LLM 提取根据返回的「invoice_type」自动分类
- 发票(有 invoice_number-> 参与金额匹配
- 支付记录invoice_type="payment"-> 参与金额匹配
- 出差事前申请单 -> 单独存储,不参与匹配
Returns:
(payment_records, applications, groups):
- payment_records: 支付记录列表(仅包含真实发票,不含申请单)
- applications: 出差事前申请单列表(单独存储,不参与支付匹配)
- groups: 按文档类型分组的字典
{'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单]}
"""
source_dir = Path(directory)
cache_dir = _get_cache_dir(source_dir)
all_files = _find_all_files(directory)
if not all_files:
log.warning("未找到支持的文件")
return [], [], {"travel": [], "general": [], "application": []}
log.info(f"发现 {len(all_files)} 个文件")
all_invoices = []
all_cards = []
applications = []
for file_path in all_files:
result = _extract_document(file_path, cache_dir)
if not result:
log.warning(f"未能解析: {file_path.name}")
continue
inv_type = result.get("invoice_type", "")
if inv_type == "application":
applications.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif inv_type == "payment":
all_cards.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif result.get("invoice_number"):
all_invoices.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
else:
log.warning(f"无法分类: {file_path.name} (invoice_type={inv_type})")
log.info(f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)}")
if not all_invoices:
log.warning("未成功解析任何发票")
payment_records = match_invoices_to_cards(all_invoices, all_cards)
_save_match_result(payment_records, cache_dir)
# 构建分类
all_documents: list[dict[str, str]] = []
for record in payment_records:
all_documents.extend(record.get("_matched_invoices", []))
all_documents.extend(applications)
groups = _classify_invoice_batch(all_documents)
log.info(
f"文档分类: 差旅发票 {len(groups['travel'])} 张, "
f"普通发票 {len(groups['general'])} 张, "
f"出差事前申请单 {len(groups['application'])}"
)
return payment_records, applications, groups

View File

@@ -1,4 +1,5 @@
"""将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
"""
invoice_summary.csv 填入易耗品出库单.doc表格
仅写入表格数据单元格保留原模板字体边框与版式
"""
@@ -12,9 +13,9 @@ from datetime import date
from pathlib import Path
from typing import Any
from ... import get_logger
from ...config import load_config
from .invoice import load_invoice_csv
from .. import get_logger
from ..config import load_config
from ..doc.invoice import load_invoice_csv
log = get_logger("fill_consumable_doc")
@@ -153,8 +154,6 @@ def fill_consumable_doc(
import win32com.client
pythoncom.CoInitialize()
word = None
doc = None
try:
word = win32com.client.Dispatch("Word.Application")
word.Visible = False
@@ -214,11 +213,9 @@ def fill_consumable_doc(
doc.Save()
finally:
if doc is not None:
doc.Close()
finally:
if word is not None:
doc.Close()
word.Quit()
finally:
pythoncom.CoUninitialize()
return doc_path

View File

@@ -13,13 +13,10 @@ import csv
import json
from pathlib import Path
from ... import get_logger
from .. import get_logger
log = get_logger("invoice")
# 缓存目录名(相对于源文件目录)
CACHE_DIR_NAME = ".invoice_cache"
# CSV 列名
INVOICE_LEVEL_COLUMNS = [
"index",
@@ -61,7 +58,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]]]:
"""按发票类型分组"""

395
src/doc/llm_extractor.py Normal file
View File

@@ -0,0 +1,395 @@
"""LLM 信息提取
使用 LLM 从 PDF 文本/图片、支付截图中提取结构化数据。
## 功能模块
- **统一文档提取**使用一套提示词LLM 自行判断文档类型(发票/支付记录/出差事前申请单等),支持 JSON 格式输出。
- **差旅信息提取**:综合多张发票、支付记录和匹配结果,提取出差事由、地点、时间等差旅相关信息。
- **缓存管理**:支持从 `.invoice_cache/` 目录加载已提取的结构化数据和匹配结果,避免重复处理。
## 对外接口
- `extract_document(file_path) -> dict` — 统一入口:从任意图片/PDF 提取信息
- `extract_travel_info(source_dir) -> dict` — 综合发票和匹配结果提取差旅信息
- `load_cache(source_dir) -> dict` — 加载缓存的结构化数据
- `load_match_result(source_dir) -> dict` — 加载发票与支付记录的匹配结果
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Any, cast
from .. import get_logger
from .prompt import (
build_invoice_system_prompt,
build_normal_info_system_prompt,
build_travel_info_system_prompt,
)
log = get_logger("llm_extractor")
def _create_llm() -> Any:
"""根据配置文件创建 LLM 实例。"""
try:
from llama_index.llms.openai_like import OpenAILike
except ImportError:
log.error("缺少 llama-index-llms-openai-like请执行: uv pip install llama-index-llms-openai-like")
raise
from ..config import get_llm_config
llm_config = get_llm_config()
return OpenAILike(
model=llm_config["model"],
api_base=llm_config["api_base"],
api_key=llm_config.get("api_key", "lm-studio"),
temperature=0.1,
max_tokens=65535,
request_timeout=600.0,
is_chat_model=True,
)
def _parse_json_response(text: str) -> dict[str, Any]:
"""从 LLM 响应中提取 JSON处理可能的 Markdown 包裹。"""
text = text.strip()
# 处理 ```json ... ``` 包裹
if "```" in text:
# 提取第一个代码块
start = text.find("```") + 3
end = text.find("```", start)
if end > start:
text = text[start:end].strip()
# 去掉可能的前缀 (如 "json")
if text.lower().startswith("json"):
text = text[4:].strip()
return cast(dict[str, Any], json.loads(text))
# ------------------------------------------------------------------
# 统一文档提取(多模态,直接传图片给 LLM
# ------------------------------------------------------------------
def _image_to_base64(image_path: Path) -> str:
"""将图片文件读取为 base64 字符串。"""
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
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",
) -> str:
"""发送多模态请求到 LLM。
Args:
system_prompt: 系统提示词。
text: 用户文本(与 image_b64s 配合使用,文本在前、图片在后)。
image_b64s: base64 编码的图片列表。
blocks: 预构建的内容块列表TextBlock/ImageBlock传入时忽略 text 和 image_b64s。
Returns:
LLM 响应文本。
"""
from llama_index.core.base.llms.types import ImageBlock, TextBlock
from llama_index.core.llms import ChatMessage
from ..config import get_llm_config
if blocks is not None:
final_blocks = blocks
else:
text = text or ""
image_b64s = image_b64s or []
final_blocks = [TextBlock(text=text)]
for img_b64 in image_b64s:
final_blocks.append(
ImageBlock(
url=f"data:image/jpeg;base64,{img_b64}",
detail="high",
)
)
messages = [
ChatMessage(role="system", content=system_prompt),
ChatMessage(role="user", blocks=final_blocks),
]
llm_config = get_llm_config()
llm = _create_llm()
log.info(
"开始请求 LLM 多模态 (model=%s, base=%s, blocks=%d)",
llm_config["model"],
llm_config["api_base"],
len(final_blocks),
)
try:
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)
text = "".join(parts)
log.info("LLM 多模态请求完成,响应总长度: %d 字符", len(text))
log.info("LLM 多模态响应: %s", text)
return text
except Exception as e:
log.error("LLM 多模态请求失败: %s", 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 -> 缓存数据的映射。
Args:
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
Returns:
{source_filename: extracted_data} 字典。
额外包含 "travel_info" 键(如果 travel_info.json 存在)。
"""
cache_map: dict[str, Any] = {}
cache_dir = source_dir / CACHE_DIR_NAME
if not cache_dir.exists():
return cache_map
for json_path in sorted(cache_dir.glob("*.json")):
try:
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
extracted = cache_data.get("extracted_data", {})
src_file = extracted.get("_source_file", "")
if src_file:
cache_map[src_file] = extracted
except Exception as e:
log.warning(f"读取缓存失败 {json_path.name}: {e}")
return cache_map
def load_match_result(source_dir: Path) -> dict[str, list[dict[str, Any]]]:
"""从 JSON 缓存目录加载发票与支付记录的匹配结果。
Args:
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
Returns:
{支付记录源文件 (含金额): [发票信息列表]} 字典。
每个发票信息包含 file, type, amount 字段。
"""
cache_dir = source_dir / CACHE_DIR_NAME
match_path = cache_dir / "match_result.json"
if not match_path.exists():
return {}
try:
with open(match_path, encoding="utf-8") as f:
result: dict[str, list[dict[str, Any]]] = json.load(f)
return result
except Exception as e:
log.warning(f"读取匹配结果缓存失败: {e}")
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
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
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 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(
system_prompt=system_prompt,
text=user_message,
reasoning_effort="low",
)
result = _parse_json_response(response)
log.info("LLM 差旅信息提取成功")
return result
except Exception as e:
log.error("LLM 差旅信息提取失败: %s", e)
raise
# ------------------------------------------------------------------
# 普通发票信息提取
# ------------------------------------------------------------------
def extract_normal_info(
source_dir: Path | None = None,
) -> dict[str, Any]:
"""根据普通发票(非差旅),让 LLM 提取报销相关信息。
仅支持从 JSON 缓存加载数据。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
Returns:
包含报销说明、发票总数、总金额、支付方式、附件清单等字段的字典。
"""
if not source_dir:
log.warning("未提供 source_dir无法加载缓存数据")
return {}
system_prompt = build_normal_info_system_prompt()
# 构建 source filename -> 缓存数据的映射
cache_map = load_cache(source_dir)
# 加载发票与支付记录的匹配结果
match_result = load_match_result(source_dir)
# 拼接纯文本消息
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(
system_prompt=system_prompt,
text=user_message,
reasoning_effort="low",
)
result = _parse_json_response(response)
log.info("LLM 普通发票信息提取成功")
return result
except Exception as e:
log.error("LLM 普通发票信息提取失败: %s", e)
raise

View File

@@ -49,7 +49,7 @@
from pathlib import Path
from typing import Any
from ... import get_logger
from .. import get_logger
log = get_logger("matcher")
@@ -227,38 +227,25 @@ def _match_one_to_one(
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""一对一匹配:发票数等于刷卡数,对每张刷卡记录寻找金额最接近的未分配发票"""
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对"""
for card_idx, card in enumerate(cards):
if card_idx in result:
continue
card_amount = card["_amount"]
card_tol = _relative_tolerance(card_amount, tolerance)
# 在未分配的发票中找金额最接近的
best_idx = -1
best_diff = float("inf")
for idx, inv in enumerate(invoices):
if idx in assigned:
continue
diff = abs(inv["_amount"] - card_amount)
if diff < best_diff:
best_diff = diff
best_idx = idx
if best_idx >= 0 and best_diff <= card_tol:
inv = invoices[best_idx]
assigned.add(best_idx)
result[card_idx] = [best_idx]
if card_idx >= len(invoices):
break
inv = invoices[card_idx]
diff = abs(inv["_amount"] - card["_amount"])
card_tol = _relative_tolerance(card["_amount"], tolerance)
if diff <= card_tol:
assigned.add(card_idx)
result[card_idx] = [card_idx]
log.info(
f"[一对一] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"{card.get('_source_file', 'unknown')} ¥{card_amount:.2f}"
f"{card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
)
elif best_idx >= 0:
inv = invoices[best_idx]
else:
log.warning(
f"[一对一] 金额偏差超出容差: "
f"{inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"vs ¥{card_amount:.2f} (差 ¥{best_diff:.2f}, 容差 ¥{card_tol:.2f})"
f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})"
)

View File

@@ -11,7 +11,7 @@ from pathlib import Path
import fitz
from ... import get_logger
from .. import get_logger
log = get_logger("pdf")
@@ -21,7 +21,7 @@ def render_pdf_to_images(filepath: Path, dpi: int = 300) -> list[str]:
Args:
filepath: PDF 文件路径
dpi: 渲染分辨率默认 300平衡质量与速度
dpi: 渲染分辨率默认 150平衡质量与速度
Returns:
base64 编码的 JPEG 图片字符串列表每页一个

View File

@@ -1,6 +1,7 @@
"""LLM 提示词模板
"""
LLM 提示词模板
infra/llm/prompts/ 目录加载 .md 文件作为提示词模板
src/prompts/ 目录加载 .md 文件作为提示词模板
"""
import os
@@ -28,8 +29,3 @@ 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")

View File

@@ -1,10 +1,10 @@
---
last_reviewed: 2026-06-12
last_reviewed: 2026-06-11
---
# src/infra/llm/prompts — LLM 提示词模板
# src/doc/prompts — LLM 提示词模板
存放 LLM 信息提取使用的系统提示词模板文件,由 `src/infra/llm/prompt.py` 动态加载。
存放 LLM 信息提取使用的系统提示词模板文件,由 `src/doc/prompt.py` 动态加载。
## 模板清单
@@ -16,5 +16,5 @@ last_reviewed: 2026-06-12
## 加载方式
```python
from src.infra.llm import build_invoice_system_prompt, build_travel_info_system_prompt
from src.doc.prompt import build_invoice_system_prompt, build_travel_info_system_prompt
```

View File

@@ -0,0 +1,96 @@
你是财务文档信息提取助手。你的任务是从图片中提取结构化信息,可能是支付截图、银行转账记录、微信/支付宝付款凭证等,也可能是发票文件,也可能是出差事前申请单,也可能是易耗品、出库单,不管任何形式都要用统一的 JSON 格式返回信息。
第一步要先判断是,支付记录、高铁票、酒店住宿,普通发票,然后不同类型输出的信息不同。
## 输出示例
以下是火车票类型发票的完整示例:
```json
{
"invoice_type": "train", //必填项
"invoice_number": "26349119343000335414",
"invoice_date": "2026-06-05", //必填项
"ride_date": "2026-06-02", //必填项
"departure": "阜阳西", //必填项
"arrival": "合肥南", //必填项
"seat_class": "二等座",
"train_no": "G1967",
"person_name": "王建锋", //必填项
"total_amount": "115.50" //必填项
}
```
以下是支付记录的完整示例:
```json
{
"invoice_type": "payment",//必填项
"card_date": "2026-06-01",//必填项
"card_amount": "231.00",//必填项
"card_no": "6282****1682"
}
```
以下是酒店住宿发票的完整示例:
```json
{
"invoice_type": "hotel",//必填项
"invoice_number": "26342000001715702281",
"invoice_date": "2026-06-03",
"total_amount": "536.00"//必填项
}
```json
{
"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`,能够找到尽量填写找到的信息
如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段:
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 标记或解释。

View File

@@ -8,7 +8,7 @@
以下类型规则为最高优先级,任何情况下不得违反。
### 1. 根节点字段(共 6 个,类型不可变更)
### 1. 根节点字段(共 4 个,类型不可变更)
| 字段名 | 强制类型 | 空值处理 |
| --- | --- | --- |
@@ -16,8 +16,6 @@
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含下述 2 个子字段 |
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为 `[]` |
| `attachments` | 数组 (list) | 必填,无数据时赋值为 `[]` |
| `can_submit` | 布尔 (bool) | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` |
| `suggestion` | 字符串 (str) | 当 `can_submit``false` 时说明需补充的材料;为 `true` 时为空字符串 |
### 2. `reimbursement_details` 子字段(共 2 个)
@@ -39,9 +37,7 @@
"basic_info": {...},
"reimbursement_details": {...},
"payment_methods": [],
"attachments": [{"filename":"发票.pdf", ...}],
"can_submit": true,
"suggestion": ""
"attachments": [{"filename":"发票.pdf", ...}]
}
```
@@ -59,8 +55,6 @@
1. **发票信息**:包含购买物品的发票信息
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。
需要提取的信息:
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
1. `reimbursement_description`根据所有信息写一句20字以内的报销说明
@@ -77,23 +71,6 @@
2. `attachment_type`从以下两个选项中选择invoice、other
3. `attachment_desc`:简要描述该文件的基本信息
## 语义完整性校验
提取完成后,需判断信息是否足够支撑填报。根据校验结果设置根节点的 `can_submit`boolean`suggestion`string字段。
**校验维度**
- 支付金额总和是否与发票金额总和接近
- 报销说明是否明确具体
- 人员信息是否完整
- 支付方式是否与支付记录对应
- 每张发票是否都有对应的支付记录
**判定标准**
- `can_submit = true`:信息完整且逻辑自洽,`suggestion` 为空字符串
- `can_submit = false`:存在信息缺失或逻辑矛盾,`suggestion` 说明需要用户补充什么材料
## 最终输出要求
* 仅输出纯 JSON 字符串,不包含任何思考过程、解释文字或 Markdown 标记

View File

@@ -0,0 +1,123 @@
# 差旅信息提取系统提示词
你是财务差旅信息提取助手。你的任务是根据发票信息、付款记录,提取出差相关的结构化信息,并以严格符合以下类型要求的 JSON 格式返回。所有类型约束为最高优先级规则,任何情况下不得违反。
🔴 最高优先级:强制性类型约束(优先级高于所有其他规则)
1. 根节点必须包含且仅包含以下 5 个字段,字段类型绝对不可变更:
| 字段名 | 强制类型 | 空值处理规则 |
| ------ | --------- | ------------------ |
| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 |
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含以下 3 个子字段 |
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为`[]` |
| `subsidy_list` | 数组 (list) | 必填,无数据时赋值为`[]` |
| `attachments` | 数组 (list) | 必填,无数据时赋值为`[]` |
2. `reimbursement_details`对象必须包含且仅包含以下 3 个子字段,每个子字段必须是数组类型:
|字段名|强制类型|空值处理规则|
|---|---|---|
|`transport_fee`|数组 (list)|无数据时赋值为`[]`|
|`hotel_fee`|数组 (list)|无数据时赋值为`[]`|
|`conference_fee`|数组 (list)|无数据时赋值为`[]`|
3. 绝对禁止以下行为:
* 省略上述任何一个根节点字段或报销明细的子字段
* 将数组类型的字段赋值为null、字符串、数字或对象
* 在报销明细中添加任何未定义的子字段
* 合并不同模块的数组数据
✅ 正确类型示例
```json
{
"basic_info": {...},
"reimbursement_details": {
"transport_fee": [{"vehicle_type":"train", ...}],
"hotel_fee": [],
"conference_fee": []
},
"payment_methods": [],
"subsidy_list": [{"person_name":"张三", ...}],
"attachments": [{"filename":"发票.pdf", ...}]
}
```
❌ 错误类型示例(绝对禁止)
```json
{
"basic_info": {...},
"reimbursement_details": {
"transport_fee": [{"vehicle_type":"train", ...}]
// 错误省略了hotel_fee和conference_fee字段
},
"payment_methods": null, // 错误数组类型不能为null
"subsidy_list": "" // 错误:数组类型不能为字符串
// 错误省略了attachments字段
}
```
## 输入数据说明
你会收到以下数据:
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`:会务培训的基本信息
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 必须语法正确,无多余逗号、引号等语法错误
* 必须严格遵守所有强制性类型约束,任何违反类型要求的输出均视为无效

View File

@@ -1,58 +0,0 @@
"""项目级异常定义
异常层次:
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 []

View File

@@ -1,21 +0,0 @@
---
last_reviewed: 2026-06-15
---
# src/infra — 基础设施层
提供浏览器自动化、文档处理和 LLM 接口等底层能力。此层不包含业务逻辑,只提供工具和平台能力。
## 子模块
| 目录 | 说明 |
|------|------|
| `browser/` | Playwright 驱动的财务系统自动填报 |
| `documents/` | 发票数据模型、PDF 渲染、Word 出库单填写 |
| `llm/` | LLM 提示词模板加载与管理 |
## 设计原则
- **无业务逻辑**:只提供工具能力,不包含业务流程判断
- **可替换性**:每个子模块通过 `__init__.py` 导出接口,便于替换实现
- **与 core 层解耦**infra 不依赖 corecore 可通过接口调用 infra

View File

@@ -1,4 +0,0 @@
"""基础设施模块
提供浏览器自动化、文档处理和 LLM 接口功能。
"""

View File

@@ -1,44 +0,0 @@
---
last_reviewed: 2026-06-15
---
# src/infra/browser — 浏览器自动化
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
## 文件
| 文件 | 职责 |
|------|------|
| `base.py` | `BaseBot` 基类:浏览器生命周期、登录信息门户、导航到报销系统、创建新单据、截图 |
| `travel.py` | 差旅报销填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `normal.py` | 普通报销填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
| `__init__.py` | 入口函数:`run_bot()` / `run_bot_web()`,负责类型路由和流程调度 |
## 对外接口
| 函数 | 说明 |
|------|------|
| `run_bot(config, travel_info, normal_info)` | CLI 模式:根据传入信息判断差旅/普通报销 |
| `run_bot_web(config, work_dir)` | Web 模式:从缓存加载信息后执行填报 |
## 填报流程
### 差旅报销travel
1. 填写基本信息(事由、地点、日期、项目编号)
2. 添加差旅明细(交通费用逐条录入)
3. 填写支付方式(公务卡刷卡记录)
4. 填写补助清单(按天计算交通补助 + 伙食补助)
5. 上传附件(发票、申请单等)
### 普通报销normal
1. 填写基本信息(报销事由、金额)
2. 填写发票明细(总数、总金额)
3. 填写支付方式
4. 上传附件
## 注意事项
- 浏览器填报会启动 Chromium请勿手动干扰自动化流程
- 调试截图保存在 `images/` 目录
- Web 模式以无头模式运行

View File

@@ -1,100 +0,0 @@
"""浏览器自动化填报
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
对外接口:
run_bot(config, travel_info, normal_info) 启动浏览器并执行填报流程
run_bot_web(config, work_dir) Web 模式填报(从缓存加载信息)
"""
from pathlib import Path
from typing import Any
from ... import get_logger
from .base import BaseBot
log = get_logger("bot")
def run_bot(
config: dict[str, Any],
headless: bool = False,
work_dir: Path | None = None,
travel_info: dict[str, Any] | None = None,
normal_info: dict[str, Any] | None = None,
) -> None:
"""启动浏览器并执行填报流程。
根据传入的报销信息判断执行差旅报销还是普通报销流程。
Args:
config: 财务系统配置(含 URL、账号密码等
headless: 是否无头模式。
work_dir: 工作目录。
travel_info: 差旅报销信息(可选)。
normal_info: 普通报销信息(可选)。
"""
invoice_type = ""
if travel_info and normal_info:
invoice_type = "mixed"
elif travel_info:
invoice_type = "travel"
elif normal_info:
invoice_type = "normal"
else:
log.error("未提供任何报销信息")
return
log.info(f"启动填报流程: {invoice_type}")
if invoice_type == "travel":
from .travel import run as run_travel
bot = BaseBot(config, headless=headless)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
bot.navigate_to_reimburse(page_key="travel_page")
bot.create_new_form()
run_travel(bot, travel_info)
finally:
bot.close()
elif invoice_type == "normal":
from .normal import run as run_normal
bot = BaseBot(config, headless=headless)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
bot.navigate_to_reimburse(page_key="reimburse_page")
bot.create_new_form()
run_normal(bot, normal_info)
finally:
bot.close()
else:
log.warning("暂不支持混合报销流程")
def run_bot_web(config: dict[str, Any], work_dir: str | Path) -> None:
"""Web 模式填报(从缓存加载信息)。
根据 work_dir 下的 .invoice_cache 目录中已提取的信息,
自动判断执行差旅报销还是普通报销流程。
Args:
config: 财务系统配置(含 URL、账号密码等
work_dir: 工作目录(包含 .invoice_cache 子目录)。
"""
from ...core.extraction import load_cache
work_dir = Path(work_dir)
cache = load_cache(work_dir)
travel_info = cache.get("travel_info")
normal_info = cache.get("normal_info")
run_bot(config, headless=True, work_dir=work_dir, travel_info=travel_info, normal_info=normal_info)

View File

@@ -1,36 +0,0 @@
---
last_reviewed: 2026-06-15
---
# src/infra/documents — 文档处理
提供发票数据模型、PDF 渲染和 Word 出库单填写功能。
## 文件
| 文件 | 职责 |
|------|------|
| `invoice.py` | 发票数据模型类型常量、CSV 列定义、CSV/JSON 读写工具、发票分类 |
| `pdf.py` | PDF 渲染为图片PyMuPDF供多模态 LLM 识别使用 |
| `consumable.py` | 易耗品出库单填写:读取 CSV → 填入 Word 模板pywin32 COM仅 Windows |
## 对外接口
| 函数 | 说明 |
|------|------|
| `load_csv(path)` | 读取支付记录 CSV |
| `save_csv(payment_records, path)` | 保存支付记录 CSV |
| `save_invoice_csv(payment_records, path)` | 保存发票级别 CSV |
| `classify_invoice_batch(cache_map)` | 按类型批量分类发票 |
| `render_pdf_to_images(pdf_path)` | PDF → 图片列表 |
| `fill_consumable_doc(csv_path, doc_path)` | 将 CSV 数据填入 Word 模板 |
## 缓存目录
`.invoice_cache/` 是系统级缓存目录名常量,定义在 `invoice.py` 中,被提取和匹配模块统一引用。
## 易耗品出库单
- 需要 **Windows + Microsoft Word + pywin32**
- 模板文件为项目根目录的 `易耗品、出库单.doc`
- 填写规则:日期用当天日期,品名/规格/数量/单价从 CSV 解析,字体统一宋体五号

View File

@@ -1,32 +0,0 @@
"""文档处理基础设施
提供发票数据模型、PDF 渲染、出库单填写等功能。
"""
from .consumable import (
CONSUMABLE_DOC_FILENAME,
fill_consumable_doc,
fill_consumable_from_template,
)
from .invoice import (
CACHE_DIR_NAME,
classify_invoice_batch,
load_csv,
load_invoice_csv,
save_application_json,
save_csv,
save_invoice_csv,
)
__all__ = [
"CACHE_DIR_NAME",
"classify_invoice_batch",
"load_csv",
"load_invoice_csv",
"save_csv",
"save_invoice_csv",
"save_application_json",
"CONSUMABLE_DOC_FILENAME",
"fill_consumable_doc",
"fill_consumable_from_template",
]

View File

@@ -1,32 +0,0 @@
---
last_reviewed: 2026-06-15
---
# src/infra/llm — LLM 提示词管理
管理 LLM 提示词模板的加载,供 `core/extraction/llm_extractor.py` 调用。
## 文件
| 文件 | 职责 |
|------|------|
| `prompt.py` | 提示词加载:从 `prompts/` 目录读取 `.md` 模板文件 |
| `prompts/` | 提示词模板目录Markdown 格式) |
## 提示词模板
| 文件 | 用途 |
|------|------|
| `invoice_system.md` | 发票提取系统提示词 |
| `travel_info_system.md` | 差旅信息提取系统提示词 |
| `normal_info_system.md` | 普通发票信息提取系统提示词 |
| `supplement_system.md` | 用户补充信息后的二次提取提示词 |
| `validation_system.md` | 校验修正提示词 |
## 对外接口
| 函数 | 说明 |
|------|------|
| `build_invoice_system_prompt()` | 构建发票提取系统提示词 |
| `build_travel_info_system_prompt()` | 构建差旅信息提取系统提示词 |
| `build_normal_info_system_prompt()` | 构建普通发票信息提取系统提示词 |

View File

@@ -1,18 +0,0 @@
"""LLM 接口模块
提供 LLM 提示词模板加载功能。
"""
from .prompt import (
build_invoice_system_prompt,
build_normal_info_system_prompt,
build_supplement_system_prompt,
build_travel_info_system_prompt,
)
__all__ = [
"build_invoice_system_prompt",
"build_normal_info_system_prompt",
"build_supplement_system_prompt",
"build_travel_info_system_prompt",
]

View File

@@ -1,246 +0,0 @@
# 角色定义
你是一个严谨合规、零容错导向的财务文档信息提取助手。你以财务数据的精准性为第一原则,对待提取结果严肃审慎,并以直接、无冗余的方式交付结构化内容。你沟通极简,在不附加无关说明的前提下,准确返回完整的提取结果。
你承接的输入涵盖支付截图、银行转账记录、微信 / 支付宝付款凭证、发票文件、出差事前申请单、易耗品出入库单等各类财务凭证。你通常不会输出解释性话术、提取过程说明或主观判定结论,只输出标准统一的 JSON 结构化数据,除非用户非常明确地要求你补充提取说明或标注识别依据。你只按规则返回结果,不需要说明执行逻辑,也不透露内部校验规则。
你具备全品类财务凭证的字段映射与口径统一能力,当用户上传多类型、多页混合的凭证时,你会自动对齐字段定义、校验数据逻辑,保障输出结构的一致性与业务可用性是你追求的目标。
**核心原则**:类型判断为最高优先级,任何情况下不得输出与判断结果不符的字段。
---
## 第一步:类型判断
收到图片后,首先判断文档类型。类型共有以下 6 种:
| 类型值 | 文档类别 | 识别特征 |
|--------|---------|---------|
| `train` | 火车票/高铁票 | 含车次号、出发站、到达站、座位等级、乘车日期等铁路票据信息 |
| `payment` | 支付记录 | 支付截图、银行转账记录、微信/支付宝付款凭证 |
| `hotel` | 酒店住宿发票 | 含"住宿服务"、"酒店"、"生产生活服务"等关键词的发票 |
| `general` | 普通发票 | 不属于以上类别的其他发票 |
| `application` | 出差事前申请单 | 含项目名称、出差事由、计划时间、出差人员等信息 |
| `note` | 易耗品/出入库单 | 易耗品出入库单、出库单等 |
---
## 第二步:按类型提取字段
### 1. `train`(火车票/高铁票)
```json
{
"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": "G1234",
"person_name": "张三",
"total_amount": "231.00"
}
```
**支付记录**
```json
{
"invoice_type": "payment",
"card_date": "2026-06-01",
"card_amount": "231.00",
"card_no": ""
}
```
**易耗品单**
```json
{
"invoice_type": "note"
}
```
---
## 空值处理规则
- **字符串字段**:无法识别时返回 `""`(空字符串),不得返回 `null`
- **金额字段**:找不到金额时返回 `"0"`
- **`person_info` 数组**:无人员信息时返回 `[]`
- **日期字段**:统一使用 `YYYY-MM-DD` 格式
---
## 最终输出要求
- 严格只输出 JSON 字符串不包含任何思考过程、解释文字、Markdown 标记或代码块包裹
- JSON 语法必须正确,无多余逗号、引号或注释
- 只输出与判断类型对应的字段,不得混入其他类型的字段
- `invoice_type` 的值必须与实际判断类型一致

View File

@@ -1,114 +0,0 @@
## 角色定义
你是财务报销信息补充助手。你的任务是根据用户输入的文字信息,分析并更新已提取的报销信息 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.01.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` 中说明

View File

@@ -1,276 +0,0 @@
# 角色定义
你是极度严谨合规的财务差旅信息提取助手。你严格恪守财务数据规范,以字段精准映射、结果零偏差为核心准则,输出直接客观,在不加入无关细节的前提下,交付完全符合要求的结构化提取结果。
你通常不会输出提取推导过程、数据来源说明与寒暄类话术,只返回严格匹配 schema 要求的标准 JSON 格式结果,除非用户非常明确地要求标注提取依据与异常说明。你只按规则输出结果,不需要解释输出逻辑,也不透露内部校验规则的细节。
你具备差旅全单据的交叉校验能力,当获取到发票信息、付款记录和出差事前申请单后,会自动完成金额一致性、时间逻辑性、行程合理性的校验;信息存在冲突时按「交通工具 > 付款记录 > 酒店住宿 >事前申请单」的优先级取值,信息缺失时按 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` 中添加未定义的子字段
- 合并不同模块的数组数据(如将去程和返程交通费合并为一条)
### 正确输出骨架
```json
{
"basic_info": { /* */ },
"reimbursement_details": {
"transport_fee": [ /* */ ],
"hotel_fee": [],
"conference_fee": []
},
"payment_methods": [],
"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
[
{
"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
}
]
```
**支付方式**(高铁票付款):
```json
[
{
"card_date": "2026-06-01",
"card_amount": 231.0,
"merchant": "中国铁路网络有限公司",
"remark": "张国庆和王建锋从阜阳西-合肥南高铁票"
},
{
"card_date": "2026-06-01",
"card_amount": 167.0,
"merchant": "中国铁路网络有限公司",
"remark": "陈曙光从阜阳西-合肥南高铁票"
}
]
```
---
## 最终输出要求
- 严格只输出 JSON 字符串不包含任何思考过程、解释文字、Markdown 标记或其他内容
- JSON 语法必须正确,无多余逗号、引号等错误
- 严格遵守所有类型约束,任何违反均视为无效输出
- 日期统一使用 `YYYY-MM-DD` 格式
- 金额使用数字类型(非字符串)
- 计数使用整数类型

View File

@@ -14,23 +14,116 @@
Bot 仅负责接收信息并填报,不再承担信息提取职责。
"""
import json
from pathlib import Path
from typing import Any
from typing import Any, cast
from . import get_logger
from .config import load_config
from .core.extraction import extract_invoices
from .pipeline_core import (
extract_and_cache_normal_info,
extract_and_cache_travel_info,
extract_info_by_type,
is_travel_invoice,
process_invoices,
from .doc.extractor import extract_invoices
from .doc.invoice import (
save_application_json,
save_invoice_csv,
)
from .doc.invoice import (
save_csv as save_payment_csv,
)
from .doc.llm_extractor import (
CACHE_DIR_NAME,
extract_normal_info,
extract_travel_info,
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")
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从缓存目录读取发票数据并按类型分组"""
from .doc.llm_extractor import load_cache
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)
def _extract_travel_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
"""当存在差旅发票时,调用 LLM 提取差旅信息并缓存。
Returns:
差旅信息字典,非差旅时返回 None。
"""
if not groups.get("travel"):
return None
from .doc.llm_extractor import CACHE_DIR_NAME, load_cache
# 检查缓存是否已有
cache_map = load_cache(cache_path)
if cache_map.get("travel_info"):
log.info("使用已有差旅信息缓存")
return cast(dict[str, Any] | None, cache_map["travel_info"])
log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=cache_path)
# 保存到缓存
cache_dir = cache_path / 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)
log.info("差旅信息已保存到缓存")
return travel_info
def _extract_normal_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
"""当存在普通发票时,调用 LLM 提取普通报销信息并缓存。
Returns:
普通报销信息字典,非普通时返回 None。
"""
if not groups.get("general"):
return None
# 检查缓存是否已有
cache_map = load_cache(cache_path)
if cache_map.get("normal_info"):
log.info("使用已有普通发票信息缓存")
return cast(dict[str, Any] | None, cache_map["normal_info"])
log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=cache_path)
# 保存到缓存
cache_dir = cache_path / 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)
log.info("普通发票信息已保存到缓存")
return normal_info
def run_pipeline(
step: str = "all",
username: str | None = None,
@@ -72,11 +165,20 @@ def run_pipeline(
log.error("未提取到任何发票数据")
return 1
# 使用公共函数处理发票数据
process_invoices(payment_records, applications, groups, cache_path)
save_payment_csv(payment_records, cache_path / "payment_records.csv")
save_invoice_csv(payment_records, cache_path / "invoice_summary.csv")
# 发票提取完成后立即判断类型并提取信息
travel_info, normal_info = extract_info_by_type(groups, cache_path)
if applications:
save_application_json(applications, cache_path / "travel_applications.json")
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])}")
# 发票提取完成后立即判断类型
is_travel = bool(groups["travel"]) and not bool(groups["general"])
if is_travel:
travel_info = _extract_travel_info_if_needed(groups, cache_path)
else:
normal_info = _extract_normal_info_if_needed(groups, cache_path)
if step == "invoice":
log.info("[1/2] 发票提取 完成")
@@ -90,22 +192,20 @@ def run_pipeline(
log.info("[2/2] 报销提交")
log.info("=" * 60)
from .infra.browser import run_bot
from .bot import run_bot
if groups is None:
# 从缓存重新分类(仅 submit 阶段需要)
from .pipeline_core import _classify_from_cache
groups = _classify_from_cache(cache_path)
if is_travel_invoice(groups):
is_travel = bool(groups["travel"]) and not bool(groups["general"])
if is_travel:
if travel_info is None:
travel_info = extract_and_cache_travel_info(groups, cache_path)
travel_info = _extract_travel_info_if_needed(groups, cache_path)
log.info("检测到纯差旅发票,使用差旅报销模式")
run_bot(config, work_dir=cache_path, travel_info=travel_info)
else:
if normal_info is None:
normal_info = extract_and_cache_normal_info(groups, cache_path)
normal_info = _extract_normal_info_if_needed(groups, cache_path)
log.info("检测到普通发票,使用普通报销模式")
run_bot(config, work_dir=cache_path, normal_info=normal_info)

View File

@@ -1,220 +0,0 @@
"""
管道核心逻辑
抽取 pipeline.pyCLI 管道)和 pipeline_web.pyWeb 管道)的公共数据流:
发票分类判断 -> 差旅/普通信息提取 -> 缓存读写
两个入口分别传入不同的目录参数,复用此模块。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, cast
from . import get_logger
from .core.extraction import (
CACHE_DIR_NAME,
extract_normal_info,
extract_travel_info,
load_cache,
)
from .infra.documents import (
classify_invoice_batch,
save_application_json,
save_invoice_csv,
)
from .infra.documents import save_csv as save_payment_csv
log = get_logger("pipeline_core")
def is_travel_invoice(groups: dict[str, list[dict[str, Any]]]) -> bool:
"""判断是否为纯差旅发票(有差旅发票且无普通发票)。
注意:系统仅支持「纯差旅」和「普通报销」两种模式。
若同时存在差旅发票和普通发票(混合),则视为普通报销模式处理——差旅发票
对应的费用仍会在普通报销中按项目填报。若需要严格区分,上游应在发票分类
后报错提示用户分开提交。
"""
return bool(groups.get("travel")) and not bool(groups.get("general"))
def save_cache_info(cache_path: Path, info_key: str, info: dict[str, Any]) -> None:
"""将提取结果保存到缓存目录
Args:
cache_path: 会话目录路径。
info_key: 缓存键名("travel_info""normal_info")。
info: 提取结果字典。
"""
cache_dir = cache_path / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / f"{info_key}.json", "w", encoding="utf-8") as f:
json.dump(info, f, ensure_ascii=False, indent=2)
log.info("%s 已保存到缓存", info_key)
def extract_and_cache_travel_info(
groups: dict[str, list[dict[str, Any]]],
cache_path: Path,
) -> dict[str, Any] | None:
"""当存在差旅发票时,调用 LLM 提取差旅信息并缓存。
Returns:
差旅信息字典,非差旅时返回 None。
"""
if not groups.get("travel"):
return None
# 检查缓存是否已有
cache_map = load_cache(cache_path)
travel_info = cache_map.get("travel_info")
if travel_info:
log.info("使用已有差旅信息缓存")
return travel_info # type: ignore[no-any-return]
log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=cache_path)
save_cache_info(cache_path, "travel_info", travel_info)
return travel_info
def extract_and_cache_normal_info(
groups: dict[str, list[dict[str, Any]]],
cache_path: Path,
) -> dict[str, Any] | None:
"""当存在普通发票时,调用 LLM 提取普通报销信息并缓存。
Returns:
普通报销信息字典,非普通时返回 None。
"""
if not groups.get("general"):
return None
# 检查缓存是否已有
cache_map = load_cache(cache_path)
normal_info = cache_map.get("normal_info")
if normal_info:
log.info("使用已有普通发票信息缓存")
return normal_info # type: ignore[no-any-return]
log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=cache_path)
save_cache_info(cache_path, "normal_info", normal_info)
return normal_info
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从缓存目录读取发票数据并按类型分组
注意load_cache 会加载所有 .invoice_cache/*.json包括 travel_info 和 normal_info
等提取结果缓存(它们没有 invoice_type 字段),需要显式过滤掉。
"""
cache_map = load_cache(cache_path)
# 过滤掉非发票的缓存条目travel_info、normal_info 等提取结果
invoices = [
data
for key, data in cache_map.items()
if key not in ("travel_info", "normal_info")
and isinstance(data, dict)
and data.get("invoice_type") not in ("application", "payment")
]
return classify_invoice_batch(invoices)
def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
"""保存发票分类结果到目录的 JSON 文件
保存完整发票分组数据(供 is_travel_invoice/extract_and_cache_* 使用),
同时保留计数字段(供快速统计使用)。
"""
groups_path = session_dir / "invoice_groups.json"
data = {
"travel": groups.get("travel", []),
"general": groups.get("general", []),
"application": groups.get("application", []),
"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, Any] | None:
"""从目录加载发票分类结果
返回包含完整发票分组数据和计数字段的字典。
"""
groups_path = session_dir / "invoice_groups.json"
if not groups_path.exists():
return None
try:
with open(groups_path, encoding="utf-8") as f:
return cast(dict[str, Any] | None, json.load(f))
except Exception:
return None
def process_invoices(
payment_records: list[dict[str, Any]],
applications: list[dict[str, Any]],
groups: dict[str, list[dict[str, Any]]],
output_dir: Path,
) -> dict[str, Any]:
"""处理提取的发票数据:保存 CSV、申请单和分类结果
Args:
payment_records: 支付记录列表。
applications: 申请单列表。
groups: 发票分类结果。
output_dir: 输出目录。
Returns:
包含发票统计信息的字典。
"""
# 保存 CSV支付记录级别供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(payment_records, output_dir / "payment_records.csv")
save_invoice_csv(payment_records, output_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, output_dir / "travel_applications.json")
# 保存分类结果(供后续步骤统一读取)
save_invoice_groups(output_dir, groups)
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in payment_records)
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])}")
return {
"invoice_count": invoice_count,
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
"application_count": len(groups.get("application", [])),
}
def extract_info_by_type(
groups: dict[str, list[dict[str, Any]]],
cache_path: Path,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""根据发票类型提取差旅或普通报销信息
Args:
groups: 发票分类结果。
cache_path: 缓存目录路径。
Returns:
(travel_info, normal_info) 元组,根据发票类型返回对应信息。
"""
if is_travel_invoice(groups):
travel_info = extract_and_cache_travel_info(groups, cache_path)
return travel_info, None
else:
normal_info = extract_and_cache_normal_info(groups, cache_path)
return None, normal_info

View File

@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-13
last_reviewed: 2026-06-11
---
# src/web 模块设计说明
@@ -11,47 +11,20 @@ last_reviewed: 2026-06-13
- **会话隔离**:每次上传生成独立 `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 # 入口:初始化 App 全局状态、绑定事件
├── state.js # 全局状态管理App 对象)
├── chat.js # 聊天气泡渲染、文件消息、LLM 流式气泡
├── agent.js # Agent 事件处理、用户输入管理
├── process.js # SSE 连接、事件路由、管道启动
├── config.js # 配置解析、逐项引导
├── upload.js # 文件上传、拖拽处理
├── sync.js # 移动端同步
└── utils.js # HTML 转义等工具函数
── index.js # 前端逻辑上传、SSE 日志、表格编辑、二维码同步)
```
## 数据流
@@ -91,8 +64,8 @@ graph TD
| GET | `/` | 主界面 |
| POST | `/api/session` | 创建会话,返回 session_id |
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
| GET | `/api/files/<sid>` | 列出会话文件(返回统一 `files` 列表,含 `name``type``size` 字段;旧字段 `pdfs`/`images` 保留向后兼容) |
| POST | `/api/agent/process/<sid>` | 启动 Agent 管道(后台线程) |
| GET | `/api/files/<sid>` | 列出会话文件 |
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
| GET | `/api/logs/<sid>` | SSE 日志流 |
| GET | `/api/data/<sid>` | 获取发票数据 JSON |
| POST | `/api/save/<sid>` | 保存前端编辑的发票数据 |
@@ -104,7 +77,7 @@ graph TD
### 日志收集
`SSELogHandler` 将管道日志写入 `session.log`SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
`_SSELogHandler` 将管道日志写入 `session.log`SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
### 发票类型分流
@@ -124,216 +97,8 @@ Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`
### 移动端同步
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的文件通过 `syncFiles()` 轮询同步到 PC 端内存中的 `allFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
### 配置管理
配置分两层:项目级 `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` 事件成对出现(如果是流式气泡)
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。

View File

@@ -1,5 +0,0 @@
"""
Web 模块
提供财务报销系统的 Web 界面功能。
"""

View File

@@ -12,46 +12,709 @@
访问: 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 flask import Flask # noqa: E402, I001
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 src.web import pipeline_web, routes # noqa: E402, I001
from src.infra.documents import CONSUMABLE_DOC_FILENAME # noqa: E402, I001
fill_log = get_logger("fill_consumable_doc")
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
_app_initialized = False
SESSION_LOG_FILE = "session.log"
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
def create_app() -> Flask:
"""应用工厂:初始化配置并注册路由"""
global _app_initialized
if _app_initialized:
return app
_app_initialized = True
# 设置出库单模板路径
pipeline_web.set_consumable_template(PROJECT_ROOT / CONSUMABLE_DOC_FILENAME)
# 初始化路由配置
routes.init_routes(UPLOAD_BASE)
# 注册 Blueprint
app.register_blueprint(routes.web_bp)
return app
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)
# 直接使用 app 实例(保持向后兼容)
create_app()
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
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:
"""查找支付记录 CSVpayment_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:
"""查找发票级别 CSVinvoice_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)
if __name__ == "__main__":
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)

View File

@@ -1,190 +0,0 @@
"""
Web 管道逻辑
负责:
- 发票提取管道编排
- 财务系统填报触发
- 易耗品出库单生成
- 发票分类数据持久化
"""
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote
# 延迟导入,避免循环引用
from src import get_logger # noqa: F401
from src.infra.documents import (
CONSUMABLE_DOC_FILENAME,
fill_consumable_from_template,
)
from src.pipeline_core import (
extract_info_by_type,
load_invoice_groups,
process_invoices,
)
fill_log = get_logger("fill_consumable_doc")
# 文件常量
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
def resolve_payment_csv(session_dir: Path) -> Path | None:
"""查找支付记录 CSVpayment_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:
"""查找发票级别 CSVinvoice_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] | None) -> dict[str, Any]:
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票无需额外提取normal_info.json 待实现)
"""
from src.core.extraction import extract_invoices
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 使用公共函数处理发票数据
stats = process_invoices(invoices, applications, groups, session_dir)
# ---- Step 2: 差旅/普通信息提取 ----
extract_info_by_type(groups, session_dir)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": stats["invoice_count"],
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
"travel_count": stats["travel_count"],
"general_count": stats["general_count"],
}
doc_fill = _try_fill_consumable_doc(session_dir, config or {})
append_doc_download(result, session_dir.name, doc_fill)
return result
def run_financial_submit(session_dir: Path, config: dict[str, Any] | None) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
- 纯差旅发票:差旅报销模式
- 含普通发票:普通报销模式
"""
csv_path = session_dir / "payment_records.csv"
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.infra.browser 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("检测到普通发票,使用普通报销模式")
try:
run_bot_web(config or {}, session_dir)
return {"ok": True}
except Exception as e:
fill_log.error("财务填报失败: %s", e)
return {"ok": False, "error": str(e)}

View File

@@ -1,822 +0,0 @@
"""
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 (
SAFE_CONFIG_KEYS,
SESSION_CONFIG_KEYS,
load_session_config,
)
from src.config import (
load_config as load_project_config,
)
from src.infra.documents 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 SESSION_CONFIG_KEYS:
if body.get(key):
# 基本类型校验:防止非字符串值写入配置
if not isinstance(body[key], (str, int, float, bool)):
continue
config[key] = body[key]
return config
def _emit_ready_and_submit(
session_dir: Path,
agent_session: Any,
config: dict[str, Any],
) -> dict[str, Any]:
"""Agent 校验通过后,直接触发财务提交(不依赖前端)。
返回 result 字典,由调用方 _run_agent_task 的 finally 块统一写入 result.json。
"""
# agent_ready 事件已由 run_agent_round 发射,此处仅执行财务提交,避免前端收到重复消息
try:
submit_result = pipeline_web.run_financial_submit(session_dir, config)
if submit_result.get("ok"):
return {
"ok": True,
"agent_ready": True,
"submit_ok": True,
"round": agent_session.rounds,
"message": "信息完整,已自动提交到财务系统",
}
return {
"ok": True,
"agent_ready": True,
"submit_ok": False,
"submit_error": submit_result.get("error"),
"round": agent_session.rounds,
"message": "校验通过但提交失败",
}
except Exception as e:
return {
"ok": True,
"agent_ready": True,
"submit_ok": False,
"submit_error": str(e),
"round": agent_session.rounds,
"message": f"校验通过但提交异常: {e}",
}
def _run_agent_task(
session_dir: Path,
handler: Any,
task_fn: Any,
config: dict[str, Any] | None = None,
) -> None:
"""Agent 任务的通用包装器
封装重复的闭包结构:清除流日志 -> 执行任务 -> 写入 result.json -> 卸载收集器。
Args:
session_dir: 会话目录
handler: SSE 日志收集器句柄
task_fn: 业务逻辑回调,接收 (session_dir, config) 返回 (agent_session, result_dict) 或仅 result_dict
config: 配置字典(可选)
"""
result = {"ok": False, "error": "未知错误"}
try:
# 清除上一轮残留文件,避免 SSE 连接立即读到旧数据
for fname in ("llm_stream.log", "agent_events.log", pipeline_web.SESSION_RESULT_FILE):
try:
(session_dir / fname).unlink(missing_ok=True)
except Exception:
pass
ret = task_fn(session_dir, config)
# task_fn 返回 (agent_session, result) 或仅 result
if isinstance(ret, tuple) and len(ret) == 2:
from src.agent import AgentState
agent_session, result = ret
if agent_session.state == AgentState.READY:
if config is not None:
result = _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 Exception as e:
result = {"ok": False, "error": str(e)}
finally:
# 统一写入 result.jsonSSE 端点检测到后发射 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
sse_handler.remove_log_collector(handler)
# ================================================================
# 页面路由
# ================================================================
@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
# 安全检查:拒绝包含路径穿越字符的文件名
if ".." in safe_name or "/" in safe_name or "\\" in safe_name:
return jsonify({"error": "文件名包含非法字符"}), 400
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:
"""获取当前会话的配置(供前端回填表单)
使用白名单过滤会话配置,防止密码等敏感字段被加载到内存。
password 字段始终返回空字符串——密码由前端用户输入,不持久化。
注意:当前为 localhost 服务,密码通过 HTTP 明文传输。如需通过代理暴露服务,
请启用 HTTPS 或使用反向代理加密。
"""
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:
session_cfg = json.load(f)
for key in SAFE_CONFIG_KEYS:
if key in session_cfg:
config[key] = session_cfg[key]
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())
# 校验前端传入的 data 字段是否与原始 CSV 列匹配
if data:
unknown_keys = set(data[0].keys()) - (set(fieldnames) | {"__row"})
if unknown_keys:
return jsonify({"error": f"数据包含未知字段: {unknown_keys}"}), 400
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 = 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)
def _task(sd: Path, cfg: dict[str, Any] | None) -> dict[str, Any]:
return pipeline_web.run_pipeline_web(sd, cfg)
handler = sse_handler.install_log_collector(session_dir)
threading.Thread(
target=_run_agent_task,
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
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 = 900 # 略大于 LLM 请求超时 (600s),防止 SSE 先断开
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)
def _task(sd: Path, cfg: dict[str, Any] | None) -> dict[str, Any]:
submit_result = pipeline_web.run_financial_submit(sd, cfg)
if submit_result.get("ok"):
return {"ok": True}
return submit_result
handler = sse_handler.install_log_collector(session_dir)
threading.Thread(
target=_run_agent_task,
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
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 _task(sd: Path, cfg: dict[str, Any] | None) -> tuple[Any, dict[str, Any]]:
from src.agent import (
AgentSession,
load_agent_state,
run_agent_round,
save_agent_state,
)
from src.core.extraction import extract_invoices
from src.infra.documents import (
save_application_json,
save_invoice_csv,
)
from src.infra.documents import (
save_csv as save_payment_csv,
)
agent_session = load_agent_state(sd)
if agent_session is None:
invoices, applications, groups = extract_invoices(str(sd))
if not invoices:
return (None, {"ok": False, "error": "未提取到任何发票数据"})
save_payment_csv(invoices, sd / "payment_records.csv")
save_invoice_csv(invoices, sd / "invoice_summary.csv")
if applications:
save_application_json(applications, sd / "travel_applications.json")
pipeline_web.save_invoice_groups(sd, groups)
from src.pipeline_core import is_travel_invoice
agent_session = AgentSession(
session_id=session_id,
invoice_type="travel" if is_travel_invoice(groups) else "normal",
)
save_agent_state(sd, agent_session)
agent_session = run_agent_round(sd, agent_session)
return (agent_session, {})
threading.Thread(
target=_run_agent_task,
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
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 (
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)
# 重新提取发票并保存
from src.core.extraction import extract_invoices
from src.infra.documents import (
save_application_json,
save_invoice_csv,
)
from src.infra.documents 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)
config_path = session_dir / "config.json"
config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
handler = sse_handler.install_log_collector(session_dir)
def _task(sd: Path, cfg: dict[str, Any] | None) -> tuple[Any, dict[str, Any]]:
new_session = run_agent_round(sd, agent_session, new_files=filenames)
return (new_session, {})
threading.Thread(
target=_run_agent_task,
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
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 (
load_agent_state,
process_user_text_supplement,
)
agent_session = load_agent_state(session_dir)
if agent_session is None:
return jsonify({"error": "未找到 Agent 状态"}), 404
config_path = session_dir / "config.json"
config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
handler = sse_handler.install_log_collector(session_dir)
def _task(sd: Path, cfg: dict[str, Any] | None) -> tuple[Any, dict[str, Any]]:
new_session = process_user_text_supplement(sd, agent_session, user_text)
return (new_session, {})
threading.Thread(
target=_run_agent_task,
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
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)
def _task(sd: Path, cfg: dict[str, Any] | None) -> dict[str, Any]:
submit_result = pipeline_web.run_financial_submit(sd, cfg)
if submit_result.get("ok"):
return {"ok": True}
return submit_result
handler = sse_handler.install_log_collector(session_dir)
threading.Thread(
target=_run_agent_task,
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
daemon=True,
).start()
return jsonify({"status": "started"})

View File

@@ -1,85 +0,0 @@
"""
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:
with self._lock:
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: ")

View File

@@ -1,5 +1,5 @@
---
last_reviewed: 2026-06-12
last_reviewed: 2026-06-11
---
# src/web/static — 静态资源目录
@@ -16,11 +16,3 @@ last_reviewed: 2026-06-12
## 技术栈
原生 JavaScript + Bootstrap 5无构建工具保持单页应用轻量可维护。
## 变更记录
- **2026-06-12**:修复配置收集流程的异步时序问题
- `parseConfigFile` 改为返回 Promise`handleFiles` 和拖拽 `drop` 处理器改为 `async/await`,确保 config.json 解析完成后再执行检查,消息显示顺序正确
- `sendUserMessage` 修复变量名错误(`pendingConfigKeys``pendingConfigKey`),修复了配置收集卡死的问题
- 拆分 `checkAutoStart` 为两个函数:`checkAutoStart` 仅做静默检查(由 `syncFiles` 轮询调用),`promptMissingConfig` 负责配置提示(由用户主动上传完成后调用),避免轮询提前触发配置提示导致消息乱序
- 移除所有聊天消息的删除逻辑,聊天窗口保留完整历史(欢迎消息、文件通知、配置交互、处理结果均不删除)

Some files were not shown because too many files have changed in this diff Show More