Compare commits
6 Commits
10115214aa
...
agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b35f07fd7 | ||
|
|
7c137c5214 | ||
|
|
e252896de9 | ||
|
|
46305fdebb | ||
|
|
8dd91df3b9 | ||
|
|
6d66a27aab |
@@ -0,0 +1,74 @@
|
||||
---
|
||||
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` 会导致前端气泡丢失,是高频回归点。
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
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` 来修复合法性问题——换名字更简单、更安全。
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
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>` — 手动财务提交
|
||||
657
.agents/docs/guides/系统事件流全景图.md
Normal file
657
.agents/docs/guides/系统事件流全景图.md
Normal file
@@ -0,0 +1,657 @@
|
||||
# 系统事件流全景图
|
||||
|
||||
> 最后更新: 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 秒超时,前端无反馈。
|
||||
|
||||
**约束 3:SSE 新建连接时,当前文件偏移必须从 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` 中添加对应的事件处理器
|
||||
401
.agents/docs/guides/项目架构全景图.md
Normal file
401
.agents/docs/guides/项目架构全景图.md
Normal file
@@ -0,0 +1,401 @@
|
||||
# 项目架构全景图
|
||||
|
||||
> 最后更新: 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` | 项目配置 |
|
||||
20
.agents/docs/plans/README.md
Normal file
20
.agents/docs/plans/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# .agents/docs/plans — 实施方案与工作交接
|
||||
|
||||
存放项目实施方案、架构分析报告、重构计划等规划类文档。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `架构分析-2026-06-15.md` | 项目架构分析与重构建议(模块拆分、分层设计、接口契约) |
|
||||
|
||||
## 用途
|
||||
|
||||
- 架构决策记录
|
||||
- 重构实施方案
|
||||
- 工作交接说明
|
||||
- 技术选型论证
|
||||
225
.agents/docs/plans/架构分析-2026-06-15.md
Normal file
225
.agents/docs/plans/架构分析-2026-06-15.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# 项目架构分析与重构建议
|
||||
|
||||
## 一、当前架构总览
|
||||
|
||||
```
|
||||
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*
|
||||
107
.agents/skills/clean-git-history/SKILL.md
Normal file
107
.agents/skills/clean-git-history/SKILL.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
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)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,4 +10,5 @@ logs/
|
||||
.vscode/
|
||||
uploads/
|
||||
.env
|
||||
images/
|
||||
scripts/data/
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.6
|
||||
rev: v0.14.6
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
|
||||
97
AGENTS.md
97
AGENTS.md
@@ -1,19 +1,96 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
description:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# AGENTS 索引
|
||||
---
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
本文件是规则的入口。详细策略文本位于 `.agents/docs/standards/*.md`。
|
||||
# AGENTS — 项目操作指南
|
||||
|
||||
本文件为 Agent 提供高信号量的项目操作知识,避免重复探索。
|
||||
|
||||
## 文档边界
|
||||
|
||||
* `docs/` 目录专门存放面向开源用户、外部贡献者的项目公开文档及说明文件。
|
||||
* 维护规范、实施方案、经验总结、拉取请求佐证材料与各类内部记录资料,均统一放置在 `.agents/` 目录下,避免内部自动化流程相关内容混入公开文档目录。
|
||||
* 每个文件夹下都有一个 `README.md` 文件用来交代这个文件夹的作用以及重要的信息。
|
||||
* **禁止使用表情文字**输出任何内容。
|
||||
* `docs/` 目录存放面向开源用户、外部贡献者的公开文档。
|
||||
* `.agents/` 目录存放维护规范、实施方案、经验总结等内部资料。
|
||||
* 每个文件夹下都有 `README.md` 说明该文件夹的作用和重要信息。
|
||||
|
||||
## 标准目录
|
||||
## 开发命令(必须使用 uv)
|
||||
|
||||
* 标准文档元数据:`.agents/docs/standards/README.md`
|
||||
* 调试规范:`.agents/docs/standards/调试规范.md`
|
||||
* 复利式工程实践:`.agents/docs/standards/复利式工程实践.md`
|
||||
项目使用 `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 = ["."]`)。
|
||||
|
||||
175
README.md
175
README.md
@@ -18,20 +18,40 @@
|
||||
├── src/
|
||||
│ ├── __init__.py # 包初始化 / 日志器
|
||||
│ ├── config.py # 配置加载
|
||||
│ ├── bot.py # 浏览器自动填报
|
||||
│ ├── exceptions.py # 异常定义
|
||||
│ ├── pipeline.py # CLI 流程编排
|
||||
│ ├── pipeline_core.py # CLI/Web 公共管道逻辑
|
||||
│ ├── main.py # CLI 入口
|
||||
│ ├── 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 服务入口
|
||||
│ ├── 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 日志流处理
|
||||
│ ├── templates/
|
||||
│ │ ├── index.html # PC 端主页
|
||||
│ │ └── mobile_upload.html # 移动端扫码上传
|
||||
@@ -48,23 +68,130 @@
|
||||
└── *.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
|
||||
flowchart TB
|
||||
PDF[PDF 发票 / 图片] --> Extract[extractor 提取]
|
||||
Extract --> Classify{发票类型分类}
|
||||
PDF[PDF 发票 / 图片] --> Extract[extractor 多模态提取]
|
||||
Extract --> Cache[(.invoice_cache/*.json)]
|
||||
|
||||
Cache --> Classify{发票类型分类}
|
||||
Classify -->|差旅发票| Travel[高铁票 / 酒店住宿]
|
||||
Classify -->|普通发票| General[普通发票]
|
||||
Travel --> CSV[(invoice_summary.csv)]
|
||||
General --> CSV
|
||||
CSV -->|仅普通发票| Fill[fill_consumable_doc]
|
||||
Classify -->|支付记录| Payment[支付截图]
|
||||
Classify -->|申请单| Application[出差事前申请单]
|
||||
|
||||
Travel --> Matcher[matcher 金额匹配]
|
||||
Payment --> Matcher
|
||||
Matcher --> MatchResult[(match_result.json)]
|
||||
|
||||
Cache --> TravelLLM[LLM 差旅信息提取]
|
||||
MatchResult --> TravelLLM
|
||||
TravelLLM --> TravelInfo[(travel_info.json)]
|
||||
|
||||
Cache --> NormalLLM[LLM 普通发票信息提取]
|
||||
MatchResult --> NormalLLM
|
||||
NormalLLM --> NormalInfo[(normal_info.json)]
|
||||
|
||||
TravelInfo -->|差旅基本信息| Bot_T[infra/browser/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
|
||||
NormalInfo -->|支付方式| Bot_G
|
||||
NormalInfo -->|附件清单| Bot_G
|
||||
Bot_G --> Submit_G[普通报销提交]
|
||||
|
||||
General --> CSV[(invoice_summary.csv)]
|
||||
CSV --> Fill[consumable.py]
|
||||
Fill --> Doc[易耗品、出库单.doc]
|
||||
CSV --> Bot[bot 浏览器自动化]
|
||||
Bot -->|差旅模式| Submit_T[差旅报销填报]
|
||||
Bot -->|普通模式| Submit_G[普通报销填报]
|
||||
```
|
||||
|
||||
### 关键中间产物
|
||||
|
||||
| 文件 | 生成阶段 | 作用 |
|
||||
|------|---------|------|
|
||||
| `.invoice_cache/*.json` | extractor 提取 | 单张发票/支付记录/申请单的结构化数据 |
|
||||
| `match_result.json` | matcher 匹配 | 支付截图与发票的关联关系(按金额匹配) |
|
||||
| `travel_info.json` | LLM 差旅信息提取 | 综合发票缓存 + 匹配结果,生成差旅报销所需的全部结构化数据 |
|
||||
| `normal_info.json` | LLM 普通发票信息提取 | 综合普通发票 + 匹配结果,生成普通报销所需的全部结构化数据 |
|
||||
| `invoice_summary.csv` | extractor 提取 | 普通发票汇总(用于生成易耗品出库单) |
|
||||
|
||||
### bot 模块架构
|
||||
|
||||
`infra/browser/` 包负责浏览器自动化填报,仅接收已提取的信息并执行填报操作,不承担信息提取职责:
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `infra/browser/base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图 |
|
||||
| `infra/browser/travel.py` | 差旅填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
|
||||
| `infra/browser/normal.py` | 普通填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
|
||||
| `infra/browser/__init__.py` | 入口函数:`run_bot()` / `run_bot_web()`,负责类型判断和流程路由 |
|
||||
|
||||
## 环境要求
|
||||
|
||||
- **Python 3.12+**
|
||||
@@ -153,10 +280,10 @@ uv run python src/main.py -u 工号 -p 密码
|
||||
需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**:
|
||||
|
||||
```bash
|
||||
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 备份
|
||||
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 备份
|
||||
```
|
||||
|
||||
填写规则概要:
|
||||
|
||||
24
config/README.md
Normal file
24
config/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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` 在启动时读取此文件,若文件不存在则使用内置默认规则。
|
||||
135
config/validation_rules.json
Normal file
135
config/validation_rules.json
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"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": "附件类型"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
732
docs/API.md
732
docs/API.md
@@ -1,5 +1,5 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
last_reviewed: 2026-06-13
|
||||
---
|
||||
|
||||
# 财务报销自动化 — API 文档
|
||||
@@ -12,17 +12,30 @@ last_reviewed: 2026-06-09
|
||||
| # | 方法 | 路径 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | GET | `/` | PC 端主页 |
|
||||
| 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>` | 移动端上传图片 |
|
||||
| 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 的场景。
|
||||
|
||||
---
|
||||
|
||||
@@ -30,7 +43,7 @@ last_reviewed: 2026-06-09
|
||||
|
||||
- 调用 `POST /api/session` 获得 `session_id`
|
||||
- 该会话下所有文件存放在 `src/web/uploads/<session_id>/`
|
||||
- 典型产物:`invoice_summary.csv`、`易耗品、出库单.doc`、`config.json`、`session.log`、`result.json`
|
||||
- 典型产物:`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`
|
||||
|
||||
---
|
||||
|
||||
@@ -87,6 +100,10 @@ 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"]
|
||||
}
|
||||
@@ -96,130 +113,7 @@ GET /api/files/<session_id>
|
||||
|
||||
---
|
||||
|
||||
### 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. 下载文件
|
||||
### 4. 下载文件
|
||||
|
||||
```
|
||||
GET /api/download/<session_id>/<filename>
|
||||
@@ -237,8 +131,12 @@ GET /api/download/<session_id>/<filename>
|
||||
|
||||
| 文件名 | 说明 |
|
||||
|--------|------|
|
||||
| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) |
|
||||
| `invoice_summary.csv` | 发票汇总 |
|
||||
| `payment_records.csv` | 支付记录 |
|
||||
| `易耗品、出库单.doc` | 自动填写的出库单 |
|
||||
| `travel_applications.json` | 差旅申请信息 |
|
||||
| `result.json` | 处理结果 |
|
||||
| `agent_state.json` | Agent 会话状态 |
|
||||
|
||||
**错误:**
|
||||
|
||||
@@ -250,6 +148,56 @@ 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. 获取发票数据
|
||||
|
||||
```
|
||||
@@ -260,7 +208,7 @@ GET /api/data/<session_id>
|
||||
|
||||
```json
|
||||
{
|
||||
"csv_filename": "invoice_summary.csv",
|
||||
"csv_filename": "payment_records.csv",
|
||||
"fields": [
|
||||
"序号", "发票号码", "开票日期", "项目名称", "规格型号",
|
||||
"价税合计", "销售方名称", "人员姓名", "刷卡日期",
|
||||
@@ -277,6 +225,8 @@ GET /api/data/<session_id>
|
||||
}
|
||||
```
|
||||
|
||||
读取优先级:`payment_records.csv` → `invoice_summary.csv` → 任意 `.csv` 文件。
|
||||
|
||||
- `fields`:列顺序
|
||||
- `data[].__row`:内部行索引(保存时不需要提交,服务端按数组顺序写回)
|
||||
|
||||
@@ -350,7 +300,82 @@ Content-Type: application/json
|
||||
|
||||
---
|
||||
|
||||
### 10. 提交到财务系统
|
||||
### 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. 提交到财务系统
|
||||
|
||||
```
|
||||
POST /api/submit-financial/<session_id>
|
||||
@@ -358,13 +383,12 @@ POST /api/submit-financial/<session_id>
|
||||
|
||||
**前置条件:**
|
||||
|
||||
- 会话目录存在 `config.json`(由 `/api/process` 写入),否则返回 `400`
|
||||
- 存在可用的发票 CSV(通常为 `invoice_summary.csv`)
|
||||
- 会话目录存在 `config.json`,否则返回 `400`
|
||||
- 存在可用的发票 CSV(通常为 `invoice_summary.csv` 或 `payment_records.csv`)
|
||||
|
||||
**说明:**
|
||||
|
||||
- 前端一般在提交前调用 `/api/save` 保存表格修改
|
||||
- 本接口**不会**自动执行发票提取或 LLM 识别
|
||||
- 根据发票类型选择填报模式:纯差旅发票走差旅报销流程,含普通发票走普通报销流程
|
||||
|
||||
**响应(立即):**
|
||||
@@ -389,28 +413,323 @@ POST /api/submit-financial/<session_id>
|
||||
|
||||
---
|
||||
|
||||
### 11. 移动端上传页面
|
||||
## Agent 多轮校验流程
|
||||
|
||||
```
|
||||
GET /mobile/<session_id>
|
||||
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
|
||||
```
|
||||
|
||||
返回移动端 HTML 页面。扫码上传的图片与 PC 端共用同一会话目录;PC 通过轮询 `GET /api/files/<session_id>` 同步文件列表。
|
||||
### 13. 获取 Agent 会话状态
|
||||
|
||||
```
|
||||
GET /api/agent/state/<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` | 出错 |
|
||||
|
||||
---
|
||||
|
||||
### 12. 移动端上传图片
|
||||
### 14. 启动 Agent 多轮处理(主入口)
|
||||
|
||||
```
|
||||
POST /api/mobile-upload/<session_id>
|
||||
Content-Type: multipart/form-data
|
||||
POST /api/agent/process/<session_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| file | File | 图片文件 |
|
||||
**请求体:**
|
||||
|
||||
逻辑与 `POST /api/upload/<session_id>` 相同。
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 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": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -418,9 +737,9 @@ Content-Type: multipart/form-data
|
||||
|
||||
| HTTP | 场景 |
|
||||
|------|------|
|
||||
| 400 | 参数缺失、未找到配置等 |
|
||||
| 404 | `session_id` 不存在、文件不存在 |
|
||||
| 500 | CSV 读取失败等内部错误 |
|
||||
| 400 | 参数缺失、未找到配置、文本为空等 |
|
||||
| 404 | `session_id` 不存在、文件不存在、Agent 状态丢失 |
|
||||
| 500 | CSV 读取失败、服务未初始化等内部错误 |
|
||||
|
||||
统一错误体:
|
||||
|
||||
@@ -447,38 +766,81 @@ Content-Type: multipart/form-data
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PC as PC 端
|
||||
participant Server as Server
|
||||
participant Mobile as 移动端
|
||||
participant Word as Word COM
|
||||
participant F as 前端
|
||||
participant S as SSE连接
|
||||
participant B as 后端线程
|
||||
participant A as Agent调度器
|
||||
|
||||
PC->>Server: POST /api/session
|
||||
Server-->>PC: session_id
|
||||
F->>B: POST /api/session
|
||||
B-->>F: session_id
|
||||
|
||||
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: 跳过出库单生成
|
||||
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
|
||||
end
|
||||
Server-->>PC: SSE done (csv_url, 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/data/{sid}
|
||||
B-->>F: fields + data
|
||||
F->>B: POST /api/save/{sid}
|
||||
Note over B: 更新 CSV,重新生成出库单
|
||||
B-->>F: doc_url
|
||||
|
||||
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} (轮询)
|
||||
F->>B: GET /api/download/{sid}/易耗品、出库单.doc
|
||||
```
|
||||
|
||||
---
|
||||
@@ -488,7 +850,7 @@ sequenceDiagram
|
||||
不经过 Web、在本地直接填写出库单:
|
||||
|
||||
```bash
|
||||
uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
uv run python -m src.infra.documents.consumable --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
```
|
||||
|
||||
详见 [README.md](./README.md)。
|
||||
@@ -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.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
uv run python -m src.infra.documents.consumable --csv invoice_summary.csv --doc "易耗品、出库单.doc"
|
||||
```
|
||||
|
||||
### 分步执行管道
|
||||
|
||||
@@ -9,7 +9,8 @@ dependencies = [
|
||||
"PyMuPDF>=1.24",
|
||||
"pywin32>=306",
|
||||
"llama-index>=0.12.0",
|
||||
"llama-index-llms-openai-like==0.7.2",
|
||||
"llama-index-llms-openai-like>=0.7.2",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -37,10 +38,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# scripts — 测试脚本目录
|
||||
# scripts — 调试脚本与数据目录
|
||||
|
||||
存放用于测试各模块功能的独立脚本,可直接运行。
|
||||
## 子目录
|
||||
|
||||
## 脚本清单
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `data/` | CLI 模式的数据目录:发票源文件、`config.json`、`.invoice_cache` 缓存 |
|
||||
|
||||
## 脚本
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `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
|
||||
```
|
||||
| `debug_stream_fields.py` | 诊断 stream_chat 返回对象的字段结构 |
|
||||
| `test_application_extract.py` | 测试出差申请单提取 |
|
||||
| `test_multimodal.py` | 测试多模态 LLM 识别 |
|
||||
| `test_travel_info.py` | 测试差旅信息提取 |
|
||||
|
||||
69
scripts/debug_stream_fields.py
Normal file
69
scripts/debug_stream_fields.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""诊断脚本:检查 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()
|
||||
208
scripts/test_application_extract.py
Normal file
208
scripts/test_application_extract.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/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()
|
||||
@@ -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.doc.llm_extractor import extract_document # noqa: E402
|
||||
from src.doc.pdf import render_pdf_to_images # noqa: E402
|
||||
from src.core.extraction import extract_document # noqa: E402
|
||||
from src.infra.documents.pdf import render_pdf_to_images # noqa: E402
|
||||
|
||||
|
||||
def test_render() -> None:
|
||||
|
||||
@@ -15,7 +15,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT)) # noqa: E402
|
||||
|
||||
from src.doc.llm_extractor import extract_travel_info # noqa: E402
|
||||
from src.core.extraction import extract_travel_info # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
111
src/README.md
111
src/README.md
@@ -1,72 +1,77 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# 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 异常定义
|
||||
```
|
||||
|
||||
## 模块清单
|
||||
|
||||
| 文件/目录 | 说明 |
|
||||
|-----------|------|
|
||||
| `__init__.py` | 包初始化:提供 `get_logger()` 日志工厂(支持终端 + 文件双输出,按日期自动分文件) |
|
||||
| `config.py` | 配置加载:从 `config.json` 读取用户凭据和默认值,从环境变量读取服务端配置(SSO 地址、LLM 参数) |
|
||||
| `pipeline.py` | 流程编排:串联发票提取 → 分类 → CSV 保存 → 浏览器填报,支持分步执行 |
|
||||
| `main.py` | CLI 入口:支持 `--step` 分步执行、`-u/-p` 覆盖凭据、`--cache-dir` 指定缓存目录 |
|
||||
| `bot.py` | 浏览器自动化:Playwright 驱动的财务系统填报机器人(含差旅/普通两种模式,支持 headless) |
|
||||
| `doc/` | 文档处理模块:PDF 渲染、LLM 提取、支付匹配、发票分类、出库单生成 |
|
||||
| `web/` | Web 界面模块:Flask 应用、SSE 日志、可编辑表格、移动端上传、会话隔离 |
|
||||
| `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` | 异常层次定义 |
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
CLI/Web 入口 → pipeline.py (编排)
|
||||
↓
|
||||
doc/extractor.py (统一提取入口)
|
||||
↓
|
||||
┌──── doc/pdf.py (PDF 渲染为图片)
|
||||
│
|
||||
├── doc/llm_extractor.py (多模态 LLM 识别)
|
||||
│ ├── 发票 (invoice_type=train/hotel/general)
|
||||
│ ├── 支付记录 (invoice_type=payment)
|
||||
│ └── 出差事前申请单 (invoice_type=application)
|
||||
│
|
||||
├── doc/matcher.py (发票与支付记录按金额匹配)
|
||||
│ ├── 一对一匹配 (发票数 == 刷卡数)
|
||||
│ └── 一对多匹配 (贪心算法,相对容差 3%)
|
||||
│
|
||||
└── doc/invoice.py (CSV/JSON 读写)
|
||||
├── payment_records.csv (支付记录级别)
|
||||
├── invoice_summary.csv (发票级别)
|
||||
└── travel_applications.json (出差申请单)
|
||||
↓
|
||||
doc/fill_consumable_doc.py (普通发票 → Word 出库单)
|
||||
↓
|
||||
bot.py (浏览器填报)
|
||||
├── 差旅模式: 差旅信息提取 → 填报差旅单 → 上传差旅附件
|
||||
└── 普通模式: 基本信息 → 录入明细 → 支付信息 → 上传附件
|
||||
```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 识别)"]
|
||||
E --> F["发票 invoice_type=train/hotel/general"]
|
||||
E --> G["支付记录 invoice_type=payment"]
|
||||
E --> H["出差事前申请单 invoice_type=application"]
|
||||
C --> I["core/matching/matcher.py (发票与支付记录按金额匹配)"]
|
||||
I --> J["一对一匹配 发票数 == 刷卡数"]
|
||||
I --> K["一对多匹配 贪心算法 相对容差 3%"]
|
||||
C --> L["infra/documents/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 (普通发票信息提取)"]
|
||||
T --> W["travel_info.json (差旅信息: 交通/住宿明细、补贴、附件清单)"]
|
||||
V --> X["normal_info.json (普通发票信息: 报销说明、发票总数、总金额、支付方式、附件清单)"]
|
||||
W --> P["infra/browser/ (浏览器填报 - 仅接收信息并填报)"]
|
||||
X --> P
|
||||
P --> Q["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
|
||||
P --> S["普通模式: 基本信息 → 录入明细 → 支付信息 → 上传附件"]
|
||||
```
|
||||
|
||||
## 文档处理子模块 (`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 数据,支持在线编辑后保存
|
||||
| 目录 | 文档 |
|
||||
|------|------|
|
||||
| `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) |
|
||||
|
||||
## 启动方式
|
||||
|
||||
|
||||
@@ -22,15 +22,26 @@ def get_logger(name: str) -> logging.Logger:
|
||||
|
||||
输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ...
|
||||
日志同时输出到终端和 logs/<日期>.log
|
||||
|
||||
只在最顶层的 logger 上添加标准 handler(stream + file),
|
||||
子 logger 通过 propagate 将日志传递给父 logger 统一处理。
|
||||
这样 _SSELogHandler 只需挂在父 logger 上即可捕获所有子日志。
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 使用专属标记判断是否已初始化标准 handler(stream + file),
|
||||
# 避免被 _SSELogHandler 等外部 handler 干扰
|
||||
if not getattr(logger, "_standard_handlers_initialized", False):
|
||||
logger.setLevel(logging.INFO)
|
||||
# 检查是否有父 logger 已初始化标准 handler
|
||||
# 如果有,子 logger 不重复添加,靠 propagate 传递即可
|
||||
parent_name = name.rsplit(".", 1)[0] if "." in name else None
|
||||
parent_has_handlers = False
|
||||
if parent_name:
|
||||
parent = logging.getLogger(parent_name)
|
||||
parent_has_handlers = getattr(parent, "_standard_handlers_initialized", False)
|
||||
|
||||
if not getattr(logger, "_standard_handlers_initialized", False) and not parent_has_handlers:
|
||||
formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT)
|
||||
|
||||
# 只有没有已初始化的父 logger 时,才添加标准 handler
|
||||
# 终端输出
|
||||
utf8_stream = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
stream_handler = logging.StreamHandler(utf8_stream)
|
||||
|
||||
242
src/agent/README.md
Normal file
242
src/agent/README.md
Normal file
@@ -0,0 +1,242 @@
|
||||
---
|
||||
|
||||
## last_reviewed: 2026-06-13
|
||||
|
||||
# src/agent — Agent 协调模块
|
||||
|
||||
作为调度中枢,编排信息提取、规则校验和语义校验的完整流程,驱动用户完成材料补充直到信息完整可提交。
|
||||
|
||||
## 模块清单
|
||||
|
||||
|
||||
| 文件 | 作用 |
|
||||
| ----------------- | ------------------------------------------ |
|
||||
| `orchestrator.py` | 调度中枢:状态机管理、轮次调度、校验-修正循环编排、语义校验、用户补充处理、事件发射 |
|
||||
|
||||
|
||||
## 架构概览
|
||||
|
||||
Agent 是报销系统的调度中枢,负责编排各模块完成信息提取和校验。它的核心职责:
|
||||
|
||||
1. **调度 LLM 提取** — 调用 `doc.llm_extractor` 的纯提取接口
|
||||
2. **调度规则校验** — 调用 `doc.validator` 按报销规范检查字段完整性
|
||||
3. **编排校验-修正循环** — 校验失败时构建修正提示,再次调度 LLM 修正
|
||||
4. **调度语义校验** — 调用 LLM 判断提取信息在语义层面是否自洽、充分
|
||||
5. **状态持久化** — 每轮结束后将会话状态写入磁盘,支持中断恢复
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph agent["src/agent (调度中枢)"]
|
||||
O[orchestrator.py]
|
||||
end
|
||||
|
||||
subgraph doc["src/doc"]
|
||||
LE[llm_extractor.py]
|
||||
VA[validator.py]
|
||||
end
|
||||
|
||||
O -->|"1. 调度 LLM 提取"| LE
|
||||
O -->|"2. 调度规则校验"| VA
|
||||
VA -->|"校验失败"| O
|
||||
O -->|"3. 构建修正提示"| LE
|
||||
O -->|"4. 调度语义校验"| LE
|
||||
LE -->|"缓存读写"| C[.invoice_cache/]
|
||||
O -->|"SSE 事件"| E[agent_events.log]
|
||||
O -->|"状态持久化"| S[agent_state.json]
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 校验-修正循环
|
||||
|
||||
Agent 编排的校验-修正循环流程:
|
||||
|
||||
1. Agent 调用 `llm_extractor.llm_query_text()` 让 LLM 提取信息
|
||||
2. Agent 调用 `validator.validate_extracted_info()` 规则校验
|
||||
3. 校验失败则 Agent 构建修正提示(包含缺失字段列表),再次调用 LLM
|
||||
4. 最多重试 3 次(`MAX_VALIDATION_RETRIES`),3 次后返回最佳结果
|
||||
|
||||
**关键点**:校验逻辑不内嵌在 `llm_extractor` 中,而是由 Agent 层调度。`llm_extractor` 只提供纯提取能力,`validator` 只提供纯校验能力,Agent 负责编排。
|
||||
|
||||
## 状态机
|
||||
|
||||
Agent 会话通过 `AgentState` 枚举管理生命周期:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> IDLE
|
||||
IDLE --> EXTRACTING
|
||||
EXTRACTING --> AWAITING_SUPPLEMENT
|
||||
EXTRACTING --> READY
|
||||
AWAITING_SUPPLEMENT --> EXTRACTING: 用户补充后重新校验
|
||||
READY --> [*]
|
||||
EXTRACTING --> ERROR: 提取失败
|
||||
AWAITING_SUPPLEMENT --> ERROR: 超出最大轮次
|
||||
READY --> SUBMITTING: 用户提交
|
||||
SUBMITTING --> DONE
|
||||
DONE --> [*]
|
||||
AWAITING_SUPPLEMENT --> READY: 用户强制提交
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 状态说明
|
||||
|
||||
|
||||
| 状态 | 含义 | 触发条件 |
|
||||
| --------------------- | ------------------------ | ------------------------ |
|
||||
| `IDLE` | 初始状态,等待启动 | 会话创建时 |
|
||||
| `EXTRACTING` | 正在执行提取-校验-修正循环 | `run_agent_round()` 开始执行 |
|
||||
| `AWAITING_SUPPLEMENT` | 语义校验未通过,等待用户补充材料或文字说明 | 语义校验失败 |
|
||||
| `READY` | 规则校验 + 语义校验均通过,信息完整,可以提交 | 双重校验通过 |
|
||||
| `SUBMITTING` | 用户确认提交,进入提交流程 | 调用提交接口 |
|
||||
| `DONE` | 提交完成,终态 | 提交成功后 |
|
||||
| `ERROR` | 提取失败或超出最大轮次(默认 5 轮) | 异常或轮次耗尽 |
|
||||
|
||||
|
||||
## 数据流
|
||||
|
||||
### 单轮处理流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[开始第 N 轮] --> B{终态保护?}
|
||||
B -->|是| Z[跳过返回]
|
||||
B -->|否| C{轮次超限?}
|
||||
C -->|是| E[转入 ERROR]
|
||||
C -->|否| D[EXTRACTING]
|
||||
|
||||
D --> F{缓存命中?}
|
||||
F -->|是| G[读取缓存数据]
|
||||
F -->|否| H[Agent 调度校验-修正循环]
|
||||
|
||||
H --> I[调用 LLM 提取]
|
||||
I --> J[调用 validator 校验]
|
||||
J --> K{校验通过?}
|
||||
K -->|是| L[写入缓存]
|
||||
K -->|否| M{重试次数<3?}
|
||||
M -->|是| N[构建修正提示]
|
||||
N --> I
|
||||
M -->|否| L
|
||||
|
||||
G --> O[语义校验 validate_semantic_completeness]
|
||||
L --> O
|
||||
|
||||
O --> P{语义通过?}
|
||||
P -->|是| Q[READY]
|
||||
P -->|否| R[发射 agent_request_supplement 事件]
|
||||
R --> S[AWAITING_SUPPLEMENT]
|
||||
Q --> T[发射 agent_ready 事件]
|
||||
|
||||
S --> U[持久化状态]
|
||||
T --> U
|
||||
U --> V[返回 session]
|
||||
E --> U
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 用户补充流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户补充] --> B{补充方式}
|
||||
B -->|文件上传| C[add_supplement 记录文件名]
|
||||
B -->|文字输入| D[process_user_text_supplement]
|
||||
B -->|强制提交| E[force_submit 跳过校验]
|
||||
|
||||
D --> F[LLM 解析文字提取字段]
|
||||
F --> G{有有效字段?}
|
||||
G -->|是| H[合并到 extracted_info]
|
||||
H --> I[更新缓存]
|
||||
I --> J[触发新一轮 run_agent_round]
|
||||
G -->|否| K[返回无变化提示]
|
||||
E --> L[直接转入 READY]
|
||||
|
||||
C --> M[等待下一轮 run_agent_round]
|
||||
J --> M
|
||||
L --> M
|
||||
K --> M
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 核心数据结构
|
||||
|
||||
### AgentSession
|
||||
|
||||
会话状态的完整载体,包含:
|
||||
|
||||
```python
|
||||
{
|
||||
"session_id": "str", # 会话唯一标识
|
||||
"state": "AgentState", # 当前状态
|
||||
"rounds": 0, # 已执行的轮次数
|
||||
"max_rounds": 5, # 最大轮次限制
|
||||
"invoice_type": "travel", # 发票类型: "travel" | "normal"
|
||||
"extracted_info": {}, # 提取的结构化报销信息
|
||||
"validation_reports": [], # 历次语义校验报告列表
|
||||
"user_supplements": [], # 用户补充的文件名列表
|
||||
"error_message": "" # 错误信息
|
||||
}
|
||||
```
|
||||
|
||||
### 校验报告
|
||||
|
||||
语义校验生成的报告条目追加到 `validation_reports`:
|
||||
|
||||
```python
|
||||
{
|
||||
"round": 1,
|
||||
"type": "semantic",
|
||||
"valid": False,
|
||||
"issues": ["金额不一致"],
|
||||
"missing_info": ["报销说明"],
|
||||
"suggestion": "请确认金额一致性并补充报销说明",
|
||||
"confidence": 0.6
|
||||
}
|
||||
```
|
||||
|
||||
### SSE 事件
|
||||
|
||||
通过 `agent_events.log` 向外部发射实时事件,每行一条 JSON:
|
||||
|
||||
|
||||
| 事件类型 | 触发时机 |
|
||||
| --------------------------- | ------------- |
|
||||
| `agent_state_change` | 状态切换时 |
|
||||
| `agent_ready` | 双重校验通过,信息完整 |
|
||||
| `agent_request_supplement` | 校验未通过,请求用户补充 |
|
||||
| `agent_supplement_received` | 收到用户补充(文件或文字) |
|
||||
| `agent_force_submit` | 用户选择强制提交 |
|
||||
| `agent_error` | 信息提取失败 |
|
||||
| `agent_max_rounds` | 达到最大轮次限制 |
|
||||
|
||||
|
||||
## 持久化机制
|
||||
|
||||
- **状态文件**:`session_dir/agent_state.json` — 原子写入(先写 `.tmp` 再 rename)
|
||||
- **事件日志**:`session_dir/agent_events.log` — 追加写入,支持前端 SSE 轮询
|
||||
- **缓存目录**:`session_dir/.invoice_cache/` — 存放 `travel_info.json` / `normal_info.json`
|
||||
|
||||
## 依赖说明
|
||||
|
||||
|
||||
| 上游依赖 | 用途 |
|
||||
| -------------------------- | ------------------------- |
|
||||
| `src/doc/llm_extractor.py` | 纯 LLM 提取、语义校验、缓存加载、用户补充解析 |
|
||||
| `src/doc/validator.py` | 纯规则校验 |
|
||||
| `src/doc/prompt.py` | 系统提示词加载 |
|
||||
|
||||
|
||||
Agent 是调度中枢,`llm_extractor` 提供纯提取能力,`validator` 提供纯校验能力,Agent 负责编排校验-修正循环。各模块职责清晰,不互相嵌套。
|
||||
|
||||
## 设计原则
|
||||
|
||||
- **Agent 是调度中枢**:校验-修正循环由 Agent 编排,不内嵌在 `llm_extractor` 中
|
||||
- **模块职责单一**:`llm_extractor` 只管提取,`validator` 只管校验,Agent 负责编排
|
||||
- **缓存优先**:信息提取优先读取 `.invoice_cache`,避免重复调用 LLM
|
||||
- **轮次保护**:默认 5 轮上限,防止无限循环;校验-修正循环最多重试 3 次
|
||||
- **终态保护**:`DONE` / `SUBMITTING` / `READY` 状态下不再重复处理
|
||||
- **容错降级**:语义校验失败不阻断流程,仅记录警告日志;规则校验 3 次重试后返回最佳结果
|
||||
|
||||
32
src/agent/__init__.py
Normal file
32
src/agent/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""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",
|
||||
]
|
||||
410
src/agent/coordinator.py
Normal file
410
src/agent/coordinator.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""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
|
||||
75
src/agent/events.py
Normal file
75
src/agent/events.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""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
|
||||
46
src/agent/orchestrator.py
Normal file
46
src/agent/orchestrator.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""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",
|
||||
]
|
||||
101
src/agent/session.py
Normal file
101
src/agent/session.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""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,
|
||||
)
|
||||
649
src/bot.py
649
src/bot.py
@@ -1,649 +0,0 @@
|
||||
"""
|
||||
浏览器自动化填报
|
||||
|
||||
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
|
||||
|
||||
对外接口:
|
||||
run_bot(config, invoices) 启动浏览器并执行填报流程
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import get_logger
|
||||
|
||||
log = get_logger("bot")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 日期格式化
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_date(date_str: str) -> str:
|
||||
"""将 '2026/5/13' 或 '2026-5-13' 转为 '2026-05-13'"""
|
||||
if not date_str:
|
||||
return ""
|
||||
parts = date_str.replace("-", "/").split("/")
|
||||
if len(parts) == 3:
|
||||
return f"{parts[0].zfill(4)}-{parts[1].zfill(2)}-{parts[2].zfill(2)}"
|
||||
return date_str
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 报销机器人
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class ReimburseBot:
|
||||
"""财务报销自动化机器人"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], headless: bool = False, work_dir: Path | None = None):
|
||||
self.config = config
|
||||
self.headless = headless
|
||||
self.work_dir: Path | None = None
|
||||
self.browser: Any = None
|
||||
self.context: Any = None
|
||||
self.page: Any = None
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self._pw_ctx = sync_playwright()
|
||||
self.pw = self._pw_ctx.__enter__()
|
||||
|
||||
def launch(self) -> None:
|
||||
"""启动浏览器"""
|
||||
self.browser = self.pw.chromium.launch(headless=self.headless)
|
||||
self.context = self.browser.new_context(viewport={"width": 1360, "height": 768})
|
||||
self.page = self.context.new_page()
|
||||
self.page.set_default_timeout(30000)
|
||||
|
||||
def login_portal(self) -> None:
|
||||
"""登录信息门户"""
|
||||
log.info("登录信息门户...")
|
||||
|
||||
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
|
||||
self._wait_for('text="微信扫码登录"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.fill('input[placeholder*="工号"], input[placeholder*="学号"]', self.config["username"])
|
||||
self.page.fill('input[placeholder*="密码"]', self.config["password"])
|
||||
except Exception:
|
||||
log.warning("未找到登录输入框,可能已登录")
|
||||
|
||||
try:
|
||||
checkbox = self.page.query_selector('input[type="checkbox"]')
|
||||
if checkbox and not checkbox.is_checked():
|
||||
checkbox.click()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
|
||||
try:
|
||||
self.page.click(selector, timeout=3000)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
self._wait_for_portal()
|
||||
|
||||
def _wait_for_portal(self) -> None:
|
||||
"""等待跳转到统一信息平台"""
|
||||
for _ in range(30):
|
||||
self.page.wait_for_timeout(1000)
|
||||
url = self.page.url
|
||||
if any(kw in url for kw in ("tyrz.fynu.edu.cn/zs-uip", "tyrz.fynu.edu.cn/oshall", "portal")):
|
||||
self._screenshot("portal_loaded")
|
||||
return
|
||||
log.error("等待门户跳转超时")
|
||||
self._screenshot("portal_timeout")
|
||||
raise TimeoutError("登录超时,未跳转到信息门户")
|
||||
|
||||
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
|
||||
"""从统一信息平台进入报销系统"""
|
||||
log.info("进入报销系统...")
|
||||
self._wait_for('text="快捷入口"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.click('text="财务系统"', timeout=5000)
|
||||
except Exception:
|
||||
log.warning("未找到财务系统入口")
|
||||
|
||||
new_tab = None
|
||||
for _ in range(15):
|
||||
self.page.wait_for_timeout(1000)
|
||||
for p in self.context.pages:
|
||||
if "dddl" in p.url or "210.45.32.214" in p.url:
|
||||
new_tab = p
|
||||
break
|
||||
if new_tab:
|
||||
break
|
||||
|
||||
if new_tab:
|
||||
self.page = new_tab
|
||||
self._wait_for('text="网络报销"', timeout=5000)
|
||||
else:
|
||||
log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}")
|
||||
|
||||
for p in self.context.pages[:-1]:
|
||||
try:
|
||||
p.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
link = self.page.query_selector('a:has(img[src*="wlbx"])')
|
||||
if link:
|
||||
reimburse_url = link.get_attribute("href")
|
||||
self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._wait_for('text="报销录入"', timeout=5000)
|
||||
common_url = self.config["reimburse_url"] + self.config[page_key]
|
||||
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
|
||||
self._wait_for('text="单据状态:"', timeout=5000)
|
||||
|
||||
def create_new_form(self) -> None:
|
||||
"""点击「新增」创建新单据"""
|
||||
log.info("创建新单据...")
|
||||
self.page.wait_for_timeout(2000)
|
||||
|
||||
try:
|
||||
self.page.click("#insert", timeout=5000)
|
||||
except Exception:
|
||||
try:
|
||||
self.page.click("text=新增", timeout=3000)
|
||||
except Exception as err:
|
||||
self._screenshot("no_add_button")
|
||||
raise RuntimeError("无法点击新增按钮") from err
|
||||
|
||||
self.page.wait_for_timeout(3000)
|
||||
self._screenshot("after_add_click")
|
||||
|
||||
def fill_travel_info(self, basic_info: dict[str, Any]) -> None:
|
||||
"""填写差旅报销基本信息"""
|
||||
log.info("填写差旅报销信息...")
|
||||
|
||||
try:
|
||||
self.page.fill("#CAUSE", basic_info.get("travel_purpose", ""))
|
||||
self.page.fill("#SITE", basic_info.get("travel_location", ""))
|
||||
|
||||
self.page.click("#PROJECTCODE", timeout=10000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
|
||||
first_row = self.page.query_selector("#promodal .fixed-table-body tbody tr")
|
||||
if first_row:
|
||||
first_row.click()
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
self.page.fill("#THEKSRQ", _format_date(basic_info.get("start_date", "")))
|
||||
self.page.fill("#THEJSRQ", _format_date(basic_info.get("end_date", "")))
|
||||
self.page.click("#saveAndNext", timeout=5000)
|
||||
self.page.wait_for_timeout(2000)
|
||||
self._screenshot("travel_basic_done")
|
||||
except Exception:
|
||||
log.error("填写基本信息失败")
|
||||
self._screenshot("travel_basic_error")
|
||||
|
||||
def fill_basic_info(self, description: str = "元器件采购报销") -> None:
|
||||
"""填写基本信息"""
|
||||
log.info("填写基本信息...")
|
||||
|
||||
try:
|
||||
self.page.fill("#EXPENEXPLAIN", description)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.page.click("#PROJECTCODE", timeout=10000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._screenshot("step3_project_modal")
|
||||
|
||||
try:
|
||||
self.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
|
||||
first_row = self.page.query_selector("#promodal .fixed-table-body tbody tr")
|
||||
if first_row:
|
||||
first_row.click()
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._screenshot("step3_project_selected")
|
||||
|
||||
try:
|
||||
self.page.click("#saveAndNext", timeout=5000)
|
||||
self.page.wait_for_timeout(2000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._screenshot("step3_done")
|
||||
|
||||
def add_travel_items(self, travel_items: dict[str, Any]) -> None:
|
||||
"""录入差旅报销明细"""
|
||||
vehicle_map = {
|
||||
"火车": "01",
|
||||
"汽车": "02",
|
||||
"轮船": "03",
|
||||
"自带车": "04",
|
||||
"公务车": "05",
|
||||
"飞机": "06",
|
||||
"租车": "07",
|
||||
"自驾车": "08",
|
||||
}
|
||||
try:
|
||||
traffic_info = travel_items.get("transport_fee") or []
|
||||
for item in traffic_info:
|
||||
self.page.click("#insertDetail", timeout=5000)
|
||||
self._wait_for('text="增加明细"', timeout=5000)
|
||||
self.page.select_option("#cost", "1")
|
||||
self.page.wait_for_timeout(500)
|
||||
vehicle = item.get("vehicle_type", "")
|
||||
if vehicle in vehicle_map:
|
||||
self.page.select_option("#jtgj", vehicle_map[vehicle])
|
||||
|
||||
self.page.fill("#ksdd", item.get("departure_place", ""))
|
||||
self.page.fill("#jsdd", item.get("arrival_place", ""))
|
||||
self.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("amount", "")),
|
||||
)
|
||||
self.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.HOWBILL"]',
|
||||
str(item.get("bill_count", "")),
|
||||
)
|
||||
self.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
self.page.click("#detailAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
hotel_info = travel_items.get("hotel_fee") or []
|
||||
for item in hotel_info:
|
||||
self.page.click("#insertDetail", timeout=5000)
|
||||
self._wait_for('text="增加明细"', timeout=5000)
|
||||
self.page.select_option("#cost", "2")
|
||||
self.page.wait_for_timeout(500)
|
||||
self.page.fill("#ksrq2", _format_date(str(item.get("checkin_date", ""))))
|
||||
self.page.fill("#jsrq2", _format_date(str(item.get("checkout_date", ""))))
|
||||
self.page.fill("#ts2", str(item.get("days", "")))
|
||||
self.page.fill("#rs2", str(item.get("person_count", "")))
|
||||
self.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.FPMONEY"]',
|
||||
str(item.get("invoice_amount", "")),
|
||||
)
|
||||
self.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("reimburse_amount", "")),
|
||||
)
|
||||
self.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
self.page.click("#detailAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
conference_info = travel_items.get("conference_fee") or []
|
||||
for item in conference_info:
|
||||
self.page.click("#insertDetail", timeout=5000)
|
||||
self._wait_for('text="增加明细"', timeout=5000)
|
||||
self.page.select_option("#cost", "3")
|
||||
self.page.wait_for_timeout(500)
|
||||
self.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.HOWBILL"]',
|
||||
str(item.get("bill_count", "")),
|
||||
)
|
||||
self.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("amount", "")),
|
||||
)
|
||||
self.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
self.page.click("#detailAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
self._screenshot("item_total_error")
|
||||
raise
|
||||
|
||||
def add_reimburse_items(self, invoices: list[dict[str, Any]]) -> None:
|
||||
"""录入报销明细(一条总明细)"""
|
||||
card_amount = sum(inv["card_amount"] for inv in invoices)
|
||||
log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...")
|
||||
|
||||
try:
|
||||
self.page.click("#insertDetail", timeout=5000)
|
||||
self._wait_for('text="经济事项名称"', timeout=5000)
|
||||
self.page.click("#economicscode2")
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
try:
|
||||
self.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000)
|
||||
rows = self.page.query_selector_all("#econmodal .fixed-table-body tbody tr")
|
||||
if len(rows) >= 3:
|
||||
rows[2].click()
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', f"{len(invoices)}")
|
||||
self.page.fill("#je_zwzcdz", f"{card_amount:.2f}")
|
||||
self.page.click("#detailAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
self._screenshot("item_total")
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
self._screenshot("item_total_error")
|
||||
raise
|
||||
|
||||
def fill_travel_payment(self, payment_info: list[dict[str, Any]]) -> None:
|
||||
"""录入差旅支付信息"""
|
||||
log.info("录入差旅支付信息...")
|
||||
try:
|
||||
self.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
self._wait_for('text="下一步(补助清单)"', timeout=5000)
|
||||
|
||||
for info in payment_info:
|
||||
self.page.click("#insertPay", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self.page.fill("#personid2", self.config["default_name"])
|
||||
self.page.fill("#accountname2", self.config["default_person_id"])
|
||||
self.page.fill("#receiptdate2", _format_date(info["card_date"]))
|
||||
self.page.fill("#localaccount2", self.config["default_card_no"])
|
||||
self.page.fill("#receiptmoney2", str(info["card_amount"]))
|
||||
self.page.fill("#money2", str(info["card_amount"]))
|
||||
self.page.fill("#merchant2", info.get("merchant", ""))
|
||||
self.page.fill("#smark2", info.get("remark", ""))
|
||||
self.page.click("#payAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
self._screenshot("step5_error")
|
||||
raise
|
||||
|
||||
self._screenshot("step5_done")
|
||||
|
||||
def fill_payment(self, invoices: list[dict[str, Any]]) -> None:
|
||||
"""录入支付信息"""
|
||||
log.info("录入支付信息...")
|
||||
try:
|
||||
self.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
self._wait_for('text="下一步(附件清单)"', timeout=5000)
|
||||
|
||||
for inv in invoices:
|
||||
self.page.click("#insertPay", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
self.page.fill("#personid2", inv["person_id"])
|
||||
self.page.fill("#accountname2", inv["person_name"])
|
||||
self.page.fill("#receiptdate2", inv["card_date"])
|
||||
self.page.fill("#localaccount2", inv["card_no"])
|
||||
self.page.fill("#receiptmoney2", str(inv["card_amount"]))
|
||||
self.page.fill("#money2", str(inv["card_amount"]))
|
||||
self.page.fill("#merchant2", inv["seller_name"])
|
||||
self.page.fill("#smark2", inv["remark"])
|
||||
self.page.click("#payAdd", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
self._screenshot("step5_error")
|
||||
raise
|
||||
|
||||
self._screenshot("step5_done")
|
||||
|
||||
def upload_attachments(self, invoices: list[dict[str, Any]]) -> None:
|
||||
"""上传附件"""
|
||||
log.info("上传附件...")
|
||||
try:
|
||||
self.page.click("#next4", timeout=5000)
|
||||
self._wait_for("#submit2", timeout=5000)
|
||||
|
||||
attachment_files = sorted((self.work_dir or Path(__file__).parent.parent).glob("*.pdf"))
|
||||
if not attachment_files:
|
||||
log.warning("未找到附件 PDF,跳过附件上传")
|
||||
return
|
||||
|
||||
for i, inv in enumerate(invoices):
|
||||
file_path = attachment_files[i] if i < len(attachment_files) else None
|
||||
try:
|
||||
self._wait_for("#insertAcc", timeout=20000)
|
||||
self.page.click("#insertAcc", timeout=5000)
|
||||
self._wait_for("#fjlx", timeout=5000)
|
||||
self.page.select_option("#fjlx", "1")
|
||||
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
|
||||
self.page.fill("#fpsmxx", explanation)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if file_path and file_path.exists():
|
||||
try:
|
||||
self.page.set_input_files("#file", str(file_path))
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"文件上传失败: {e}")
|
||||
try:
|
||||
self.page.click("#cjtj", timeout=5000)
|
||||
except Exception:
|
||||
try:
|
||||
self.page.press("body", "Escape")
|
||||
except Exception:
|
||||
pass
|
||||
log.info("上传附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"附件上传失败: {e}")
|
||||
self._screenshot("step6_error")
|
||||
raise
|
||||
|
||||
self._screenshot("step6_done")
|
||||
|
||||
def upload_travel_attachments(self, attachment_info: list[dict[str, Any]]) -> None:
|
||||
"""上传差旅附件"""
|
||||
log.info("上传差旅附件...")
|
||||
try:
|
||||
self.page.click("#next4", timeout=5000)
|
||||
self._wait_for("#submit2", timeout=5000)
|
||||
|
||||
for info in attachment_info:
|
||||
attachment_file = self.work_dir / info["filename"]
|
||||
self._wait_for("#insertAcc", timeout=20000)
|
||||
self.page.click("#insertAcc", timeout=5000)
|
||||
self._wait_for("#fjlx", timeout=5000)
|
||||
if info["attachment_type"] == "invoice":
|
||||
self.page.select_option("#fjlx", "1")
|
||||
else:
|
||||
self.page.select_option("#fjlx", "2")
|
||||
self.page.fill("#fpsmxx", info["attachment_desc"])
|
||||
if attachment_file and attachment_file.exists():
|
||||
self.page.set_input_files("#file", str(attachment_file))
|
||||
self.page.wait_for_timeout(1000)
|
||||
self.page.click("#cjtj", timeout=5000)
|
||||
log.info("上传差旅附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"差旅附件上传失败: {e}")
|
||||
self._screenshot("travel_attachment_error")
|
||||
raise
|
||||
self._screenshot("travel_attachment_done")
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭浏览器"""
|
||||
if self.context:
|
||||
self.context.close()
|
||||
if self.browser:
|
||||
self.browser.close()
|
||||
try:
|
||||
self._pw_ctx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
|
||||
self.page.wait_for_selector(selector, timeout=timeout)
|
||||
|
||||
def _screenshot(self, name: str) -> None:
|
||||
img_dir = Path(__file__).parent.parent / "images"
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
|
||||
|
||||
def fill_travel_subsidy(self, subsidy_info: list[dict[str, Any]]) -> None:
|
||||
"""录入差旅补助清单"""
|
||||
log.info("录入差旅补助清单...")
|
||||
try:
|
||||
self.page.click("#next3", timeout=5000)
|
||||
self._wait_for("#next4", timeout=5000)
|
||||
|
||||
for info in subsidy_info:
|
||||
self.page.click("#insertSubsidy", timeout=5000)
|
||||
self._wait_for('text="增加补助清单"', timeout=5000)
|
||||
self.page.click("#jzg3", timeout=5000)
|
||||
self.page.wait_for_timeout(500)
|
||||
if info["person_name"] and info["person_name"] != "":
|
||||
self.page.fill("#seacher", info["person_name"])
|
||||
elif info["person_id"] and info["person_id"] != "":
|
||||
self.page.fill("#seacher", info["person_id"])
|
||||
else:
|
||||
raise ValueError(f"人员编号和人员姓名不能同时为空: {info}")
|
||||
self.page.click("#cx", timeout=5000)
|
||||
self.page.wait_for_selector("div.fixed-table-loading", state="hidden", timeout=10000)
|
||||
self.page.click("#tableEmp tbody tr", timeout=10000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
open_bank = self.page.input_value("#openbank1")
|
||||
if not open_bank:
|
||||
log.info("员工开户行未填写,默认填写中国工商银行")
|
||||
self.page.fill("#openbank1", "中国工商银行")
|
||||
|
||||
self.page.fill("#startdate1", _format_date(info["start_date"]))
|
||||
self.page.fill("#enddate1", _format_date(info["end_date"]))
|
||||
self.page.fill("#trafficdays1", str(info["days"]))
|
||||
self.page.fill("#fooddays1", str(info["days"]))
|
||||
self.page.fill("#trafficnorm1", str(80))
|
||||
self.page.fill("#foodnorm1", str(100))
|
||||
trafficmoney = int(info["days"]) * 80
|
||||
foodmoney = int(info["days"]) * 100
|
||||
subsidymoney = trafficmoney + foodmoney
|
||||
self.page.fill("#trafficmoney1", str(trafficmoney))
|
||||
self.page.fill("#foodmoney1", str(foodmoney))
|
||||
self.page.fill("#subsidymoney1", str(subsidymoney))
|
||||
self.page.click("#add", timeout=3000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"差旅补助清单录入失败: {e}")
|
||||
self._screenshot("subsidy_error")
|
||||
raise
|
||||
|
||||
self._screenshot("subsidy_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 对外入口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_bot(config: dict[str, Any], headless: bool = False, work_dir: Path | None = None) -> None:
|
||||
"""执行完整的浏览器填报流程,根据发票类型自动路由"""
|
||||
if not config["username"] or not config["password"]:
|
||||
raise ValueError("缺少用户名或密码")
|
||||
|
||||
if not work_dir:
|
||||
raise ValueError("缺少工作目录")
|
||||
|
||||
from .doc.llm_extractor import load_cache
|
||||
|
||||
cache_map = load_cache(work_dir)
|
||||
doc_values = [v for k, v in cache_map.items() if k != "travel_info"]
|
||||
invoices = [data for data in doc_values if data.get("invoice_type") not in ("application", "payment")]
|
||||
applications = [data for data in doc_values if data.get("invoice_type") == "application"]
|
||||
|
||||
groups = _classify_invoice_batch(invoices)
|
||||
travel_invoices = groups["travel"]
|
||||
general_invoices = groups["general"]
|
||||
|
||||
log.info(
|
||||
f"发票分类: 差旅 {len(travel_invoices)} 张, 普通 {len(general_invoices)} 张, "
|
||||
f"出差事前申请单 {len(applications)} 份"
|
||||
)
|
||||
|
||||
if travel_invoices and general_invoices:
|
||||
raise ValueError(
|
||||
f"发票类型混合(差旅 {len(travel_invoices)} 张 + 普通 {len(general_invoices)} 张),不支持混报,请分开提交"
|
||||
)
|
||||
|
||||
is_travel = bool(travel_invoices)
|
||||
travel_info: dict[str, Any] = cache_map.get("travel_info", {})
|
||||
if is_travel and not travel_info:
|
||||
from .doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info
|
||||
|
||||
travel_info = extract_travel_info(source_dir=work_dir)
|
||||
cache_dir = work_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)
|
||||
log.info("差旅信息已保存到缓存")
|
||||
|
||||
bot = ReimburseBot(config, headless=headless, work_dir=work_dir)
|
||||
bot.work_dir = work_dir
|
||||
try:
|
||||
bot.launch()
|
||||
bot.login_portal()
|
||||
|
||||
if is_travel:
|
||||
log.info("处理差旅发票...")
|
||||
bot.navigate_to_reimburse(page_key="travel_page")
|
||||
bot.create_new_form()
|
||||
basic_info = travel_info["basic_info"]
|
||||
bot.fill_travel_info(basic_info)
|
||||
travel_items = travel_info["reimbursement_details"]
|
||||
bot.add_travel_items(travel_items)
|
||||
payment_info = travel_info["payment_methods"]
|
||||
bot.fill_travel_payment(payment_info)
|
||||
subsidy_info = travel_info["subsidy_list"]
|
||||
bot.fill_travel_subsidy(subsidy_info)
|
||||
attachment_info = travel_info["attachments"]
|
||||
bot.upload_travel_attachments(attachment_info)
|
||||
else:
|
||||
log.info("处理普通发票...")
|
||||
bot.navigate_to_reimburse(page_key="reimburse_page")
|
||||
bot.create_new_form()
|
||||
bot.fill_basic_info()
|
||||
bot.add_reimburse_items(general_invoices)
|
||||
bot.fill_payment(general_invoices)
|
||||
bot.upload_attachments(general_invoices)
|
||||
|
||||
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,附件从指定目录读取"""
|
||||
run_bot(config, headless=True, work_dir=work_dir)
|
||||
@@ -7,12 +7,39 @@
|
||||
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",
|
||||
}
|
||||
)
|
||||
|
||||
def load_config() -> dict[str, str | Path]:
|
||||
"""加载并合并配置,缺失字段使用默认值"""
|
||||
# 前端安全的配置字段白名单(不含密码)
|
||||
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]:
|
||||
"""读取并合并配置,缺失字段使用默认值"""
|
||||
raw = {}
|
||||
if _CONFIG_PATH.exists():
|
||||
with open(_CONFIG_PATH, encoding="utf-8") as f:
|
||||
@@ -36,6 +63,37 @@ def load_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 {
|
||||
|
||||
21
src/core/README.md
Normal file
21
src/core/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# src/core — 核心业务逻辑
|
||||
|
||||
项目的核心业务层,负责信息提取、金额匹配和信息校验。此层不依赖 Web 框架或浏览器自动化等基础设施。
|
||||
|
||||
## 子模块
|
||||
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `extraction/` | 文档信息提取:PDF/图片 → LLM 多模态识别 → 结构化数据 |
|
||||
| `matching/` | 发票与支付记录按金额匹配(一对一 / 一对多) |
|
||||
| `validation/` | 声明式信息完整性校验,规则从 JSON 配置文件加载 |
|
||||
|
||||
## 设计原则
|
||||
|
||||
- **零外部依赖**:不依赖 Flask、Playwright 等框架
|
||||
- **接口契约**:每个子模块通过 `__init__.py` 导出稳定的对外接口
|
||||
- **错误传播**:明确的异常层次,便于上层统一处理
|
||||
4
src/core/__init__.py
Normal file
4
src/core/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""核心业务逻辑模块
|
||||
|
||||
提供信息提取、规则校验和发票匹配功能。
|
||||
"""
|
||||
29
src/core/extraction/README.md
Normal file
29
src/core/extraction/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
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。
|
||||
42
src/core/extraction/__init__.py
Normal file
42
src/core/extraction/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""信息提取模块
|
||||
|
||||
提供发票/文档结构化提取、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",
|
||||
]
|
||||
358
src/core/extraction/extractor.py
Normal file
358
src/core/extraction/extractor.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""发票提取编排
|
||||
|
||||
统一扫描目录下所有文件(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
|
||||
591
src/core/extraction/llm_extractor.py
Normal file
591
src/core/extraction/llm_extractor.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""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
|
||||
27
src/core/matching/README.md
Normal file
27
src/core/matching/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# src/core/matching — 金额匹配
|
||||
|
||||
将提取到的发票数据与支付记录(刷卡截图)按金额进行匹配。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `matcher.py` | 匹配引擎:一对一匹配、一对多贪心匹配、未匹配发票处理 |
|
||||
|
||||
## 匹配策略
|
||||
|
||||
| 场景 | 策略 |
|
||||
|------|------|
|
||||
| 发票数 == 支付记录数 | 一对一匹配:按金额降序配对,相对容差内即匹配 |
|
||||
| 发票数 > 支付记录数 | 一对多匹配:贪心算法凑金额,相对容差 3% |
|
||||
| 文件名匹配 | 最高优先级:文件名(不含后缀)一致时直接匹配 |
|
||||
| 未匹配发票 | 单独列为一条支付记录,`remark` 标记为 `"unmatched"` |
|
||||
|
||||
## 业务约束
|
||||
|
||||
- 发票总金额 >= 支付总金额
|
||||
- 输出以支付记录为主键的结果列表
|
||||
8
src/core/matching/__init__.py
Normal file
8
src/core/matching/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""匹配模块
|
||||
|
||||
提供发票与支付记录的金额匹配功能。
|
||||
"""
|
||||
|
||||
from .matcher import match_invoices_to_cards
|
||||
|
||||
__all__ = ["match_invoices_to_cards"]
|
||||
@@ -18,12 +18,13 @@
|
||||
2. 解析发票和刷卡记录的金额,进行总额校验
|
||||
- 发票总额 < 刷卡总额时发出 warning
|
||||
3. 按金额降序排序
|
||||
4. 根据数量关系选择匹配策略:
|
||||
4. 文件名匹配(最高优先级):发票和刷卡记录的文件名(不含后缀)一致时直接匹配
|
||||
5. 根据数量关系选择匹配策略:
|
||||
- 数量相等 → 一对一匹配:按金额从大到小依次配对,相对容差内即匹配
|
||||
- 发票更多 → 一对多匹配:对每张刷卡记录贪心凑金额,相对容差内结束
|
||||
5. 构建以支付记录为主键的结果列表
|
||||
6. 未匹配的发票单独作为一条记录(无刷卡信息)
|
||||
7. 清理内部字段,输出支付记录列表
|
||||
6. 构建以支付记录为主键的结果列表
|
||||
7. 未匹配的发票单独作为一条记录(无刷卡信息)
|
||||
8. 清理内部字段,输出支付记录列表
|
||||
|
||||
## 容差计算
|
||||
|
||||
@@ -45,9 +46,10 @@
|
||||
match_invoices_to_cards(invoices, cards, tolerance) -> list[dict]
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from ... import get_logger
|
||||
|
||||
log = get_logger("matcher")
|
||||
|
||||
@@ -170,10 +172,18 @@ def _match(
|
||||
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
|
||||
|
||||
tolerance 为相对容差比例(如 0.03 表示 3%)
|
||||
|
||||
匹配优先级(从高到低):
|
||||
1. 文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配
|
||||
2. 精确匹配:金额差 <= 0.01 元
|
||||
3. 一对一 / 一对多贪心匹配:按金额容差匹配
|
||||
"""
|
||||
result: dict[int, list[int]] = {}
|
||||
assigned: set[int] = set()
|
||||
|
||||
# ---- 阶段 0:文件名匹配(最高优先级)----
|
||||
_match_by_filename(invoices, cards, assigned, result)
|
||||
|
||||
if len(invoices) == len(cards):
|
||||
_match_one_to_one(invoices, cards, tolerance, assigned, result)
|
||||
else:
|
||||
@@ -182,6 +192,34 @@ def _match(
|
||||
return result
|
||||
|
||||
|
||||
def _match_by_filename(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]],
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配"""
|
||||
for card_idx, card in enumerate(cards):
|
||||
card_name = card.get("_source_file", "")
|
||||
if not card_name:
|
||||
continue
|
||||
card_stem = Path(card_name).stem
|
||||
|
||||
for idx, inv in enumerate(invoices):
|
||||
if idx in assigned:
|
||||
continue
|
||||
inv_name = inv.get("_source_file", "")
|
||||
if not inv_name:
|
||||
continue
|
||||
inv_stem = Path(inv_name).stem
|
||||
|
||||
if inv_stem == card_stem:
|
||||
assigned.add(idx)
|
||||
result[card_idx] = [idx]
|
||||
log.info(f"[文件名匹配] {inv_name} ↔ {card_name}")
|
||||
break
|
||||
|
||||
|
||||
def _match_one_to_one(
|
||||
invoices: list[dict[str, Any]],
|
||||
cards: list[dict[str, Any]],
|
||||
@@ -189,25 +227,38 @@ def _match_one_to_one(
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对"""
|
||||
"""一对一匹配:发票数等于刷卡数,对每张刷卡记录寻找金额最接近的未分配发票"""
|
||||
for card_idx, card in enumerate(cards):
|
||||
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]
|
||||
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]
|
||||
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}"
|
||||
)
|
||||
else:
|
||||
elif best_idx >= 0:
|
||||
inv = invoices[best_idx]
|
||||
log.warning(
|
||||
f"[一对一] 金额偏差超出容差: "
|
||||
f"{inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
|
||||
f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})"
|
||||
f"vs ¥{card_amount:.2f} (差 ¥{best_diff:.2f}, 容差 ¥{card_tol:.2f})"
|
||||
)
|
||||
|
||||
|
||||
34
src/core/validation/README.md
Normal file
34
src/core/validation/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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)` | 提取缺失字段列表 |
|
||||
28
src/core/validation/__init__.py
Normal file
28
src/core/validation/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""校验模块
|
||||
|
||||
提供报销信息的规则级校验功能。
|
||||
"""
|
||||
|
||||
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",
|
||||
]
|
||||
537
src/core/validation/validator.py
Normal file
537
src/core/validation/validator.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""信息完整性校验器
|
||||
|
||||
对 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("校验规则已重新加载")
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
---
|
||||
|
||||
# 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`) |
|
||||
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
PDF 发票 → pdf.py → llm_extractor.py → [发票列表]
|
||||
支付截图 → llm_extractor.py → [刷卡记录]
|
||||
↓
|
||||
matcher.py(按金额贪心匹配,相对容差 3%)
|
||||
↓
|
||||
invoice.py 分类 → CSV(已回填刷卡日期/卡号/金额)
|
||||
↓
|
||||
fill_consumable_doc → 易耗品出库单.doc
|
||||
```
|
||||
|
||||
## 依赖说明
|
||||
|
||||
- **PyMuPDF (pymupdf)** — PDF 图片渲染
|
||||
- **pywin32** — Word COM 自动化(仅 Windows)
|
||||
- **llama-index** — LLM 信息提取
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `fill_consumable_doc.py` 依赖 Microsoft Word + COM,仅 Windows 可用
|
||||
- LLM 提取不会覆盖 CSV 中已有非空字段
|
||||
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
|
||||
- LLM 提取失败时直接报错,无正则回退
|
||||
@@ -1,4 +0,0 @@
|
||||
"""文档处理模块
|
||||
|
||||
包含发票提取、LLM 信息提取、出库单填写等功能。
|
||||
"""
|
||||
@@ -1,237 +0,0 @@
|
||||
"""发票提取编排
|
||||
|
||||
统一扫描目录下所有文件(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 缓存路径"""
|
||||
return cache_dir / f"{file_path.stem}.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,
|
||||
"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) -> 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)
|
||||
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)
|
||||
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
|
||||
@@ -1,326 +0,0 @@
|
||||
"""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_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 结构不同,直接存储
|
||||
if json_path.name == "travel_info.json":
|
||||
cache_map["travel_info"] = 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
|
||||
@@ -1,88 +0,0 @@
|
||||
你是财务文档信息提取助手。你的任务是从图片中提取结构化信息,可能是支付截图、银行转账记录、微信/支付宝付款凭证等,也可能是发票文件,也可能是出差事前申请单,不管任何形式都要用统一的 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"//必填项
|
||||
}
|
||||
```
|
||||
## 重要规则
|
||||
- **必填项**: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: 卖方全称
|
||||
|
||||
**千万注意!千万注意!**:严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。
|
||||
@@ -1,123 +0,0 @@
|
||||
# 差旅信息提取系统提示词
|
||||
|
||||
你是财务差旅信息提取助手。你的任务是根据发票信息、付款记录,提取出差相关的结构化信息,并以严格符合以下类型要求的 JSON 格式返回。所有类型约束为最高优先级规则,任何情况下不得违反。
|
||||
|
||||
🔴 最高优先级:强制性类型约束(优先级高于所有其他规则)
|
||||
1. 根节点必须包含且仅包含以下 6 个字段,字段类型绝对不可变更:
|
||||
|
||||
| 字段名 | 强制类型 | 空值处理规则 |
|
||||
| ------ | --------- | ------------------ |
|
||||
| `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 必须语法正确,无多余逗号、引号等语法错误
|
||||
* 必须严格遵守所有强制性类型约束,任何违反类型要求的输出均视为无效
|
||||
58
src/exceptions.py
Normal file
58
src/exceptions.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""项目级异常定义
|
||||
|
||||
异常层次:
|
||||
ReimbursementError — 所有业务异常的基类
|
||||
├── ExtractionError — 文档提取失败(单个文件/批量全失败)
|
||||
├── BrowserError — 浏览器自动化失败
|
||||
└── ValidationError — 校验失败(规则校验/语义校验)
|
||||
|
||||
使用规则:
|
||||
- 模块内部: 捕获具体异常 → 记录日志 → 截图(如适用) → re-raise
|
||||
- 模块边界: 不吞异常,向上传播
|
||||
- 顶层 (routes.py / orchestrator.py): 统一捕获 ReimbursementError
|
||||
- 可恢复场景: 返回结构化结果而非 raise
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ReimbursementError(Exception):
|
||||
"""业务异常基类"""
|
||||
|
||||
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
class ExtractionError(ReimbursementError):
|
||||
"""文档提取失败"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
failed_files: list[str] | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message, details)
|
||||
self.failed_files = failed_files or []
|
||||
|
||||
|
||||
class BrowserError(ReimbursementError):
|
||||
"""浏览器自动化操作失败"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(ReimbursementError):
|
||||
"""校验失败"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
missing_fields: list[str] | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message, details)
|
||||
self.missing_fields = missing_fields or []
|
||||
21
src/infra/README.md
Normal file
21
src/infra/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# src/infra — 基础设施层
|
||||
|
||||
提供浏览器自动化、文档处理和 LLM 接口等底层能力。此层不包含业务逻辑,只提供工具和平台能力。
|
||||
|
||||
## 子模块
|
||||
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `browser/` | Playwright 驱动的财务系统自动填报 |
|
||||
| `documents/` | 发票数据模型、PDF 渲染、Word 出库单填写 |
|
||||
| `llm/` | LLM 提示词模板加载与管理 |
|
||||
|
||||
## 设计原则
|
||||
|
||||
- **无业务逻辑**:只提供工具能力,不包含业务流程判断
|
||||
- **可替换性**:每个子模块通过 `__init__.py` 导出接口,便于替换实现
|
||||
- **与 core 层解耦**:infra 不依赖 core,core 可通过接口调用 infra
|
||||
4
src/infra/__init__.py
Normal file
4
src/infra/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""基础设施模块
|
||||
|
||||
提供浏览器自动化、文档处理和 LLM 接口功能。
|
||||
"""
|
||||
44
src/infra/browser/README.md
Normal file
44
src/infra/browser/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
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 模式以无头模式运行
|
||||
100
src/infra/browser/__init__.py
Normal file
100
src/infra/browser/__init__.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""浏览器自动化填报
|
||||
|
||||
使用 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)
|
||||
203
src/infra/browser/base.py
Normal file
203
src/infra/browser/base.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""浏览器自动化填报 — 公共基类
|
||||
|
||||
提供浏览器生命周期管理、登录、导航、截图等公共操作。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ... import get_logger
|
||||
|
||||
log = get_logger("bot")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 工具函数
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_date(date_str: str) -> str:
|
||||
"""将 '2026/5/13' 或 '2026-5-13' 转为 '2026-05-13'"""
|
||||
if not date_str:
|
||||
return ""
|
||||
parts = date_str.replace("-", "/").split("/")
|
||||
if len(parts) == 3:
|
||||
return f"{parts[0].zfill(4)}-{parts[1].zfill(2)}-{parts[2].zfill(2)}"
|
||||
return date_str
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基类
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class BaseBot:
|
||||
"""浏览器自动化基类 — 管理浏览器生命周期与公共操作"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], headless: bool = False) -> None:
|
||||
self.config = config
|
||||
self.headless = headless
|
||||
self.work_dir: Path | None = None
|
||||
self.browser: Any = None
|
||||
self.context: Any = None
|
||||
self.page: Any = None
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self._pw_ctx = sync_playwright()
|
||||
self.pw = self._pw_ctx.__enter__()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 浏览器生命周期
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def launch(self) -> None:
|
||||
"""启动浏览器"""
|
||||
self.browser = self.pw.chromium.launch(headless=self.headless)
|
||||
self.context = self.browser.new_context(viewport={"width": 1360, "height": 768})
|
||||
self.page = self.context.new_page()
|
||||
self.page.set_default_timeout(30000)
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭浏览器"""
|
||||
if self.context:
|
||||
self.context.close()
|
||||
if self.browser:
|
||||
self.browser.close()
|
||||
try:
|
||||
self._pw_ctx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 登录
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def login_portal(self) -> None:
|
||||
"""登录信息门户"""
|
||||
log.info("登录信息门户...")
|
||||
|
||||
self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded")
|
||||
self._wait_for('text="微信扫码登录"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.fill(
|
||||
'input[placeholder*="工号"], input[placeholder*="学号"]',
|
||||
self.config["username"],
|
||||
)
|
||||
self.page.fill('input[placeholder*="密码"]', self.config["password"])
|
||||
except Exception:
|
||||
log.warning("未找到登录输入框,可能已登录")
|
||||
|
||||
try:
|
||||
checkbox = self.page.query_selector('input[type="checkbox"]')
|
||||
if checkbox and not checkbox.is_checked():
|
||||
checkbox.click()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']:
|
||||
try:
|
||||
self.page.click(selector, timeout=3000)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
self._wait_for_portal()
|
||||
|
||||
def _wait_for_portal(self) -> None:
|
||||
"""等待跳转到统一信息平台"""
|
||||
for _ in range(30):
|
||||
self.page.wait_for_timeout(1000)
|
||||
url = self.page.url
|
||||
if any(
|
||||
kw in url
|
||||
for kw in (
|
||||
"tyrz.fynu.edu.cn/zs-uip",
|
||||
"tyrz.fynu.edu.cn/oshall",
|
||||
"portal",
|
||||
)
|
||||
):
|
||||
self._screenshot("portal_loaded")
|
||||
return
|
||||
log.error("等待门户跳转超时")
|
||||
self._screenshot("portal_timeout")
|
||||
raise TimeoutError("登录超时,未跳转到信息门户")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 导航
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
|
||||
"""从统一信息平台进入报销系统"""
|
||||
log.info("进入报销系统...")
|
||||
self._wait_for('text="快捷入口"', timeout=5000)
|
||||
|
||||
try:
|
||||
self.page.click('text="财务系统"', timeout=5000)
|
||||
except Exception:
|
||||
log.warning("未找到财务系统入口")
|
||||
|
||||
new_tab = None
|
||||
for _ in range(15):
|
||||
self.page.wait_for_timeout(1000)
|
||||
for p in self.context.pages:
|
||||
if "dddl" in p.url or "210.45.32.214" in p.url:
|
||||
new_tab = p
|
||||
break
|
||||
if new_tab:
|
||||
break
|
||||
|
||||
if new_tab:
|
||||
self.page = new_tab
|
||||
self._wait_for('text="网络报销"', timeout=5000)
|
||||
else:
|
||||
log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}")
|
||||
|
||||
for p in self.context.pages[:-1]:
|
||||
try:
|
||||
p.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
link = self.page.query_selector('a:has(img[src*="wlbx"])')
|
||||
if link:
|
||||
reimburse_url = link.get_attribute("href")
|
||||
self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._wait_for('text="报销录入"', timeout=5000)
|
||||
common_url = self.config["reimburse_url"] + self.config[page_key]
|
||||
self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000)
|
||||
self._wait_for('text="单据状态:"', timeout=5000)
|
||||
|
||||
def create_new_form(self) -> None:
|
||||
"""点击「新增」创建新单据"""
|
||||
log.info("创建新单据...")
|
||||
self.page.wait_for_timeout(2000)
|
||||
|
||||
try:
|
||||
self.page.click("#insert", timeout=5000)
|
||||
except Exception:
|
||||
try:
|
||||
self.page.click("text=新增", timeout=3000)
|
||||
except Exception as err:
|
||||
self._screenshot("no_add_button")
|
||||
raise RuntimeError("无法点击新增按钮") from err
|
||||
|
||||
self.page.wait_for_timeout(3000)
|
||||
self._screenshot("after_add_click")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 辅助方法
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
|
||||
self.page.wait_for_selector(selector, timeout=timeout)
|
||||
|
||||
def _screenshot(self, name: str) -> None:
|
||||
img_dir = Path(__file__).parent.parent.parent / "images"
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
|
||||
179
src/infra/browser/normal.py
Normal file
179
src/infra/browser/normal.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""普通报销填报流程
|
||||
|
||||
负责普通发票报销的完整填报步骤:
|
||||
基本信息 → 总明细 → 支付方式 → 附件上传
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ... import get_logger
|
||||
from .base import BaseBot, format_date
|
||||
|
||||
log = get_logger("bot.normal")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 普通报销流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(
|
||||
bot: BaseBot,
|
||||
normal_info: dict[str, Any],
|
||||
) -> None:
|
||||
"""执行普通发票报销填报流程
|
||||
|
||||
Args:
|
||||
bot: 已启动并登录的 BaseBot 实例。
|
||||
normal_info: 普通发票信息字典。
|
||||
"""
|
||||
log.info("开始普通发票报销填报...")
|
||||
|
||||
log.info("填写基本信息...")
|
||||
description = normal_info.get("basic_info", {}).get("reimbursement_description", "元器件采购报销")
|
||||
fill_basic_info(bot, description)
|
||||
|
||||
total_invoices = normal_info.get("reimbursement_details", {}).get("total_invoices", 0)
|
||||
total_amount = normal_info.get("reimbursement_details", {}).get("total_amount", 0)
|
||||
log.info(f"录入普通发票总明细 (共 {total_invoices} 张, 合计 ¥{total_amount:.2f})...")
|
||||
add_normal_item(bot, total_invoices, total_amount)
|
||||
|
||||
payment_info = normal_info.get("payment_methods", [])
|
||||
log.info(f"录入普通发票支付信息 (共 {len(payment_info)} 笔)...")
|
||||
fill_normal_payment(bot, payment_info)
|
||||
|
||||
log.info("上传普通发票附件...")
|
||||
attachment_info = normal_info.get("attachments", [])
|
||||
upload_normal_attachments(bot, attachment_info)
|
||||
|
||||
log.info("普通发票报销填报完成")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基本信息
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") -> None:
|
||||
"""填写基本信息"""
|
||||
try:
|
||||
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.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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 总明细
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_normal_item(bot: BaseBot, total_invoices: int, total_amount: float) -> None:
|
||||
"""录入普通发票总明细"""
|
||||
try:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="经济事项名称"', timeout=5000)
|
||||
bot.page.click("#economicscode2")
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
try:
|
||||
bot.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000)
|
||||
rows = bot.page.query_selector_all("#econmodal .fixed-table-body tbody tr")
|
||||
if len(rows) >= 3:
|
||||
rows[2].click()
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bot.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', str(total_invoices))
|
||||
bot.page.fill("#je_zwzcdz", f"{total_amount:.2f}")
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
bot._screenshot("normal_item_done")
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
bot._screenshot("normal_item_error")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 支付方式
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_normal_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
|
||||
"""录入普通发票支付信息"""
|
||||
try:
|
||||
bot.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
bot._wait_for('text="下一步(附件清单)"', timeout=5000)
|
||||
|
||||
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("#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)))
|
||||
bot.page.fill("#money2", str(info.get("card_amount", 0)))
|
||||
bot.page.fill("#merchant2", str(info.get("merchant", "")))
|
||||
bot.page.fill("#smark2", str(info.get("remark", "")))
|
||||
bot.page.click("#payAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
bot._screenshot("normal_payment_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("normal_payment_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 附件上传
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def upload_normal_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
|
||||
"""上传普通发票附件"""
|
||||
log.info(f"上传普通发票附件 (共 {len(attachment_info)} 个)...")
|
||||
try:
|
||||
bot.page.click("#next3", timeout=5000)
|
||||
bot._wait_for("#submit2", timeout=5000)
|
||||
|
||||
for info in attachment_info:
|
||||
attachment_file = bot.work_dir / info["filename"]
|
||||
bot._wait_for("#insertAcc", timeout=20000)
|
||||
bot.page.click("#insertAcc", timeout=5000)
|
||||
bot._wait_for("#fjlx", timeout=5000)
|
||||
if info.get("attachment_type") == "invoice":
|
||||
bot.page.select_option("#fjlx", "1")
|
||||
else:
|
||||
bot.page.select_option("#fjlx", "2")
|
||||
bot.page.fill("#fpsmxx", info.get("attachment_desc", ""))
|
||||
if attachment_file and attachment_file.exists():
|
||||
bot.page.set_input_files("#file", str(attachment_file))
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.click("#cjtj", timeout=5000)
|
||||
log.info("上传普通发票附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"附件上传失败: {e}")
|
||||
bot._screenshot("normal_attachment_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("normal_attachment_done")
|
||||
307
src/infra/browser/travel.py
Normal file
307
src/infra/browser/travel.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""差旅报销填报流程
|
||||
|
||||
负责差旅报销的完整填报步骤:
|
||||
基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ... import get_logger
|
||||
from .base import BaseBot, format_date
|
||||
|
||||
log = get_logger("bot.travel")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 差旅填报流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(bot: BaseBot, travel_info: dict[str, Any]) -> None:
|
||||
"""执行差旅报销填报流程
|
||||
|
||||
Args:
|
||||
bot: 已启动并登录的 BaseBot 实例。
|
||||
travel_info: 差旅信息字典(包含 basic_info、reimbursement_details 等)。
|
||||
"""
|
||||
log.info("开始差旅报销填报...")
|
||||
|
||||
log.info("填写差旅报销信息...")
|
||||
basic_info = travel_info["basic_info"]
|
||||
fill_travel_info(bot, basic_info)
|
||||
|
||||
log.info("填写差旅报销明细...")
|
||||
details = travel_info["reimbursement_details"]
|
||||
add_travel_items(bot, details)
|
||||
|
||||
log.info("填写差旅报销支付方式...")
|
||||
payment_info = travel_info["payment_methods"]
|
||||
fill_travel_payment(bot, payment_info)
|
||||
|
||||
log.info("填写差旅报销补助清单...")
|
||||
subsidy_info = travel_info["subsidy_list"]
|
||||
fill_travel_subsidy(bot, subsidy_info)
|
||||
|
||||
log.info("上传差旅报销附件...")
|
||||
attachment_info = travel_info["attachments"]
|
||||
upload_travel_attachments(bot, attachment_info)
|
||||
|
||||
log.info("差旅报销填报完成")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基本信息
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None:
|
||||
"""填写差旅报销基本信息"""
|
||||
try:
|
||||
bot.page.fill("#CAUSE", basic_info.get("travel_purpose", ""))
|
||||
bot.page.fill("#SITE", basic_info.get("travel_location", ""))
|
||||
|
||||
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.fill("#THEKSRQ", format_date(basic_info.get("start_date", "")))
|
||||
bot.page.fill("#THEJSRQ", format_date(basic_info.get("end_date", "")))
|
||||
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}")
|
||||
bot._screenshot("travel_basic_error")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 差旅明细
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
|
||||
"""录入差旅报销明细
|
||||
|
||||
Args:
|
||||
bot: 已启动的 BaseBot 实例。
|
||||
details: 报销明细字典(travel_info["reimbursement_details"]),包含
|
||||
transport_fee、hotel_fee、conference_fee 等子字段。
|
||||
"""
|
||||
vehicle_map = {
|
||||
"火车": "01",
|
||||
"汽车": "02",
|
||||
"轮船": "03",
|
||||
"自带车": "04",
|
||||
"公务车": "05",
|
||||
"飞机": "06",
|
||||
"租车": "07",
|
||||
"自驾车": "08",
|
||||
}
|
||||
|
||||
try:
|
||||
traffic_info = details.get("transport_fee") or []
|
||||
for item in traffic_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
bot.page.select_option("#cost", "1")
|
||||
bot.page.wait_for_timeout(500)
|
||||
vehicle = item.get("vehicle_type", "")
|
||||
if vehicle in vehicle_map:
|
||||
bot.page.select_option("#jtgj", vehicle_map[vehicle])
|
||||
|
||||
bot.page.fill("#ksdd", item.get("departure_place", ""))
|
||||
bot.page.fill("#jsdd", item.get("arrival_place", ""))
|
||||
bot.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.HOWBILL"]',
|
||||
str(item.get("bill_count", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
hotel_info = details.get("hotel_fee") or []
|
||||
for item in hotel_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
bot.page.select_option("#cost", "2")
|
||||
bot.page.wait_for_timeout(500)
|
||||
bot.page.fill("#ksrq2", format_date(str(item.get("checkin_date", ""))))
|
||||
bot.page.fill("#jsrq2", format_date(str(item.get("checkout_date", ""))))
|
||||
bot.page.fill("#ts2", str(item.get("days", "")))
|
||||
bot.page.fill("#rs2", str(item.get("person_count", "")))
|
||||
bot.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.FPMONEY"]',
|
||||
str(item.get("invoice_amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("reimburse_amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
conference_info = details.get("conference_fee") or []
|
||||
for item in conference_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
bot.page.select_option("#cost", "3")
|
||||
bot.page.wait_for_timeout(500)
|
||||
bot.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.HOWBILL"]',
|
||||
str(item.get("bill_count", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
bot._screenshot("item_total_error")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 支付方式
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_travel_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
|
||||
"""录入差旅支付信息"""
|
||||
try:
|
||||
bot.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
bot._wait_for('text="下一步(补助清单)"', timeout=5000)
|
||||
|
||||
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("#receiptdate2", format_date(info["card_date"]))
|
||||
bot.page.fill("#localaccount2", bot.config["default_card_no"])
|
||||
bot.page.fill("#receiptmoney2", str(info["card_amount"]))
|
||||
bot.page.fill("#money2", str(info["card_amount"]))
|
||||
bot.page.fill("#merchant2", info.get("merchant", ""))
|
||||
bot.page.fill("#smark2", info.get("remark", ""))
|
||||
bot.page.click("#payAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
bot._screenshot("step5_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("step5_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 补助清单
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> None:
|
||||
"""录入差旅补助清单"""
|
||||
try:
|
||||
bot.page.click("#next3", timeout=5000)
|
||||
bot._wait_for("#next4", timeout=5000)
|
||||
|
||||
for info in subsidy_info:
|
||||
bot.page.click("#insertSubsidy", timeout=5000)
|
||||
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"] != "":
|
||||
bot.page.fill("#seacher", info["person_id"])
|
||||
else:
|
||||
raise ValueError(f"人员编号和人员姓名不能同时为空: {info}")
|
||||
bot.page.click("#cx", timeout=5000)
|
||||
bot.page.wait_for_selector("div.fixed-table-loading", state="hidden", timeout=10000)
|
||||
bot.page.click("#tableEmp tbody tr", timeout=10000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
open_bank = bot.page.input_value("#openbank1")
|
||||
if not open_bank:
|
||||
log.info("员工开户行未填写,默认填写中国工商银行")
|
||||
bot.page.fill("#openbank1", "中国工商银行")
|
||||
|
||||
bot.page.fill("#startdate1", format_date(info["start_date"]))
|
||||
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
|
||||
foodmoney = int(info["days"]) * 100
|
||||
subsidymoney = trafficmoney + foodmoney
|
||||
bot.page.fill("#trafficmoney1", str(trafficmoney))
|
||||
bot.page.fill("#foodmoney1", str(foodmoney))
|
||||
bot.page.fill("#subsidymoney1", str(subsidymoney))
|
||||
bot.page.click("#add", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"差旅补助清单录入失败: {e}")
|
||||
bot._screenshot("subsidy_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("subsidy_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 附件上传
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
|
||||
"""上传差旅附件"""
|
||||
try:
|
||||
bot.page.click("#next4", timeout=5000)
|
||||
bot._wait_for("#submit2", timeout=5000)
|
||||
|
||||
for info in attachment_info:
|
||||
attachment_file = bot.work_dir / info["filename"]
|
||||
bot._wait_for("#insertAcc", timeout=20000)
|
||||
bot.page.click("#insertAcc", timeout=5000)
|
||||
bot._wait_for("#fjlx", timeout=5000)
|
||||
if info["attachment_type"] == "invoice":
|
||||
bot.page.select_option("#fjlx", "1")
|
||||
else:
|
||||
bot.page.select_option("#fjlx", "2")
|
||||
bot.page.fill("#fpsmxx", info.get("attachment_desc", ""))
|
||||
if attachment_file and attachment_file.exists():
|
||||
bot.page.set_input_files("#file", str(attachment_file))
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.click("#cjtj", timeout=5000)
|
||||
log.info("上传差旅附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"差旅附件上传失败: {e}")
|
||||
bot._screenshot("travel_attachment_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("travel_attachment_done")
|
||||
36
src/infra/documents/README.md
Normal file
36
src/infra/documents/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
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 解析,字体统一宋体五号
|
||||
32
src/infra/documents/__init__.py
Normal file
32
src/infra/documents/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""文档处理基础设施
|
||||
|
||||
提供发票数据模型、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",
|
||||
]
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
|
||||
"""将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
|
||||
|
||||
仅写入表格数据单元格,保留原模板字体、边框与版式。
|
||||
"""
|
||||
@@ -13,9 +12,9 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from ..config import load_config
|
||||
from ..doc.invoice import load_invoice_csv
|
||||
from ... import get_logger
|
||||
from ...config import load_config
|
||||
from .invoice import load_invoice_csv
|
||||
|
||||
log = get_logger("fill_consumable_doc")
|
||||
|
||||
@@ -154,6 +153,8 @@ def fill_consumable_doc(
|
||||
import win32com.client
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
word = None
|
||||
doc = None
|
||||
try:
|
||||
word = win32com.client.Dispatch("Word.Application")
|
||||
word.Visible = False
|
||||
@@ -213,9 +214,11 @@ def fill_consumable_doc(
|
||||
|
||||
doc.Save()
|
||||
finally:
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
if doc is not None:
|
||||
doc.Close()
|
||||
finally:
|
||||
if word is not None:
|
||||
word.Quit()
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
return doc_path
|
||||
@@ -13,10 +13,13 @@ 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",
|
||||
@@ -58,7 +61,7 @@ def _is_application_document(invoice_type: str) -> bool:
|
||||
return invoice_type == "application"
|
||||
|
||||
|
||||
def _classify_invoice_batch(
|
||||
def classify_invoice_batch(
|
||||
invoices: list[dict[str, str]],
|
||||
) -> dict[str, list[dict[str, str]]]:
|
||||
"""按发票类型分组"""
|
||||
@@ -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: 渲染分辨率(默认 150,平衡质量与速度)。
|
||||
dpi: 渲染分辨率(默认 300,平衡质量与速度)。
|
||||
|
||||
Returns:
|
||||
base64 编码的 JPEG 图片字符串列表(每页一个)。
|
||||
32
src/infra/llm/README.md
Normal file
32
src/infra/llm/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
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()` | 构建普通发票信息提取系统提示词 |
|
||||
18
src/infra/llm/__init__.py
Normal file
18
src/infra/llm/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
LLM 提示词模板
|
||||
"""LLM 提示词模板
|
||||
|
||||
从 src/prompts/ 目录加载 .md 文件作为提示词模板。
|
||||
从 infra/llm/prompts/ 目录加载 .md 文件作为提示词模板。
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -24,3 +23,13 @@ def build_invoice_system_prompt() -> str:
|
||||
def build_travel_info_system_prompt() -> str:
|
||||
"""构建差旅信息提取系统提示词。"""
|
||||
return _load_prompt("travel_info_system.md")
|
||||
|
||||
|
||||
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")
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
last_reviewed: 2026-06-12
|
||||
---
|
||||
|
||||
# src/doc/prompts — LLM 提示词模板
|
||||
# src/infra/llm/prompts — LLM 提示词模板
|
||||
|
||||
存放 LLM 信息提取使用的系统提示词模板文件,由 `src/doc/prompt.py` 动态加载。
|
||||
存放 LLM 信息提取使用的系统提示词模板文件,由 `src/infra/llm/prompt.py` 动态加载。
|
||||
|
||||
## 模板清单
|
||||
|
||||
@@ -16,5 +16,5 @@ last_reviewed: 2026-06-11
|
||||
## 加载方式
|
||||
|
||||
```python
|
||||
from src.doc.prompt import build_invoice_system_prompt, build_travel_info_system_prompt
|
||||
from src.infra.llm import build_invoice_system_prompt, build_travel_info_system_prompt
|
||||
```
|
||||
246
src/infra/llm/prompts/invoice_system.md
Normal file
246
src/infra/llm/prompts/invoice_system.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# 角色定义
|
||||
|
||||
你是一个严谨合规、零容错导向的财务文档信息提取助手。你以财务数据的精准性为第一原则,对待提取结果严肃审慎,并以直接、无冗余的方式交付结构化内容。你沟通极简,在不附加无关说明的前提下,准确返回完整的提取结果。
|
||||
|
||||
你承接的输入涵盖支付截图、银行转账记录、微信 / 支付宝付款凭证、发票文件、出差事前申请单、易耗品出入库单等各类财务凭证。你通常不会输出解释性话术、提取过程说明或主观判定结论,只输出标准统一的 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` 的值必须与实际判断类型一致
|
||||
101
src/infra/llm/prompts/normal_info_system.md
Normal file
101
src/infra/llm/prompts/normal_info_system.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# 普通报销信息提取系统提示词
|
||||
|
||||
你是财务报销信息提取助手。根据发票信息和付款记录,提取报销相关的结构化数据,并以严格符合类型要求的 JSON 格式返回。
|
||||
|
||||
> **核心原则**:类型约束为最高优先级规则,任何情况下不得违反。
|
||||
|
||||
## 强制类型约束
|
||||
|
||||
以下类型规则为最高优先级,任何情况下不得违反。
|
||||
|
||||
### 1. 根节点字段(共 6 个,类型不可变更)
|
||||
|
||||
| 字段名 | 强制类型 | 空值处理 |
|
||||
| --- | --- | --- |
|
||||
| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 |
|
||||
| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含下述 2 个子字段 |
|
||||
| `payment_methods` | 数组 (list) | 必填,无数据时赋值为 `[]` |
|
||||
| `attachments` | 数组 (list) | 必填,无数据时赋值为 `[]` |
|
||||
| `can_submit` | 布尔 (bool) | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` |
|
||||
| `suggestion` | 字符串 (str) | 当 `can_submit` 为 `false` 时说明需补充的材料;为 `true` 时为空字符串 |
|
||||
|
||||
### 2. `reimbursement_details` 子字段(共 2 个)
|
||||
|
||||
| 字段名 | 强制类型 | 空值处理 |
|
||||
| --- | --- | --- |
|
||||
| `total_invoices` | 数字 (int) | 必填 |
|
||||
| `total_amount` | 数字 (float) | 必填 |
|
||||
|
||||
### 3. 禁止行为
|
||||
|
||||
* 省略任何根节点字段或 `reimbursement_details` 的子字段
|
||||
* 将 `payment_methods` 或 `attachments` 赋值为 `null`、字符串、数字或对象
|
||||
* 在 `reimbursement_details` 中添加未定义的子字段
|
||||
* 合并不同模块的数组数据
|
||||
|
||||
**正确示例**:
|
||||
```json
|
||||
{
|
||||
"basic_info": {...},
|
||||
"reimbursement_details": {...},
|
||||
"payment_methods": [],
|
||||
"attachments": [{"filename":"发票.pdf", ...}],
|
||||
"can_submit": true,
|
||||
"suggestion": ""
|
||||
}
|
||||
```
|
||||
|
||||
**错误示例**:
|
||||
```json
|
||||
{
|
||||
"basic_info": {...},
|
||||
"reimbursement_details": {...},
|
||||
"payment_methods": null,
|
||||
}
|
||||
```
|
||||
|
||||
## 输入数据说明
|
||||
你会收到以下数据:
|
||||
1. **发票信息**:包含购买物品的发票信息
|
||||
2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息
|
||||
|
||||
**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。
|
||||
|
||||
需要提取的信息:
|
||||
1. `basic_info`:(必填,每一项都必须填,给出合理的猜测)
|
||||
1. `reimbursement_description`:根据所有信息写一句20字以内的报销说明
|
||||
2. `reimbursement_details`:(至少有一项)
|
||||
1. `total_invoices`: 发票的总份数
|
||||
2. `total_amount`:填写付款记录总金额
|
||||
3. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式)
|
||||
1. `card_date`:根据付款记录,格式 YYYY-M-D
|
||||
2. `card_amount`:根据付款记录填写,单位为元,数字
|
||||
3. `merchant`:根据发票信息推测商户信息
|
||||
4. `remark`:说明该笔付款关联的发票信息
|
||||
4. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`,"invoice_type": "payment"的不作为附件)
|
||||
1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符
|
||||
2. `attachment_type`:从以下两个选项中选择:invoice、other
|
||||
3. `attachment_desc`:简要描述该文件的基本信息
|
||||
|
||||
## 语义完整性校验
|
||||
|
||||
提取完成后,需判断信息是否足够支撑填报。根据校验结果设置根节点的 `can_submit`(boolean)和 `suggestion`(string)字段。
|
||||
|
||||
**校验维度**:
|
||||
|
||||
- 支付金额总和是否与发票金额总和接近
|
||||
- 报销说明是否明确具体
|
||||
- 人员信息是否完整
|
||||
- 支付方式是否与支付记录对应
|
||||
- 每张发票是否都有对应的支付记录
|
||||
|
||||
**判定标准**:
|
||||
|
||||
- `can_submit = true`:信息完整且逻辑自洽,`suggestion` 为空字符串
|
||||
- `can_submit = false`:存在信息缺失或逻辑矛盾,`suggestion` 说明需要用户补充什么材料
|
||||
|
||||
## 最终输出要求
|
||||
|
||||
* 仅输出纯 JSON 字符串,不包含任何思考过程、解释文字或 Markdown 标记
|
||||
* JSON 语法必须正确,无多余逗号、引号等错误
|
||||
* 严格遵守所有强制性类型约束,违反类型要求的输出视为无效
|
||||
114
src/infra/llm/prompts/supplement_system.md
Normal file
114
src/infra/llm/prompts/supplement_system.md
Normal file
@@ -0,0 +1,114 @@
|
||||
## 角色定义
|
||||
|
||||
你是财务报销信息补充助手。你的任务是根据用户输入的文字信息,分析并更新已提取的报销信息 JSON。
|
||||
|
||||
**核心原则**:从用户输入中提取与报销相关的信息,智能合并到现有 JSON 中,不破坏已有数据。
|
||||
|
||||
---
|
||||
|
||||
## 输入数据说明
|
||||
|
||||
你会收到以下数据:
|
||||
1. **用户输入的文字**:用户补充的信息说明
|
||||
2. **当前已提取的报销信息 JSON**:包含基本信息、报销明细、支付方式等
|
||||
3. **发票类型**:差旅报销或普通报销
|
||||
|
||||
---
|
||||
|
||||
## 处理规则
|
||||
|
||||
### 1. 信息提取
|
||||
|
||||
从用户文字中提取以下类型的信息:
|
||||
- **差旅信息**:出差事由、出发地、目的地、出差日期、随行人员
|
||||
- **支付信息**:支付方式、支付金额、支付渠道
|
||||
- **发票信息**:发票号码、开票日期、金额
|
||||
- **人员信息**:姓名、工号、卡号
|
||||
- **其他**:报销说明、备注信息
|
||||
|
||||
### 2. 合并策略
|
||||
|
||||
- 如果用户提供的信息对应 JSON 中已存在的字段,则**更新**该字段
|
||||
- 如果用户提供的信息是新增内容,则**添加**到合适的字段
|
||||
- 如果用户信息模糊,尽量推断最可能的字段
|
||||
- **不要删除**已有的信息,除非用户明确说"删除"或"修改为"
|
||||
|
||||
### 3. 差旅报销字段映射
|
||||
|
||||
| 用户可能说的内容 | 对应 JSON 字段 |
|
||||
|----------------|--------------|
|
||||
| 出差原因/目的 | `basic_info.travel_purpose` |
|
||||
| 去哪里出差 | `basic_info.travel_location` |
|
||||
| 出发日期 | `basic_info.start_date` |
|
||||
| 返回日期 | `basic_info.end_date` |
|
||||
| 同行人员 | `subsidy_list` 数组 |
|
||||
| 交通方式 | `reimbursement_details.transport_fee` |
|
||||
| 酒店信息 | `reimbursement_details.accommodation` |
|
||||
|
||||
### 4. 普通报销字段映射
|
||||
|
||||
| 用户可能说的内容 | 对应 JSON 字段 |
|
||||
|----------------|--------------|
|
||||
| 报销说明 | `basic_info.reimbursement_description` |
|
||||
| 总金额 | `reimbursement_details.total_amount` |
|
||||
| 支付方式 | `payment_methods` |
|
||||
| 附件说明 | `attachments` |
|
||||
|
||||
---
|
||||
|
||||
## 输出格式
|
||||
|
||||
严格返回以下 JSON 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"updated_fields": {
|
||||
"field_path": "新值",
|
||||
"another_field": "新值"
|
||||
},
|
||||
"changes": ["修改说明1", "修改说明2"],
|
||||
"confidence": 0.0到1.0之间的数字,
|
||||
"unparsed_info": "无法解析的信息(如果有)"
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `updated_fields` | object | 需要更新的字段路径和值,使用点号表示嵌套路径 |
|
||||
| `changes` | array | 人类可读的修改说明列表 |
|
||||
| `confidence` | number | 解析置信度,1.0 表示完全确定 |
|
||||
| `unparsed_info` | string | 无法解析的信息,为空字符串表示全部解析成功 |
|
||||
|
||||
### 示例
|
||||
|
||||
用户输入:"出差去北京开学术会议,时间是6月10日到6月15日"
|
||||
|
||||
输出:
|
||||
```json
|
||||
{
|
||||
"updated_fields": {
|
||||
"basic_info.travel_purpose": "参加学术会议",
|
||||
"basic_info.travel_location": "北京",
|
||||
"basic_info.start_date": "2026-06-10",
|
||||
"basic_info.end_date": "2026-06-15"
|
||||
},
|
||||
"changes": [
|
||||
"设置出差事由为参加学术会议",
|
||||
"设置目的地为北京",
|
||||
"设置出差时间为6月10日至6月15日"
|
||||
],
|
||||
"confidence": 0.95,
|
||||
"unparsed_info": ""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最终输出要求
|
||||
|
||||
- 严格只输出 JSON 字符串
|
||||
- JSON 语法必须正确
|
||||
- 不要包含任何思考过程或解释文字
|
||||
- 如果用户输入与报销无关,返回空的 `updated_fields` 并在 `unparsed_info` 中说明
|
||||
276
src/infra/llm/prompts/travel_info_system.md
Normal file
276
src/infra/llm/prompts/travel_info_system.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# 角色定义
|
||||
|
||||
你是极度严谨合规的财务差旅信息提取助手。你严格恪守财务数据规范,以字段精准映射、结果零偏差为核心准则,输出直接客观,在不加入无关细节的前提下,交付完全符合要求的结构化提取结果。
|
||||
|
||||
你通常不会输出提取推导过程、数据来源说明与寒暄类话术,只返回严格匹配 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` 格式
|
||||
- 金额使用数字类型(非字符串)
|
||||
- 计数使用整数类型
|
||||
@@ -1,12 +1,17 @@
|
||||
"""
|
||||
报销全流程编排
|
||||
|
||||
将发票提取 → 浏览器填报串联为一条管道,
|
||||
将发票提取 → 差旅/普通信息提取 → 浏览器填报串联为一条管道,
|
||||
数据在内存中流转,同时生成 CSV 中间产物。
|
||||
|
||||
发票类型区分:
|
||||
- 差旅发票(train/hotel):不生成易耗品出库单,走差旅报销流程
|
||||
- 普通发票(general):生成易耗品出库单,走普通报销流程
|
||||
|
||||
数据流变更(2026-06-11):
|
||||
在发票提取和匹配完成后立即判断报销类型(差旅/普通),
|
||||
差旅调用 LLM 提取 travel_info.json,普通预留 normal_info.json。
|
||||
Bot 仅负责接收信息并填报,不再承担信息提取职责。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -14,46 +19,18 @@ from typing import Any
|
||||
|
||||
from . import get_logger
|
||||
from .config import load_config
|
||||
from .doc.extractor import extract_invoices
|
||||
from .doc.invoice import (
|
||||
save_application_json,
|
||||
save_invoice_csv,
|
||||
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.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
|
||||
|
||||
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 run_pipeline(
|
||||
step: str = "all",
|
||||
username: str | None = None,
|
||||
@@ -78,10 +55,12 @@ def run_pipeline(
|
||||
cache_path = Path(cache_dir) if cache_dir else project_dir / "scripts" / "data"
|
||||
|
||||
# --------------------------------------------------
|
||||
# Step 1: 发票提取
|
||||
# Step 1: 发票提取 + 类型判断 + 信息提取
|
||||
# --------------------------------------------------
|
||||
payment_records: list[dict[str, str]] | None = None
|
||||
groups: dict[str, list[dict[str, str]]] | None = None
|
||||
travel_info: dict[str, Any] | None = None
|
||||
normal_info: dict[str, Any] | None = None
|
||||
|
||||
if step in ("all", "invoice"):
|
||||
log.info("=" * 60)
|
||||
@@ -93,13 +72,11 @@ def run_pipeline(
|
||||
log.error("未提取到任何发票数据")
|
||||
return 1
|
||||
|
||||
save_payment_csv(payment_records, cache_path / "payment_records.csv")
|
||||
save_invoice_csv(payment_records, cache_path / "invoice_summary.csv")
|
||||
# 使用公共函数处理发票数据
|
||||
process_invoices(payment_records, applications, groups, cache_path)
|
||||
|
||||
if applications:
|
||||
save_application_json(applications, cache_path / "travel_applications.json")
|
||||
|
||||
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张")
|
||||
# 发票提取完成后立即判断类型并提取信息
|
||||
travel_info, normal_info = extract_info_by_type(groups, cache_path)
|
||||
|
||||
if step == "invoice":
|
||||
log.info("[1/2] 发票提取 完成")
|
||||
@@ -113,17 +90,24 @@ def run_pipeline(
|
||||
log.info("[2/2] 报销提交")
|
||||
log.info("=" * 60)
|
||||
|
||||
from .bot import run_bot
|
||||
from .infra.browser import run_bot
|
||||
|
||||
if groups is None:
|
||||
# 从缓存重新分类(仅 submit 阶段需要)
|
||||
from .pipeline_core import _classify_from_cache
|
||||
|
||||
groups = _classify_from_cache(cache_path)
|
||||
|
||||
if groups["travel"] and not groups["general"]:
|
||||
if is_travel_invoice(groups):
|
||||
if travel_info is None:
|
||||
travel_info = extract_and_cache_travel_info(groups, cache_path)
|
||||
log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
run_bot(config, work_dir=cache_path)
|
||||
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)
|
||||
log.info("检测到普通发票,使用普通报销模式")
|
||||
run_bot(config, work_dir=cache_path)
|
||||
run_bot(config, work_dir=cache_path, normal_info=normal_info)
|
||||
|
||||
if step == "submit":
|
||||
log.info("[2/2] 报销提交 完成")
|
||||
|
||||
220
src/pipeline_core.py
Normal file
220
src/pipeline_core.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
管道核心逻辑
|
||||
|
||||
抽取 pipeline.py(CLI 管道)和 pipeline_web.py(Web 管道)的公共数据流:
|
||||
发票分类判断 -> 差旅/普通信息提取 -> 缓存读写
|
||||
|
||||
两个入口分别传入不同的目录参数,复用此模块。
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
last_reviewed: 2026-06-09
|
||||
last_reviewed: 2026-06-13
|
||||
---
|
||||
|
||||
# src/web 模块设计说明
|
||||
@@ -11,36 +11,77 @@ last_reviewed: 2026-06-09
|
||||
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
|
||||
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
|
||||
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。
|
||||
- **统一文件上传**:2026-06-12 改造,将 PDF 和图片上传入口合并为单一上传区,用户通过一个入口上传所有文件类型。
|
||||
|
||||
## 变更历史
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-06-13 | LLM 流式思考过程展示:后端 SSE 推送 `llm_stream` 事件(start/chunk/end/error),前端聊天气泡实时展示 AI 思考过程 |
|
||||
| 2026-06-12 | 文件进度实时反馈:后端 SSE 推送 `file_progress` 事件(processing/done/cached/error),前端文件消息实时更新状态 + 展示提取摘要 |
|
||||
| 2026-06-12 | 配置交互改为逐项引导:config.json 缺失字段时 AI 逐个提示用户通过聊天输入,全部完成后自动开始处理 |
|
||||
| 2026-06-12 | 聊天窗口精简:移除 SSE 日志流显示,仅保留关键状态消息;config.json 配置不完整时 AI 主动提示缺失字段 |
|
||||
| 2026-06-12 | 移除开始处理按钮,改为自动触发:config.json 解析完成且配置完整(username/password)且有发票文件时自动开始处理 |
|
||||
| 2026-06-12 | 文件上传通知改为逐条消息:每个文件单独一条聊天气泡,上传框 flex 居中、固定高度 |
|
||||
| 2026-06-12 | 文件上传反馈移至聊天窗口:上传/拖拽/同步文件后以聊天气泡通知,上传框固定高度不再显示文件标签 |
|
||||
| 2026-06-12 | 修复聊天窗口消息覆盖问题:将消息区域与输入区域分离,消息区独立滚动,新增用户文本输入功能 |
|
||||
| 2026-06-12 | 合并 PDF/图片上传入口,`/api/files` 返回统一文件列表,前端使用 `allFiles` 单一数组管理 |
|
||||
| 2026-06-12 | 移除配置表单,config.json 通过统一上传入口自动解析,配置存入 `sessionConfig` 对象,前端不再展示配置输入框 |
|
||||
| 2026-06-12 | 暗色日志窗口替换为 AI 聊天风格窗口,SSE 日志以聊天气泡形式展示,支持打字指示器动画 |
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/web/
|
||||
├── app.py # Flask 应用入口,路由、管道编排、日志收集
|
||||
├── sse_handler.py # SSE 日志收集器、日志转义工具
|
||||
├── routes.py # 路由定义、SSE 端点
|
||||
├── templates/
|
||||
│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交)
|
||||
│ ├── index.html # PC 端主界面(上传、处理、编辑、提交)
|
||||
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
|
||||
└── static/
|
||||
├── css/
|
||||
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
|
||||
└── js/
|
||||
└── index.js # 前端逻辑(上传、SSE 日志、表格编辑、二维码同步)
|
||||
├── index.js # 入口:初始化 App 全局状态、绑定事件
|
||||
├── state.js # 全局状态管理(App 对象)
|
||||
├── chat.js # 聊天气泡渲染、文件消息、LLM 流式气泡
|
||||
├── agent.js # Agent 事件处理、用户输入管理
|
||||
├── process.js # SSE 连接、事件路由、管道启动
|
||||
├── config.js # 配置解析、逐项引导
|
||||
├── upload.js # 文件上传、拖拽处理
|
||||
├── sync.js # 移动端同步
|
||||
└── utils.js # HTML 转义等工具函数
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
用户上传文件 → 创建 session → 文件写入 uploads/<sid>/
|
||||
↓
|
||||
后台线程执行管道: extract_invoices() → enrich_with_llm() → save_csv()
|
||||
↓
|
||||
结果写入 session 目录: invoice_summary.csv / result.json / session.log
|
||||
↓
|
||||
前端 SSE 轮询 result.json 变化 → 显示完成状态
|
||||
↓
|
||||
前端加载 CSV 数据 → 可编辑表格展示 → 用户修改后保存
|
||||
↓
|
||||
用户点击提交 → run_financial_submit() → bot 自动填报财务系统
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户上传文件] --> B[创建 session]
|
||||
B --> C[文件写入 uploads/<sid>/]
|
||||
C --> D[后台线程执行管道]
|
||||
D --> E["extract_invoices()"]
|
||||
E --> F["enrich_with_llm()"]
|
||||
F --> G["save_csv()"]
|
||||
G --> H["结果写入 session 目录"]
|
||||
H --> I["invoice_summary.csv"]
|
||||
H --> J["invoice_groups.json"]
|
||||
H --> K["result.json"]
|
||||
H --> L["session.log"]
|
||||
E --> M{"判断报销类型"}
|
||||
M -->|差旅| N["extract_travel_info()"]
|
||||
M -->|普通| O["extract_normal_info()"]
|
||||
N --> P["travel_info.json"]
|
||||
O --> Q["normal_info.json"]
|
||||
K --> R["前端 SSE 轮询 → 显示完成状态"]
|
||||
R --> S["前端加载 CSV → 可编辑表格"]
|
||||
S --> T["用户修改后保存"]
|
||||
T --> U["用户点击提交"]
|
||||
U --> V["run_financial_submit()"]
|
||||
V --> W["bot 自动填报财务系统"]
|
||||
W --> X["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
|
||||
W --> Y["普通模式: normal_info.json → 基本信息 → 录入明细 → 支付信息 → 上传附件"]
|
||||
```
|
||||
|
||||
## API 路由
|
||||
@@ -50,8 +91,8 @@ src/web/
|
||||
| GET | `/` | 主界面 |
|
||||
| POST | `/api/session` | 创建会话,返回 session_id |
|
||||
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
|
||||
| GET | `/api/files/<sid>` | 列出会话文件 |
|
||||
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
|
||||
| GET | `/api/files/<sid>` | 列出会话文件(返回统一 `files` 列表,含 `name`、`type`、`size` 字段;旧字段 `pdfs`/`images` 保留向后兼容) |
|
||||
| POST | `/api/agent/process/<sid>` | 启动 Agent 管道(后台线程) |
|
||||
| GET | `/api/logs/<sid>` | SSE 日志流 |
|
||||
| GET | `/api/data/<sid>` | 获取发票数据 JSON |
|
||||
| POST | `/api/save/<sid>` | 保存前端编辑的发票数据 |
|
||||
@@ -63,19 +104,236 @@ src/web/
|
||||
|
||||
### 日志收集
|
||||
|
||||
`_SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
|
||||
`SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
|
||||
|
||||
### 发票类型分流
|
||||
|
||||
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
|
||||
- **普通发票**:生成易耗品出库单(Word 文档),走普通报销流程
|
||||
|
||||
`classify_invoice_batch()` 根据发票内容自动分类,`_try_fill_consumable_doc()` 仅对普通发票生成出库单。
|
||||
`extract_invoices()` 在提取阶段完成分类,结果保存为 `invoice_groups.json`(包含 `travel_count`、`general_count`、`application_count`)。后续步骤(出库单生成、财务填报)统一从该文件读取分类结果,避免重复解析 CSV 和 JSON 字段。
|
||||
|
||||
### LLM 信息提取(2026-06-11)
|
||||
|
||||
发票提取和匹配完成后,根据类型分流调用 LLM 提取结构化报销信息:
|
||||
|
||||
- **差旅发票**:调用 `extract_travel_info()`,提取出差事由、地点、时间、交通/住宿明细、补助清单、支付方式、附件清单,缓存为 `travel_info.json`。
|
||||
- **普通发票**:调用 `extract_normal_info()`,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`。
|
||||
|
||||
Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`),降级时从缓存加载原始发票数据。
|
||||
|
||||
### 移动端同步
|
||||
|
||||
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
|
||||
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的文件通过 `syncFiles()` 轮询同步到 PC 端内存中的 `allFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
|
||||
|
||||
### 配置管理
|
||||
|
||||
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。
|
||||
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端通过统一上传入口接收 `config.json`,自动解析到 `sessionConfig` 对象,不再展示配置输入表单。
|
||||
|
||||
### 聊天气泡消息系统
|
||||
|
||||
聊天窗口的所有消息通过 **事件文件 + SSE 轮询** 机制传输。后端不直接推送消息,而是将事件追加到 session 目录下的日志文件,SSE 端点以 0.5 秒间隔轮询文件增量,再通过 EventSource 推送到前端。
|
||||
|
||||
#### 事件文件总览
|
||||
|
||||
session 目录下有四个事件文件:
|
||||
|
||||
| 文件 | 用途 | 写入方 | 读取方 |
|
||||
|------|------|--------|--------|
|
||||
| `llm_stream.log` | LLM 流式输出(思考过程 + 正式回答) | `llm_extractor` 模块 | SSE 端点 |
|
||||
| `file_events.log` | 文件处理进度 | `extractor` 模块 | SSE 端点 |
|
||||
| `agent_events.log` | Agent 状态变更、请求补充等 | `orchestrator` 模块 | SSE 端点 |
|
||||
| `session.log` | 普通 INFO 日志 | `SSELogHandler` | SSE 端点(当前仅保留,前端已不做处理) |
|
||||
|
||||
#### 后端发送事件
|
||||
|
||||
所有事件文件遵循相同的写入协议:每行一个 JSON 对象,写入后 flush。
|
||||
|
||||
**`llm_stream.log`** — 由 `_emit_llm_stream(source_dir, phase, ...)` 写入:
|
||||
|
||||
```python
|
||||
# 开始 LLM 调用(必需)
|
||||
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
|
||||
|
||||
# 流式文本片段(可选,有内容时发)
|
||||
_emit_llm_stream(source_dir, "chunk", text="让我来分析...")
|
||||
|
||||
# 思考过程片段(可选,模型支持时发)
|
||||
_emit_llm_stream(source_dir, "reasoning", text="根据发票信息...")
|
||||
|
||||
# 调用完成(必需)
|
||||
_emit_llm_stream(source_dir, "end", label="分析完成")
|
||||
|
||||
# 调用失败(异常时发)
|
||||
_emit_llm_stream(source_dir, "error", error="连接超时")
|
||||
```
|
||||
|
||||
**`agent_events.log`** — 由 `_emit_agent_event(session_dir, event_type, ...)` 写入:
|
||||
|
||||
```python
|
||||
# 状态变更
|
||||
_emit_agent_event(session_dir, "agent_state_change", state="extracting", message="正在分析文件...")
|
||||
|
||||
# 请求补充材料
|
||||
_emit_agent_event(session_dir, "agent_request_supplement", message="请上传返程车票...")
|
||||
|
||||
# 信息完整,可以提交
|
||||
_emit_agent_event(session_dir, "agent_ready")
|
||||
|
||||
# 错误
|
||||
_emit_agent_event(session_dir, "agent_error", message="LLM 提取失败")
|
||||
```
|
||||
|
||||
**`file_events.log`** — 由 `_emit_file_event(source_dir, ...)` 写入:
|
||||
|
||||
```python
|
||||
# 文件开始处理
|
||||
_emit_file_event(source_dir, filename, "processing")
|
||||
|
||||
# 文件处理完成(带摘要)
|
||||
_emit_file_event(source_dir, filename, "done", summary={"invoice_number": "...", ...})
|
||||
|
||||
# 使用缓存
|
||||
_emit_file_event(source_dir, filename, "cached")
|
||||
|
||||
# 处理失败
|
||||
_emit_file_event(source_dir, filename, "error", error="PDF 解析失败")
|
||||
```
|
||||
|
||||
**重要约束:`start` 和 `end` 事件不可省略。** 前端 `llmStreamState` 状态机依赖 `start` 创建气泡 DOM,没有 `start` 时后续的 `chunk` 和 `reasoning` 会因守卫条件直接返回。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`。
|
||||
|
||||
#### SSE 传输层
|
||||
|
||||
`/api/logs/<sid>` 端点(`routes.py`)的轮询逻辑:
|
||||
|
||||
```
|
||||
每 0.5 秒:
|
||||
1. 读取 session.log 增量 → yield "data: <日志行>"
|
||||
2. 读取 file_events.log 增量 → 逐行 yield "data: <JSON>"
|
||||
3. 读取 llm_stream.log 增量 → 逐行 yield "data: <JSON>"
|
||||
4. 读取 agent_events.log 增量 → 逐行 yield "data: <JSON>"
|
||||
5. 检查 result.json 是否存在 → yield "data: {type: 'done'}" 后退出
|
||||
```
|
||||
|
||||
#### 前端事件路由
|
||||
|
||||
`process.js` 的 `EventSource` 监听器按 `msg.type` 分发:
|
||||
|
||||
```
|
||||
msg.type === 'file_progress' → setFileProcessing / setFileDone / setFileCached / setFileError
|
||||
msg.type === 'llm_stream' → handleLLMStream()
|
||||
msg.type 以 'agent_' 开头 → handleAgentEvent()
|
||||
msg.type === 'done' → 关闭 EventSource,展示结果
|
||||
```
|
||||
|
||||
#### 前端聊天气泡渲染
|
||||
|
||||
**LLM 流式气泡**(`chat.js`):
|
||||
|
||||
```
|
||||
start → _createLLMStreamBubble()
|
||||
├─ 创建 <div class="chat-bubble processing llm-stream-bubble">
|
||||
├─ 创建 label 元素(显示 label 文本)
|
||||
├─ 创建 <details> 可折叠区域(思考过程)
|
||||
└─ 创建 textContent 元素(正式回答)
|
||||
└─ 注册到 llmStreamState
|
||||
|
||||
reasoning → _appendLLMStreamReasoning()
|
||||
└─ 追加到 reasoningContent.textContent
|
||||
|
||||
chunk → _appendLLMStreamChunk()
|
||||
└─ 追加到 textContent.textContent
|
||||
|
||||
end → _closeLLMStreamBubble()
|
||||
├─ 气泡 class 从 processing 变为 done
|
||||
└─ 清空 llmStreamState
|
||||
|
||||
error → _errorLLMStreamBubble()
|
||||
├─ 气泡 class 变为 error
|
||||
└─ 清空 llmStreamState
|
||||
```
|
||||
|
||||
**Agent 状态气泡**(`agent.js`):
|
||||
|
||||
```
|
||||
agent_state_change → _handleAgentStateChange()
|
||||
└─ 更新最后一条状态消息(不追加新气泡)
|
||||
|
||||
agent_request_supplement → _handleAgentRequestSupplement()
|
||||
└─ 追加请求补充的气泡
|
||||
|
||||
agent_ready → _handleAgentReady()
|
||||
└─ 追加完成状态气泡
|
||||
|
||||
agent_error → _handleAgentError()
|
||||
└─ 追加错误气泡
|
||||
```
|
||||
|
||||
**文件进度气泡**(`chat.js`):
|
||||
|
||||
```
|
||||
file_progress (processing) → setFileProcessing() → 三点动画
|
||||
file_progress (done) → setFileDone() → 提取摘要
|
||||
file_progress (cached) → setFileCached() → 缓存标识
|
||||
file_progress (error) → setFileError() → 错误信息
|
||||
```
|
||||
|
||||
#### 完整数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Pipe as 后台线程<br/>(管道)
|
||||
participant Files as 事件文件<br/>(session 目录)
|
||||
participant SSE as Flask SSE<br/>(routes.py)
|
||||
participant ES as EventSource<br/>(process.js)
|
||||
participant Chat as chat.js
|
||||
participant Agent as agent.js
|
||||
|
||||
Note over Pipe: 启动管道
|
||||
Pipe->>Files: 追加 llm_stream start
|
||||
Pipe->>Files: 追加 agent_events state_change
|
||||
|
||||
Note over SSE: 0.5s 轮询
|
||||
SSE->>Files: seek(last_size) + read()
|
||||
SSE->>ES: yield "data: start"
|
||||
SSE->>ES: yield "data: state_change"
|
||||
|
||||
ES->>Chat: handleLLMStream({phase:"start"})
|
||||
Chat->>Chat: 创建流式气泡
|
||||
ES->>Agent: handleAgentEvent({type:"agent_state_change"})
|
||||
Agent->>Agent: 显示状态消息
|
||||
|
||||
Note over Pipe: LLM 流式输出
|
||||
Pipe->>Files: 追加 llm_stream reasoning
|
||||
Pipe->>Files: 追加 llm_stream chunk
|
||||
|
||||
SSE->>Files: seek(last_size) + read()
|
||||
SSE->>ES: yield "data: reasoning"
|
||||
SSE->>ES: yield "data: chunk"
|
||||
|
||||
ES->>Chat: handleLLMStream({phase:"reasoning"})
|
||||
Chat->>Chat: 追加思考内容
|
||||
ES->>Chat: handleLLMStream({phase:"chunk"})
|
||||
Chat->>Chat: 追加正式回答
|
||||
|
||||
Note over Pipe: 完成
|
||||
Pipe->>Files: 追加 llm_stream end
|
||||
Pipe->>Files: 写入 result.json
|
||||
|
||||
SSE->>Files: seek(last_size) + read()
|
||||
SSE->>ES: yield "data: end"
|
||||
ES->>Chat: handleLLMStream({phase:"end"})
|
||||
Chat->>Chat: 气泡变完成状态
|
||||
|
||||
SSE->>Files: 检测 result.json
|
||||
SSE->>ES: yield "data: {type:'done'}"
|
||||
ES->>ES: 关闭连接
|
||||
```
|
||||
|
||||
#### 添加新消息类型的步骤
|
||||
|
||||
1. 在对应模块定义 `_emit_xxx()` 函数,写入 session 目录的 JSON 文件
|
||||
2. 在 `routes.py` 的 `stream_logs()` 轮询循环中新增对该文件的轮询
|
||||
3. 在 `process.js` 的 EventSource 监听器中按 `msg.type` 路由到新处理器
|
||||
4. 在 `chat.js` 或 `agent.js` 中实现渲染逻辑
|
||||
5. 确保 `start` 和 `end` 事件成对出现(如果是流式气泡)
|
||||
5
src/web/__init__.py
Normal file
5
src/web/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Web 模块
|
||||
|
||||
提供财务报销系统的 Web 界面功能。
|
||||
"""
|
||||
702
src/web/app.py
702
src/web/app.py
@@ -12,710 +12,46 @@
|
||||
访问: http://localhost:5000
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
||||
|
||||
# 确保项目根目录在 sys.path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src import get_logger # noqa: E402, I001
|
||||
from src.config import load_config as load_project_config # noqa: E402, I001
|
||||
from src.doc.extractor import ( # noqa: E402, I001
|
||||
extract_invoices,
|
||||
)
|
||||
from src.doc.fill_consumable_doc import ( # noqa: E402, I001
|
||||
CONSUMABLE_DOC_FILENAME,
|
||||
fill_consumable_from_template,
|
||||
)
|
||||
from src.doc.invoice import ( # noqa: E402, I001
|
||||
load_csv,
|
||||
load_invoice_csv,
|
||||
save_csv as save_payment_csv,
|
||||
save_invoice_csv,
|
||||
save_application_json,
|
||||
)
|
||||
from flask import Flask # noqa: E402, I001
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
fill_log = get_logger("fill_consumable_doc")
|
||||
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
|
||||
from src.web import pipeline_web, routes # noqa: E402, I001
|
||||
from src.infra.documents import CONSUMABLE_DOC_FILENAME # noqa: E402, I001
|
||||
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
|
||||
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
|
||||
SESSION_LOG_FILE = "session.log"
|
||||
SESSION_RESULT_FILE = "result.json"
|
||||
|
||||
_app_initialized = False
|
||||
|
||||
# ================================================================
|
||||
# 日志收集器 — 捕获管道日志到文件,SSE 端点通过 tail -f 读取
|
||||
# ================================================================
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""应用工厂:初始化配置并注册路由"""
|
||||
global _app_initialized
|
||||
if _app_initialized:
|
||||
return app
|
||||
_app_initialized = True
|
||||
|
||||
class _SSELogHandler(logging.Handler):
|
||||
"""将日志写入指定文件(线程安全)"""
|
||||
# 设置出库单模板路径
|
||||
pipeline_web.set_consumable_template(PROJECT_ROOT / CONSUMABLE_DOC_FILENAME)
|
||||
|
||||
def __init__(self, log_path: Path):
|
||||
super().__init__()
|
||||
self._lock = threading.Lock()
|
||||
self._file = open(log_path, "w", encoding="utf-8")
|
||||
# 初始化路由配置
|
||||
routes.init_routes(UPLOAD_BASE)
|
||||
|
||||
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
|
||||
# 注册 Blueprint
|
||||
app.register_blueprint(routes.web_bp)
|
||||
|
||||
def close_file(self) -> None:
|
||||
try:
|
||||
self._file.close()
|
||||
except Exception:
|
||||
pass
|
||||
return app
|
||||
|
||||
|
||||
def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
||||
"""安装日志收集器到 app.* 模块"""
|
||||
log_path = session_dir / SESSION_LOG_FILE
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler = _SSELogHandler(log_path)
|
||||
handler.setFormatter(fmt)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _remove_log_collector(handler: _SSELogHandler) -> None:
|
||||
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
logging.getLogger(name).removeHandler(handler)
|
||||
handler.close_file()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 出库单填写
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _load_session_config(session_dir: Path) -> dict[str, Any]:
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_payment_csv(session_dir: Path) -> Path | None:
|
||||
"""查找支付记录 CSV(payment_records.csv)"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if csv_path.exists():
|
||||
return csv_path
|
||||
for f in session_dir.glob("*.csv"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
|
||||
"""查找发票级别 CSV(invoice_summary.csv)"""
|
||||
csv_path = session_dir / "invoice_summary.csv"
|
||||
if csv_path.exists():
|
||||
return csv_path
|
||||
for f in session_dir.glob("*.csv"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _has_general_invoices(rows: list[dict[str, str]]) -> bool:
|
||||
"""检查发票列表中是否包含普通发票(需要生成易耗品出库单)"""
|
||||
invoices = []
|
||||
for row in rows:
|
||||
# 支付记录格式:从 _invoices_json 还原
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if invoices_json:
|
||||
try:
|
||||
invoices.extend(json.loads(invoices_json))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 发票级别格式:直接使用
|
||||
elif "发票类型" in row:
|
||||
invoices.append(row)
|
||||
if not invoices:
|
||||
return False
|
||||
groups = _classify_invoice_batch(invoices)
|
||||
return len(groups["general"]) > 0
|
||||
|
||||
|
||||
def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]:
|
||||
"""统计发票类型分布"""
|
||||
invoices = []
|
||||
for row in rows:
|
||||
# 支付记录格式:从 _invoices_json 还原
|
||||
invoices_json = row.get("_invoices_json", "")
|
||||
if invoices_json:
|
||||
try:
|
||||
invoices.extend(json.loads(invoices_json))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 发票级别格式:直接使用
|
||||
elif "发票类型" in row:
|
||||
invoices.append(row)
|
||||
groups = _classify_invoice_batch(invoices)
|
||||
return {
|
||||
"travel_count": len(groups["travel"]),
|
||||
"general_count": len(groups["general"]),
|
||||
}
|
||||
|
||||
|
||||
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""根据 CSV 填写易耗品出库单,供会话目录下载。
|
||||
|
||||
仅当存在普通发票时才生成出库单。纯差旅发票跳过。
|
||||
"""
|
||||
if not CONSUMABLE_TEMPLATE.exists():
|
||||
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
|
||||
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
|
||||
|
||||
csv_path = _resolve_payment_csv(session_dir)
|
||||
if csv_path is None:
|
||||
return {"ok": False, "error": "未找到发票 CSV"}
|
||||
|
||||
# 检查是否有普通发票
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
return {"ok": False, "error": "CSV 读取失败"}
|
||||
|
||||
if not _has_general_invoices(rows):
|
||||
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
|
||||
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
|
||||
|
||||
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> 触发。
|
||||
"""
|
||||
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")
|
||||
|
||||
# 统计发票总数
|
||||
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]:
|
||||
"""执行财务系统填报(从前端确认后调用)
|
||||
|
||||
根据发票类型选择填报模式:
|
||||
- 纯差旅发票:差旅报销模式(TODO)
|
||||
- 含普通发票:普通报销模式
|
||||
"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if not csv_path.exists():
|
||||
return {"ok": False, "error": "未找到发票数据,请先处理"}
|
||||
|
||||
from src.bot import run_bot_web
|
||||
|
||||
# TODO: 改为从缓存读取或直接请求 LLM,与差旅报销保持一致
|
||||
# bot_invoices = load_invoice_data(str(csv_path), config)
|
||||
|
||||
# 判断发票类型
|
||||
rows = load_csv(csv_path)
|
||||
if rows:
|
||||
invoice_groups = _get_invoice_groups(rows)
|
||||
if invoice_groups["travel_count"] and not invoice_groups["general_count"]:
|
||||
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
# TODO: 差旅报销填报流程
|
||||
else:
|
||||
fill_log.info("检测到普通发票,使用普通报销模式")
|
||||
|
||||
run_bot_web(config, session_dir)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Flask 路由
|
||||
# ================================================================
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index() -> Any:
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/session", methods=["POST"])
|
||||
def create_session() -> Any:
|
||||
"""创建上传会话,返回 session_id"""
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
session_dir = UPLOAD_BASE / sid
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
return jsonify({"session_id": sid})
|
||||
|
||||
|
||||
@app.route("/api/upload/<session_id>", methods=["POST"])
|
||||
def upload_file(session_id: str) -> Any:
|
||||
"""上传 PDF 或图片"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
return jsonify({"error": "未选择文件"}), 400
|
||||
|
||||
safe_name = Path(f.filename).name
|
||||
f.save(str(session_dir / safe_name))
|
||||
return jsonify({"ok": True, "filename": safe_name})
|
||||
|
||||
|
||||
@app.route("/api/files/<session_id>", methods=["GET"])
|
||||
def list_files(session_id: str) -> Any:
|
||||
"""列出会话目录中的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
|
||||
imgs = sorted(f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} for f in session_dir.glob(f"*{ext}"))
|
||||
return jsonify({"pdfs": pdfs, "images": imgs})
|
||||
|
||||
|
||||
@app.route("/api/process/<session_id>", methods=["POST"])
|
||||
def start_process(session_id: str) -> Any:
|
||||
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
# 读取配置
|
||||
config = _build_web_config(body)
|
||||
|
||||
# 写入配置到会话目录
|
||||
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
# 在后台线程执行
|
||||
handler = _install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
result = run_pipeline_web(session_dir, config)
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
_remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@app.route("/api/logs/<session_id>")
|
||||
def stream_logs(session_id: str) -> Any:
|
||||
"""SSE 日志流"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
def generate() -> Any:
|
||||
# 先发送已有日志
|
||||
log_file = session_dir / SESSION_LOG_FILE
|
||||
last_size = 0
|
||||
start_time = time.time()
|
||||
timeout = 600 # 10 分钟超时
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
if log_file.exists():
|
||||
current_size = log_file.stat().st_size
|
||||
if current_size > last_size:
|
||||
with open(log_file, encoding="utf-8", errors="replace") as f:
|
||||
f.seek(last_size)
|
||||
chunk = f.read()
|
||||
if chunk:
|
||||
yield f"data: {_escape_sse(chunk)}\n\n"
|
||||
last_size = current_size
|
||||
|
||||
# 也通过队列发送实时日志
|
||||
# 检查是否完成
|
||||
result_file = session_dir / SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
with open(result_file, encoding="utf-8") as f:
|
||||
result = json.load(f)
|
||||
yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
|
||||
break
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/download/<session_id>/<filename>")
|
||||
def download_file(session_id: str, filename: str) -> Any:
|
||||
"""下载生成的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 防止路径穿越
|
||||
safe_name = Path(filename).name
|
||||
filepath = session_dir / safe_name
|
||||
if not filepath.exists():
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
|
||||
if safe_name.endswith(".doc"):
|
||||
mimetype = "application/msword"
|
||||
elif safe_name.endswith(".csv"):
|
||||
mimetype = "text/csv; charset=utf-8"
|
||||
else:
|
||||
mimetype = "application/octet-stream"
|
||||
|
||||
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
|
||||
return Response(
|
||||
filepath.read_bytes(),
|
||||
mimetype=mimetype,
|
||||
headers={"Content-Disposition": disposition},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/config/<session_id>", methods=["GET"])
|
||||
def get_session_config(session_id: str) -> Any:
|
||||
"""获取当前会话的配置(供前端回填表单)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
# 只返回前端需要的字段
|
||||
return jsonify(
|
||||
{
|
||||
"username": config.get("username", ""),
|
||||
"password": "", # 不返回密码
|
||||
"default_name": config.get("default_name", ""),
|
||||
"default_card_no": config.get("default_card_no", ""),
|
||||
"default_person_id": config.get("default_person_id", ""),
|
||||
"consumable_storage": config.get("consumable_storage", ""),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/data/<session_id>", methods=["GET"])
|
||||
def get_invoice_data(session_id: str) -> Any:
|
||||
"""读取发票数据并返回 JSON(供前端表格编辑)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 优先读取支付记录 CSV
|
||||
payment_csv = session_dir / "payment_records.csv"
|
||||
if payment_csv.exists():
|
||||
rows = load_csv(payment_csv)
|
||||
if rows is not None:
|
||||
data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry: dict[str, Any] = dict(row)
|
||||
entry["__row"] = i
|
||||
data.append(entry)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
|
||||
|
||||
# 回退到发票级别 CSV
|
||||
invoice_csv = session_dir / "invoice_summary.csv"
|
||||
if invoice_csv.exists():
|
||||
rows = load_invoice_csv(invoice_csv)
|
||||
if rows is not None:
|
||||
invoice_data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry2: dict[str, Any] = dict(row)
|
||||
entry2["__row"] = i
|
||||
invoice_data.append(entry2)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
|
||||
|
||||
# 最后尝试任意 CSV
|
||||
csv_files = list(session_dir.glob("*.csv"))
|
||||
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
|
||||
if csv_files:
|
||||
csv_path = csv_files[0]
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
rows = load_invoice_csv(csv_path)
|
||||
if rows is not None:
|
||||
fallback_data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry3: dict[str, Any] = dict(row)
|
||||
entry3["__row"] = i
|
||||
fallback_data.append(entry3)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
|
||||
|
||||
return jsonify({"error": "未找到发票数据,请先处理"}), 404
|
||||
|
||||
|
||||
@app.route("/api/save/<session_id>", methods=["POST"])
|
||||
def save_invoice_data(session_id: str) -> Any:
|
||||
"""保存前端编辑后的发票数据到 CSV"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
data = body.get("data", [])
|
||||
csv_filename = body.get("csv_filename", "invoice_summary.csv")
|
||||
|
||||
csv_path = session_dir / csv_filename
|
||||
if not csv_path.exists():
|
||||
return jsonify({"error": "CSV 文件不存在"}), 404
|
||||
|
||||
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys)
|
||||
original_rows = load_csv(csv_path)
|
||||
if original_rows is None or len(original_rows) == 0:
|
||||
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
|
||||
|
||||
# 从原数据中获取字段顺序(去掉内部字段)
|
||||
fieldnames = list(original_rows[0].keys())
|
||||
|
||||
import csv as csv_module
|
||||
|
||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for entry in data:
|
||||
row = {k: entry.get(k, "") for k in fieldnames}
|
||||
writer.writerow(row)
|
||||
|
||||
resp: dict[str, str | bool | None] = {"ok": True}
|
||||
config = _load_session_config(session_dir)
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
resp["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
resp["doc_ok"] = None
|
||||
resp["doc_skipped"] = True
|
||||
else:
|
||||
resp["doc_ok"] = False
|
||||
resp["doc_error"] = doc_fill.get("error") or ""
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
|
||||
def submit_financial(session_id: str) -> Any:
|
||||
"""手动触发财务系统填报"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 读取配置
|
||||
config_path = session_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# 清除上次处理留下的结果文件,避免 SSE 误判为已完成
|
||||
result_file = session_dir / SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
result_file.unlink()
|
||||
|
||||
# 在后台线程执行提交
|
||||
handler = _install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
submit_result = run_financial_submit(session_dir, config)
|
||||
if submit_result.get("ok"):
|
||||
result = {"ok": True}
|
||||
else:
|
||||
result = submit_result
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
_remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 辅助函数
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return jsonify({"error": "会话不存在"}), 404
|
||||
return session_dir
|
||||
|
||||
|
||||
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从请求体构建配置"""
|
||||
config = load_project_config()
|
||||
for key in (
|
||||
"username",
|
||||
"password",
|
||||
"default_name",
|
||||
"default_card_no",
|
||||
"default_person_id",
|
||||
"consumable_storage",
|
||||
):
|
||||
if body.get(key):
|
||||
config[key] = body[key]
|
||||
return config
|
||||
|
||||
|
||||
def _escape_sse(text: str) -> str:
|
||||
"""SSE 数据转义,同时处理 Windows 行尾 \\r\\n"""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
|
||||
|
||||
|
||||
@app.route("/mobile/<session_id>")
|
||||
def mobile_upload(session_id: str) -> Any:
|
||||
"""移动端上传页面"""
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return render_template("mobile_upload.html", error="会话不存在"), 404
|
||||
return render_template("mobile_upload.html", session_id=session_id)
|
||||
|
||||
|
||||
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
|
||||
def mobile_upload_file(session_id: str) -> Any:
|
||||
"""移动端上传图片(复用 PC 上传逻辑)"""
|
||||
return upload_file(session_id)
|
||||
|
||||
# 直接使用 app 实例(保持向后兼容)
|
||||
create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
190
src/web/pipeline_web.py
Normal file
190
src/web/pipeline_web.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
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:
|
||||
"""查找支付记录 CSV(payment_records.csv)"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if csv_path.exists():
|
||||
return csv_path
|
||||
for f in session_dir.glob("*.csv"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def resolve_invoice_csv(session_dir: Path) -> Path | None:
|
||||
"""查找发票级别 CSV(invoice_summary.csv)"""
|
||||
csv_path = session_dir / "invoice_summary.csv"
|
||||
if csv_path.exists():
|
||||
return csv_path
|
||||
for f in session_dir.glob("*.csv"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 出库单填写
|
||||
# ================================================================
|
||||
|
||||
# 在模块加载时确定模板路径(由 app.py 传入 PROJECT_ROOT)
|
||||
_consumable_template: Path | None = None
|
||||
|
||||
|
||||
def set_consumable_template(template_path: Path) -> None:
|
||||
"""设置出库单模板路径(由 app.py 在启动时调用)"""
|
||||
global _consumable_template
|
||||
_consumable_template = template_path
|
||||
|
||||
|
||||
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""根据 CSV 填写易耗品出库单,供会话目录下载。
|
||||
|
||||
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
|
||||
"""
|
||||
template = _consumable_template
|
||||
if template is None or not template.exists():
|
||||
fill_log.warning("出库单模板不存在: %s", template)
|
||||
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
|
||||
|
||||
groups = load_invoice_groups(session_dir)
|
||||
if groups is None:
|
||||
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
|
||||
|
||||
if not groups.get("general_count", 0):
|
||||
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
|
||||
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
|
||||
|
||||
csv_path = resolve_payment_csv(session_dir)
|
||||
if csv_path is None:
|
||||
return {"ok": False, "error": "未找到发票 CSV"}
|
||||
|
||||
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
|
||||
try:
|
||||
fill_log.info("开始填写出库单: %s", out_doc.name)
|
||||
fill_consumable_from_template(csv_path, template, out_doc, config=config)
|
||||
fill_log.info("出库单填写完成")
|
||||
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
|
||||
except ImportError:
|
||||
fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32")
|
||||
return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"}
|
||||
except Exception as e:
|
||||
fill_log.exception("填写出库单失败: %s", e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
|
||||
"""将出库单下载信息追加到结果字典"""
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
result["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
result["doc_ok"] = None
|
||||
result["doc_skipped"] = True
|
||||
result["doc_message"] = doc_fill.get("error", "")
|
||||
else:
|
||||
result["doc_ok"] = False
|
||||
result["doc_error"] = doc_fill.get("error", "未知错误")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 管道入口
|
||||
# ================================================================
|
||||
|
||||
|
||||
def run_pipeline_web(session_dir: Path, config: dict[str, Any] | 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)}
|
||||
822
src/web/routes.py
Normal file
822
src/web/routes.py
Normal file
@@ -0,0 +1,822 @@
|
||||
"""
|
||||
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.json,SSE 端点检测到后发射 done 事件
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
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"})
|
||||
85
src/web/sse_handler.py
Normal file
85
src/web/sse_handler.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
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: ")
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
last_reviewed: 2026-06-12
|
||||
---
|
||||
|
||||
# src/web/static — 静态资源目录
|
||||
@@ -15,4 +15,12 @@ last_reviewed: 2026-06-11
|
||||
|
||||
## 技术栈
|
||||
|
||||
原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。
|
||||
原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。
|
||||
|
||||
## 变更记录
|
||||
|
||||
- **2026-06-12**:修复配置收集流程的异步时序问题
|
||||
- `parseConfigFile` 改为返回 Promise,`handleFiles` 和拖拽 `drop` 处理器改为 `async/await`,确保 config.json 解析完成后再执行检查,消息显示顺序正确
|
||||
- `sendUserMessage` 修复变量名错误(`pendingConfigKeys` → `pendingConfigKey`),修复了配置收集卡死的问题
|
||||
- 拆分 `checkAutoStart` 为两个函数:`checkAutoStart` 仅做静默检查(由 `syncFiles` 轮询调用),`promptMissingConfig` 负责配置提示(由用户主动上传完成后调用),避免轮询提前触发配置提示导致消息乱序
|
||||
- 移除所有聊天消息的删除逻辑,聊天窗口保留完整历史(欢迎消息、文件通知、配置交互、处理结果均不删除)
|
||||
@@ -1,16 +1,241 @@
|
||||
body { background: #f5f7fa; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
|
||||
body {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.upload-zone {
|
||||
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
|
||||
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
|
||||
cursor: pointer; transition: all .2s; background: #fff; height: 100px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
|
||||
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
|
||||
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
|
||||
.upload-zone .icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.upload-zone .icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 可折叠上传面板(在文档流中,向上推挤聊天窗口) */
|
||||
.upload-panel {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.upload-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
background: #fafbfc;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.upload-panel-header:hover {
|
||||
background: #f0f2ff;
|
||||
}
|
||||
.upload-panel-toggle {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.upload-panel-toggle.collapsed {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.upload-panel-body {
|
||||
padding: 16px;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
|
||||
max-height: 400px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.upload-panel-body.collapsed {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
|
||||
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
|
||||
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-container .empty { color: #666; font-style: italic; }
|
||||
.chat-container {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
flex: 1 1 0;
|
||||
min-height: 300px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scroll-behavior: smooth;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.chat-input-area input:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.chat-input-area button {
|
||||
border-radius: 20px;
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-avatar img {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 85%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-bubble.system {
|
||||
background: #f0f2f5;
|
||||
color: #333;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble.processing {
|
||||
background: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble.success {
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble.error {
|
||||
background: #fce8e6;
|
||||
color: #c5221f;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble.done {
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
border-bottom-left-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-bubble.user {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* 打字指示器 */
|
||||
.typing-indicator {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #667eea;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
|
||||
.btn-process { font-size: 17px; padding: 10px 40px; }
|
||||
.status-badge { font-size: 13px; }
|
||||
@@ -21,4 +246,347 @@ body { background: #f5f7fa; }
|
||||
.table-editable td { vertical-align: middle; }
|
||||
.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; }
|
||||
.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; }
|
||||
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
|
||||
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
|
||||
|
||||
/* ================================================================ */
|
||||
/* 文件消息样式 */
|
||||
/* ================================================================ */
|
||||
|
||||
.file-bubble {
|
||||
background: #fafbfc;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-bottom-left-radius: 4px;
|
||||
transition: border-color 0.3s, background 0.3s;
|
||||
}
|
||||
|
||||
.file-bubble.file-processing {
|
||||
background: #f0f4ff;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.file-bubble.file-done {
|
||||
background: #f0fff4;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.file-bubble.file-error {
|
||||
background: #fff5f5;
|
||||
border-color: #c5221f;
|
||||
}
|
||||
|
||||
.file-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.file-status-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 文件消息的三点动画(与文件名同一行) */
|
||||
.file-typing-indicator {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.file-typing-indicator span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #667eea;
|
||||
animation: fileTyping 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.file-typing-indicator span:nth-child(1) { animation-delay: 0s; }
|
||||
.file-typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.file-typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes fileTyping {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 文件详情区域 */
|
||||
.file-detail {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #ddd;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.detail-value.error-text {
|
||||
color: #c5221f;
|
||||
}
|
||||
|
||||
/* ================================================================ */
|
||||
/* LLM 流式聊天气泡样式 */
|
||||
/* ================================================================ */
|
||||
|
||||
.llm-stream-bubble {
|
||||
background: #f0f4ff;
|
||||
border: 1px solid #667eea;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.llm-stream-bubble.done {
|
||||
background: #e6f4ea;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.llm-stream-bubble.error {
|
||||
background: #fce8e6;
|
||||
border-color: #c5221f;
|
||||
}
|
||||
|
||||
.llm-stream-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.llm-stream-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 可折叠的思考过程区域 */
|
||||
.llm-reasoning-section {
|
||||
margin: 6px 0;
|
||||
padding: 4px 0;
|
||||
border-top: 1px dashed #ddd;
|
||||
border-bottom: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
.llm-reasoning-summary {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.llm-reasoning-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.llm-reasoning-summary::before {
|
||||
content: '▶';
|
||||
font-size: 8px;
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.llm-reasoning-section[open] > .llm-reasoning-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.llm-reasoning-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #777;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
margin-top: 4px;
|
||||
background: #fafbfc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ================================================================ */
|
||||
/* Agent 请求面板样式 */
|
||||
/* ================================================================ */
|
||||
|
||||
.agent-request-panel {
|
||||
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin: 8px 0;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.agent-request-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #856404;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.agent-request-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-request-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.agent-request-materials {
|
||||
font-size: 13px;
|
||||
color: #856404;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.material-tag {
|
||||
display: inline-block;
|
||||
background: #ffc107;
|
||||
color: #000;
|
||||
border-radius: 4px;
|
||||
padding: 2px 10px;
|
||||
margin: 2px 4px 2px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.agent-request-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agent-request-actions .btn {
|
||||
font-size: 13px;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
/* ================================================================ */
|
||||
/* 提取结果摘要卡片样式 */
|
||||
/* ================================================================ */
|
||||
|
||||
.extraction-summary {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #ccc;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-label {
|
||||
color: #888;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-value {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-table th {
|
||||
background: #f5f6f8;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-table td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: #333;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-table tbody tr:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-attachment-list {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 20px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-attachment-item::marker {
|
||||
color: #999;
|
||||
font-size: 10px;
|
||||
}
|
||||
1
src/web/static/icon/agent.svg
Normal file
1
src/web/static/icon/agent.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319741857" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1691" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M690.185 275.766c88.365 0 160 71.634 160 160v239.488c0 88.365-71.635 160-160 160H339.766c-88.366 0-160-71.635-160-160V435.766c0-88.366 71.634-160 160-160h350.419z m-350.419 64c-53.02 0-96 42.98-96 96v239.488c0 53.019 42.98 96 96 96h350.419c53.019 0 96-42.981 96-96V435.766c0-53.02-42.981-96-96-96H339.766z m56.28 120.882c52.056 0.001 94.256 42.201 94.256 94.257 0 52.057-42.2 94.256-94.256 94.256s-94.257-42.199-94.257-94.256c0-52.056 42.2-94.257 94.257-94.257z m240.348 0c52.056 0.001 94.255 42.201 94.255 94.257 0 52.057-42.199 94.256-94.255 94.256-52.057 0-94.257-42.199-94.257-94.256 0-52.056 42.2-94.257 94.257-94.257zM128 481.562c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m769.113 0c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m-501.067 43.086c-16.71 0-30.257 13.547-30.257 30.257s13.547 30.256 30.257 30.256 30.256-13.546 30.256-30.256-13.546-30.256-30.256-30.257z m240.348 0c-16.711 0-30.257 13.547-30.257 30.257s13.546 30.256 30.257 30.256c16.71 0 30.255-13.546 30.255-30.256s-13.545-30.256-30.255-30.257zM556.931 192c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32h-83.908c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32h83.908z" fill="#1296db" p-id="1692"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
src/web/static/icon/file.svg
Normal file
1
src/web/static/icon/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319878861" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10502" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M716.8 704h-51.2v-25.6h38.4V140.8H320v38.4h-25.6v-51.2a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8zM806.4 614.4h-51.2v-25.6h38.4V230.4h25.6v371.2a12.8 12.8 0 0 1-12.8 12.8zM793.6 179.2h25.6v25.6h-25.6z" fill="#1296db" p-id="10503"></path><path d="M819.2 153.6h-25.6V51.2H409.6v38.4h-25.6V38.4a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v115.2zM627.2 793.6H217.6a12.8 12.8 0 0 1-12.8-12.8V217.6a12.8 12.8 0 0 1 12.8-12.8h243.2v25.6H230.4v537.6h384V230.4h-76.8v-25.6h89.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8z" fill="#1296db" p-id="10504"></path><path d="M486.4 204.8h25.6v25.6h-25.6zM268.8 448h307.2v25.6H268.8zM268.8 524.8h307.2v25.6H268.8zM512 601.6h64v25.6h-64zM460.8 601.6h25.6v25.6h-25.6zM268.8 601.6h166.4v25.6H268.8zM268.8 678.4h307.2v25.6H268.8zM268.8 294.4h307.2v25.6H268.8zM409.6 371.2h166.4v25.6H409.6zM358.4 371.2h25.6v25.6h-25.6zM268.8 371.2h64v25.6h-64z" fill="#1296db" p-id="10505"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/web/static/icon/folder.svg
Normal file
1
src/web/static/icon/folder.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319817389" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6095" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M912 208H427.872l-50.368-94.176A63.936 63.936 0 0 0 321.056 80H112c-35.296 0-64 28.704-64 64v736c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-608c0-35.296-28.704-64-64-64z m-800-64h209.056l68.448 128H912v97.984c-0.416 0-0.8-0.128-1.216-0.128H113.248c-0.416 0-0.8 0.128-1.248 0.128V144z m0 736v-96l1.248-350.144 798.752 1.216V784h0.064v96H112z" fill="#1296db" p-id="6096"></path></svg>
|
||||
|
After Width: | Height: | Size: 727 B |
1
src/web/static/icon/gallery.svg
Normal file
1
src/web/static/icon/gallery.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319921047" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14940" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M746.6496 198.4a129.6384 129.6384 0 0 1 129.536 124.0064l0.1024 5.632v367.9232a129.6384 129.6384 0 0 1-124.0064 129.536l-5.632 0.1024H306.9184a129.6384 129.6384 0 0 1-129.536-124.0064l-0.128-5.632v-57.9328c0-7.4752 2.6368-14.6944 7.3728-20.4032l2.5344-2.7136 131.84-126.0544a32 32 0 0 1 35.4304-5.9648l3.1488 1.664 116.0704 69.5808 74.7008-66.0992a32 32 0 0 1 35.072-4.864l3.072 1.6896 177.1008 110.6688a32 32 0 0 1-30.848 55.9616l-3.072-1.6896-156.8256-97.9968-74.3424 65.792a32 32 0 0 1-34.6112 5.12l-3.072-1.6384-115.2512-69.0944-104.32 99.712v44.2624a65.6384 65.6384 0 0 0 61.4912 65.5104l4.1728 0.128h439.7312a65.6384 65.6384 0 0 0 65.5104-61.4912l0.128-4.1472V328.0384a65.6384 65.6384 0 0 0-61.4912-65.5104l-4.1472-0.128H306.9184a65.6384 65.6384 0 0 0-65.536 61.4912l-0.128 4.1472v134.3488a32 32 0 0 1-63.8208 3.2768l-0.1792-3.2768v-134.3488a129.6384 129.6384 0 0 1 124.032-129.536l5.632-0.1024h439.7312z" fill="#FB553C" p-id="14941"></path><path d="M692.5312 398.3104m-47.4112 0a47.4112 47.4112 0 1 0 94.8224 0 47.4112 47.4112 0 1 0-94.8224 0Z" fill="#FB553C" p-id="14942"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/web/static/icon/phone.svg
Normal file
1
src/web/static/icon/phone.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319971806" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19425" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M820.409449 797.228346q0 25.19685-10.07874 46.866142t-27.716535 38.299213-41.322835 26.204724-50.897638 9.574803l-357.795276 0q-27.212598 0-50.897638-9.574803t-41.322835-26.204724-27.716535-38.299213-10.07874-46.866142l0-675.275591q0-25.19685 10.07874-47.370079t27.716535-38.80315 41.322835-26.204724 50.897638-9.574803l357.795276 0q27.212598 0 50.897638 9.574803t41.322835 26.204724 27.716535 38.80315 10.07874 47.370079l0 675.275591zM738.771654 170.330709l-455.559055 0 0 577.511811 455.559055 0 0-577.511811zM510.992126 776.062992q-21.165354 0-36.787402 15.11811t-15.622047 37.291339q0 21.165354 15.622047 36.787402t36.787402 15.622047q22.173228 0 37.291339-15.622047t15.11811-36.787402q0-22.173228-15.11811-37.291339t-37.291339-15.11811zM591.622047 84.661417q0-8.062992-5.03937-12.598425t-11.086614-4.535433l-128 0q-5.03937 0-10.582677 4.535433t-5.543307 12.598425 5.03937 12.598425 11.086614 4.535433l128 0q6.047244 0 11.086614-4.535433t5.03937-12.598425z" p-id="19426" fill="#1296db"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/web/static/icon/user.svg
Normal file
1
src/web/static/icon/user.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.5 KiB |
186
src/web/static/js/README.md
Normal file
186
src/web/static/js/README.md
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
last_reviewed: 2026-06-16
|
||||
---
|
||||
|
||||
# src/web/static/js — 前端逻辑模块
|
||||
|
||||
## 概述
|
||||
|
||||
财务报销自动化系统的前端入口脚本,按功能拆分为多个模块文件,通过 `App` 共享状态对象通信。采用原生 JavaScript 编写,通过 HTTP 请求与 Flask 后端通信,使用 SSE 接收处理进度。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `state.js` | 全局状态管理 (`App` 对象,含处理状态机) |
|
||||
| `utils.js` | 工具函数 (HTML 转义、会话管理) |
|
||||
| `chat.js` | 聊天窗口消息管理(含文件消息、处理进度、三点动画、LLM 流式气泡) |
|
||||
| `upload.js` | 文件上传(含拖拽、config.json 解析) |
|
||||
| `config.js` | 配置收集与校验(基于状态机的自动触发) |
|
||||
| `process.js` | 发票处理流程与 SSE 监听(含 file_progress、llm_stream 事件处理) |
|
||||
| `agent.js` | Agent 交互(含 supplement 流程、强制提交、用户文字补充、自动提交) |
|
||||
| `sync.js` | 移动端同步轮询(状态感知模式) |
|
||||
| `index.js` | 入口初始化 |
|
||||
|
||||
## 加载顺序
|
||||
|
||||
```
|
||||
state.js → utils.js → chat.js → upload.js → config.js → process.js → sync.js → index.js
|
||||
```
|
||||
|
||||
## 模块依赖关系
|
||||
|
||||
```
|
||||
state.js (无依赖)
|
||||
│
|
||||
├── utils.js ────────────────┐
|
||||
│ │
|
||||
├── chat.js ─────────────────┐ │
|
||||
│ │ │
|
||||
├── upload.js ──────────────┐ │ │
|
||||
│ │ │ │
|
||||
├── config.js ─────────────┐ │ │ │
|
||||
│ │ │ │ │
|
||||
├── process.js ───────────┐ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
├── sync.js ──────────┐ │ │ │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
└── index.js ────────┘ │ │ │ │ │ │
|
||||
```
|
||||
|
||||
## 处理状态机
|
||||
|
||||
`App.processState` 控制何时触发自动处理,避免重复执行:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle
|
||||
|
||||
idle --> processing: 配置完整 + 有文件
|
||||
processing --> done: 处理完成
|
||||
processing --> awaiting_supplement: Agent 要求补充材料
|
||||
|
||||
done --> idle: 有新文件 / forceStart = true
|
||||
done --> done: 无新文件(保持,不重复)
|
||||
|
||||
awaiting_supplement --> idle: 用户补充文件(只上传新文件)
|
||||
|
||||
idle --> processing: idle + newFilenames > 0(走 supplement 流程)
|
||||
idle --> awaiting_files: AI 判断文件不足(未来功能)
|
||||
awaiting_files --> idle: 用户补充文件
|
||||
awaiting_files --> processing: 用户说"直接开始" (forceStart = true)
|
||||
```
|
||||
|
||||
状态字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `processState` | string | 当前处理状态:idle / processing / done / awaiting_files / awaiting_supplement |
|
||||
| `lastProcessedFileCount` | number | 上次处理时的文件数,用于检测是否有新文件 |
|
||||
| `forceStart` | boolean | 用户强制开始标记(兼容"文件不足也要处理"的场景) |
|
||||
| `newFilenames` | array | 待补充上传的文件名列表,用于区分首次处理和补充处理 |
|
||||
|
||||
## 核心流程
|
||||
|
||||
### 1. 文件上传
|
||||
|
||||
```
|
||||
用户上传文件 → addFileMessage() → 注册到 App.fileMessageMap → promptMissingConfig()
|
||||
→ processState 为 done 时重置为 idle(支持增量上传后重新处理)
|
||||
```
|
||||
|
||||
### 2. 配置收集
|
||||
|
||||
```
|
||||
promptMissingConfig() → 检查 CONFIG_FIELDS → 有缺失则提示 → 用户输入 → sendUserMessage() → 递归直到完整
|
||||
```
|
||||
|
||||
### 3. 自动触发检查 (checkAutoStart)
|
||||
|
||||
```
|
||||
checkAutoStart() → 状态机判断:
|
||||
- processing → 跳过
|
||||
- done + 无新文件 → 跳过(修复重复执行问题)
|
||||
- done + 有新文件 → 走 supplement 流程(不再走 startProcess)
|
||||
- idle + newFilenames > 0 → 走 supplement 流程
|
||||
- idle + 配置完整 + 有文件 → 走 startProcess(首次处理)
|
||||
- awaiting_files + forceStart → 触发
|
||||
```
|
||||
|
||||
### 4. 发票处理(首次)
|
||||
|
||||
```
|
||||
startProcess() → ensureSession → 上传全部文件 → /api/agent/process → SSE 监听
|
||||
→ 收到 file_progress(processing) → setFileProcessing()(三点动画)
|
||||
→ 收到 file_progress(done) → setFileDone()(展示提取摘要)
|
||||
→ 收到 file_progress(cached) → setFileCached()
|
||||
→ 收到 file_progress(error) → setFileError()
|
||||
→ 收到 llm_stream(start) → handleLLMStream() 创建流式气泡
|
||||
→ 收到 llm_stream(chunk) → handleLLMStream() 逐字追加 AI 输出
|
||||
→ 收到 llm_stream(end) → handleLLMStream() 关闭流式气泡
|
||||
→ 收到 done → 设置 processState = 'done' + 记录文件数 → 显示汇总结果
|
||||
```
|
||||
|
||||
### 5. 补充文件处理(新增)
|
||||
|
||||
当 Agent 返回 `awaiting_supplement` 状态时,用户补充文件的流程:
|
||||
|
||||
```
|
||||
用户上传补充文件
|
||||
→ handleFiles() 检测 processState === 'awaiting_supplement'
|
||||
→ _uploadNewFiles() 只上传新文件(不再重新上传所有文件)
|
||||
→ App.newFilenames = [新文件名列表]
|
||||
→ App.processState = 'idle'
|
||||
→ syncFiles() 3 秒后触发 checkAutoStart()
|
||||
→ checkAutoStart() 检测到 idle + newFilenames > 0
|
||||
→ handleSupplementUpload(filenames)
|
||||
→ POST /api/agent/supplement/(后端会重新 extract_invoices,旧文件走缓存)
|
||||
→ 新文件走 LLM 提取,旧文件命中 JSON 缓存直接返回
|
||||
→ run_agent_round() 重新分析
|
||||
→ SSE 返回 done → 根据结果进入 awaiting_supplement 或 done 状态
|
||||
```
|
||||
|
||||
**关键区别**:补充文件走 `/api/agent/supplement/` 而非 `/api/agent/process/`,后者在已有 agent_session 时会跳过提取阶段。
|
||||
|
||||
## 数据流
|
||||
|
||||
### 配置数据流
|
||||
|
||||
```
|
||||
config.json → parseConfigFile → App.sessionConfig → startProcess → /api/agent/process
|
||||
```
|
||||
|
||||
### 发票数据流(首次处理)
|
||||
|
||||
```
|
||||
发票文件 → startProcess → /api/upload → /api/agent/process → SSE file_events.log → 文件消息实时更新
|
||||
```
|
||||
|
||||
### 发票数据流(补充文件)
|
||||
|
||||
```
|
||||
新发票文件 → _uploadNewFiles() → /api/upload → /api/agent/supplement
|
||||
→ extract_invoices()(旧文件走缓存,新文件走 LLM)
|
||||
→ run_agent_round() → SSE file_events.log → 文件消息实时更新
|
||||
```
|
||||
|
||||
### 文件消息状态机
|
||||
|
||||
```
|
||||
addFileMessage(filename)
|
||||
→ file_progress(processing) → 三点动画(同一行)
|
||||
→ file_progress(done/cached/error) → 详情展开 + 状态图标
|
||||
```
|
||||
|
||||
## 变更记录
|
||||
|
||||
- **2026-06-16**:修复补充文件重复上传问题 — 新增 `newFilenames` 状态字段,补充文件时只上传新文件并走 `/api/agent/supplement/` 接口。`upload.js` 新增 `_uploadNewFiles()` 辅助函数。`config.js` 的 `checkAutoStart()` 在 `done` 和 `idle` 状态下有 `newFilenames` 时走 supplement 流程而非 `startProcess()`
|
||||
- **2026-06-16**:新增 LLM 流式思考展示 — `chat.js` 新增 `handleLLMStream` 状态机(start/chunk/end/error 四阶段),`process.js` 的 SSE 监听增加 `llm_stream` 事件分支,AI 思考过程以聊天气泡逐字展示
|
||||
- **2026-06-16**:引入处理状态机 — 新增 `processState` / `lastProcessedFileCount` / `forceStart` 状态字段,修复 `syncFiles` 轮询每 3 秒重复触发处理的 bug。`checkAutoStart` 改为基于状态机判断,done 状态下只有检测到新文件才重新触发。为未来 AI 判断文件是否充足预留了 `awaiting_files` 状态和 `forceStart` 标记
|
||||
- **2026-06-12**:上传区域可折叠 — 新增 `toggleUploadPanel()` 函数,配合 CSS `max-height` 动画实现面板收起/展开
|
||||
- **2026-06-12**:文件消息框对齐到用户侧 — `addFileMessage` 的 wrapper 新增 `user` 类,上传的文件消息显示在右侧(用户侧),与 agent 消息区分
|
||||
- **2026-06-12**:配置完成增加互动提示 — `promptMissingConfig` 在配置完整时发送系统消息告知用户即将开始处理发票
|
||||
- **2026-06-14**:新增文件进度实时反馈 — 后端 extractor 在每处理一个文件时产生 SSE 进度事件,前端根据文件名定位 DOM 并更新状态(处理中三点动画、完成摘要展示、缓存/错误状态)
|
||||
- **2026-06-14**:移除发票编辑和财务提交流程 — 删除 table.js、financial.js 及相关 HTML 区域,后续开发不再支持手动编辑发票信息
|
||||
- **2026-06-12**:模块拆分重构 — 将 index.js (625 行) 拆分为 10 个独立模块,引入 `App` 共享状态对象
|
||||
- **2026-06-12**:修复配置收集流程的异步时序问题 — parseConfigFile 改为 Promise,拆分 checkAutoStart 为静默检查/配置提示两个函数
|
||||
291
src/web/static/js/agent.js
Normal file
291
src/web/static/js/agent.js
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Agent 交互模块
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
import { escapeHtml } from './utils.js';
|
||||
import { addChatMessage, showStatus } from './chat.js';
|
||||
import { createSSEConnection, handleDoneResult } from './sse.js';
|
||||
|
||||
// ================================================================
|
||||
// Agent 事件处理
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 处理 Agent SSE 事件
|
||||
*
|
||||
* @param {Object} msg - SSE 传来的 agent 事件对象
|
||||
*/
|
||||
export function handleAgentEvent(msg) {
|
||||
switch (msg.type) {
|
||||
case 'agent_state_change':
|
||||
_handleAgentStateChange(msg);
|
||||
break;
|
||||
case 'agent_request_supplement':
|
||||
_handleAgentRequestSupplement(msg);
|
||||
break;
|
||||
case 'agent_ready':
|
||||
_handleAgentReady(msg);
|
||||
break;
|
||||
case 'agent_error':
|
||||
_handleAgentError(msg);
|
||||
break;
|
||||
case 'agent_supplement_received':
|
||||
_handleSupplementReceived(msg);
|
||||
break;
|
||||
case 'agent_force_submit':
|
||||
_handleForceSubmit(msg);
|
||||
break;
|
||||
case 'agent_max_rounds':
|
||||
_handleAgentError(msg);
|
||||
break;
|
||||
case 'agent_extract_status':
|
||||
_handleAgentStateChange(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态变更通知 — 使用瞬态消息,状态变化时更新而非追加
|
||||
*/
|
||||
function _handleAgentStateChange(msg) {
|
||||
const stateLabels = {
|
||||
'extracting': '正在分析文件...',
|
||||
'validating': '正在校验信息完整性...',
|
||||
'awaiting_supplement': '等待补充材料',
|
||||
'ready': '信息完整,可以提交',
|
||||
'submitting': '正在提交...',
|
||||
};
|
||||
|
||||
const label = stateLabels[msg.state] || msg.message || '处理中...';
|
||||
|
||||
if (msg.state === 'ready') {
|
||||
showStatus(label, 'done');
|
||||
} else {
|
||||
showStatus(label);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求补充材料 — 将 suggestion 以消息气泡形式单独展示
|
||||
*/
|
||||
export function _handleAgentRequestSupplement(msg) {
|
||||
const suggestion = msg.suggestion || '请补充上传相关材料';
|
||||
showStatus('信息不完整,请补充材料');
|
||||
addChatMessage(suggestion, 'system');
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息完整 — 在消息气泡中提醒用户,最终结果由 done 事件处理
|
||||
*/
|
||||
function _handleAgentReady(msg) {
|
||||
addChatMessage(msg.message || '信息完整,正在提交到财务系统', 'done');
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 错误
|
||||
*/
|
||||
function _handleAgentError(msg) {
|
||||
addChatMessage(msg.message || 'Agent 处理出错', 'error');
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到补充文件
|
||||
*/
|
||||
function _handleSupplementReceived(msg) {
|
||||
const fileNames = (msg.files || []).join('、');
|
||||
addChatMessage(`已收到补充文件:${fileNames}`, 'done');
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制提交
|
||||
*/
|
||||
function _handleForceSubmit(msg) {
|
||||
showStatus('已跳过校验,开始提交到财务系统');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Agent 请求面板
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 显示 Agent 请求补充材料的 UI
|
||||
*/
|
||||
export function showAgentRequest(data) {
|
||||
const existing = document.getElementById('agent-request-panel');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
if (!chatMessages) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'agent-request-panel';
|
||||
panel.className = 'agent-request-panel';
|
||||
|
||||
let materialsHtml = '';
|
||||
if (data.missing_materials && data.missing_materials.length > 0) {
|
||||
materialsHtml = `
|
||||
<div class="agent-request-materials">
|
||||
<strong>需要补充:</strong>
|
||||
${data.missing_materials.map(m => `<span class="material-tag">${escapeHtml(m)}</span>`).join(' ')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="agent-request-header">
|
||||
<span class="agent-request-icon"><img src="/static/icon/file.svg" alt=""></span>
|
||||
<span>第 ${data.round || 1} 轮分析 - 信息不完整</span>
|
||||
</div>
|
||||
${materialsHtml}
|
||||
<div class="agent-request-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="document.getElementById('file-input').click()">
|
||||
上传补充材料
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="handleForceSubmit()">
|
||||
直接提交
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
chatMessages.appendChild(panel);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Agent 请求面板
|
||||
*/
|
||||
export function hideAgentRequest() {
|
||||
const panel = document.getElementById('agent-request-panel');
|
||||
if (panel) panel.remove();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 用户文字补充
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 处理用户通过输入框补充的文字信息
|
||||
*
|
||||
* @param {string} text - 用户输入的文字
|
||||
*/
|
||||
export async function handleUserSupplement(text) {
|
||||
if (!App.sessionId) return;
|
||||
|
||||
try {
|
||||
showStatus('正在解析补充信息...');
|
||||
|
||||
const response = await fetch(`/api/agent/user-supplement/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
showStatus('补充信息已收到,正在重新分析...');
|
||||
App.isProcessing = true;
|
||||
|
||||
createSSEConnection(App.sessionId, {
|
||||
onDone: (result) => {
|
||||
handleDoneResult(result);
|
||||
},
|
||||
handleFileProgress: false,
|
||||
handleLLMStream: false,
|
||||
});
|
||||
} else if (result.error) {
|
||||
addChatMessage(`处理失败:${result.error}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
addChatMessage(`请求失败:${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 用户输入处理
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 处理强制提交
|
||||
*/
|
||||
export async function handleForceSubmit() {
|
||||
if (!App.sessionId) {
|
||||
addChatMessage('请先上传文件并处理', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (App.agentEventSource) {
|
||||
App.agentEventSource.close();
|
||||
App.agentEventSource = null;
|
||||
}
|
||||
|
||||
App.forceSubmitting = true;
|
||||
|
||||
try {
|
||||
showStatus('正在跳过校验并提交...');
|
||||
hideAgentRequest();
|
||||
|
||||
const response = await fetch(`/api/agent/force-submit/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
showStatus('已跳过校验,开始提交到财务系统');
|
||||
App.isProcessing = true;
|
||||
App.processState = 'submitting';
|
||||
|
||||
createSSEConnection(App.sessionId, {
|
||||
onDone: (result) => {
|
||||
handleDoneResult(result);
|
||||
},
|
||||
handleFileProgress: false,
|
||||
handleLLMStream: false,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
App.forceSubmitting = false;
|
||||
addChatMessage(`请求失败:${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 补充文件处理
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 处理补充文件上传完成后的重新分析
|
||||
*
|
||||
* @param {Array<string>} filenames - 新上传的文件名列表
|
||||
*/
|
||||
export async function handleSupplementUpload(filenames) {
|
||||
if (!App.sessionId) return;
|
||||
|
||||
try {
|
||||
showStatus('收到补充文件,正在重新分析...');
|
||||
hideAgentRequest();
|
||||
|
||||
const response = await fetch(`/api/agent/supplement/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ files: filenames }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
App.isProcessing = true;
|
||||
|
||||
createSSEConnection(App.sessionId, {
|
||||
onDone: (result) => {
|
||||
handleDoneResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
addChatMessage(`请求失败:${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
28
src/web/static/js/chat.js
Normal file
28
src/web/static/js/chat.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 聊天窗口模块入口
|
||||
*
|
||||
* 从 chat/ 子模块重新导出所有公开函数,保持向后兼容。
|
||||
* 外部模块通过 import 方式使用,HTML 中的 script 标签直接加载本文件。
|
||||
*/
|
||||
|
||||
export {
|
||||
addChatMessage,
|
||||
addTypingIndicator,
|
||||
removeTypingIndicator,
|
||||
addFileMessage,
|
||||
setFileProcessing,
|
||||
setFileDone,
|
||||
setFileCached,
|
||||
setFileError,
|
||||
} from './chat/persistent.js';
|
||||
|
||||
// 瞬态消息
|
||||
export {
|
||||
showStatus,
|
||||
clearStatus,
|
||||
} from './chat/ephemeral.js';
|
||||
|
||||
// LLM 流式消息
|
||||
export {
|
||||
handleLLMStream,
|
||||
} from './chat/stream.js';
|
||||
198
src/web/static/js/chat/README.md
Normal file
198
src/web/static/js/chat/README.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# chat/ 模块说明
|
||||
|
||||
## 目录
|
||||
|
||||
- [架构概览](#架构概览)
|
||||
- [模块职责](#模块职责)
|
||||
- [消息类型](#消息类型)
|
||||
- [数据流](#数据流)
|
||||
- [状态管理](#状态管理)
|
||||
- [注意事项](#注意事项)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
chat/
|
||||
├── renderer.js # 底层 DOM 渲染工具
|
||||
├── persistent.js # 持久消息(永久保留在聊天历史中)
|
||||
├── ephemeral.js # 瞬态消息(只显示最新一条,新状态覆盖旧状态)
|
||||
└── stream.js # LLM 流式消息(处理中显示思考过程,结束后固化)
|
||||
|
||||
chat.js # 入口文件,统一 re-export 所有公开函数
|
||||
```
|
||||
|
||||
设计原则:将**持久消息**和**瞬态消息**分离,避免聊天历史被中间状态消息堆积。
|
||||
|
||||
---
|
||||
|
||||
## 模块职责
|
||||
|
||||
### renderer.js
|
||||
|
||||
最底层渲染工具,不维护任何状态。提供:
|
||||
|
||||
- `getChatMessages()` — 获取 `#chat-messages` 容器
|
||||
- `scrollToBottom()` — 滚动到底部
|
||||
- `createChatBubble(text, type, isUser)` — 创建消息气泡 DOM
|
||||
- `createSystemBubble(text, type)` — 创建系统气泡 DOM
|
||||
- `appendMessage(wrapper)` — 将消息追加到容器并滚动
|
||||
|
||||
### persistent.js
|
||||
|
||||
管理永久保留在聊天历史中的消息:
|
||||
|
||||
- `addChatMessage(text, type, isUser)` — 添加普通聊天消息
|
||||
- `addTypingIndicator()` / `removeTypingIndicator()` — 加载三点动画
|
||||
- `addFileMessage(filename)` — 添加文件上传消息
|
||||
- `setFileProcessing(filename)` — 文件处理中状态
|
||||
- `setFileDone(filename, summary)` — 文件处理完成,展开提取摘要
|
||||
- `setFileCached(filename)` — 文件使用缓存
|
||||
- `setFileError(filename, errorMsg)` — 文件处理错误
|
||||
|
||||
依赖 `App.fileMessageMap`(定义在 `state.js`)维护文件名到 DOM 元素的映射。
|
||||
|
||||
### ephemeral.js
|
||||
|
||||
管理瞬态状态消息,同一时刻只显示最新一条:
|
||||
|
||||
- `showStatus(text, type)` — 显示/更新状态消息
|
||||
- `clearStatus()` — 清除当前状态消息
|
||||
|
||||
内部维护 `ephemeralState` 对象记录当前活跃的状态气泡引用。重复调用 `showStatus` 时直接更新已有气泡的文本和样式,不创建新 DOM。
|
||||
|
||||
### stream.js
|
||||
|
||||
管理 LLM 流式响应的气泡生命周期:
|
||||
|
||||
- `handleLLMStream(msg)` — 根据 SSE 事件阶段分发处理
|
||||
|
||||
支持的阶段:
|
||||
|
||||
| phase | 说明 |
|
||||
|-------|------|
|
||||
| `start` | 创建流式气泡,初始化思考过程区域(默认展开) |
|
||||
| `reasoning` | 追加思考过程文本 |
|
||||
| `chunk` | 追加正式回复文本 |
|
||||
| `end` | 关闭气泡,切换为 `done` 样式,折叠思考过程区域,气泡保留在历史中 |
|
||||
| `error` | 切换为 `error` 样式,显示错误信息 |
|
||||
|
||||
内部维护 `llmStreamState` 对象记录当前活跃的流式气泡引用。
|
||||
|
||||
---
|
||||
|
||||
## 消息类型
|
||||
|
||||
### 持久消息
|
||||
|
||||
永久保留在聊天历史中,不会被自动清除:
|
||||
|
||||
- 用户输入的文字
|
||||
- 文件上传记录及其处理状态
|
||||
- LLM 流式响应的最终结果(`end` 阶段后固化)
|
||||
- 系统通知(如"信息完整,可以提交")
|
||||
- 错误消息
|
||||
|
||||
调用 `addChatMessage()` 或 `addFileMessage()` 创建。
|
||||
|
||||
### 瞬态消息
|
||||
|
||||
只显示最新一条,新状态覆盖旧状态:
|
||||
|
||||
- "正在分析文件..."
|
||||
- "正在校验信息完整性..."
|
||||
- "请输入登录账号:"
|
||||
- "配置信息已完整,开始处理发票……"
|
||||
- "收到补充文件,正在重新分析..."
|
||||
|
||||
调用 `showStatus()` 创建/更新,调用 `clearStatus()` 清除。
|
||||
|
||||
---
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
外部模块 (agent.js / config.js / process.js / upload.js / sync.js)
|
||||
│
|
||||
├── import { addChatMessage, addFileMessage, ... } from './chat.js'
|
||||
├── import { showStatus, clearStatus } from './chat.js'
|
||||
└── import { handleLLMStream } from './chat.js'
|
||||
│
|
||||
▼
|
||||
chat.js (re-export)
|
||||
│
|
||||
├── → chat/persistent.js ──→ chat/renderer.js
|
||||
├── → chat/ephemeral.js ──→ chat/renderer.js
|
||||
└── → chat/stream.js ──→ chat/renderer.js
|
||||
```
|
||||
|
||||
- 外部模块统一从 `chat.js` 导入函数
|
||||
- `chat.js` 只做 re-export,不引入循环依赖
|
||||
- 三个子模块通过 `renderer.js` 共享底层 DOM 操作
|
||||
- `persistent.js` 额外依赖 `state.js` 的 `App.fileMessageMap`
|
||||
|
||||
---
|
||||
|
||||
## 状态管理
|
||||
|
||||
### ephemeralState (ephemeral.js)
|
||||
|
||||
```js
|
||||
{
|
||||
wrapper: HTMLElement | null, // 状态消息的 wrapper 元素
|
||||
bubble: HTMLElement | null, // 状态气泡元素
|
||||
}
|
||||
```
|
||||
|
||||
- 初始为 `null`
|
||||
- `showStatus` 首次调用时创建并记录引用
|
||||
- 后续调用直接更新 `bubble.textContent` 和 `bubble.className`
|
||||
- `clearStatus` 时移除 DOM 并重置为 `null`
|
||||
|
||||
### llmStreamState (stream.js)
|
||||
|
||||
```js
|
||||
{
|
||||
wrapper: HTMLElement | null, // 流式消息 wrapper
|
||||
bubble: HTMLElement | null, // 流式气泡
|
||||
textContent: HTMLElement | null, // 正式文本容器
|
||||
accumulated: string, // 累积的正式文本
|
||||
reasoningContent: HTMLElement | null, // 思考过程容器
|
||||
reasoningAccumulated: string, // 累积的思考文本
|
||||
}
|
||||
```
|
||||
|
||||
- `start` 阶段创建并记录引用
|
||||
- `chunk` / `reasoning` 阶段追加文本
|
||||
- `end` / `error` 阶段重置为 `null`(DOM 保留在历史中)
|
||||
|
||||
### App.fileMessageMap (state.js)
|
||||
|
||||
```js
|
||||
Map<string, { wrapper, bubble, nameRow, detailRow }>
|
||||
```
|
||||
|
||||
- 文件名 → DOM 元素映射
|
||||
- `addFileMessage` 创建时写入
|
||||
- `setFileProcessing` / `setFileDone` / `setFileCached` / `setFileError` 读取并更新对应文件的状态
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **不要直接操作 `#chat-messages` 容器**。所有消息创建都通过本模块的 API 进行。
|
||||
|
||||
2. **瞬态消息和持久消息不要混用**。中间处理状态用 `showStatus`,最终结果用 `addChatMessage`。
|
||||
|
||||
3. **`showStatus` 不需要手动清除**。调用 `showStatus` 显示新状态时会自动覆盖旧状态;在处理流程结束时,后续的消息或状态会自然覆盖。
|
||||
|
||||
4. **LLM 流式气泡在 `end` 阶段后变为持久消息**。不需要额外调用 `addChatMessage` 来保留结果。
|
||||
|
||||
5. **SSE 事件必须包含 `start` 和 `end` 阶段**。缺少 `start` 会导致气泡未创建,缺少 `end` 会导致气泡一直处于处理中状态。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`。
|
||||
|
||||
6. **`handleLLMStream` 不处理普通日志行**。SSE 的 `message` 事件中,只有 `type === 'llm_stream'` 的事件才会被转发到此模块。
|
||||
|
||||
7. **文件消息的 DOM 生命周期由 `fileMessageMap` 管理**。文件处理完成后,摘要信息会展开显示在文件气泡下方。
|
||||
|
||||
8. **所有模块通过 `chat.js` 统一导入**,不要直接从 `chat/` 子目录导入(外部模块层面)。子模块之间的内部导入不受此限制。
|
||||
62
src/web/static/js/chat/ephemeral.js
Normal file
62
src/web/static/js/chat/ephemeral.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 瞬态消息管理器
|
||||
*
|
||||
* 瞬态消息只保留最新一条,新状态覆盖旧状态,不会堆积在聊天历史中。
|
||||
*/
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { getChatMessages, scrollToBottom } from './renderer.js';
|
||||
|
||||
/**
|
||||
* 当前活跃的瞬态消息 DOM 引用
|
||||
*/
|
||||
let ephemeralState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* 显示瞬态状态消息
|
||||
*
|
||||
* 同一时刻只保留一条状态消息,调用此函数会更新已有消息或创建新消息。
|
||||
*
|
||||
* @param {string} text - 状态文本
|
||||
* @param {string} [type='processing'] - 消息类型
|
||||
*/
|
||||
export function showStatus(text, type) {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
|
||||
if (ephemeralState.bubble) {
|
||||
ephemeralState.bubble.textContent = escapeHtml(text);
|
||||
ephemeralState.bubble.className = 'chat-bubble ' + (type || 'processing');
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'chat-message';
|
||||
wrapper.innerHTML = `
|
||||
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
|
||||
<div class="chat-bubble ${type || 'processing'}">${escapeHtml(text)}</div>
|
||||
`;
|
||||
chatMessages.appendChild(wrapper);
|
||||
scrollToBottom();
|
||||
|
||||
ephemeralState = {
|
||||
wrapper,
|
||||
bubble: wrapper.querySelector('.chat-bubble'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除瞬态状态消息
|
||||
*/
|
||||
export function clearStatus() {
|
||||
if (ephemeralState.wrapper) {
|
||||
ephemeralState.wrapper.remove();
|
||||
}
|
||||
ephemeralState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
};
|
||||
}
|
||||
246
src/web/static/js/chat/persistent.js
Normal file
246
src/web/static/js/chat/persistent.js
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 持久消息管理器
|
||||
*
|
||||
* 持久消息永久保留在聊天历史中,包括:
|
||||
* - 普通聊天消息
|
||||
* - 文件上传消息及其状态
|
||||
* - 加载指示器
|
||||
*/
|
||||
import { App } from '../state.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { createChatBubble, appendMessage, getChatMessages, scrollToBottom } from './renderer.js';
|
||||
|
||||
// ================================================================
|
||||
// 普通聊天消息
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 添加聊天消息
|
||||
*
|
||||
* @param {string} text - 消息内容
|
||||
* @param {string} [type='processing'] - 消息类型
|
||||
* @param {boolean} [isUser=false] - 是否为用户消息
|
||||
*/
|
||||
export function addChatMessage(text, type, isUser) {
|
||||
const wrapper = createChatBubble(text, type, isUser);
|
||||
appendMessage(wrapper);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 加载指示器
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 显示加载指示器(三点动画)
|
||||
*/
|
||||
export function addTypingIndicator() {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'chat-message';
|
||||
indicator.id = 'typing-indicator';
|
||||
indicator.innerHTML = `
|
||||
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
|
||||
<div class="chat-bubble processing">
|
||||
<div class="typing-indicator"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
`;
|
||||
chatMessages.appendChild(indicator);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除加载指示器
|
||||
*/
|
||||
export function removeTypingIndicator() {
|
||||
const indicator = document.getElementById('typing-indicator');
|
||||
if (indicator) indicator.remove();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 文件消息管理
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 添加文件消息(上传文件时调用)
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
*/
|
||||
export function addFileMessage(filename) {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'chat-message user file-message';
|
||||
wrapper.dataset.file = filename;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'chat-bubble file-bubble';
|
||||
|
||||
const nameRow = document.createElement('div');
|
||||
nameRow.className = 'file-name-row';
|
||||
nameRow.innerHTML = `
|
||||
<span class="file-icon"><img src="/static/icon/file.svg" alt=""></span>
|
||||
<span class="file-name">${escapeHtml(filename)}</span>
|
||||
<span class="file-status" style="display:none"></span>
|
||||
`;
|
||||
|
||||
const detailRow = document.createElement('div');
|
||||
detailRow.className = 'file-detail';
|
||||
detailRow.style.display = 'none';
|
||||
|
||||
bubble.appendChild(nameRow);
|
||||
bubble.appendChild(detailRow);
|
||||
wrapper.appendChild(bubble);
|
||||
chatMessages.appendChild(wrapper);
|
||||
scrollToBottom();
|
||||
|
||||
App.fileMessageMap.set(filename, { wrapper, bubble, nameRow, detailRow });
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文件处理中状态
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
*/
|
||||
export function setFileProcessing(filename) {
|
||||
const entry = App.fileMessageMap.get(filename);
|
||||
if (!entry) return;
|
||||
|
||||
const { bubble, nameRow, detailRow } = entry;
|
||||
bubble.classList.remove('file-done', 'file-error');
|
||||
bubble.classList.add('file-processing');
|
||||
|
||||
const statusEl = nameRow.querySelector('.file-status');
|
||||
statusEl.style.display = 'inline-flex';
|
||||
statusEl.innerHTML = `
|
||||
<span class="file-typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
`;
|
||||
|
||||
detailRow.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件消息为完成状态
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
* @param {Object} summary - 提取摘要对象
|
||||
*/
|
||||
export function setFileDone(filename, summary) {
|
||||
const entry = App.fileMessageMap.get(filename);
|
||||
if (!entry) return;
|
||||
|
||||
const { bubble, nameRow, detailRow } = entry;
|
||||
bubble.classList.remove('file-processing');
|
||||
bubble.classList.add('file-done');
|
||||
|
||||
const statusEl = nameRow.querySelector('.file-status');
|
||||
statusEl.style.display = 'inline';
|
||||
statusEl.innerHTML = '<span class="file-status-icon">OK</span>';
|
||||
|
||||
const labelMap = {
|
||||
card_date: '刷卡日期',
|
||||
person_id: '人员编号',
|
||||
person_name: '人员姓名',
|
||||
invoice_type_label: '类型',
|
||||
invoice_number: '发票号码',
|
||||
invoice_date: '开票日期',
|
||||
ride_date: '乘车日期',
|
||||
departure: '出发站',
|
||||
arrival: '到达站',
|
||||
seat_class: '座位等级',
|
||||
train_no: '车次',
|
||||
total_amount: '金额',
|
||||
amount: '金额',
|
||||
seller_name: '销售方',
|
||||
buyer_name: '购买方',
|
||||
goods_name: '货物/服务',
|
||||
remark: '备注',
|
||||
card_no: '卡号',
|
||||
card_amount: '刷卡金额',
|
||||
pay_date: '支付日期',
|
||||
pay_time: '支付时间',
|
||||
pay_channel: '支付渠道',
|
||||
transaction_no: '交易单号',
|
||||
merchant_name: '商户名称',
|
||||
order_no: '订单号',
|
||||
project_name: '项目名称',
|
||||
purpose: '出差事由',
|
||||
start_date: '开始日期',
|
||||
end_date: '结束日期',
|
||||
person_info: '随行人员',
|
||||
travel_purpose: '出差事由',
|
||||
travel_from: '出发地',
|
||||
travel_to: '目的地',
|
||||
travel_start: '出发日期',
|
||||
travel_end: '返回日期',
|
||||
departure_place: '出发地',
|
||||
arrival_place: '目的地',
|
||||
hotel_name: '酒店名称',
|
||||
checkin_date: '入住日期',
|
||||
checkout_date: '退房日期',
|
||||
room_type: '房型',
|
||||
days: '天数',
|
||||
attachments: '附件',
|
||||
subsidy_list: '补助清单',
|
||||
};
|
||||
|
||||
let lines = [];
|
||||
for (const [key, value] of Object.entries(summary)) {
|
||||
const label = labelMap[key] || key;
|
||||
const displayValue = escapeHtml(String(value)).replace(/\n/g, '<br>');
|
||||
lines.push(`<span class="detail-label">${label}:</span><span class="detail-value">${displayValue}</span>`);
|
||||
}
|
||||
|
||||
detailRow.innerHTML = lines.join('<br>');
|
||||
detailRow.style.display = 'block';
|
||||
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件消息为缓存状态
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
*/
|
||||
export function setFileCached(filename) {
|
||||
const entry = App.fileMessageMap.get(filename);
|
||||
if (!entry) return;
|
||||
|
||||
const { bubble, nameRow, detailRow } = entry;
|
||||
bubble.classList.remove('file-processing');
|
||||
bubble.classList.add('file-done');
|
||||
|
||||
const statusEl = nameRow.querySelector('.file-status');
|
||||
statusEl.style.display = 'inline';
|
||||
statusEl.innerHTML = '<span class="file-status-icon">CACHE</span>';
|
||||
|
||||
detailRow.innerHTML = '<span class="detail-label">状态:</span><span class="detail-value">使用缓存(已提取)</span>';
|
||||
detailRow.style.display = 'block';
|
||||
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件消息为错误状态
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
* @param {string} [errorMsg] - 错误信息
|
||||
*/
|
||||
export function setFileError(filename, errorMsg) {
|
||||
const entry = App.fileMessageMap.get(filename);
|
||||
if (!entry) return;
|
||||
|
||||
const { bubble, nameRow, detailRow } = entry;
|
||||
bubble.classList.remove('file-processing');
|
||||
bubble.classList.add('file-error');
|
||||
|
||||
const statusEl = nameRow.querySelector('.file-status');
|
||||
statusEl.style.display = 'inline';
|
||||
statusEl.innerHTML = '<span class="file-status-icon">ERR</span>';
|
||||
|
||||
detailRow.innerHTML = `<span class="detail-label">错误:</span><span class="detail-value error-text">${escapeHtml(errorMsg || '提取失败')}</span>`;
|
||||
detailRow.style.display = 'block';
|
||||
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
64
src/web/static/js/chat/renderer.js
Normal file
64
src/web/static/js/chat/renderer.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 聊天消息渲染器
|
||||
*
|
||||
* 提供通用的气泡创建、DOM 操作和滚动功能。
|
||||
*/
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
/**
|
||||
* 获取聊天消息容器
|
||||
*/
|
||||
export function getChatMessages() {
|
||||
return document.getElementById('chat-messages');
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动聊天容器到底部
|
||||
*/
|
||||
export function scrollToBottom() {
|
||||
const chatMessages = getChatMessages();
|
||||
if (chatMessages) {
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建聊天消息气泡
|
||||
*
|
||||
* @param {string} text - 消息内容
|
||||
* @param {string} type - 消息类型(影响气泡样式)
|
||||
* @param {boolean} isUser - 是否为用户消息
|
||||
* @returns {HTMLElement} wrapper 元素
|
||||
*/
|
||||
export function createChatBubble(text, type, isUser) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `chat-message${isUser ? ' user' : ''}`;
|
||||
wrapper.innerHTML = `
|
||||
<div class="ai-avatar"><img src="/static/icon/${isUser ? 'user' : 'agent'}.svg" alt=""></div>
|
||||
<div class="chat-bubble ${isUser ? 'user' : (type || 'processing')}">${escapeHtml(text)}</div>
|
||||
`;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带系统头像的气泡
|
||||
*
|
||||
* @param {string} text - 消息内容
|
||||
* @param {string} type - 消息类型
|
||||
* @returns {HTMLElement} wrapper 元素
|
||||
*/
|
||||
export function createSystemBubble(text, type) {
|
||||
return createChatBubble(text, type, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将消息追加到聊天容器
|
||||
*
|
||||
* @param {HTMLElement} wrapper - 消息 wrapper 元素
|
||||
*/
|
||||
export function appendMessage(wrapper) {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
chatMessages.appendChild(wrapper);
|
||||
scrollToBottom();
|
||||
}
|
||||
407
src/web/static/js/chat/stream.js
Normal file
407
src/web/static/js/chat/stream.js
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* LLM 流式聊天气泡管理器
|
||||
*
|
||||
* 流式消息在处理时显示思考过程和逐字输出,结束后固化为持久消息。
|
||||
*/
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { getChatMessages, scrollToBottom } from './renderer.js';
|
||||
|
||||
/**
|
||||
* 当前活跃的 LLM 流式气泡状态
|
||||
*/
|
||||
let llmStreamState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
textContent: null,
|
||||
accumulated: '',
|
||||
reasoningContent: null,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 LLM 流式事件
|
||||
*
|
||||
* @param {Object} msg - SSE 传来的 llm_stream 事件对象
|
||||
*/
|
||||
export function handleLLMStream(msg) {
|
||||
switch (msg.phase) {
|
||||
case 'start':
|
||||
_createLLMStreamBubble(msg.label || '正在分析中...');
|
||||
break;
|
||||
case 'reasoning':
|
||||
_appendLLMStreamReasoning(msg.text || '');
|
||||
break;
|
||||
case 'chunk':
|
||||
_appendLLMStreamChunk(msg.text || '');
|
||||
break;
|
||||
case 'end':
|
||||
_closeLLMStreamBubble(msg.label || '分析完成');
|
||||
break;
|
||||
case 'error':
|
||||
_errorLLMStreamBubble(msg.error || '分析失败');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 LLM 流式气泡(start 阶段)
|
||||
*/
|
||||
function _createLLMStreamBubble(label) {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
|
||||
if (llmStreamState.wrapper) {
|
||||
_closeLLMStreamBubbleSilent();
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'chat-message';
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'chat-bubble processing llm-stream-bubble';
|
||||
|
||||
const labelEl = document.createElement('div');
|
||||
labelEl.className = 'llm-stream-label';
|
||||
labelEl.textContent = label;
|
||||
|
||||
const reasoningSection = document.createElement('details');
|
||||
reasoningSection.className = 'llm-reasoning-section';
|
||||
reasoningSection.open = true;
|
||||
|
||||
const reasoningSummary = document.createElement('summary');
|
||||
reasoningSummary.className = 'llm-reasoning-summary';
|
||||
reasoningSummary.textContent = '思考过程(点击折叠)';
|
||||
|
||||
const reasoningText = document.createElement('div');
|
||||
reasoningText.className = 'llm-reasoning-text';
|
||||
reasoningText.textContent = '';
|
||||
|
||||
reasoningSection.appendChild(reasoningSummary);
|
||||
reasoningSection.appendChild(reasoningText);
|
||||
|
||||
const textEl = document.createElement('div');
|
||||
textEl.className = 'llm-stream-text';
|
||||
textEl.textContent = '';
|
||||
|
||||
bubble.appendChild(labelEl);
|
||||
bubble.appendChild(reasoningSection);
|
||||
bubble.appendChild(textEl);
|
||||
wrapper.appendChild(bubble);
|
||||
chatMessages.appendChild(wrapper);
|
||||
scrollToBottom();
|
||||
|
||||
llmStreamState = {
|
||||
wrapper,
|
||||
bubble,
|
||||
textContent: textEl,
|
||||
accumulated: '',
|
||||
reasoningContent: reasoningText,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加流式文本片段(chunk 阶段)
|
||||
*/
|
||||
function _appendLLMStreamChunk(text) {
|
||||
if (!llmStreamState.textContent) return;
|
||||
|
||||
llmStreamState.accumulated += text;
|
||||
llmStreamState.textContent.textContent = llmStreamState.accumulated;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加思考过程片段(reasoning 阶段)
|
||||
*/
|
||||
function _appendLLMStreamReasoning(text) {
|
||||
if (!llmStreamState.reasoningContent) return;
|
||||
|
||||
llmStreamState.reasoningAccumulated += text;
|
||||
llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭流式气泡(end 阶段)— 气泡保留在聊天历史中作为持久消息。
|
||||
* 如果累积内容是提取结果 JSON,则渲染为美观的摘要卡片。
|
||||
*/
|
||||
function _closeLLMStreamBubble(label) {
|
||||
if (!llmStreamState.bubble) return;
|
||||
|
||||
const accumulated = llmStreamState.accumulated;
|
||||
|
||||
llmStreamState.bubble.classList.remove('processing');
|
||||
llmStreamState.bubble.classList.add('done');
|
||||
|
||||
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = label;
|
||||
}
|
||||
|
||||
const reasoningSection = llmStreamState.bubble.querySelector('.llm-reasoning-section');
|
||||
if (reasoningSection) {
|
||||
reasoningSection.open = false;
|
||||
}
|
||||
|
||||
// 尝试将累积文本解析为提取结果 JSON,渲染为摘要卡片
|
||||
const textEl = llmStreamState.bubble.querySelector('.llm-stream-text');
|
||||
if (textEl && accumulated) {
|
||||
const parsed = _tryParseExtractionJson(accumulated);
|
||||
if (parsed) {
|
||||
textEl.innerHTML = '';
|
||||
textEl.appendChild(_buildExtractionSummary(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
llmStreamState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
textContent: null,
|
||||
accumulated: '',
|
||||
reasoningContent: null,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默关闭流式气泡(不改变样式,用于 start 前清理)
|
||||
*/
|
||||
function _closeLLMStreamBubbleSilent() {
|
||||
if (llmStreamState.wrapper) {
|
||||
llmStreamState.wrapper.remove();
|
||||
}
|
||||
llmStreamState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
textContent: null,
|
||||
accumulated: '',
|
||||
reasoningContent: null,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误状态(error 阶段)
|
||||
*/
|
||||
function _errorLLMStreamBubble(errorMsg) {
|
||||
if (!llmStreamState.bubble) return;
|
||||
|
||||
llmStreamState.bubble.classList.remove('processing');
|
||||
llmStreamState.bubble.classList.add('error');
|
||||
|
||||
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = '分析失败';
|
||||
}
|
||||
|
||||
if (llmStreamState.textContent) {
|
||||
llmStreamState.textContent.textContent = escapeHtml(errorMsg);
|
||||
}
|
||||
|
||||
llmStreamState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
textContent: null,
|
||||
accumulated: '',
|
||||
reasoningContent: null,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试将 LLM 输出文本解析为提取结果 JSON
|
||||
* 返回解析后的对象,或 null(非提取结果 JSON)
|
||||
*/
|
||||
function _tryParseExtractionJson(text) {
|
||||
try {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{')) return null;
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object' && (parsed.basic_info || parsed.reimbursement_details)) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将提取结果 JSON 渲染为美观的摘要卡片 DOM 元素
|
||||
*
|
||||
* @param {Object} data - 提取结果 JSON
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function _buildExtractionSummary(data) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'extraction-summary';
|
||||
|
||||
// 基本信息
|
||||
if (data.basic_info) {
|
||||
const bi = data.basic_info;
|
||||
const section = _summarySection('基本信息');
|
||||
_summaryRow(section, '出差事由', bi.travel_purpose || '');
|
||||
_summaryRow(section, '出差地点', bi.travel_location || '');
|
||||
const dates = bi.start_date && bi.end_date ? `${bi.start_date} 至 ${bi.end_date}` : (bi.start_date || bi.end_date || '');
|
||||
_summaryRow(section, '出差日期', dates);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 交通费
|
||||
if (data.reimbursement_details?.transport_fee?.length) {
|
||||
const section = _summarySection('交通费');
|
||||
const table = _summaryTable(['日期', '出发', '到达', '金额', '备注']);
|
||||
for (const item of data.reimbursement_details.transport_fee) {
|
||||
_summaryTableRow(table, [
|
||||
item.start_date || '',
|
||||
item.departure_place || '',
|
||||
item.arrival_place || '',
|
||||
item.amount != null ? `¥${item.amount.toFixed(2)}` : '',
|
||||
item.remark || '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 住宿费
|
||||
if (data.reimbursement_details?.hotel_fee?.length) {
|
||||
const section = _summarySection('住宿费');
|
||||
const table = _summaryTable(['入住日期', '离店日期', '天数', '发票金额', '报销金额', '备注']);
|
||||
for (const item of data.reimbursement_details.hotel_fee) {
|
||||
_summaryTableRow(table, [
|
||||
item.checkin_date || '',
|
||||
item.checkout_date || '',
|
||||
item.days != null ? String(item.days) : '',
|
||||
item.invoice_amount != null ? `¥${item.invoice_amount.toFixed(2)}` : '',
|
||||
item.reimburse_amount != null ? `¥${item.reimburse_amount.toFixed(2)}` : '',
|
||||
item.remark || '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 会议/培训费
|
||||
if (data.reimbursement_details?.conference_fee?.length) {
|
||||
const section = _summarySection('会议/培训费');
|
||||
const table = _summaryTable(['日期', '金额', '备注']);
|
||||
for (const item of data.reimbursement_details.conference_fee) {
|
||||
_summaryTableRow(table, [
|
||||
item.start_date || '',
|
||||
item.amount != null ? `¥${item.amount.toFixed(2)}` : '',
|
||||
item.remark || '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 支付记录
|
||||
if (data.payment_methods?.length) {
|
||||
const section = _summarySection('支付记录');
|
||||
const table = _summaryTable(['日期', '金额', '商户', '备注']);
|
||||
for (const item of data.payment_methods) {
|
||||
_summaryTableRow(table, [
|
||||
item.card_date || '',
|
||||
item.card_amount != null ? `¥${item.card_amount.toFixed(2)}` : '',
|
||||
item.merchant || '',
|
||||
item.remark || '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 补助
|
||||
if (data.subsidy_list?.length) {
|
||||
const section = _summarySection('补助');
|
||||
const table = _summaryTable(['姓名', '开始日期', '结束日期', '天数']);
|
||||
for (const item of data.subsidy_list) {
|
||||
_summaryTableRow(table, [
|
||||
item.person_name || '',
|
||||
item.start_date || '',
|
||||
item.end_date || '',
|
||||
item.days != null ? String(item.days) : '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 附件
|
||||
if (data.attachments?.length) {
|
||||
const section = _summarySection('附件');
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'summary-attachment-list';
|
||||
for (const item of data.attachments) {
|
||||
const li = document.createElement('li');
|
||||
li.className = `summary-attachment-item summary-attachment-${item.attachment_type || 'other'}`;
|
||||
li.textContent = `${item.attachment_desc || item.filename || ''}`;
|
||||
list.appendChild(li);
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/* ---- 提取摘要卡片构建辅助函数 ---- */
|
||||
|
||||
function _summarySection(title) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'summary-section';
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'summary-section-title';
|
||||
titleEl.textContent = title;
|
||||
section.appendChild(titleEl);
|
||||
return section;
|
||||
}
|
||||
|
||||
function _summaryRow(section, label, value) {
|
||||
if (!value) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'summary-row';
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'summary-label';
|
||||
labelEl.textContent = label;
|
||||
const valueEl = document.createElement('span');
|
||||
valueEl.className = 'summary-value';
|
||||
valueEl.textContent = value;
|
||||
row.appendChild(labelEl);
|
||||
row.appendChild(valueEl);
|
||||
section.appendChild(row);
|
||||
}
|
||||
|
||||
function _summaryTable(headers) {
|
||||
const table = document.createElement('table');
|
||||
table.className = 'summary-table';
|
||||
const thead = document.createElement('thead');
|
||||
const tr = document.createElement('tr');
|
||||
for (const h of headers) {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = h;
|
||||
tr.appendChild(th);
|
||||
}
|
||||
thead.appendChild(tr);
|
||||
table.appendChild(thead);
|
||||
const tbody = document.createElement('tbody');
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
}
|
||||
|
||||
function _summaryTableRow(table, cells) {
|
||||
const tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
const tr = document.createElement('tr');
|
||||
for (const cell of cells) {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = cell;
|
||||
tr.appendChild(td);
|
||||
}
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
144
src/web/static/js/config.js
Normal file
144
src/web/static/js/config.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 配置收集模块
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
import { addChatMessage, showStatus } from './chat.js';
|
||||
import { handleUserSupplement, handleForceSubmit, handleSupplementUpload } from './agent.js';
|
||||
import { startProcess } from './process.js';
|
||||
|
||||
export const CONFIG_FIELDS = {
|
||||
username: '登录账号',
|
||||
password: '登录密码',
|
||||
default_name: '报销人姓名',
|
||||
default_card_no: '公务卡号',
|
||||
default_person_id: '报销人工号',
|
||||
consumable_storage: '易耗品存放地点',
|
||||
};
|
||||
|
||||
/**
|
||||
* 静默检查:仅当配置完整且有文件时自动开始处理。
|
||||
*/
|
||||
export function checkAutoStart() {
|
||||
if (App.isProcessing) return;
|
||||
if (App.processState === 'processing') return;
|
||||
|
||||
if (App.processState === 'done') {
|
||||
const hasNewFiles = App.allFiles.length > App.lastProcessedFileCount;
|
||||
if (!hasNewFiles && !App.forceStart) return;
|
||||
App.forceStart = false;
|
||||
// done 状态下有新文件时,走 supplement 流程
|
||||
if (hasNewFiles) {
|
||||
App.isProcessing = true;
|
||||
App.processState = 'processing';
|
||||
if (App.newFilenames.length > 0) {
|
||||
const filenames = App.newFilenames;
|
||||
App.newFilenames = [];
|
||||
handleSupplementUpload(filenames);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (App.processState === 'awaiting_supplement') {
|
||||
return;
|
||||
}
|
||||
|
||||
// idle 状态下有补充文件时,走 supplement 流程
|
||||
if (App.processState === 'idle' && App.newFilenames.length > 0) {
|
||||
App.isProcessing = true;
|
||||
App.processState = 'processing';
|
||||
const filenames = App.newFilenames;
|
||||
App.newFilenames = [];
|
||||
handleSupplementUpload(filenames);
|
||||
return;
|
||||
}
|
||||
|
||||
const allKeys = Object.keys(CONFIG_FIELDS);
|
||||
const missing = allKeys.filter(k => !App.sessionConfig[k]);
|
||||
|
||||
if (missing.length) return;
|
||||
|
||||
App.pendingConfigKey = null;
|
||||
|
||||
if (!App.allFiles.length) return;
|
||||
|
||||
startProcess();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置提示:在所有文件加载完成后调用,逐个提示缺失的配置项。
|
||||
*/
|
||||
export function promptMissingConfig() {
|
||||
if (App.isProcessing) return;
|
||||
|
||||
const allKeys = Object.keys(CONFIG_FIELDS);
|
||||
const missing = allKeys.filter(k => !App.sessionConfig[k]);
|
||||
|
||||
if (missing.length) {
|
||||
if (App.pendingConfigKey === null) {
|
||||
App.pendingConfigKey = missing[0];
|
||||
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system');
|
||||
} else if (!missing.includes(App.pendingConfigKey)) {
|
||||
App.pendingConfigKey = missing[0];
|
||||
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
App.pendingConfigKey = null;
|
||||
|
||||
if (App.allFiles.length) {
|
||||
showStatus('配置信息已完整,开始处理发票……');
|
||||
startProcess();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户输入处理入口
|
||||
*/
|
||||
export async function sendUserMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// 优先处理配置收集阶段
|
||||
if (App.pendingConfigKey) {
|
||||
App.sessionConfig[App.pendingConfigKey] = text;
|
||||
addChatMessage(text, 'user', true);
|
||||
addChatMessage(`${CONFIG_FIELDS[App.pendingConfigKey]} 已设置`, 'system');
|
||||
input.value = '';
|
||||
promptMissingConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
addChatMessage(text, 'user', true);
|
||||
input.value = '';
|
||||
|
||||
// 检查是否是强制提交指令
|
||||
if (text.includes('直接提交') || text.includes('强制提交') || text.includes('继续提交')) {
|
||||
await handleForceSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否包含文件上传指令
|
||||
if (text.includes('上传') || text.includes('补充')) {
|
||||
document.getElementById('file-input').click();
|
||||
return;
|
||||
}
|
||||
|
||||
// awaiting_supplement 状态下,将用户输入发给后端 LLM 处理
|
||||
if (App.processState === 'awaiting_supplement') {
|
||||
await handleUserSupplement(text);
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('请通过上传区补充文件,或输入"直接提交"跳过校验');
|
||||
}
|
||||
|
||||
export function handleChatKey(event) {
|
||||
if (event.key === 'Enter') {
|
||||
sendUserMessage();
|
||||
}
|
||||
}
|
||||
@@ -1,483 +1,22 @@
|
||||
let sessionId = null;
|
||||
const pdfFiles = [], imgFiles = [];
|
||||
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
|
||||
let csvFilename = ''; // 当前 CSV 文件名
|
||||
let lastDownloadUrls = {}; // 最近一次可下载文件链接
|
||||
/**
|
||||
* 入口文件 — ES Module 入口
|
||||
*
|
||||
* 所有模块通过 import 加载,依赖顺序由 import 自动解决。
|
||||
* 需要 HTML 内联事件调用的函数挂载到 window。
|
||||
*/
|
||||
import { initDragDrop, toggleUploadPanel, handleFiles } from './upload.js';
|
||||
import { sendUserMessage, handleChatKey } from './config.js';
|
||||
import { generateQr, startSync } from './sync.js';
|
||||
import { handleForceSubmit } from './agent.js';
|
||||
|
||||
// ---- Session ----
|
||||
async function ensureSession() {
|
||||
if (sessionId) return sessionId;
|
||||
const r = await fetch('/api/session', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
sessionId = d.session_id;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
function handleFiles(input, type) {
|
||||
const files = Array.from(input.files);
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
|
||||
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
|
||||
|
||||
files.forEach(f => {
|
||||
if (!list.find(x => x.name === f.name)) {
|
||||
f.__source = 'local'; // 标记为本地手动选择
|
||||
list.push(f);
|
||||
}
|
||||
});
|
||||
|
||||
renderFileList(type);
|
||||
document.getElementById(zoneId).classList.add('active');
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeFile(type, index) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
list.splice(index, 1);
|
||||
renderFileList(type);
|
||||
if (list.length === 0) {
|
||||
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(type) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
|
||||
box.innerHTML = list.map((f, i) =>
|
||||
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">×</span></span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ---- 配置上传 ----
|
||||
function handleConfigUpload(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const cfg = JSON.parse(e.target.result);
|
||||
const map = {
|
||||
'cfg-username': cfg.username,
|
||||
'cfg-password': cfg.password,
|
||||
'cfg-name': cfg.default_name,
|
||||
'cfg-card': cfg.default_card_no,
|
||||
'cfg-person-id': cfg.default_person_id,
|
||||
'cfg-storage': cfg.consumable_storage,
|
||||
};
|
||||
for (const [id, val] of Object.entries(map)) {
|
||||
if (val) document.getElementById(id).value = val;
|
||||
}
|
||||
// 同步 config.json 中的工号和公务卡号到表格
|
||||
if (cfg.username) syncConfigToTable('工号');
|
||||
if (cfg.default_card_no) syncConfigToTable('公务卡号');
|
||||
alert('配置已加载');
|
||||
} catch (err) {
|
||||
alert('config.json 解析失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// ---- 拖拽 ----
|
||||
['pdf','img'].forEach(type => {
|
||||
const zone = document.getElementById(type + '-zone');
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
const files = Array.from(e.dataTransfer.files).filter(f => {
|
||||
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
|
||||
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
|
||||
});
|
||||
if (files.length) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
files.forEach(f => {
|
||||
f.__source = 'local'; // 标记为本地拖拽
|
||||
if (!list.find(x => x.name === f.name)) list.push(f);
|
||||
});
|
||||
renderFileList(type);
|
||||
zone.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 处理 ----
|
||||
async function startProcess() {
|
||||
if (!pdfFiles.length && !imgFiles.length) {
|
||||
alert('请先上传文件或图片');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-start');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '处理中...';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
document.getElementById('edit-section').style.display = 'none';
|
||||
document.getElementById('download-section').style.display = 'none';
|
||||
|
||||
try {
|
||||
await ensureSession();
|
||||
|
||||
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
|
||||
for (const {f} of allFiles) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
|
||||
|
||||
const cfg = {
|
||||
username: document.getElementById('cfg-username').value,
|
||||
password: document.getElementById('cfg-password').value,
|
||||
default_name: document.getElementById('cfg-name').value,
|
||||
default_card_no: document.getElementById('cfg-card').value,
|
||||
default_person_id: document.getElementById('cfg-person-id').value,
|
||||
consumable_storage: document.getElementById('cfg-storage').value,
|
||||
};
|
||||
|
||||
await fetch(`/api/process/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
|
||||
// 监听 SSE 日志
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
const result = msg.result;
|
||||
document.getElementById('status').innerHTML = result.ok
|
||||
? '<span class="badge bg-success status-badge">完成</span>'
|
||||
: '<span class="badge bg-danger status-badge">失败</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
|
||||
if (result.ok) {
|
||||
showDownloadLinks(result);
|
||||
loadInvoiceData(); // 加载可编辑数据
|
||||
} else {
|
||||
alert('处理失败: ' + (result.error || '未知错误'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
alert('请求失败: ' + (e.message || '未知错误'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 下载链接 ----
|
||||
function showDownloadLinks(result) {
|
||||
const section = document.getElementById('download-section');
|
||||
const box = document.getElementById('download-links');
|
||||
const warn = document.getElementById('doc-fill-warning');
|
||||
if (!section || !box) return;
|
||||
|
||||
const items = [];
|
||||
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
|
||||
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
|
||||
|
||||
lastDownloadUrls = {};
|
||||
items.forEach(it => { lastDownloadUrls[it.label] = it.url; });
|
||||
|
||||
box.innerHTML = items.map(it =>
|
||||
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
|
||||
).join('');
|
||||
|
||||
if (warn) {
|
||||
if (result.doc_skipped) {
|
||||
warn.style.display = 'block';
|
||||
warn.style.color = '#0d6efd';
|
||||
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
|
||||
} else if (result.doc_ok === false && result.doc_error) {
|
||||
warn.style.display = 'block';
|
||||
warn.style.color = '';
|
||||
warn.textContent = '出库单未生成:' + result.doc_error;
|
||||
} else {
|
||||
warn.style.display = 'none';
|
||||
warn.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ---- 发票数据编辑 ----
|
||||
async function loadInvoiceData() {
|
||||
try {
|
||||
const r = await fetch(`/api/data/${sessionId}`);
|
||||
const d = await r.json();
|
||||
if (d.error) return;
|
||||
|
||||
csvFilename = d.csv_filename || 'invoice_summary.csv';
|
||||
invoiceData = d.data || [];
|
||||
const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__'));
|
||||
renderTable(invoiceData, fields);
|
||||
|
||||
// 渲染后自动将配置区的工号/公务卡号同步到表格
|
||||
syncConfigToTable('工号');
|
||||
syncConfigToTable('公务卡号');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 配置区 → 表格的同步映射:工号 ↔ cfg-username,公务卡号 ↔ cfg-card
|
||||
const CONFIG_SYNC = {
|
||||
'工号': 'cfg-username',
|
||||
'公务卡号': 'cfg-card',
|
||||
};
|
||||
|
||||
// 从配置区输入框的值更新到所有表格行
|
||||
function syncConfigToTable(field) {
|
||||
const inputId = CONFIG_SYNC[field];
|
||||
if (!inputId) return;
|
||||
const val = document.getElementById(inputId).value || '';
|
||||
invoiceData.forEach((row, i) => {
|
||||
row[field] = val;
|
||||
});
|
||||
// 更新表格中对应单元格的显示
|
||||
const tbody = document.getElementById('invoice-tbody');
|
||||
if (!tbody) return;
|
||||
const rows = tbody.querySelectorAll('tr');
|
||||
rows.forEach((tr, i) => {
|
||||
const inputs = tr.querySelectorAll('input');
|
||||
fieldsCache.forEach((f, colIdx) => {
|
||||
if (f === field && inputs[colIdx]) {
|
||||
inputs[colIdx].value = val;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引
|
||||
|
||||
function renderTable(data, fields) {
|
||||
const section = document.getElementById('edit-section');
|
||||
if (!data.length) { section.style.display = 'none'; return; }
|
||||
section.style.display = 'block';
|
||||
|
||||
// 使用后端返回的字段顺序, fallback 到 Object.keys
|
||||
if (!fields || !fields.length) {
|
||||
fields = Object.keys(data[0]).filter(k => !k.startsWith('__'));
|
||||
}
|
||||
fieldsCache = fields;
|
||||
|
||||
// 表头
|
||||
document.getElementById('invoice-thead').innerHTML = `
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
${fields.map(f => `<th>${f}</th>`).join('')}
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// 表体:每行首列为序号,其后为各字段 input
|
||||
const rows = data.map((row, i) => {
|
||||
const cells = fields.map(f => {
|
||||
let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`;
|
||||
// 如果该字段参与配置区同步,额外调用 syncTableToConfig
|
||||
if (CONFIG_SYNC[f]) {
|
||||
onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`;
|
||||
}
|
||||
return `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
|
||||
onchange="${onChangeStr}"></td>`;
|
||||
}).join('');
|
||||
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('invoice-tbody').innerHTML = rows;
|
||||
}
|
||||
|
||||
// 从表格修改同步回配置区
|
||||
function syncTableToConfig(field, value) {
|
||||
const inputId = CONFIG_SYNC[field];
|
||||
if (inputId) {
|
||||
document.getElementById(inputId).value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 将当前表格编辑内容写回服务器(内部调用)
|
||||
async function saveInvoiceData() {
|
||||
try {
|
||||
const r = await fetch(`/api/save/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.doc_url || d.doc_ok === false) {
|
||||
showDownloadLinks({
|
||||
csv_url: lastDownloadUrls['invoice_summary.csv'],
|
||||
doc_url: d.doc_url,
|
||||
doc_ok: d.doc_ok,
|
||||
doc_error: d.doc_error,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('自动保存失败,继续提交:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 提交到财务系统 ----
|
||||
async function submitFinancial() {
|
||||
const btn = document.getElementById('btn-submit');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '提交中...';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
|
||||
try {
|
||||
// 提交前先自动保存当前表格编辑内容
|
||||
await saveInvoiceData();
|
||||
|
||||
await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' });
|
||||
|
||||
// 监听日志流(复用 SSE)
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
btn.disabled = false;
|
||||
const result = msg.result;
|
||||
if (result && result.submit_ok) {
|
||||
btn.textContent = '✅ 提交完成';
|
||||
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000);
|
||||
} else {
|
||||
const errorMsg = result?.submit_error || result?.error || '未知错误';
|
||||
btn.textContent = '❌ 提交失败';
|
||||
logBox.innerHTML += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
console.warn('提交失败:', errorMsg);
|
||||
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 提交到财务系统';
|
||||
};
|
||||
} catch (e) {
|
||||
alert('提交失败: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 提交到财务系统';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 二维码 / 手机扫码上传 ----
|
||||
let qrGenerated = false;
|
||||
let syncTimer = null;
|
||||
|
||||
async function generateQr() {
|
||||
await ensureSession();
|
||||
const mobileUrl = window.location.origin + '/mobile/' + sessionId;
|
||||
const box = document.getElementById('qrcode');
|
||||
box.innerHTML = '';
|
||||
new QRCode(box, {
|
||||
text: mobileUrl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
colorDark: '#333',
|
||||
colorLight: '#fff',
|
||||
});
|
||||
qrGenerated = true;
|
||||
}
|
||||
|
||||
// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ----
|
||||
async function startSync() {
|
||||
if (syncTimer) return;
|
||||
await syncFiles();
|
||||
syncTimer = setInterval(syncFiles, 3000);
|
||||
}
|
||||
|
||||
function stopSync() {
|
||||
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
|
||||
}
|
||||
|
||||
async function syncFiles() {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch(`/api/files/${sessionId}`);
|
||||
const d = await r.json();
|
||||
const serverNames = new Set(d.images || []);
|
||||
const localNames = new Set(imgFiles.map(f => f.name));
|
||||
|
||||
for (const name of serverNames) {
|
||||
if (!localNames.has(name)) {
|
||||
const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`);
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], name, { type: blob.type });
|
||||
file.__source = 'server'; // 标记为扫码上传
|
||||
imgFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// 只清理来自服务器但已不存在的文件,保留本地手动选择的文件
|
||||
for (const f of [...imgFiles]) {
|
||||
if (!serverNames.has(f.name) && f.__source !== 'local') {
|
||||
const idx = imgFiles.indexOf(f);
|
||||
if (idx > -1) imgFiles.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
renderFileList('img');
|
||||
if (imgFiles.length) {
|
||||
document.getElementById('img-zone').classList.add('active');
|
||||
} else {
|
||||
document.getElementById('img-zone').classList.remove('active');
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// 将需要 HTML 内联事件调用的函数挂载到 window
|
||||
window.handleChatKey = handleChatKey;
|
||||
window.sendUserMessage = sendUserMessage;
|
||||
window.initDragDrop = initDragDrop;
|
||||
window.toggleUploadPanel = toggleUploadPanel;
|
||||
window.handleFiles = handleFiles;
|
||||
window.handleForceSubmit = handleForceSubmit;
|
||||
|
||||
initDragDrop();
|
||||
generateQr();
|
||||
startSync();
|
||||
|
||||
// ---- 配置区 → 表格的双向同步绑定 ----
|
||||
Object.values(CONFIG_SYNC).forEach(inputId => {
|
||||
const el = document.getElementById(inputId);
|
||||
if (el) {
|
||||
el.addEventListener('input', () => {
|
||||
// 根据 inputId 反查对应的字段名
|
||||
const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0];
|
||||
if (field) syncConfigToTable(field);
|
||||
});
|
||||
}
|
||||
});
|
||||
startSync();
|
||||
68
src/web/static/js/process.js
Normal file
68
src/web/static/js/process.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 发票处理模块
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
import { ensureSession } from './utils.js';
|
||||
import {
|
||||
addTypingIndicator,
|
||||
removeTypingIndicator,
|
||||
addChatMessage,
|
||||
} from './chat.js';
|
||||
import { createSSEConnection, handleDoneResult } from './sse.js';
|
||||
|
||||
export async function startProcess() {
|
||||
if (!App.allFiles.length) {
|
||||
alert('请先上传文件或图片');
|
||||
return;
|
||||
}
|
||||
|
||||
App.isProcessing = true;
|
||||
App.processState = 'processing';
|
||||
|
||||
addTypingIndicator();
|
||||
|
||||
try {
|
||||
await ensureSession();
|
||||
|
||||
for (const f of App.allFiles) {
|
||||
// 跳过服务器同步的文件(已在服务器上)
|
||||
if (f.__source === 'server') continue;
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
removeTypingIndicator();
|
||||
addTypingIndicator();
|
||||
|
||||
const cfg = {
|
||||
username: App.sessionConfig.username || '',
|
||||
password: App.sessionConfig.password || '',
|
||||
default_name: App.sessionConfig.default_name || '',
|
||||
default_card_no: App.sessionConfig.default_card_no || '',
|
||||
default_person_id: App.sessionConfig.default_person_id || '',
|
||||
consumable_storage: App.sessionConfig.consumable_storage || '',
|
||||
};
|
||||
|
||||
await fetch(`/api/agent/process/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
|
||||
const es = createSSEConnection(App.sessionId, {
|
||||
onDone: (result) => {
|
||||
removeTypingIndicator();
|
||||
handleDoneResult(result);
|
||||
},
|
||||
});
|
||||
App.agentEventSource = es;
|
||||
|
||||
} catch (e) {
|
||||
removeTypingIndicator();
|
||||
alert('请求失败: ' + (e.message || '未知错误'));
|
||||
App.isProcessing = false;
|
||||
App.processState = 'idle';
|
||||
addChatMessage(`请求失败:${e.message || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user