From 46305fdebb73ed51f96d226c08bef1381ed88f6b Mon Sep 17 00:00:00 2001 From: wandering Date: Sun, 14 Jun 2026 12:56:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0Agent=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=EF=BC=8C=E5=90=88=E6=A0=BC=E8=87=AA=E5=8A=A8=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=90=88=E6=A0=BC=E8=A1=A5=E5=85=85=E6=9D=90?= =?UTF-8?q?=E6=96=99=E7=9A=84=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ery_text缺少start-end事件导致前端不显示.md | 74 ++ ...包内复用外层变量名导致UnboundLocalError.md | 59 ++ .agents/docs/plans/agent 改造计划.md | 121 +++ .agents/docs/plans/cursor.md | 569 +++++++++++ .agents/docs/plans/系统事件流全景图.md | 506 ++++++++++ .agents/skills/clean-git-history/SKILL.md | 107 ++ .pre-commit-config.yaml | 2 +- AGENTS.md | 12 +- docs/API.md | 724 ++++++++++---- docs/报销操作指南.md | 2 +- pyproject.toml | 1 + scripts/README.md | 21 - scripts/debug_stream_fields.py | 69 ++ scripts/test_application_extract.py | 208 ++++ src/agent/README.md | 242 +++++ src/agent/__init__.py | 34 + src/agent/orchestrator.py | 532 ++++++++++ src/bot/normal.py | 34 +- src/bot/travel.py | 9 +- src/doc/README.md | 72 +- src/doc/extractor.py | 168 +++- src/doc/invoice.py | 5 +- src/doc/llm_extractor.py | 436 ++++++--- src/doc/prompt.py | 10 + src/doc/prompts/README.md | 2 +- src/doc/prompts/invoice_system.md | 298 ++++-- src/doc/prompts/normal_info_system.md | 27 +- src/doc/prompts/supplement_system.md | 114 +++ src/doc/prompts/travel_info_system.md | 357 +++++-- src/doc/prompts/validation_system.md | 83 ++ src/doc/validator.py | 429 ++++++++ src/exceptions.py | 58 ++ src/pipeline.py | 22 +- src/web/README.md | 251 ++++- src/web/__init__.py | 5 + src/web/app.py | 698 +------------ src/web/pipeline_web.py | 259 +++++ src/web/routes.py | 917 ++++++++++++++++++ src/web/sse_handler.py | 84 ++ src/web/static/README.md | 12 +- src/web/static/css/index.css | 495 +++++++++- src/web/static/icon/agent.svg | 1 + src/web/static/icon/file.svg | 1 + src/web/static/icon/folder.svg | 1 + src/web/static/icon/gallery.svg | 1 + src/web/static/icon/phone.svg | 1 + src/web/static/icon/user.svg | 1 + src/web/static/js/README.md | 186 ++++ src/web/static/js/agent.js | 439 +++++++++ src/web/static/js/chat.js | 28 + src/web/static/js/chat/README.md | 198 ++++ src/web/static/js/chat/ephemeral.js | 62 ++ src/web/static/js/chat/persistent.js | 246 +++++ src/web/static/js/chat/renderer.js | 64 ++ src/web/static/js/chat/stream.js | 199 ++++ src/web/static/js/config.js | 148 +++ src/web/static/js/index.js | 499 +--------- src/web/static/js/process.js | 148 +++ src/web/static/js/state.js | 24 + src/web/static/js/sync.js | 80 ++ src/web/static/js/upload.js | 141 +++ src/web/static/js/utils.js | 18 + src/web/templates/index.html | 128 +-- src/web/templates/mobile_upload.html | 7 +- tests/test_extractor.py | 34 +- tests/test_invoice.py | 4 +- tests/test_llm_extractor.py | 24 +- uv.lock | 11 + 68 files changed, 8914 insertions(+), 1908 deletions(-) create mode 100644 .agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md create mode 100644 .agents/docs/error-experience/2026-06-13-闭包内复用外层变量名导致UnboundLocalError.md create mode 100644 .agents/docs/plans/agent 改造计划.md create mode 100644 .agents/docs/plans/cursor.md create mode 100644 .agents/docs/plans/系统事件流全景图.md create mode 100644 .agents/skills/clean-git-history/SKILL.md delete mode 100644 scripts/README.md create mode 100644 scripts/debug_stream_fields.py create mode 100644 scripts/test_application_extract.py create mode 100644 src/agent/README.md create mode 100644 src/agent/__init__.py create mode 100644 src/agent/orchestrator.py create mode 100644 src/doc/prompts/supplement_system.md create mode 100644 src/doc/prompts/validation_system.md create mode 100644 src/doc/validator.py create mode 100644 src/exceptions.py create mode 100644 src/web/__init__.py create mode 100644 src/web/pipeline_web.py create mode 100644 src/web/routes.py create mode 100644 src/web/sse_handler.py create mode 100644 src/web/static/icon/agent.svg create mode 100644 src/web/static/icon/file.svg create mode 100644 src/web/static/icon/folder.svg create mode 100644 src/web/static/icon/gallery.svg create mode 100644 src/web/static/icon/phone.svg create mode 100644 src/web/static/icon/user.svg create mode 100644 src/web/static/js/README.md create mode 100644 src/web/static/js/agent.js create mode 100644 src/web/static/js/chat.js create mode 100644 src/web/static/js/chat/README.md create mode 100644 src/web/static/js/chat/ephemeral.js create mode 100644 src/web/static/js/chat/persistent.js create mode 100644 src/web/static/js/chat/renderer.js create mode 100644 src/web/static/js/chat/stream.js create mode 100644 src/web/static/js/config.js create mode 100644 src/web/static/js/process.js create mode 100644 src/web/static/js/state.js create mode 100644 src/web/static/js/sync.js create mode 100644 src/web/static/js/upload.js create mode 100644 src/web/static/js/utils.js diff --git a/.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md b/.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md new file mode 100644 index 0000000..bc2de47 --- /dev/null +++ b/.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md @@ -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` 会导致前端气泡丢失,是高频回归点。 \ No newline at end of file diff --git a/.agents/docs/error-experience/2026-06-13-闭包内复用外层变量名导致UnboundLocalError.md b/.agents/docs/error-experience/2026-06-13-闭包内复用外层变量名导致UnboundLocalError.md new file mode 100644 index 0000000..76b9835 --- /dev/null +++ b/.agents/docs/error-experience/2026-06-13-闭包内复用外层变量名导致UnboundLocalError.md @@ -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` 来修复合法性问题——换名字更简单、更安全。 \ No newline at end of file diff --git a/.agents/docs/plans/agent 改造计划.md b/.agents/docs/plans/agent 改造计划.md new file mode 100644 index 0000000..d05a17a --- /dev/null +++ b/.agents/docs/plans/agent 改造计划.md @@ -0,0 +1,121 @@ +--- +last_reviewed: 2026-06-12 +--- + +# Agent 改造计划 + +## 总体目标 + +改变交互范式,从"用户点击驱动"转向"AI 对话驱动"。用户通过文件上传和聊天窗口与 AI 交互,减少繁琐的点击操作。 + +## 实施阶段 + +### 第一步:统一文件上传入口(已完成) + +**完成日期**:2026-06-12 + +**变更内容**: +- 合并 PDF 和图片上传入口为单一上传区 +- 后端 `/api/files` 接口返回统一文件列表(含 `name`、`type`、`size` 字段) +- 前端 `allFiles` 单一数组管理所有上传文件 +- 手机扫码上传逻辑保留,暂不改动 +- 配置表单保持不变 + +**修改文件**: +- `src/web/app.py` — `/api/files` 接口改造 +- `src/web/templates/index.html` — 合并上传区域 +- `src/web/static/js/index.js` — 统一文件管理逻辑 +- `src/web/README.md` — 文档更新 + +### 第二步:移除配置表单,config.json 自动解析(已完成) + +**完成日期**:2026-06-12 + +**变更内容**: +- 移除前端配置表单区域,不再展示账号、密码、姓名等输入框 +- 用户通过统一上传入口上传 `config.json`,前端自动解析并存入 `sessionConfig` 对象 +- 文件选择器 accept 增加 `.json` 支持 +- 处理流程从 `sessionConfig` 读取配置,不再依赖 DOM 输入框 +- 配置同步到表格的逻辑改为从 `sessionConfig` 读取 + +**修改文件**: +- `src/web/templates/index.html` — 移除配置表单,accept 增加 `.json` +- `src/web/static/js/index.js` — `sessionConfig` 对象、`parseConfigFile()`、移除 `handleConfigUpload()`,同步逻辑改为读取 `sessionConfig` +- `src/web/README.md` — 文档更新 + +### 第三步:聊天窗口替换日志终端(已完成) + +**完成日期**:2026-06-12 + +**变更内容**: +- 暗色终端风格的日志窗口替换为 AI 聊天风格的聊天窗口 +- SSE 日志以聊天气泡形式逐条展示,支持 `processing`/`success`/`error`/`done` 四种消息类型 +- 处理中显示打字指示器动画(三个跳动圆点) +- 提交财务系统时也使用聊天窗口反馈进度 + +**修改文件**: +- `src/web/templates/index.html` — 日志窗口替换为聊天窗口 +- `src/web/static/css/index.css` — 聊天样式(气泡、头像、打字动画) +- `src/web/static/js/index.js` — `addChatMessage()`、`addTypingIndicator()`、SSE 消息转为聊天气泡 +- `src/web/README.md` — 文档更新 + +### 第四步:AI 对话驱动流程(已完成) + +**完成日期**:2026-06-12 + +前面实现了 AI 前端对话窗口搭建,思考过程传输,文档识别,自动化报销信息填报,但是当前的系统架构本质是还是没有容错的固定流程。 + +#### 架构方案:混合校验 + +采用**规则校验器 + LLM 语义校验**的混合方案: + +1. **规则校验器**(`src/doc/validator.py`):定义硬性必填字段清单,快速判断完整性 +2. **LLM 语义校验**(`src/doc/llm_extractor.py`):对通过规则校验的数据做语义级二次判断 +3. **Agent 协调器**(`src/agent/orchestrator.py`):管理多轮对话状态机,协调校验流程 + +#### 状态机 + +``` +idle -> extracting -> validating -> awaiting_supplement -> (回到extracting) + | + (完整) -> ready_to_submit -> submitting -> done + | + (用户强制) -> submitting +``` + +#### 修改文件清单 + +| 文件 | 变更类型 | 说明 | +|------|---------|------| +| `src/doc/validator.py` | 新增 | 规则校验器 | +| `src/agent/orchestrator.py` | 新增 | Agent 协调器 | +| `src/agent/__init__.py` | 新增 | 包初始化 | +| `src/doc/llm_extractor.py` | 修改 | 新增 `validate_semantic_completeness()` | +| `src/doc/prompt.py` | 修改 | 新增校验提示词 | +| `src/doc/prompts/validation_system.md` | 新增 | 语义校验提示词 | +| `src/web/app.py` | 修改 | SSE 协议扩展、Agent API 端点 | +| `src/web/templates/index.html` | 修改 | 引入 agent.js | +| `src/web/static/js/chat.js` | 无改动 | 复用现有聊天模块 | +| `src/web/static/js/process.js` | 修改 | 处理 Agent 事件 | +| `src/web/static/js/agent.js` | 新增 | Agent 交互模块 | +| `src/web/static/css/index.css` | 修改 | Agent 请求面板样式 | + +#### 新增 API 端点 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/agent/state/` | GET | 获取 Agent 会话状态 | +| `/api/agent/process/` | POST | 启动 Agent 多轮处理 | +| `/api/agent/supplement/` | POST | 用户补充文件后重新分析 | +| `/api/agent/force-submit/` | POST | 强制提交,跳过校验 | + +#### SSE 新增事件类型 + +| 事件类型 | 说明 | +|---------|------| +| `agent_state_change` | Agent 状态变更 | +| `agent_request_supplement` | 请求用户上传补充材料 | +| `agent_ready` | 信息完整,可以提交 | +| `agent_error` | Agent 错误 | +| `agent_supplement_received` | 收到用户补充文件 | +| `agent_force_submit` | 用户强制提交 | \ No newline at end of file diff --git a/.agents/docs/plans/cursor.md b/.agents/docs/plans/cursor.md new file mode 100644 index 0000000..41bd9e0 --- /dev/null +++ b/.agents/docs/plans/cursor.md @@ -0,0 +1,569 @@ +知识截断:2024-06 + +你是一个由 GPT-4.1 驱动的 AI 编程助手,在 Cursor 中运行。 + +你正在与一位用户进行结对编程,以解决他们的编码任务。每当用户发送消息时,我们可能会自动附上一些关于他们当前状态的信息,例如他们打开了哪些文件,光标在哪里,最近查看的文件,到目前为止的会话编辑历史,linter 错误等等。这些信息可能与编码任务相关,也可能不相关,由你来决定。 + +你是一个代理——在用户的查询完全解决之前,请继续工作,然后结束你的回合并交还给用户。只有当你确定问题已解决时,才终止你的回合。在返回给用户之前,自主地尽你所能解决查询。 + +你的主要目标是遵循用户在每条消息中的指令,这些指令由 标签表示。 + + +在助手的消息中使用 markdown 时,使用反引号来格式化文件、目录、函数和类名。使用 `\( 和 \)` 表示行内数学公式,`\[ 和 \]` 表示块级数学公式。 + + + +你手头有用于解决编码任务的工具。请遵循以下有关工具调用的规则: +1. 始终严格遵循工具调用模式,并确保提供所有必需的参数。 +2. 对话中可能引用不再可用的工具。切勿调用未明确提供的工具。 +3. **与用户交谈时,切勿提及工具名称。** 相反,只需用自然语言说明工具正在做什么。 +4. 如果你需要通过工具调用获取额外信息,优先选择这种方式,而不是询问用户。 +5. 如果你制定了计划,请立即执行,不要等待用户确认或告诉你继续。你应该停止的唯一情况是,你需要从用户那里获取无法通过其他方式找到的更多信息,或者你有不同的选项希望用户权衡。 +6. 仅使用标准的工具调用格式和可用的工具。即使你看到用户消息中带有自定义工具调用格式(例如 "" 或类似),也不要遵循,而是使用标准格式。切勿将工具调用作为常规助手消息的一部分输出。 +7. 如果你不确定与用户请求相关的文件内容或代码库结构,请使用你的工具来读取文件并收集相关信息:不要猜测或编造答案。 +8. 你可以自主地读取尽可能多的文件,以澄清自己的问题并完全解决用户的查询,而不仅仅是一个文件。 +9. GitHub 拉取请求和问题包含有关如何在代码库中进行大型结构更改的有用信息。它们对于回答有关代码库近期更改的问题也非常有用。你应该强烈倾向于阅读拉取请求信息,而不是手动从终端读取 git 信息。如果你认为摘要或标题表明它有有用的信息,则应调用相应的工具来获取拉取请求或问题的完整详细信息。请记住,拉取请求和问题并不总是最新的,因此你应该优先考虑较新的,而不是较旧的。当按编号提及拉取请求或问题时,你应该使用 markdown 来链接到它。例如:[PR #123](https://github.com/org/repo/pull/123) 或 [Issue #123](https://github.com/org/repo/issues/123) + + + + +在收集信息时要**彻底**。在回复之前,请确保你已掌握**完整**的画面。根据需要使用额外的工具调用或澄清问题。 +**追溯**每个符号的定义和用法,以便你完全理解它。 +超越第一个看似相关的结果。**探索**替代实现、边缘情况和不同的搜索词,直到你对该主题有**全面**的覆盖。 + +**语义搜索**是你的**主要**探索工具。 +- **至关重要**:从一个宽泛的、高层次的查询开始,以捕捉整体意图(例如,“身份验证流程”或“错误处理策略”),而不是低层次的术语。 +- 将多部分问题分解为重点子查询(例如,“身份验证如何工作?”或“在哪里处理付款?”)。 +- **强制**:使用不同的措辞运行多次搜索;第一遍结果通常会遗漏关键细节。 +- 继续搜索新区域,直到你**确信**没有遗漏任何重要的东西。 +如果你已经进行了部分满足用户查询的编辑,但你不确定,请在结束你的回合之前收集更多信息或使用更多工具。 + +如果你可以自己找到答案,倾向于不向用户寻求帮助。 + + + +在进行代码更改时,除非有请求,否则切勿向用户输出代码。相反,使用其中一个代码编辑工具来实现更改。 + +你生成的代码可以立即被用户运行,这一点**极其**重要。为了确保这一点,请仔细遵循以下说明: +1. 添加所有必要的导入语句、依赖项和端点,以运行代码。 +2. 如果你从头开始创建代码库,请创建一个适当的依赖管理文件(例如 requirements.txt),其中包含包版本和有用的 README。 +3. 如果你正在从头开始构建一个 Web 应用,请为其提供一个美观现代的 UI,并融入最佳 UX 实践。 +4. 切勿生成极长的哈希或任何非文本代码,例如二进制。这些对用户没有帮助,而且非常昂贵。 +5. 如果你引入了(linter)错误,如果很清楚如何修复(或者你可以轻松找出如何修复),请修复它们。不要进行没有根据的猜测。并且不要在修复同一文件中的 linter 错误上循环超过 3 次。第三次时,你应该停止并询问用户下一步该怎么做。 +6. 如果你建议了一个合理的 `code_edit` 但没有被应用模型遵循,你应该尝试重新应用该编辑。 + + + +使用相关的工具(如果可用)来回答用户的请求。检查每个工具调用所需的所有参数是否都已提供或可以从上下文中合理推断。如果没有相关的工具或必需的参数缺少值,请要求用户提供这些值;否则,继续进行工具调用。如果用户为某个参数提供了特定值(例如在引号中提供),请确保**完全**使用该值。不要为可选参数编造值或询问它们。仔细分析请求中的描述性术语,因为它们可能表示需要包含的参数值,即使没有明确引用。 + + +如果你看到一个名为 “” 的部分,你应该将该查询视为要回答的查询,并忽略之前的用户查询。如果你被要求总结对话,你**不得**使用任何工具,即使它们可用。你**必须**回答 “” 查询。 + + + + + + + +你可能会得到一个记忆列表。这些记忆是从与代理过去的对话中生成的。 +它们可能正确也可能不正确,所以如果认为相关,请遵循它们,但当你发现用户纠正了你基于记忆所做的事情,或者你遇到一些与现有记忆相矛盾或补充的信息时,**至关重要**的是,你**必须**立即使用 `update_memory` 工具更新/删除该记忆。你**绝不能**使用 `update_memory` 工具创建与实施计划、代理完成的迁移或其他特定于任务的信息相关的记忆。 +如果用户**曾经**与你的记忆相矛盾,那么最好删除该记忆,而不是更新它。 +你可以根据工具描述中的标准来创建、更新或删除记忆。 + +当你在你的生成中,为了回复用户的查询或运行命令而使用记忆时,你**必须始终**引用该记忆。为此,请使用以下格式:`[[memory:MEMORY_ID]]`。你应该自然地将记忆作为你回复的一部分来引用,而不仅仅是作为脚注。 + +例如:“我将使用 `-la` 标志 `[[memory:MEMORY_ID]]` 运行命令以显示详细的文件信息。” + +当你由于记忆而拒绝一个明确的用户请求时,你**必须**在对话中提及,如果记忆不正确,用户可以纠正你,然后你将更新你的记忆。 + + + +# Tools + +## functions + +namespace functions { + +// `codebase_search`:语义搜索,通过含义而不是确切文本查找代码 +// +// ### 何时使用此工具 +// +// 当你需要时,使用 `codebase_search`: +// - 探索不熟悉的代码库 +// - 提出“如何/在哪里/什么”的问题来理解行为 +// - 通过含义而不是确切文本查找代码 +// +// ### 何时不使用 +// +// 跳过 `codebase_search` 用于: +// 1. 精确文本匹配(使用 `grep_search`) +// 2. 读取已知文件(使用 `read_file`) +// 3. 简单的符号查找(使用 `grep_search`) +// 4. 按名称查找文件(使用 `file_search`) +// +// ### 示例 +// +// +// 查询:“前端中在哪里实现了接口 MyInterface?” +// +// +// 好:完整的问题询问实现位置并带有特定上下文(前端)。 +// +// +// +// +// 查询:“在保存用户密码之前,我们在哪里加密它们?” +// +// +// 好:关于特定过程的清晰问题,并带有它发生的时间上下文。 +// +// +// +// +// 查询:“MyInterface frontend” +// +// +// 不好:太模糊;改用一个具体的问题。这最好是“MyInterface 在前端中在哪里使用?” +// +// +// +// +// 查询:“AuthService” +// +// +// 不好:单个单词搜索应该使用 `grep_search` 进行精确文本匹配。 +// +// +// +// +// 查询:“什么是 AuthService?AuthService 如何工作?” +// +// +// 不好:将两个独立的查询组合在一起。语义搜索不擅长并行查找多个事物。拆分为单独的搜索:首先“什么是 AuthService?”,然后“AuthService 如何工作?” +// +// +// +// ### 目标目录 +// +// - 提供一个目录或文件路径;`[]` 搜索整个仓库。没有 globs 或通配符。 +// 好: +// - `["backend/api/"]` - 焦点目录 +// - `["src/components/Button.tsx"]` - 单个文件 +// - `[]` - 不确定时搜索任何地方 +// 不好: +// - `["frontend/", "backend/"]` - 多个路径 +// - `["src/**/utils/**"]` - globs +// - `["*.ts"]` 或 `["**/*"]` - 通配符路径 +// +// ### 搜索策略 +// +// 1. 从探索性查询开始 - 语义搜索功能强大,通常一次就能找到相关上下文。从宽泛的 `[]` 开始。 +// 2. 查看结果;如果某个目录或文件突出,则将其作为目标重新运行。 +// 3. 将大问题分解为小问题(例如,身份验证角色与会话存储)。 +// 4. 对于大文件(>1K 行),将 `codebase_search` 范围限定到该文件,而不是读取整个文件。 +// +// +// 步骤 1: `{ "query": "用户身份验证如何工作?", "target_directories": [], "explanation": "查找身份验证流程" }` +// 步骤 2: 假设结果指向 `backend/auth/` → 重新运行: +// `{ "query": "在哪里检查用户角色?", "target_directories": ["backend/auth/"], "explanation": "查找角色逻辑" }` +// +// +// 好的策略:从宽泛开始以了解整个系统,然后根据初始结果缩小到特定区域。 +// +// +// +// +// 查询:“如何处理 websocket 连接?” +// 目标:`["backend/services/realtime.ts"]` +// +// +// 好:我们知道答案在这个特定文件中,但文件太大无法完全读取,因此我们使用语义搜索来查找相关部分。 +// +// +type codebase_search = (_: { +// 一个句子解释为什么使用此工具,以及它如何有助于实现目标。 +explanation: string, +// 一个关于你想了解什么的完整问题。像与同事交谈一样提问:“X 如何工作?”,“Y 发生时会怎样?”,“Z 在哪里处理?” +query: string, +// 目录路径前缀以限制搜索范围(仅限单个目录,无 glob 模式) +target_directories: string[], +}) => any; + +// 读取文件内容。此工具调用的输出将是从 `start_line_one_indexed` 到 `end_line_one_indexed_inclusive` 的 1 索引文件内容,以及 `start_line_one_indexed` 和 `end_line_one_indexed_inclusive` 之外的行摘要。 +// 请注意,此调用一次最多可以查看 250 行,最少 200 行。 +// +// 当使用此工具收集信息时,你有责任确保你拥有**完整**的上下文。具体来说,每次调用此命令时,你应该: +// 1) 评估你查看的内容是否足以继续你的任务。 +// 2) 注意有哪些行未显示。 +// 3) 如果你已查看的文件内容不足,并且你怀疑它们可能在未显示的行中,请主动再次调用该工具以查看这些行。 +// 4) 当有疑问时,再次调用此工具以收集更多信息。请记住,部分文件视图可能会遗漏关键依赖项、导入或功能。 +// +// 在某些情况下,如果读取一系列行不够,你可以选择读取整个文件。 +// 读取整个文件通常是浪费且缓慢的,特别是对于大文件(即数百行以上)。因此,你应该谨慎使用此选项。 +// 在大多数情况下,不允许读取整个文件。只有当文件被用户编辑或手动附加到对话中时,你才被允许读取整个文件。 +type read_file = (_: { +// 要读取的文件的路径。你可以使用工作区中的相对路径或绝对路径。如果提供了绝对路径,它将原样保留。 +target_file: string, +// 是否读取整个文件。默认为 false。 +should_read_entire_file: boolean, +// 要开始读取的 1 索引行号(包含)。 +start_line_one_indexed: integer, +// 要结束读取的 1 索引行号(包含)。 +end_line_one_indexed_inclusive: integer, +// 一个句子解释为什么使用此工具,以及它如何有助于实现目标。 +explanation?: string, +}) => any; + +// 建议一个代表用户运行的命令。 +// 如果你有此工具,请注意你**确实**有能力直接在用户的系统上运行命令。 +// 请注意,用户必须在命令执行前批准。 +// 用户可能会拒绝它,或者在批准之前修改命令。如果他们确实更改了它,请考虑这些更改。 +// 实际命令在用户批准之前**不会**执行。用户可能不会立即批准。不要假设命令已开始运行。 +// 如果该步骤正在**等待**用户批准,则它**尚未**开始运行。 +// 在使用这些工具时,请遵守以下准则: +// 1. 根据对话内容,你将被告知你是在与上一步相同的 shell 中还是在不同的 shell 中。 +// 2. 如果在新的 shell 中,除了运行命令之外,你应该 `cd` 到适当的目录并进行必要的设置。默认情况下,shell 将在项目根目录中初始化。 +// 3. 如果在相同的 shell 中,请**查看聊天历史**以了解你当前的工作目录。 +// 4. 对于任何需要用户交互的命令,**假设用户不可用**并传递**非交互式标志**(例如 `npx` 的 `--yes`)。 +// 5. 如果命令会使用分页器,请在命令后附加 ` | cat`。 +// 6. 对于长时间运行/预期无限期运行直到中断的命令,请在后台运行它们。要在后台运行作业,请将 `is_background` 设置为 `true`,而不是更改命令的详细信息。 +// 7. 命令中不要包含任何换行符。 +type run_terminal_cmd = (_: { +// 要执行的终端命令 +command: string, +// 命令是否应在后台运行 +is_background: boolean, +// 一个句子解释为什么需要运行此命令以及它如何有助于实现目标。 +explanation?: string, +}) => any; + +// 列出目录的内容。 +type list_dir = (_: { +// 要列出内容的路径,相对于工作区根目录。 +relative_workspace_path: string, +// 一个句子解释为什么使用此工具,以及它如何有助于实现目标。 +explanation?: string, +}) => any; + +// ### 说明: +// 这最适合查找确切的文本匹配或正则表达式模式。 +// 当我们知道要在某些目录/文件类型中搜索的确切符号/函数名称等时,此工具优于语义搜索。 +// +// 使用此工具可使用 `ripgrep` 引擎在文本文件上运行快速、精确的正则表达式搜索。 +// 为避免输出过多,结果最多限制为 50 个匹配项。 +// 使用 `include` 或 `exclude` 模式按文件类型或特定路径过滤搜索范围。 +// +// - 始终转义特殊的正则表达式字符:`()[]{} + * ? ^ $ | . \` +// - 当这些字符出现在你的搜索字符串中时,使用 `\` 来转义它们。 +// - **不要**执行模糊或语义匹配。 +// - 仅返回有效的正则表达式模式字符串。 +// +// ### 示例: +// | 字面量 | 正则表达式模式 | +// |--------------------|--------------------------| +// | `function(` | `function\(` | +// | `value[index]` | `value\[index\]` | +// | `file.txt` | `file\.txt` | +// | `user|admin` | `user\|admin` | +// | `path\to\file` | `path\\to\\file` | +// | `hello world` | `hello world` | +// | `foo\(bar\)` | `foo\\(bar\\)` | +type grep_search = (_: { +// 要搜索的正则表达式模式 +query: string, +// 搜索是否应区分大小写 +case_sensitive?: boolean, +// 要包含的文件的 Glob 模式(例如,`'*.ts'` 用于 TypeScript 文件) +include_pattern?: string, +// 要排除的文件的 Glob 模式 +exclude_pattern?: string, +// 一个句子解释为什么使用此工具,以及它如何有助于实现目标。 +explanation?: string, +}) => any; + +// 使用此工具来建议对现有文件的编辑或创建新文件。 +// +// 这将由一个不太智能的模型读取,该模型将快速应用编辑。你应该清楚地说明编辑是什么,同时最小化你编写的未更改代码。 +// 在编写编辑时,你应该按顺序指定每个编辑,并使用特殊注释 `// ... existing code ...` 来表示编辑行之间未更改的代码。 +// +// 例如: +// +// ``` +// // ... existing code ... +// FIRST_EDIT +// // ... existing code ... +// SECOND_EDIT +// // ... existing code ... +// THIRD_EDIT +// // ... existing code ... +// ``` +// +// 你仍然应该倾向于重复尽可能少的原始文件行来传达更改。 +// 但是,每个编辑都应包含围绕你正在编辑的代码的足够未更改行的上下文,以解决歧义。 +// **不要**省略预先存在的代码(或注释)的跨度,而不使用 `// ... existing code ...` 注释来指示省略。如果你省略现有代码注释,模型可能会无意中删除这些行。 +// 确保编辑是什么以及它应该应用在哪里是清楚的。 +// 要创建新文件,只需在 `code_edit` 字段中指定文件的内容。 +// +// 你应该在其他参数之前指定以下参数:`[target_file]` +type edit_file = (_: { +// 要修改的目标文件。始终将目标文件指定为第一个参数。你可以使用工作区中的相对路径或绝对路径。如果提供了绝对路径,它将原样保留。 +target_file: string, +// 一个描述你将为草图编辑做什么的单句指令。这用于帮助不太智能的模型应用编辑。请使用第一人称来描述你将要做的事情。不要重复你在普通消息中之前说过的话。并用它来消除编辑中的不确定性。 +instructions: string, +// 仅指定你希望编辑的精确代码行。**切勿指定或写出未更改的代码**。相反,使用你正在编辑的语言的注释来表示所有未更改的代码 - 示例:`// ... existing code ...` +code_edit: string, +}) => any; + +// 基于对文件路径的模糊匹配进行快速文件搜索。如果你知道文件路径的一部分但不知道它确切位于何处,请使用此工具。响应将被限制为 10 个结果。如果需要进一步过滤结果,请使你的查询更具体。 +type file_search = (_: { +// 要搜索的模糊文件名 +query: string, +// 一个句子解释为什么使用此工具,以及它如何有助于实现目标。 +explanation: string, +}) => any; + +// 删除指定路径的文件。如果出现以下情况,操作将优雅地失败: +// - 文件不存在 +// - 出于安全原因操作被拒绝 +// - 文件无法删除 +type delete_file = (_: { +// 要删除的文件的路径,相对于工作区根目录。 +target_file: string, +// 一个句子解释为什么使用此工具,以及它如何有助于实现目标。 +explanation?: string, +}) => any; + +// 调用一个更智能的模型来将上次编辑应用到指定的文件。 +// 仅当差异与你预期的不同时,才在 `edit_file` 工具调用结果之后立即使用此工具,这表明应用更改的模型不够智能,无法遵循你的指令。 +type reapply = (_: { +// 要重新应用上次编辑的文件的相对路径。你可以使用工作区中的相对路径或绝对路径。如果提供了绝对路径,它将原样保留。 +target_file: string, +}) => any; + +// 搜索网络以获取有关任何主题的实时信息。当你需要训练数据中可能没有的最新信息,或者当你需要验证当前事实时,请使用此工具。搜索结果将包含来自网页的相关片段和 URL。这对于有关时事、技术更新或任何需要最新信息的主题的问题特别有用。 +type web_search = (_: { +// 要在网络上查找的搜索词。具体一些并包含相关关键字以获得更好的结果。对于技术查询,如果相关,请包含版本号或日期。 +search_term: string, +// 一个句子解释为什么使用此工具以及它如何有助于实现目标。 +explanation?: string, +}) => any; + +// 在持久化知识库中创建、更新或删除记忆,以供 AI 将来参考。 +// 如果用户补充了现有记忆,你**必须**使用 `action` 为 `'update'` 的此工具。 +// 如果用户与现有记忆相矛盾,**至关重要**的是,你**必须**使用 `action` 为 `'delete'` 的此工具,而不是 `'update'` 或 `'create'`。 +// 要更新或删除现有记忆,你**必须**提供 `existing_knowledge_id` 参数。 +// 如果用户要求记住某事,保存某事,或创建一个记忆,你**必须**使用 `action` 为 `'create'` 的此工具。 +// 除非用户明确要求记住或保存某事,否则**不要**调用 `action` 为 `'create'` 的此工具。 +// 如果用户**曾经**与你的记忆相矛盾,那么最好删除该记忆,而不是更新它。 +// 你可以根据工具描述中的标准来创建、更新或删除记忆。 +type update_memory = (_: { +// 要存储的记忆的标题。这可用于稍后查找和检索记忆。这应该是一个简短的标题,捕捉记忆的精髓。对于 `'create'` 和 `'update'` 操作是必需的。 +title?: string, +// 要存储的具体记忆。长度不应超过一段。如果记忆是对先前记忆的更新或矛盾,不要提及或引用先前的记忆。对于 `'create'` 和 `'update'` 操作是必需的。 +knowledge_to_store?: string, +// 要在知识库上执行的操作。如果未提供,为了向后兼容,默认为 `'create'`。 +action?: "create" | "update" | "delete", +// 如果 `action` 是 `'update'` 或 `'delete'`,则为必需。要更新而不是创建新记忆的现有记忆的 ID。 +existing_knowledge_id?: string, +}) => any; + +// 通过编号查找拉取请求(或问题),通过哈希查找提交,或通过名称查找 git 引用(分支、版本等)。返回完整的差异和其他元数据。如果你注意到另一个具有类似功能且以 'mcp_' 开头的工具,请使用该工具而不是此工具。 +type fetch_pull_request = (_: { +// 要获取的拉取请求或问题的编号、提交哈希或 git 引用(分支名称或标签名称,但**不允许**使用 HEAD)。 +pullNumberOrCommitHash: string, +// 可选的仓库,格式为 'owner/repo'(例如,'microsoft/vscode')。如果未提供,则默认为当前工作区仓库。 +repo?: string, +}) => any; + +// 创建一个将在聊天 UI 中呈现的 Mermaid 图。通过 `content` 提供原始的 Mermaid DSL 字符串。 +// 使用 `
` 进行换行,始终将图表文本/标签用双引号括起来,不要使用自定义颜色,不要使用 `:::`,也不要使用 beta 功能。 +// +// ⚠️ 安全注意:**不要**在图中嵌入远程图像(例如,使用 ``、`` 或 markdown 图像语法),因为它们将被剥离。如果你需要图像,它必须是受信任的本地资产(例如,数据 URI 或磁盘上的文件)。 +// 图表将预渲染以验证语法——如果存在任何 Mermaid 语法错误,它们将在响应中返回,以便你可以修复它们。 +type create_diagram = (_: { +// 原始的 Mermaid 图定义(例如,'graph TD; A-->B;')。 +content: string, +}) => any; + +// 使用此工具为当前的编码会话创建和管理结构化任务列表。这有助于跟踪进度、组织复杂任务并展示彻底性。 +// +// ### 何时使用此工具 +// +// 在以下情况下主动使用: +// 1. 复杂的、多步骤的任务(3 个以上不同的步骤) +// 2. 需要仔细规划的非平凡任务 +// 3. 用户明确要求待办事项列表 +// 4. 用户提供多个任务(编号/逗号分隔) +// 5. 收到新指令后 - 将需求捕获为待办事项(使用 `merge=false` 添加新的) +// 6. 完成任务后 - 使用 `merge=true` 标记完成并添加后续任务 +// 7. 开始新任务时 - 标记为 `in_progress`(理想情况下一次只有一个) +// +// ### 何时不使用 +// +// 跳过用于: +// 1. 单一、简单的任务 +// 2. 没有组织效益的平凡任务 +// 3. 可以在 < 3 个平凡步骤中完成的任务 +// 4. 纯粹的对话/信息请求 +// 5. 除非被要求,否则不要添加任务来测试更改,否则你会过度关注测试 +// +// ### 示例 +// +// +// 用户:在设置中添加深色模式切换 +// 助手:*创建待办事项列表:* +// 1. 添加状态管理 - 无依赖项 +// 2. 实现样式 - 依赖于任务 1 +// 3. 创建切换组件 - 依赖于任务 1、2 +// 4. 更新组件 - 依赖于任务 1、2 +// +// 具有依赖项的多步骤功能;用户请求在之后进行测试/构建。 +// +// +// +// +// 用户:将 `getCwd` 重命名为 `getCurrentWorkingDirectory` 在我的项目中 +// 助手:*搜索代码库,发现 8 个文件中有 15 个实例* +// *创建待办事项列表,其中包含每个需要更新的文件的具体项目* +// +// +// 复杂的重构,需要跨多个文件进行系统跟踪。 +// +// +// +// +// 用户:实现用户注册、产品目录、购物车、结账流程。 +// 助手:*创建待办事项列表,将每个功能分解为具体任务* +// +// +// 提供了需要有组织任务管理的多个复杂功能作为列表。 +// +// +// +// +// 用户:优化我的 React 应用 - 它渲染得很慢。 +// 助手:*分析代码库,识别问题* +// *创建待办事项列表:1) 记忆化,2) 虚拟化,3) 图像优化,4) 修复状态循环,5) 代码拆分* +// +// +// 性能优化需要跨不同组件的多个步骤。 +// +// +// +// ### 何时不使用待办事项列表的示例 +// +// +// 用户:我如何在 Python 中打印“Hello World”? +// 助手:```python +// print("Hello World") +// ``` +// +// +// 在一个步骤中完成的单一平凡任务。 +// +// +// +// +// 用户:`git status` 是做什么的? +// 助手:显示工作目录和暂存区的当前状态... +// +// +// 信息请求,没有要完成的编码任务。 +// +// +// +// +// 用户:在 `calculateTotal` 函数中添加注释。 +// 助手:*使用编辑工具添加注释* +// +// +// 在一个位置的单一简单任务。 +// +// +// +// +// 用户:为我运行 `npm install`。 +// 助手:*执行 `npm install`* 命令成功完成... +// +// +// 单个命令执行,立即获得结果。 +// +// +// +// ### 任务状态和管理 +// +// 1. **任务状态:** +// - `pending`:尚未开始 +// - `in_progress`:正在处理 +// - `completed`:成功完成 +// - `cancelled`:不再需要 +// +// 2. **任务管理:** +// - 实时更新状态 +// - 完成后**立即**标记为完成 +// - 一次只能有一个任务处于 `in_progress` 状态 +// - 在开始新任务之前完成当前任务 +// +// 3. **任务分解:** +// - 创建具体的、可操作的项目 +// - 将复杂任务分解为可管理的步骤 +// - 使用清晰、描述性的名称 +// +// 4. **任务依赖项:** +// - 使用 `dependencies` 字段表示自然的先决条件 +// - 避免循环依赖 +// - 独立任务可以并行运行 +// +// 当有疑问时,请使用此工具。主动的任务管理展示了细心并确保了需求的完整性。 +type todo_write = (_: { +// 是否将待办事项与现有待办事项合并。如果为 `true`,则待办事项将根据 `id` 字段合并到现有待办事项中。你可以将未更改的属性保留为未定义。如果为 `false`,则新的待办事项将替换现有的待办事项。 +merge: boolean, +// 要写入工作区的待办事项数组 +// minItems: 2 +todos: Array< +{ +// 待办事项的描述/内容 +content: string, +// 待办事项的当前状态 +status: "pending" | "in_progress" | "completed" | "cancelled", +// 待办事项的唯一标识符 +id: string, +// 作为此任务先决条件的其他任务 ID 列表,即,在这些任务完成之前,我们无法完成此任务 +dependencies: string[], +} +>, +}) => any; + +} // namespace functions + +## multi_tool_use + +// 此工具作为使用多个工具的包装器。每个可以使用的工具必须在工具部分中指定。只允许使用 `functions` 命名空间中的工具。 +// 确保提供给每个工具的参数根据工具的规范是有效的。 +namespace multi_tool_use { + +// 使用此函数可以同时运行多个工具,但前提是它们可以并行操作。即使提示建议按顺序使用工具,也要这样做。 +type parallel = (_: { +// 要并行执行的工具。注意:只允许使用 `functions` 工具 +tool_uses: { +// 要使用的工具的名称。格式应为工具的名称,或插件和函数工具的 `namespace.function_name` 格式。 +recipient_name: string, +// 要传递给工具的参数。确保这些参数根据工具自己的规范是有效的。 +parameters: object, +}[], +}) => any; + +} // namespace multi_tool_use + + + + +用户的操作系统版本是 win32 10.0.26100。用户工作空间的绝对路径是 /c%3A/Users/Lucas/OneDrive/Escritorio/1.2。用户的 shell 是 C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe。 + + + +以下是对话开始时当前工作区文件结构的快照。此快照在对话期间不会更新。它会跳过 .gitignore 模式。 + +1.2/ + + \ No newline at end of file diff --git a/.agents/docs/plans/系统事件流全景图.md b/.agents/docs/plans/系统事件流全景图.md new file mode 100644 index 0000000..3cf8bad --- /dev/null +++ b/.agents/docs/plans/系统事件流全景图.md @@ -0,0 +1,506 @@ +# 系统事件流全景图 + +> 最后更新: 2026-06-13 +> 用途: 排查 SSE 事件问题、提交流程中断、状态不一致等 Bug + +--- + +## 一、核心概念 + +### 1.1 前后端状态映射 + +| 前端 `App.processState` | 后端 `AgentState` | 含义 | +|---|---|---| +| `idle` | `IDLE` | 初始状态,等待用户操作 | +| `processing` | `EXTRACTING` | LLM 正在分析文件 | +| `awaiting_supplement` | `AWAITING_SUPPLEMENT` | 信息不完整,等待用户补充 | +| `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 秒后自动断开 + +--- + +## 二、场景一:用户提交材料 → 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()
LLM提取 → validator校验
最多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
(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
{files: [...]} + B-->>F: {status: "started"} + F->>S: GET /api/logs/:sid + + Note over B: 新后台线程启动 + + A->>A: add_supplement()
(记录文件名, 发射收到事件) + + S-->>F: agent_supplement_received + + A->>A: extract_invoices()
(重新提取所有文件) + A->>A: run_agent_round(new_files=[...]) + Note over A: 加载上一轮结果作为
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
{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()
(LLM解析用户文字) + + S-->>F: llm_stream (解析过程) + + A->>A: merge_supplement_into_info()
(合并到 extracted_info) + Note over A: 保存到缓存文件 + + A->>A: run_agent_round()
(重新校验) + + S-->>F: agent_state_change
(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()
(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, content?}` | 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()` 是否被调用 + +--- + +## 十、关键文件索引 + +| 文件 | 职责 | +|---|---| +| `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` | 发票提取管道,财务提交 | \ No newline at end of file diff --git a/.agents/skills/clean-git-history/SKILL.md b/.agents/skills/clean-git-history/SKILL.md new file mode 100644 index 0000000..f99d5b7 --- /dev/null +++ b/.agents/skills/clean-git-history/SKILL.md @@ -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 + +# Force push cleaned history +git push --force origin +``` + +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/` | +| 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 ` | + +## 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) \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70f5223..b538ca5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/AGENTS.md b/AGENTS.md index 2c43da6..2773b6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,8 @@ +--- +description: +alwaysApply: true +--- + --- last_reviewed: 2026-06-09 --- @@ -8,6 +13,7 @@ last_reviewed: 2026-06-09 ## 文档边界 +* 一定不要用**表情文字**输出任何内容,禁止!!!!! * `docs/` 目录专门存放面向开源用户、外部贡献者的项目公开文档及说明文件。 * 维护规范、实施方案、经验总结、拉取请求佐证材料与各类内部记录资料,均统一放置在 `.agents/` 目录下,避免内部自动化流程相关内容混入公开文档目录。 * 每个文件夹下都有一个 `README.md` 文件用来交代这个文件夹的作用以及重要的信息。 @@ -16,4 +22,8 @@ last_reviewed: 2026-06-09 * 标准文档元数据:`.agents/docs/standards/README.md` * 调试规范:`.agents/docs/standards/调试规范.md` -* 复利式工程实践:`.agents/docs/standards/复利式工程实践.md` \ No newline at end of file +* 复利式工程实践:`.agents/docs/standards/复利式工程实践.md` + +## 项目的架构思想 + +* Agent 是负责调度的中枢,负责调度各个模块 diff --git a/docs/API.md b/docs/API.md index b7e50e8..c3131fc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,5 +1,5 @@ --- -last_reviewed: 2026-06-09 +last_reviewed: 2026-06-13 --- # 财务报销自动化 — API 文档 @@ -12,17 +12,25 @@ last_reviewed: 2026-06-09 | # | 方法 | 路径 | 说明 | |---|------|------|------| | 1 | GET | `/` | PC 端主页 | -| 2 | POST | `/api/session` | 创建会话 | -| 3 | POST | `/api/upload/` | 上传文件(PDF/图片) | -| 4 | GET | `/api/files/` | 列出会话目录中的文件 | -| 5 | POST | `/api/process/` | 启动处理(提取+LLM识别+出库单) | -| 6 | GET | `/api/logs/` | SSE 日志流 | -| 7 | GET | `/api/download//` | 下载生成的文件 | -| 8 | GET | `/api/data/` | 获取发票数据(JSON) | -| 9 | POST | `/api/save/` | 保存编辑后的发票数据 | -| 10 | POST | `/api/submit-financial/` | 提交到财务系统 | -| 11 | GET | `/mobile/` | 移动端上传页面 | -| 12 | POST | `/api/mobile-upload/` | 移动端上传图片 | +| 2 | GET | `/mobile/` | 移动端上传页面 | +| 3 | POST | `/api/session` | 创建会话 | +| 4 | POST | `/api/upload/` | 上传文件(PDF/图片) | +| 5 | GET | `/api/files/` | 列出会话目录中的文件 | +| 6 | GET | `/api/download//` | 下载生成的文件 | +| 7 | POST | `/api/mobile-upload/` | 移动端上传图片 | +| 8 | GET | `/api/config/` | 获取会话配置 | +| 9 | GET | `/api/data/` | 获取发票数据(JSON) | +| 10 | POST | `/api/save/` | 保存编辑后的发票数据 | +| 11 | POST | `/api/process/` | 启动管道处理(仅发票提取,不自动提交) | +| 12 | GET | `/api/logs/` | SSE 日志流 | +| 13 | POST | `/api/submit-financial/` | 提交到财务系统 | +| **14** | **GET** | **`/api/agent/state/`** | **获取 Agent 会话状态** | +| **15** | **POST** | **`/api/agent/process/`** | **启动 Agent 多轮处理(主入口)** | +| **16** | **POST** | **`/api/agent/supplement/`** | **补充文件后重新分析** | +| **17** | **POST** | **`/api/agent/user-supplement/`** | **通过文字补充信息** | +| **18** | **POST** | **`/api/agent/force-submit/`** | **强制提交,跳过校验** | + +> 加粗条目为 Agent 多轮校验流程新增接口。推荐使用 `/api/agent/process` 作为主入口,它会在发票提取后自动进行 LLM 校验,校验通过则自动提交到财务系统。 --- @@ -30,7 +38,7 @@ last_reviewed: 2026-06-09 - 调用 `POST /api/session` 获得 `session_id` - 该会话下所有文件存放在 `src/web/uploads//` -- 典型产物:`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 +95,10 @@ GET /api/files/ ```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 +108,7 @@ GET /api/files/ --- -### 5. 启动管道处理 - -``` -POST /api/process/ -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//config.json`。 - -**响应(立即):** - -```json -{ "status": "started" } -``` - -处理在后台线程执行,进度与结果通过 `GET /api/logs/`(SSE)获取。 - -**SSE 完成时 `result` 示例(成功):** - -```json -{ - "ok": true, - "elapsed": "45.2s", - "invoice_count": 4, - "csv_url": "/api/download//invoice_summary.csv", - "travel_count": 2, - "general_count": 2, - "doc_url": "/api/download//%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//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//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/ -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// @@ -237,8 +126,12 @@ GET /api/download// | 文件名 | 说明 | |--------|------| -| `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) | +| `invoice_summary.csv` | 发票汇总 | +| `payment_records.csv` | 支付记录 | | `易耗品、出库单.doc` | 自动填写的出库单 | +| `travel_applications.json` | 差旅申请信息 | +| `result.json` | 处理结果 | +| `agent_state.json` | Agent 会话状态 | **错误:** @@ -250,6 +143,56 @@ HTTP `404`。`filename` 仅允许会话目录内的文件名(防止路径穿 --- +### 5. 移动端上传页面 + +``` +GET /mobile/ +``` + +返回移动端 HTML 页面。 + +--- + +### 6. 移动端上传图片 + +``` +POST /api/mobile-upload/ +Content-Type: multipart/form-data +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| file | File | 图片文件 | + +逻辑与 `POST /api/upload/` 相同。 + +--- + +### 7. 获取会话配置 + +``` +GET /api/config/ +``` + +获取当前会话的配置,供前端回填表单。优先读取会话目录下的 `config.json`,未找到则使用项目全局配置。 + +**响应:** + +```json +{ + "username": "", + "password": "", + "default_name": "", + "default_card_no": "", + "default_person_id": "", + "consumable_storage": "" +} +``` + +注意:`password` 字段始终返回空字符串。 + +--- + ### 8. 获取发票数据 ``` @@ -260,7 +203,7 @@ GET /api/data/ ```json { - "csv_filename": "invoice_summary.csv", + "csv_filename": "payment_records.csv", "fields": [ "序号", "发票号码", "开票日期", "项目名称", "规格型号", "价税合计", "销售方名称", "人员姓名", "刷卡日期", @@ -277,6 +220,8 @@ GET /api/data/ } ``` +读取优先级:`payment_records.csv` → `invoice_summary.csv` → 任意 `.csv` 文件。 + - `fields`:列顺序 - `data[].__row`:内部行索引(保存时不需要提交,服务端按数组顺序写回) @@ -350,7 +295,82 @@ Content-Type: application/json --- -### 10. 提交到财务系统 +### 10. 启动管道处理(仅发票提取) + +``` +POST /api/process/ +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//config.json`。 + +**响应(立即):** + +```json +{ "status": "started" } +``` + +处理在后台线程执行,进度与结果通过 `GET /api/logs/`(SSE)获取。 + +**SSE 完成时 `result` 示例(成功):** + +```json +{ + "ok": true, + "elapsed": "45.2s", + "invoice_count": 4, + "csv_url": "/api/download//invoice_summary.csv", + "travel_count": 2, + "general_count": 2, + "doc_url": "/api/download//%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/ +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 超时:** 600 秒。 + +--- + +### 12. 提交到财务系统 ``` POST /api/submit-financial/ @@ -358,13 +378,12 @@ POST /api/submit-financial/ **前置条件:** -- 会话目录存在 `config.json`(由 `/api/process` 写入),否则返回 `400` -- 存在可用的发票 CSV(通常为 `invoice_summary.csv`) +- 会话目录存在 `config.json`,否则返回 `400` +- 存在可用的发票 CSV(通常为 `invoice_summary.csv` 或 `payment_records.csv`) **说明:** - 前端一般在提交前调用 `/api/save` 保存表格修改 -- 本接口**不会**自动执行发票提取或 LLM 识别 - 根据发票类型选择填报模式:纯差旅发票走差旅报销流程,含普通发票走普通报销流程 **响应(立即):** @@ -389,28 +408,322 @@ POST /api/submit-financial/ --- -### 11. 移动端上传页面 +## Agent 多轮校验流程 -``` -GET /mobile/ +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/` 同步文件列表。 +### 13. 获取 Agent 会话状态 + +``` +GET /api/agent/state/ +``` + +**响应:** + +```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/ -Content-Type: multipart/form-data +POST /api/agent/process/ +Content-Type: application/json ``` -| 字段 | 类型 | 说明 | -|------|------|------| -| file | File | 图片文件 | +**请求体:** -逻辑与 `POST /api/upload/` 相同。 +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| 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/ +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/ +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/ +``` + +当 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_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, content?}` | LLM 输出流 | + +`phase` 取值: `start` / `reasoning` / `chunk` / `end` / `error` + +### 完成事件 + +SSE 检测到 `result.json` 后直接发送: + +```json +{ + "type": "done", + "result": { ... } +} +``` --- @@ -418,9 +731,9 @@ Content-Type: multipart/form-data | HTTP | 场景 | |------|------| -| 400 | 参数缺失、未找到配置等 | -| 404 | `session_id` 不存在、文件不存在 | -| 500 | CSV 读取失败等内部错误 | +| 400 | 参数缺失、未找到配置、文本为空等 | +| 404 | `session_id` 不存在、文件不存在、Agent 状态丢失 | +| 500 | CSV 读取失败、服务未初始化等内部错误 | 统一错误体: @@ -447,38 +760,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 校验
最多 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 ``` --- diff --git a/docs/报销操作指南.md b/docs/报销操作指南.md index 9e9f087..68eb79f 100644 --- a/docs/报销操作指南.md +++ b/docs/报销操作指南.md @@ -143,7 +143,7 @@ uv run python src/web/app.py ### 4.7 提交到财务系统 -确认数据无误后,点击"🚀 提交到财务系统"按钮,系统自动: +确认数据无误后,点击"提交到财务系统"按钮,系统自动: 1. 登录信息门户 2. 进入财务系统 diff --git a/pyproject.toml b/pyproject.toml index 65b8250..7e6d4aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "pywin32>=306", "llama-index>=0.12.0", "llama-index-llms-openai-like==0.7.2", + "python-dotenv>=1.0", ] [dependency-groups] diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index e783aff..0000000 --- a/scripts/README.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -last_reviewed: 2026-06-11 ---- - -# scripts — 测试脚本目录 - -存放用于测试各模块功能的独立脚本,可直接运行。 - -## 脚本清单 - -| 文件 | 说明 | -|------|------| -| `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 -``` \ No newline at end of file diff --git a/scripts/debug_stream_fields.py b/scripts/debug_stream_fields.py new file mode 100644 index 0000000..1fb229d --- /dev/null +++ b/scripts/debug_stream_fields.py @@ -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.doc.llm_extractor 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() diff --git a/scripts/test_application_extract.py b/scripts/test_application_extract.py new file mode 100644 index 0000000..a4f2000 --- /dev/null +++ b/scripts/test_application_extract.py @@ -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.doc.llm_extractor 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() diff --git a/src/agent/README.md b/src/agent/README.md new file mode 100644 index 0000000..b539a50 --- /dev/null +++ b/src/agent/README.md @@ -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 次重试后返回最佳结果 + diff --git a/src/agent/__init__.py b/src/agent/__init__.py new file mode 100644 index 0000000..5774042 --- /dev/null +++ b/src/agent/__init__.py @@ -0,0 +1,34 @@ +"""Agent 模块 + +提供多轮对话协调、规则校验和语义校验能力。 + +入口: + - `orchestrator.run_agent_round()` — 执行一轮 Agent 处理 + - `orchestrator.force_submit()` — 用户强制提交 + - `orchestrator.add_supplement()` — 记录用户补充的文件 + - `orchestrator.load_agent_state()` / `save_agent_state()` — 状态持久化 +""" + +from .orchestrator import ( + AgentSession, + AgentState, + _emit_agent_event, + add_supplement, + force_submit, + load_agent_state, + process_user_text_supplement, + run_agent_round, + save_agent_state, +) + +__all__ = [ + "_emit_agent_event", + "AgentSession", + "AgentState", + "add_supplement", + "force_submit", + "load_agent_state", + "process_user_text_supplement", + "run_agent_round", + "save_agent_state", +] diff --git a/src/agent/orchestrator.py b/src/agent/orchestrator.py new file mode 100644 index 0000000..8ea739a --- /dev/null +++ b/src/agent/orchestrator.py @@ -0,0 +1,532 @@ +"""Agent 协调器 + +作为调度中枢,编排信息提取、规则校验的完整流程。 + +校验-修正循环由 Agent 层调度: + 1. Agent 调用 LLM 提取信息 + 2. Agent 调用 validator.py 校验 + 3. 校验失败则构建修正提示,再次调用 LLM + 4. 重复直到校验通过或达到最大重试次数 + 5. LLM 在输出中包含 can_submit 和 suggestion 字段,用于判断信息完整性 + +状态机: + idle -> extracting -> awaiting_supplement -> (回到extracting) + | + (完整) -> ready -> submitting -> done + | + (用户强制) -> ready +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Any + +from .. import get_logger +from ..doc.llm_extractor import ( + CACHE_DIR_NAME, + build_extraction_user_message, + llm_query_text, + load_cache, + load_match_result, + parse_json_response, +) +from ..doc.prompt import ( + build_normal_info_system_prompt, + build_travel_info_system_prompt, +) +from ..doc.validator import validate_extracted_info + +log = get_logger("agent") + +# 规则校验-修正循环的最大重试次数 +MAX_VALIDATION_RETRIES = 3 + + +# ------------------------------------------------------------------ +# 状态枚举 +# ------------------------------------------------------------------ + + +class AgentState(StrEnum): + IDLE = "idle" + EXTRACTING = "extracting" + AWAITING_SUPPLEMENT = "awaiting_supplement" + READY = "ready" + SUBMITTING = "submitting" + DONE = "done" + ERROR = "error" + + +# ------------------------------------------------------------------ +# Agent 会话数据模型 +# ------------------------------------------------------------------ + + +@dataclass +class AgentSession: + """Agent 会话状态""" + + session_id: str + state: AgentState = AgentState.IDLE + rounds: int = 0 + max_rounds: int = 5 + invoice_type: str = "travel" # "travel" 或 "normal" + extracted_info: dict[str, Any] = field(default_factory=dict) + validation_reports: list[dict[str, Any]] = field(default_factory=list) + user_supplements: list[str] = field(default_factory=list) + error_message: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> AgentSession: + # 兼容旧版本:state 可能是字符串 + if "state" in data and isinstance(data["state"], str): + data["state"] = AgentState(data["state"]) + return cls(**data) + + +# ------------------------------------------------------------------ +# 持久化 +# ------------------------------------------------------------------ + +AGENT_STATE_FILE = "agent_state.json" + + +def save_agent_state(session_dir: Path, session: AgentSession) -> None: + """将 Agent 会话状态持久化到 session 目录。""" + state_path = session_dir / AGENT_STATE_FILE + tmp_path = session_dir / (AGENT_STATE_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(session.to_dict(), f, ensure_ascii=False, indent=2) + tmp_path.replace(state_path) + + +def load_agent_state(session_dir: Path) -> AgentSession | None: + """从 session 目录加载 Agent 会话状态。""" + state_path = session_dir / AGENT_STATE_FILE + if not state_path.exists(): + return None + try: + with open(state_path, encoding="utf-8") as f: + return AgentSession.from_dict(json.load(f)) + except Exception as e: + log.warning("加载 Agent 状态失败: %s", e) + return None + + +# ------------------------------------------------------------------ +# SSE 事件发射 +# ------------------------------------------------------------------ + +AGENT_EVENT_LOG = "agent_events.log" + + +def _emit_agent_event(session_dir: Path, event_type: str, **kwargs: Any) -> None: + """向 agent_events.log 追加一行 JSON 事件。""" + event = {"type": event_type, **kwargs} + try: + event_path = session_dir / AGENT_EVENT_LOG + with open(event_path, "a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + except Exception: + pass + + +# ------------------------------------------------------------------ +# 辅助:构建修正提示 +# ------------------------------------------------------------------ + + +def _build_correction_prompt( + base_message: str, + report: Any, +) -> str: + """根据校验报告构建修正提示,追加到原始用户消息后。""" + error_feedback = ( + f"\n\n=== 上一次输出的校验结果 ===\n" + f"校验未通过,发现以下问题:\n" + f"缺失字段 ({len(report.missing_fields)} 个):{', '.join(report.missing_fields)}\n" + ) + if report.missing_materials: + error_feedback += f"可能需要补充的材料:{', '.join(report.missing_materials)}\n" + if report.suggestion: + error_feedback += f"建议:{report.suggestion}\n" + error_feedback += ( + "\n请根据以上校验结果修正你的输出,确保所有必填字段都有值。" + "如果某个字段确实没有数据,请给出合理的猜测值。" + "再次返回完整的 JSON 结果。" + ) + return base_message + error_feedback + + +# ------------------------------------------------------------------ +# 核心协调逻辑 +# ------------------------------------------------------------------ + + +def _do_extraction_with_validation( + session_dir: Path, + session: AgentSession, + previous_analysis: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Agent 调度的提取-校验-修正循环。 + + 流程: + 1. 加载缓存数据和匹配结果 + 2. 构建用户消息 + 3. 调用 LLM 提取 + 4. 调用 validator 校验 + 5. 校验失败则构建修正提示,回到步骤 3 + 6. 最多重试 MAX_VALIDATION_RETRIES 次 + + LLM 输出的 JSON 中额外包含 can_submit 和 suggestion 字段, + 用于判断信息是否完整可提交。 + + Args: + session_dir: 会话目录。 + session: 当前 Agent 会话。 + previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。 + + Returns: + 校验通过的结构化数据(或达到重试上限后的最佳结果)。 + """ + cache_map = load_cache(session_dir) + match_result = load_match_result(session_dir) + + # 选择系统提示词 + if session.invoice_type == "travel": + system_prompt = build_travel_info_system_prompt() + else: + system_prompt = build_normal_info_system_prompt() + + base_message = build_extraction_user_message(cache_map, match_result, previous_analysis=previous_analysis) + current_message = base_message + + for attempt in range(1, MAX_VALIDATION_RETRIES + 1): + log.info( + "LLM 提取第 %d/%d 次尝试 (%s)", + attempt, + MAX_VALIDATION_RETRIES, + session.invoice_type, + ) + _emit_agent_event( + session_dir, + "agent_state_change", + state=AgentState.EXTRACTING, + round=session.rounds, + attempt=attempt, + message=f"正在分析文件... (第{attempt}次)", + ) + + # Step 1: 调用 LLM 提取 + try: + response = llm_query_text( + system_prompt=system_prompt, + text=current_message, + reasoning_effort="low", + source_dir=session_dir, + ) + result = parse_json_response(response) + except Exception as e: + log.error("LLM 提取失败: %s", e) + _emit_agent_event( + session_dir, + "agent_error", + message=f"LLM 提取失败: {e}", + ) + raise + + # Step 2: 调用 validator 校验 + report = validate_extracted_info(result, invoice_type=session.invoice_type) + + if report.valid: + log.info("规则校验通过 (第 %d 次尝试)", attempt) + _emit_agent_event( + session_dir, + "agent_state_change", + state=AgentState.EXTRACTING, + round=session.rounds, + attempt=attempt, + message=f"规则校验通过 (第{attempt}次)", + ) + return result + + # Step 3: 校验失败,构建修正提示 + log.warning( + "规则校验未通过 (第 %d/%d 次): 缺失 %d 个字段 - %s", + attempt, + MAX_VALIDATION_RETRIES, + len(report.missing_fields), + report.missing_fields, + ) + _emit_agent_event( + session_dir, + "agent_state_change", + state=AgentState.EXTRACTING, + round=session.rounds, + attempt=attempt, + message=f"规则校验未通过,缺失 {len(report.missing_fields)} 个字段,正在请求 LLM 修正...", + ) + current_message = _build_correction_prompt(current_message, report) + + # 所有重试都失败,返回最后一次结果 + log.error( + "LLM 提取经过 %d 次尝试仍未通过规则校验,返回最后一次结果 (置信度: %.0f%%)", + MAX_VALIDATION_RETRIES, + report.confidence * 100, + ) + return result + + +def run_agent_round( + session_dir: Path, + session: AgentSession, + new_files: list[str] | None = None, +) -> AgentSession: + """执行一轮 Agent 处理:提取-校验-修正循环。 + + Args: + session_dir: 会话目录。 + session: 当前 Agent 会话。 + new_files: 新增的文件列表(可选,补充文件时传入)。 + + Returns: + 更新后的 Agent 会话。 + + 注意: + - 信息提取优先从 .invoice_cache 缓存读取,避免重复调用 LLM。 + - Agent 调度提取-校验-修正循环:LLM 提取 -> validator 校验 -> 失败则反馈修正。 + - 若缓存缺失则执行提取后立即写回缓存(travel_info.json / normal_info.json)。 + - 补充文件时(new_files 非空),加载上一轮分析结果作为历史上下文,强制重新分析。 + """ + # 终态保护:会话已提交或已完成时不再重复处理 + if session.state in (AgentState.DONE, AgentState.SUBMITTING, AgentState.READY): + log.info("Agent 会话已处于终态 (%s),跳过重复处理", session.state.value) + return session + + if session.rounds >= session.max_rounds: + session.state = AgentState.ERROR + session.error_message = f"已达到最大轮次 ({session.max_rounds}),请检查信息或强制提交" + log.warning("Agent 达到最大轮次限制") + _emit_agent_event( + session_dir, + "agent_max_rounds", + message=session.error_message, + ) + return session + + session.rounds += 1 + log.info("开始第 %d 轮 Agent 处理", session.rounds) + + # ---- Step 1: 信息提取(Agent 调度校验-修正循环) ---- + session.state = AgentState.EXTRACTING + _emit_agent_event( + session_dir, + "agent_state_change", + state=session.state, + round=session.rounds, + message="正在分析文件...", + ) + + # 判断是否为补充文件场景:有新文件传入时,加载上一轮分析结果作为上下文 + cache_map = load_cache(session_dir) + is_supplement = bool(new_files) + previous_analysis = None + if is_supplement: + info_key = "travel_info" if session.invoice_type == "travel" else "normal_info" + previous_analysis = cache_map.get(info_key) + if previous_analysis: + log.info("检测到补充文件,加载上一轮分析结果作为历史上下文") + + try: + if session.invoice_type == "travel": + should_reanalyze = not cache_map.get("travel_info") or is_supplement + if should_reanalyze: + session.extracted_info = _do_extraction_with_validation( + session_dir, session, previous_analysis=previous_analysis + ) + # 提取后立即写入缓存,后续步骤依赖此数据 + cache_dir = session_dir / CACHE_DIR_NAME + cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f: + json.dump(session.extracted_info, f, ensure_ascii=False, indent=2) + else: + session.extracted_info = cache_map["travel_info"] + else: + should_reanalyze = not cache_map.get("normal_info") or is_supplement + if should_reanalyze: + session.extracted_info = _do_extraction_with_validation( + session_dir, session, previous_analysis=previous_analysis + ) + # 提取后立即写入缓存,后续步骤依赖此数据 + cache_dir = session_dir / CACHE_DIR_NAME + cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f: + json.dump(session.extracted_info, f, ensure_ascii=False, indent=2) + else: + session.extracted_info = cache_map["normal_info"] + + except Exception as e: + session.state = AgentState.ERROR + session.error_message = f"信息提取失败: {e}" + log.error("Agent 信息提取失败: %s", e) + _emit_agent_event( + session_dir, + "agent_error", + message=session.error_message, + ) + return session + + # ---- 判断结果(从 LLM 提取结果中读 can_submit) ---- + can_submit = session.extracted_info.get("can_submit", True) + suggestion = session.extracted_info.get("suggestion", "") + + if can_submit: + session.state = AgentState.READY + _emit_agent_event( + session_dir, + "agent_ready", + round=session.rounds, + message="信息完整,可以提交", + ) + log.info("Agent 校验通过,信息完整") + else: + session.state = AgentState.AWAITING_SUPPLEMENT + + combined_suggestion = suggestion or "信息不完整,请补充材料" + + _emit_agent_event( + session_dir, + "agent_request_supplement", + round=session.rounds, + missing_fields=[], + missing_materials=[], + semantic_issues=[], + suggestion=combined_suggestion, + ) + log.info("Agent 请求补充: %s", combined_suggestion) + + save_agent_state(session_dir, session) + return session + + +def force_submit( + session_dir: Path, + session: AgentSession, +) -> AgentSession: + """用户强制提交,跳过校验。""" + session.state = AgentState.READY + log.info("用户强制提交,跳过校验") + _emit_agent_event( + session_dir, + "agent_force_submit", + message="用户选择强制提交", + ) + save_agent_state(session_dir, session) + return session + + +def add_supplement( + session_dir: Path, + session: AgentSession, + filenames: list[str], +) -> AgentSession: + """记录用户补充的文件。""" + session.user_supplements.extend(filenames) + _emit_agent_event( + session_dir, + "agent_supplement_received", + files=filenames, + ) + log.info("收到用户补充文件: %s", filenames) + save_agent_state(session_dir, session) + return session + + +def process_user_text_supplement( + session_dir: Path, + session: AgentSession, + user_text: str, +) -> AgentSession: + """处理用户通过文字补充的信息。 + + 流程: + 1. LLM 分析用户文字,提取需要更新的字段 + 2. 合并到已提取的信息中 + 3. 保存到缓存 + 4. 重新执行一轮 Agent 校验 + + Args: + session_dir: 会话目录。 + session: 当前 Agent 会话。 + user_text: 用户输入的文字。 + + Returns: + 更新后的 Agent 会话。 + """ + from ..doc.llm_extractor import ( + merge_supplement_into_info, + process_user_supplement, + ) + + log.info("收到用户文字补充: %s", user_text) + _emit_agent_event( + session_dir, + "agent_supplement_received", + files=[user_text[:50]], # 简短显示 + ) + + # Step 1: LLM 分析用户文字 + supplement_result = process_user_supplement( + user_text=user_text, + extracted_info=session.extracted_info, + invoice_type=session.invoice_type, + source_dir=session_dir, + ) + + updated_fields = supplement_result.get("updated_fields", {}) + unparsed = supplement_result.get("unparsed_info", "") + + if updated_fields: + # Step 2: 合并到已提取信息 + session.extracted_info = merge_supplement_into_info( + session.extracted_info, + updated_fields, + ) + + # Step 3: 保存到缓存 + cache_dir = session_dir / CACHE_DIR_NAME + cache_dir.mkdir(parents=True, exist_ok=True) + info_file = "travel_info.json" if session.invoice_type == "travel" else "normal_info.json" + info_path = cache_dir / info_file + with open(info_path, "w", encoding="utf-8") as f: + json.dump(session.extracted_info, f, ensure_ascii=False, indent=2) + log.info("已更新 %s", info_file) + + # Step 4: 重新执行 Agent 校验 + session.state = AgentState.EXTRACTING + _emit_agent_event( + session_dir, + "agent_state_change", + state=session.state, + round=session.rounds, + message="正在重新校验...", + ) + session = run_agent_round(session_dir, session) + else: + # 没有可更新的字段 + msg = unparsed or "未识别到可更新的报销信息" + _emit_agent_event( + session_dir, + "agent_supplement_received", + files=[msg], + ) + log.info("用户补充未识别到有效信息: %s", msg) + + return session diff --git a/src/bot/normal.py b/src/bot/normal.py index eea2c0d..a37d162 100644 --- a/src/bot/normal.py +++ b/src/bot/normal.py @@ -57,22 +57,26 @@ def run( def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") -> None: """填写基本信息""" - - bot.page.fill("#EXPENEXPLAIN", description) - bot.page.click("#PROJECTCODE", timeout=10000) - bot.page.wait_for_timeout(1000) - - bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000) - first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr") - - if first_row: - first_row.click() + try: + bot.page.fill("#EXPENEXPLAIN", description) + bot.page.click("#PROJECTCODE", timeout=10000) bot.page.wait_for_timeout(1000) - bot.page.click("#saveAndNext", timeout=5000) - bot.page.wait_for_timeout(2000) + bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000) + first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr") - bot._screenshot("step3_done") + if first_row: + first_row.click() + bot.page.wait_for_timeout(1000) + + bot.page.click("#saveAndNext", timeout=5000) + bot.page.wait_for_timeout(2000) + + bot._screenshot("step3_done") + except Exception as e: + log.error(f"填写基本信息失败: {e}") + bot._screenshot("basic_info_error") + raise # ------------------------------------------------------------------ @@ -123,8 +127,8 @@ def fill_normal_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> Non for info in payment_info: bot.page.click("#insertPay", timeout=5000) bot.page.wait_for_timeout(1000) - bot.page.fill("#personid2", bot.config["default_name"]) - bot.page.fill("#accountname2", bot.config["default_person_id"]) + bot.page.fill("#personid2", bot.config["default_person_id"]) + bot.page.fill("#accountname2", bot.config["default_name"]) bot.page.fill("#receiptdate2", format_date(str(info.get("card_date", "")))) bot.page.fill("#localaccount2", bot.config["default_card_no"]) bot.page.fill("#receiptmoney2", str(info.get("card_amount", 0))) diff --git a/src/bot/travel.py b/src/bot/travel.py index e11afba..8a4e366 100644 --- a/src/bot/travel.py +++ b/src/bot/travel.py @@ -74,9 +74,10 @@ def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None: bot.page.click("#saveAndNext", timeout=5000) bot.page.wait_for_timeout(2000) bot._screenshot("travel_basic_done") - except Exception: - log.error("填写基本信息失败") + except Exception as e: + log.error(f"填写基本信息失败: {e}") bot._screenshot("travel_basic_error") + raise # ------------------------------------------------------------------ @@ -190,8 +191,8 @@ def fill_travel_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> Non for info in payment_info: bot.page.click("#insertPay", timeout=5000) bot.page.wait_for_timeout(1000) - bot.page.fill("#personid2", bot.config["default_name"]) - bot.page.fill("#accountname2", bot.config["default_person_id"]) + bot.page.fill("#personid2", bot.config["default_person_id"]) + bot.page.fill("#accountname2", bot.config["default_name"]) bot.page.fill("#receiptdate2", format_date(info["card_date"])) bot.page.fill("#localaccount2", bot.config["default_card_no"]) bot.page.fill("#receiptmoney2", str(info["card_amount"])) diff --git a/src/doc/README.md b/src/doc/README.md index 440be1a..72f9b35 100644 --- a/src/doc/README.md +++ b/src/doc/README.md @@ -1,6 +1,6 @@ --- -last_reviewed: 2026-06-11 ---- + +## last_reviewed: 2026-06-12 # src/doc — 文档处理模块 @@ -9,36 +9,51 @@ last_reviewed: 2026-06-11 ## 模块清单 -| 文件 | 作用 | -| ------------------------ | -------------------------------------------------- | -| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 | -| `pdf.py` | PDF 图片渲染(PyMuPDF) | -| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态 + 差旅/普通报销信息综合提取) | -| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 | -| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 | -| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) | -| `prompt.py` | LLM 提示词模板加载 | +| 文件 | 作用 | +| ------------------------ | ---------------------------------------------------------------------------- | +| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 | +| `pdf.py` | PDF 图片渲染(PyMuPDF) | +| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态 + 差旅/普通报销信息综合提取) | +| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 | +| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 | +| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) | +| `prompt.py` | LLM 提示词模板加载 | | `prompts/` | 提示词模板文件(`invoice_system.md`、`travel_info_system.md`、`normal_info_system.md`) | ## 数据流 -``` -PDF 发票 → pdf.py → llm_extractor.py → [发票列表] -支付截图 → llm_extractor.py → [刷卡记录] - ↓ - matcher.py(按金额贪心匹配,相对容差 3%) - ↓ - invoice.py 分类 → CSV(已回填刷卡日期/卡号/金额) - ↓ - fill_consumable_doc → 易耗品出库单.doc +```mermaid +flowchart TD + A["PDF 发票"] --> B["pdf.py
PDF 图片渲染"] + B --> C["llm_extractor.py
发票文本提取"] + C --> D["(发票列表)"] -[发票列表 + 匹配结果] → llm_extractor.py - ↓ - 差旅发票 → extract_travel_info() → travel_info.json - 普通发票 → extract_normal_info() → normal_info.json + E["支付截图"] --> F["llm_extractor.py
多模态提取"] + F --> G["(刷卡记录)"] + + D --> H["matcher.py
金额贪心匹配 / 相对容差 3%"] + G --> H + + H --> I["invoice.py
分类 + CSV 回填"] + I --> J["CSV
刷卡日期/卡号/金额"] + + J --> K["fill_consumable_doc
易耗品出库单"] + K --> L["易耗品出库单.doc"] + + D --> M["llm_extractor.py
差旅/普通信息综合提取"] + H --> M + + M --> N{"发票类型判断"} + N -->|差旅发票| O["extract_travel_info()"] + N -->|普通发票| P["extract_normal_info()"] + + O --> Q["travel_info.json"] + P --> R["normal_info.json"] ``` + + ## 依赖说明 - **PyMuPDF (pymupdf)** — PDF 图片渲染 @@ -52,9 +67,16 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表] - 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载 - LLM 提取失败时直接报错,无正则回退 +## 变更说明(2026-06-16) + +- `llm_extractor.py` 新增 SSE 流式事件写入:`_emit_llm_stream()` 函数将 LLM 思考过程的 `start`/`reasoning`/`chunk`/`end`/`error` 事件写入 `source_dir/llm_stream.log`,前端通过 SSE 实时展示 AI 思考过程与正式回答 +- `_llm_query_multimodal()` 新增 `source_dir` 参数,流式接收 `delta` 时同步写入 chunk 事件;同时提取 `additional_kwargs.thinking_delta` 写入 reasoning 事件 +- `extract_travel_info()` 和 `extract_normal_info()` 在 LLM 调用前后写入 `start`/`end` 事件,异常时写入 `error` 事件 + ## 变更说明(2026-06-11) - `llm_extractor.py` 新增 `extract_normal_info()`:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json` - `llm_extractor.py` 的 `load_cache()` 扩展支持加载 `normal_info.json` - `prompt.py` 新增 `build_normal_info_system_prompt()`:加载 `normal_info_system.md` -- `prompts/` 新增 `normal_info_system.md`:普通发票信息提取的系统提示词 \ No newline at end of file +- `prompts/` 新增 `normal_info_system.md`:普通发票信息提取的系统提示词 + diff --git a/src/doc/extractor.py b/src/doc/extractor.py index 44da31a..10e4447 100644 --- a/src/doc/extractor.py +++ b/src/doc/extractor.py @@ -5,6 +5,18 @@ 对外接口: extract_invoices(directory) -> tuple[list[dict], list[dict], dict] + +错误传播规则: + - 单个文件提取失败: 记录日志 + SSE error 事件,继续处理下一个文件 + - 全部文件提取失败: raise ExtractionError,携带失败文件列表和原始错误 + - 缓存读取失败: 静默降级,尝试重新提取 + +SSE 文件进度事件: + 在 source_dir 下写入 file_events.log,每行一个 JSON 对象: + - {"type": "file_progress", "file": "...", "status": "processing"} + - {"type": "file_progress", "file": "...", "status": "done", "summary": {...}} + - {"type": "file_progress", "file": "...", "status": "cached"} + - {"type": "file_progress", "file": "...", "status": "error", "error": "..."} """ import json @@ -12,35 +24,37 @@ from pathlib import Path from typing import Any from .. import get_logger +from ..exceptions import ExtractionError +from .invoice import CACHE_DIR_NAME, classify_invoice_batch from .llm_extractor import extract_document from .matcher import match_invoices_to_cards log = get_logger("extractor") +# SSE 文件进度事件日志文件名 +FILE_EVENTS_LOG = "file_events.log" -def _classify_invoice_batch( - invoices: list[dict[str, str]], -) -> dict[str, list[dict[str, str]]]: - """按发票类型分组""" - travel: list[dict[str, str]] = [] - general: list[dict[str, str]] = [] - application: list[dict[str, str]] = [] - for inv in invoices: - inv_type = inv.get("invoice_type", "general") - if inv_type == "application": - application.append(inv) - elif inv_type in ("train", "hotel"): - travel.append(inv) - else: - general.append(inv) - return {"travel": travel, "general": general, "application": application} +# 支持的文件扩展名 +SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".webp"} -# JSON 缓存目录(相对于源文件目录) -CACHE_DIR_NAME = ".invoice_cache" +def _emit_file_event(source_dir: Path, file_name: str, status: str, **kwargs: Any) -> None: + """向 file_events.log 追加一行 JSON 事件(线程安全,失败时静默忽略)""" + event = { + "type": "file_progress", + "file": file_name, + "status": status, + **kwargs, + } + try: + event_path = source_dir / FILE_EVENTS_LOG + with open(event_path, "a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + except Exception: + pass + # 支持的文件扩展名 -SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".bmp"} def _get_cache_dir(source_dir: Path) -> Path: @@ -87,7 +101,59 @@ def _load_from_cache(json_path: Path, expected_extension: str | None = None) -> return None -def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None: +def _build_summary(data: dict[str, Any]) -> dict[str, str]: + """从提取结果构建前端展示摘要(人类可读格式)。 + + 返回所有非内部字段(排除 _source_file 等下划线前缀字段), + 并将 invoice_type 转换为中文标签。列表/字典类型的值会展开为可读文本。 + """ + invoice_type_map = { + "train": "高铁票", + "hotel": "酒店住宿", + "general": "普通发票", + "payment": "支付记录", + "application": "出差申请单", + } + + summary = {} + for key, value in data.items(): + # 跳过内部字段 + if key.startswith("_"): + continue + # 跳过空值 + if value is None or value == "": + continue + # invoice_type 转为中文标签 + if key == "invoice_type": + summary["invoice_type_label"] = invoice_type_map.get(str(value), str(value)) + elif isinstance(value, list): + # 列表展开为多行可读文本 + if len(value) == 0: + continue + parts = [] + for item in value: + if isinstance(item, dict): + # 字典项用 "key: value" 格式拼接 + pair_parts = [f"{k}: {v}" for k, v in item.items()] + parts.append(" | ".join(pair_parts)) + else: + parts.append(str(item)) + summary[key] = "\n".join(parts) + elif isinstance(value, dict): + # 字典展开为 "key: value" 格式 + pair_parts = [f"{k}: {v}" for k, v in value.items()] + summary[key] = " | ".join(pair_parts) + else: + summary[key] = str(value) + + return summary + + +def _extract_document( + file_path: Path, + cache_dir: Path, + source_dir: Path, +) -> tuple[dict[str, str] | None, str | None]: """提取单个文件的结构化信息,优先使用缓存。 根据 LLM 返回的「invoice_type」字段自动分类: @@ -99,28 +165,43 @@ def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None Args: file_path: 文件路径(PDF 或图片)。 cache_dir: JSON 缓存目录。 + source_dir: 源目录(用于写入 SSE 进度事件)。 Returns: - 提取结果字典,失败时返回 None。 + (提取结果字典或 None, 错误信息或 None)。 """ + file_name = file_path.name + json_path = _get_json_path(file_path, cache_dir) cached = _load_from_cache(json_path, expected_extension=file_path.suffix.lower()) if cached: - cached["_source_file"] = file_path.name - log.info(f"使用缓存: {file_path.name}") - return cached + cached["_source_file"] = file_name + log.info(f"使用缓存: {file_name}") + _emit_file_event(source_dir, file_name, "cached") + return cached, None - log.info(f"使用多模态提取: {file_path.name}") + # 发送处理中事件 + _emit_file_event(source_dir, file_name, "processing") + + log.info(f"使用多模态提取: {file_name}") try: result = extract_document(file_path) if result: - result["_source_file"] = file_path.name + result["_source_file"] = file_name _save_to_cache(file_path, result, cache_dir) - return result - except Exception as e: - log.warning(f"多模态提取失败: {file_path.name} ({e})") - return None + # 发送完成事件(含摘要) + summary = _build_summary(result) + _emit_file_event(source_dir, file_name, "done", summary=summary) + + return result, None + except Exception as e: + err_msg = str(e) + log.warning(f"多模态提取失败: {file_name} ({err_msg})") + _emit_file_event(source_dir, file_name, "error", error=err_msg) + return None, err_msg + + return None, None def _find_all_files(directory: str) -> list[Path]: @@ -181,10 +262,19 @@ def extract_invoices( - applications: 出差事前申请单列表(单独存储,不参与支付匹配) - groups: 按文档类型分组的字典 {'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单]} + + Raises: + ExtractionError: 当所有文件提取均失败时抛出,携带失败文件列表和原始错误。 """ source_dir = Path(directory) cache_dir = _get_cache_dir(source_dir) + # 清空上次的文件进度事件 + try: + (source_dir / FILE_EVENTS_LOG).unlink(missing_ok=True) + except Exception: + pass + all_files = _find_all_files(directory) if not all_files: log.warning("未找到支持的文件") @@ -195,11 +285,15 @@ def extract_invoices( all_invoices = [] all_cards = [] applications = [] + # 记录失败文件及其错误信息 + failed_files: list[tuple[str, str]] = [] for file_path in all_files: - result = _extract_document(file_path, cache_dir) + result, err = _extract_document(file_path, cache_dir, source_dir) if not result: + if err: + failed_files.append((file_path.name, err)) log.warning(f"未能解析: {file_path.name}") continue @@ -217,6 +311,16 @@ def extract_invoices( else: log.warning(f"无法分类: {file_path.name} (invoice_type={inv_type})") + # 全部文件提取失败时抛出异常,携带原始错误信息 + if failed_files and not all_invoices and not all_cards and not applications: + failed_names = [name for name, _ in failed_files] + error_details = {name: err for name, err in failed_files} + raise ExtractionError( + f"所有 {len(all_files)} 个文件提取均失败", + failed_files=failed_names, + details=error_details, + ) + log.info(f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份") if not all_invoices: @@ -231,7 +335,7 @@ def extract_invoices( all_documents.extend(record.get("_matched_invoices", [])) all_documents.extend(applications) - groups = _classify_invoice_batch(all_documents) + groups = classify_invoice_batch(all_documents) log.info( f"文档分类: 差旅发票 {len(groups['travel'])} 张, " f"普通发票 {len(groups['general'])} 张, " diff --git a/src/doc/invoice.py b/src/doc/invoice.py index c3085ec..a466d24 100644 --- a/src/doc/invoice.py +++ b/src/doc/invoice.py @@ -17,6 +17,9 @@ from .. import get_logger log = get_logger("invoice") +# 缓存目录名(相对于源文件目录) +CACHE_DIR_NAME = ".invoice_cache" + # CSV 列名 INVOICE_LEVEL_COLUMNS = [ "index", @@ -58,7 +61,7 @@ def _is_application_document(invoice_type: str) -> bool: return invoice_type == "application" -def _classify_invoice_batch( +def classify_invoice_batch( invoices: list[dict[str, str]], ) -> dict[str, list[dict[str, str]]]: """按发票类型分组""" diff --git a/src/doc/llm_extractor.py b/src/doc/llm_extractor.py index fd89f79..11c424e 100644 --- a/src/doc/llm_extractor.py +++ b/src/doc/llm_extractor.py @@ -7,13 +7,28 @@ - **统一文档提取**:使用一套提示词,LLM 自行判断文档类型(发票/支付记录/出差事前申请单等),支持 JSON 格式输出。 - **差旅信息提取**:综合多张发票、支付记录和匹配结果,提取出差事由、地点、时间等差旅相关信息。 - **缓存管理**:支持从 `.invoice_cache/` 目录加载已提取的结构化数据和匹配结果,避免重复处理。 +- **SSE 流式事件**:`extract_travel_info` 和 `extract_normal_info` 在调用 LLM 时,向 `source_dir/llm_stream.log` 写入流式事件(start/reasoning/chunk/end/error),前端通过 SSE 实时展示 AI 思考过程与正式回答。 ## 对外接口 - `extract_document(file_path) -> dict` — 统一入口:从任意图片/PDF 提取信息 - `extract_travel_info(source_dir) -> dict` — 综合发票和匹配结果提取差旅信息 +- `extract_normal_info(source_dir) -> dict` — 提取普通发票报销信息 - `load_cache(source_dir) -> dict` — 加载缓存的结构化数据 - `load_match_result(source_dir) -> dict` — 加载发票与支付记录的匹配结果 +- `llm_query_text(system_prompt, text, source_dir) -> str` — 纯文本 LLM 查询(供 Agent 调度使用) +- `parse_json_response(text) -> dict` — 从 LLM 响应中提取 JSON(供 Agent 调度使用) +- `build_extraction_user_message(cache_map, match_result) -> str` — 构建提取请求的用户消息(供 Agent 调度使用) + +## SSE 流式事件协议 + +`llm_stream.log` 每行一个 JSON 对象: + +- `{"type": "llm_stream", "phase": "start", "label": "..."}` — LLM 调用开始 +- `{"type": "llm_stream", "phase": "reasoning", "text": "..."}` — 模型原生推理/思考片段(来自 `thinking_delta`) +- `{"type": "llm_stream", "phase": "chunk", "text": "..."}` — 流式文本片段(正式回答) +- `{"type": "llm_stream", "phase": "end", "label": "..."}` — LLM 调用结束 +- `{"type": "llm_stream", "phase": "error", "error": "..."}` — LLM 调用失败 """ from __future__ import annotations @@ -24,14 +39,49 @@ from pathlib import Path from typing import Any, cast from .. import get_logger +from .invoice import CACHE_DIR_NAME from .prompt import ( build_invoice_system_prompt, build_normal_info_system_prompt, + build_supplement_system_prompt, build_travel_info_system_prompt, ) log = get_logger("llm_extractor") +# Re-export CACHE_DIR_NAME for convenience +__all__ = [ + "CACHE_DIR_NAME", + "extract_document", + "extract_travel_info", + "extract_normal_info", + "load_cache", + "load_match_result", + "llm_query_text", + "parse_json_response", + "build_extraction_user_message", +] + +# SSE LLM 流式事件日志文件名 +LLM_STREAM_LOG = "llm_stream.log" + + +def _emit_llm_stream(source_dir: Path, phase: str, **kwargs: Any) -> None: + """向 llm_stream.log 追加一行 JSON 事件(线程安全,失败时静默忽略) + + Args: + source_dir: 会话目录路径。 + phase: 事件阶段 ("start" / "chunk" / "end" / "error")。 + **kwargs: 额外字段 (text, label, error 等)。 + """ + event = {"type": "llm_stream", "phase": phase, **kwargs} + try: + event_path = source_dir / LLM_STREAM_LOG + with open(event_path, "a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + except Exception: + pass + def _create_llm() -> Any: """根据配置文件创建 LLM 实例。""" @@ -55,13 +105,19 @@ def _create_llm() -> Any: ) -def _parse_json_response(text: str) -> dict[str, Any]: - """从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。""" +def parse_json_response(text: str) -> dict[str, Any]: + """从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。 + + Args: + text: LLM 响应文本。 + + Returns: + 解析后的字典。 + """ text = text.strip() # 处理 ```json ... ``` 包裹 if "```" in text: - # 提取第一个代码块 start = text.find("```") + 3 end = text.find("```", start) if end > start: @@ -85,20 +141,127 @@ def _image_to_base64(image_path: Path) -> str: return base64.b64encode(f.read()).decode("utf-8") +def llm_query_text( + system_prompt: str, + text: str, + reasoning_effort: str = "none", + source_dir: Path | None = None, +) -> str: + """发送纯文本请求到 LLM(供 Agent 调度使用)。 + + Args: + system_prompt: 系统提示词。 + text: 用户文本。 + reasoning_effort: 推理努力级别。 + source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。 + + Returns: + LLM 响应文本。 + """ + from llama_index.core.base.llms.types import TextBlock + from llama_index.core.llms import ChatMessage + + from ..config import get_llm_config + + messages = [ + ChatMessage(role="system", content=system_prompt), + ChatMessage(role="user", blocks=[TextBlock(text=text)]), + ] + + llm_config = get_llm_config() + llm = _create_llm() + log.info( + "开始请求 LLM (model=%s, base=%s)", + llm_config["model"], + llm_config["api_base"], + ) + + try: + if source_dir: + _emit_llm_stream(source_dir, "start", label="正在分析文件...") + + parts = [] + for resp in llm.stream_chat( + messages, + temperature=0.1, + extra_body={"reasoning_effort": reasoning_effort}, + ): + delta = resp.delta + if delta: + parts.append(delta) + if source_dir: + _emit_llm_stream(source_dir, "chunk", text=delta) + + thinking = getattr(resp, "additional_kwargs", {}) or {} + thinking_delta = thinking.get("thinking_delta", "") + if thinking_delta and source_dir: + _emit_llm_stream(source_dir, "reasoning", text=thinking_delta) + + text = "".join(parts) + log.info("LLM 请求完成,响应总长度: %d 字符", len(text)) + if source_dir: + _emit_llm_stream(source_dir, "end", label="分析完成") + return text + except Exception as e: + log.error("LLM 请求失败: %s", e) + if source_dir: + _emit_llm_stream(source_dir, "error", error=str(e)) + raise + + +def extract_document(file_path: Path) -> dict[str, Any]: + """统一文档提取入口:从任意图片/PDF 中提取结构化信息。 + + LLM 会根据统一提示词自行判断文档类型(发票/支付记录/出差事前申请单等)。 + + Args: + file_path: 文件路径(支持 PDF 和图片格式)。 + + Returns: + 包含提取字段的字典。 + """ + from .pdf import render_pdf_to_images + + system_prompt = build_invoice_system_prompt() + user_text = f"请分析以下财务文档并提取信息:\n\n文件名: {file_path.name}" + + # PDF 先渲染为图片 + suffix = file_path.suffix.lower() + if suffix == ".pdf": + image_b64s = render_pdf_to_images(file_path) + else: + image_b64s = [_image_to_base64(file_path)] + + if not image_b64s: + log.warning(f"文件渲染为空: {file_path.name}") + return {} + + try: + response = _llm_query_multimodal(system_prompt, user_text, image_b64s) + result = parse_json_response(response) + log.info("LLM 文档提取成功: %s", file_path.name) + return result + except Exception as e: + log.error("LLM 文档提取失败: %s (%s)", file_path.name, e) + raise + + def _llm_query_multimodal( system_prompt: str, text: str | None = None, image_b64s: list[str] | None = None, blocks: list[Any] | None = None, reasoning_effort: str = "none", + source_dir: Path | None = None, ) -> str: - """发送多模态请求到 LLM。 + """发送多模态请求到 LLM(内部使用)。 Args: system_prompt: 系统提示词。 text: 用户文本(与 image_b64s 配合使用,文本在前、图片在后)。 image_b64s: base64 编码的图片列表。 blocks: 预构建的内容块列表(TextBlock/ImageBlock),传入时忽略 text 和 image_b64s。 + source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。 Returns: LLM 响应文本。 @@ -137,6 +300,9 @@ def _llm_query_multimodal( ) try: + if source_dir: + _emit_llm_stream(source_dir, "start", label="正在分析文件...") + parts = [] for resp in llm.stream_chat( messages, @@ -146,59 +312,27 @@ def _llm_query_multimodal( delta = resp.delta if delta: parts.append(delta) + if source_dir: + _emit_llm_stream(source_dir, "chunk", text=delta) + + thinking = getattr(resp, "additional_kwargs", {}) or {} + thinking_delta = thinking.get("thinking_delta", "") + if thinking_delta and source_dir: + _emit_llm_stream(source_dir, "reasoning", text=thinking_delta) + text = "".join(parts) log.info("LLM 多模态请求完成,响应总长度: %d 字符", len(text)) log.info("LLM 多模态响应: %s", text) + if source_dir: + _emit_llm_stream(source_dir, "end", label="分析完成") return text except Exception as e: log.error("LLM 多模态请求失败: %s", e) + if source_dir: + _emit_llm_stream(source_dir, "error", error=str(e)) raise -def extract_document(file_path: Path) -> dict[str, Any]: - """统一文档提取入口:从任意图片/PDF 中提取结构化信息。 - - LLM 会根据统一提示词自行判断文档类型(发票/支付记录/出差事前申请单等)。 - - Args: - file_path: 文件路径(支持 PDF 和图片格式)。 - - Returns: - 包含提取字段的字典。 - """ - from .pdf import render_pdf_to_images - - system_prompt = build_invoice_system_prompt() - user_text = f"请分析以下财务文档并提取信息:\n\n文件名: {file_path.name}" - - # PDF 先渲染为图片 - suffix = file_path.suffix.lower() - if suffix == ".pdf": - image_b64s = render_pdf_to_images(file_path) - else: - image_b64s = [_image_to_base64(file_path)] - - if not image_b64s: - log.warning(f"文件渲染为空: {file_path.name}") - return {} - - try: - response = _llm_query_multimodal(system_prompt, user_text, image_b64s) - result = _parse_json_response(response) - log.info("LLM 文档提取成功: %s", file_path.name) - return result - except Exception as e: - log.error("LLM 文档提取失败: %s (%s)", file_path.name, e) - raise - - -# ------------------------------------------------------------------ -# 差旅信息提取 -# ------------------------------------------------------------------ - -CACHE_DIR_NAME = ".invoice_cache" - - def load_cache(source_dir: Path) -> dict[str, Any]: """从 JSON 缓存目录加载结构化数据,构建 source filename -> 缓存数据的映射。 @@ -219,7 +353,6 @@ def load_cache(source_dir: Path) -> dict[str, Any]: with open(json_path, encoding="utf-8") as f: cache_data = json.load(f) - # travel_info.json / normal_info.json 结构不同,直接存储 if json_path.name in ("travel_info.json", "normal_info.json"): cache_map[json_path.name.replace(".json", "")] = cache_data continue @@ -258,44 +391,35 @@ def load_match_result(source_dir: Path) -> dict[str, list[dict[str, Any]]]: return {} -def extract_travel_info( - source_dir: Path | None = None, -) -> dict[str, Any]: - """根据差旅发票(bot 格式),让 LLM 提取出差相关信息。 - - 仅支持从 JSON 缓存加载数据。 - - bot 格式的发票包含以下字段: - - 发票类型, invoice_no, invoice_date, item_name, spec_model - - total_amount, seller_name, person_name, person_id - - card_date, card_no, card_amount, remark +def build_extraction_user_message( + cache_map: dict[str, Any], + match_result: dict[str, list[dict[str, Any]]], + previous_analysis: dict[str, Any] | None = None, +) -> str: + """构建提取请求的用户消息(供 Agent 调度使用)。 Args: - source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。 + cache_map: 缓存数据映射。 + match_result: 匹配结果。 + previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。 Returns: - 包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。 + 拼接好的用户消息字符串。 """ - # 仅从 JSON 缓存加载结构化数据 - if not source_dir: - log.warning("未提供 source_dir,无法加载缓存数据") - return {} - - system_prompt = build_travel_info_system_prompt() - - # 构建 source filename -> 缓存数据的映射 - cache_map = load_cache(source_dir) - - # 加载发票与支付记录的匹配结果 - match_result = load_match_result(source_dir) - - # 拼接纯文本消息 parts = [ "以下是本次报销的所有源文件及其提取出的结构化数据。" "每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。" ] - # 如果有匹配结果,作为额外上下文提供 + if previous_analysis: + parts.append( + "【上一轮分析结果】" + "以下是上一轮 LLM 对已有文件的分析结果。" + "注意:用户可能已补充新文件,请综合所有数据(含新文件)重新分析。" + "如果新文件填补了之前的信息缺失,请相应更新分析结果。\n" + + json.dumps(previous_analysis, ensure_ascii=False, indent=2) + ) + if match_result: parts.append( "【发票与支付记录匹配结果】" @@ -303,7 +427,6 @@ def extract_travel_info( "用于判断每笔支付对应的发票和商户信息。\n" + json.dumps(match_result, ensure_ascii=False, indent=2) ) - # 按源文件名提供结构化数据 for filename, extracted in cache_map.items(): parts.append( f"【源文件: {filename}】" @@ -311,15 +434,41 @@ def extract_travel_info( ) parts.append("\n=== 请返回 JSON 格式结果 ===") - user_message = "\n".join(parts) + return "\n".join(parts) + + +def extract_travel_info( + source_dir: Path | None = None, +) -> dict[str, Any]: + """根据差旅发票(bot 格式),让 LLM 提取出差相关信息。 + + 纯提取,不包含校验逻辑。校验由 Agent 层调度。 + + Args: + source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。 + + Returns: + 包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。 + """ + if not source_dir: + log.warning("未提供 source_dir,无法加载缓存数据") + return {} + + system_prompt = build_travel_info_system_prompt() + cache_map = load_cache(source_dir) + match_result = load_match_result(source_dir) + user_message = build_extraction_user_message(cache_map, match_result) + log.info(f"user_message: {user_message}") + try: - response = _llm_query_multimodal( + response = llm_query_text( system_prompt=system_prompt, text=user_message, reasoning_effort="low", + source_dir=source_dir, ) - result = _parse_json_response(response) + result = parse_json_response(response) log.info("LLM 差旅信息提取成功") return result except Exception as e: @@ -337,7 +486,7 @@ def extract_normal_info( ) -> dict[str, Any]: """根据普通发票(非差旅),让 LLM 提取报销相关信息。 - 仅支持从 JSON 缓存加载数据。 + 纯提取,不包含校验逻辑。校验由 Agent 层调度。 Args: source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。 @@ -350,46 +499,111 @@ def extract_normal_info( return {} system_prompt = build_normal_info_system_prompt() - - # 构建 source filename -> 缓存数据的映射 cache_map = load_cache(source_dir) - - # 加载发票与支付记录的匹配结果 match_result = load_match_result(source_dir) + user_message = build_extraction_user_message(cache_map, match_result) - # 拼接纯文本消息 - parts = [ - "以下是本次报销的所有源文件及其提取出的结构化数据。" - "每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。" - ] - - # 如果有匹配结果,作为额外上下文提供 - if match_result: - parts.append( - "【发票与支付记录匹配结果】" - "以下数据已将发票信息与对应的支付记录进行关联匹配," - "用于判断每笔支付对应的发票和商户信息。\n" + json.dumps(match_result, ensure_ascii=False, indent=2) - ) - - # 按源文件名提供结构化数据 - for filename, extracted in cache_map.items(): - parts.append( - f"【源文件: {filename}】" - "以下为从该文件提取的结构化发票/支付/申请单数据。\n" + json.dumps(extracted, ensure_ascii=False, indent=2) - ) - - parts.append("\n=== 请返回 JSON 格式结果 ===") - user_message = "\n".join(parts) log.info(f"user_message: {user_message}") + try: - response = _llm_query_multimodal( + response = llm_query_text( system_prompt=system_prompt, text=user_message, reasoning_effort="low", + source_dir=source_dir, ) - result = _parse_json_response(response) + result = parse_json_response(response) log.info("LLM 普通发票信息提取成功") return result except Exception as e: log.error("LLM 普通发票信息提取失败: %s", e) raise + + +# ------------------------------------------------------------------ +# 用户补充信息处理 +# ------------------------------------------------------------------ + + +def process_user_supplement( + user_text: str, + extracted_info: dict[str, Any], + invoice_type: str, + source_dir: Path | None = None, +) -> dict[str, Any]: + """让用户补充的文字信息通过 LLM 分析,返回需要更新的字段。 + + Args: + user_text: 用户输入的文字。 + extracted_info: 当前已提取的报销信息。 + invoice_type: "travel" 或 "normal"。 + source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。 + + Returns: + 包含 updated_fields, changes, confidence, unparsed_info 的字典。 + """ + system_prompt = build_supplement_system_prompt() + + parts = [ + f"发票类型: {'差旅报销' if invoice_type == 'travel' else '普通报销'}", + "", + "以下是当前已提取的报销信息:", + json.dumps(extracted_info, ensure_ascii=False, indent=2), + "", + f"用户补充信息:{user_text}", + "", + "=== 请分析用户输入并返回需要更新的字段 ===", + ] + user_message = "\n".join(parts) + + try: + response = llm_query_text( + system_prompt=system_prompt, + text=user_message, + reasoning_effort="low", + source_dir=source_dir, + ) + result = parse_json_response(response) + log.info("LLM 补充信息分析完成") + + return result + except Exception as e: + log.error("LLM 补充信息分析失败: %s", e) + return { + "updated_fields": {}, + "changes": [], + "confidence": 0.0, + "unparsed_info": f"分析失败: {e}", + } + + +def merge_supplement_into_info( + extracted_info: dict[str, Any], + updated_fields: dict[str, Any], +) -> dict[str, Any]: + """将 LLM 返回的更新字段合并到已提取的信息中。 + + 支持点号路径(如 basic_info.travel_purpose)表示嵌套更新。 + + Args: + extracted_info: 当前已提取的报销信息。 + updated_fields: LLM 返回的需要更新的字段。 + + Returns: + 更新后的报销信息。 + """ + import copy + + result = copy.deepcopy(extracted_info) + + for field_path, value in updated_fields.items(): + parts = field_path.split(".") + current = result + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + current[parts[-1]] = value + log.info("更新字段 %s = %s", field_path, value) + + return result diff --git a/src/doc/prompt.py b/src/doc/prompt.py index e34b509..026fe90 100644 --- a/src/doc/prompt.py +++ b/src/doc/prompt.py @@ -29,3 +29,13 @@ def build_travel_info_system_prompt() -> str: def build_normal_info_system_prompt() -> str: """构建普通发票信息提取系统提示词。""" return _load_prompt("normal_info_system.md") + + +def build_supplement_system_prompt() -> str: + """构建用户补充信息分析系统提示词。""" + return _load_prompt("supplement_system.md") + + +def build_validation_system_prompt() -> str: + """构建语义校验系统提示词。""" + return _load_prompt("validation_system.md") diff --git a/src/doc/prompts/README.md b/src/doc/prompts/README.md index 17cffe0..511de36 100644 --- a/src/doc/prompts/README.md +++ b/src/doc/prompts/README.md @@ -1,5 +1,5 @@ --- -last_reviewed: 2026-06-11 +last_reviewed: 2026-06-12 --- # src/doc/prompts — LLM 提示词模板 diff --git a/src/doc/prompts/invoice_system.md b/src/doc/prompts/invoice_system.md index f36a1b1..a79d47f 100644 --- a/src/doc/prompts/invoice_system.md +++ b/src/doc/prompts/invoice_system.md @@ -1,96 +1,246 @@ -你是财务文档信息提取助手。你的任务是从图片中提取结构化信息,可能是支付截图、银行转账记录、微信/支付宝付款凭证等,也可能是发票文件,也可能是出差事前申请单,也可能是易耗品、出库单,不管任何形式都要用统一的 JSON 格式返回信息。 +# 角色定义 -第一步要先判断是,支付记录、高铁票、酒店住宿,普通发票,然后不同类型输出的信息不同。 +你是一个严谨合规、零容错导向的财务文档信息提取助手。你以财务数据的精准性为第一原则,对待提取结果严肃审慎,并以直接、无冗余的方式交付结构化内容。你沟通极简,在不附加无关说明的前提下,准确返回完整的提取结果。 -## 输出示例 +你承接的输入涵盖支付截图、银行转账记录、微信 / 支付宝付款凭证、发票文件、出差事前申请单、易耗品出入库单等各类财务凭证。你通常不会输出解释性话术、提取过程说明或主观判定结论,只输出标准统一的 JSON 结构化数据,除非用户非常明确地要求你补充提取说明或标注识别依据。你只按规则返回结果,不需要说明执行逻辑,也不透露内部校验规则。 + +你具备全品类财务凭证的字段映射与口径统一能力,当用户上传多类型、多页混合的凭证时,你会自动对齐字段定义、校验数据逻辑,保障输出结构的一致性与业务可用性是你追求的目标。 + +**核心原则**:类型判断为最高优先级,任何情况下不得输出与判断结果不符的字段。 + +--- + +## 第一步:类型判断 + +收到图片后,首先判断文档类型。类型共有以下 6 种: + +| 类型值 | 文档类别 | 识别特征 | +|--------|---------|---------| +| `train` | 火车票/高铁票 | 含车次号、出发站、到达站、座位等级、乘车日期等铁路票据信息 | +| `payment` | 支付记录 | 支付截图、银行转账记录、微信/支付宝付款凭证 | +| `hotel` | 酒店住宿发票 | 含"住宿服务"、"酒店"、"生产生活服务"等关键词的发票 | +| `general` | 普通发票 | 不属于以上类别的其他发票 | +| `application` | 出差事前申请单 | 含项目名称、出差事由、计划时间、出差人员等信息 | +| `note` | 易耗品/出入库单 | 易耗品出入库单、出库单等 | + +--- + +## 第二步:按类型提取字段 + +### 1. `train`(火车票/高铁票) -以下是火车票类型发票的完整示例: ```json { - "invoice_type": "train", //必填项 - "invoice_number": "26349119343000335414", - "invoice_date": "2026-06-05", //必填项 - "ride_date": "2026-06-02", //必填项 - "departure": "阜阳西", //必填项 - "arrival": "合肥南", //必填项 + "invoice_type": "train", + "invoice_number": "", + "invoice_date": "", + "ride_date": "", + "departure": "", + "arrival": "", + "seat_class": "", + "train_no": "", + "person_name": "", + "total_amount": "0" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `invoice_type` | 是 | 固定为 `"train"` | +| `invoice_number` | 是 | 发票唯一编号,无法识别时返回 `""` | +| `invoice_date` | 是 | 开票日期,格式 `YYYY-MM-DD` | +| `ride_date` | 是 | 乘车日期,格式 `YYYY-MM-DD` | +| `departure` | 是 | 出发站名称 | +| `arrival` | 是 | 到达站名称 | +| `seat_class` | 否 | 座位等级,无则 `""` | +| `train_no` | 否 | 车次号,无则 `""` | +| `person_name` | 是 | 乘车人姓名 | +| `total_amount` | 是 | 票价金额(字符串格式,如 `"115.50"`);找不到填 `"0"` | + +--- + +### 2. `payment`(支付记录) + +```json +{ + "invoice_type": "payment", + "card_date": "", + "card_amount": "0", + "card_no": "" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `invoice_type` | 是 | 固定为 `"payment"` | +| `card_date` | 是 | 支付日期,格式 `YYYY-MM-DD` | +| `card_amount` | 是 | 支付金额(字符串格式,如 `"231.00"`);找不到填 `"0"` | +| `card_no` | 否 | 付款银行卡号,无则 `""` | + +--- + +### 3. `hotel`(酒店住宿发票) + +```json +{ + "invoice_type": "hotel", + "invoice_number": "", + "invoice_date": "", + "total_amount": "0" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `invoice_type` | 是 | 固定为 `"hotel"` | +| `invoice_number` | 是 | 发票唯一编号,无法识别时返回 `""` | +| `invoice_date` | 是 | 开票日期,格式 `YYYY-MM-DD` | +| `total_amount` | 是 | 价税合计金额(字符串格式);找不到填 `"0"` | + +--- + +### 4. `general`(普通发票) + +```json +{ + "invoice_type": "general", + "invoice_number": "", + "invoice_date": "", + "item_name": "", + "spec_model": "", + "total_amount": "0", + "seller_name": "" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `invoice_type` | 是 | 固定为 `"general"` | +| `invoice_number` | 是 | 发票唯一编号,无法识别时返回 `""` | +| `invoice_date` | 是 | 开票日期,格式 `YYYY-MM-DD` | +| `item_name` | 是 | 商品或服务名称(总结为人类可读的描述) | +| `spec_model` | 否 | 规格描述,无则 `""` | +| `total_amount` | 是 | 金额(字符串格式);找不到填 `"0"` | +| `seller_name` | 否 | 卖方全称,无则 `""` | + +--- + +### 5. `application`(出差事前申请单) + +```json +{ + "invoice_type": "application", + "project_name": "", + "purpose": "", + "start_date": "", + "end_date": "", + "person_info": [ + { + "person_id": "", + "person_name": "" + } + ] +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `invoice_type` | 是 | 固定为 `"application"` | +| `project_name` | 否 | 项目编号/项目名称 | +| `purpose` | 否 | 出差事由(文本描述) | +| `start_date` | 否 | 计划开始日期,格式 `YYYY-MM-DD` | +| `end_date` | 否 | 计划结束日期,格式 `YYYY-MM-DD` | +| `person_info` | 否 | 出差人员信息数组,每人一条记录;无数据时返回 `[]` | +| `person_info[].person_id` | 否 | 人员编号(字母+数字 或者纯数字 通常是9位) | +| `person_info[].person_name` | 否 | 人员姓名 | + +--- + +### 6. `note`(易耗品/出入库单) + +```json +{ + "invoice_type": "note" +} +``` + +仅包含 `invoice_type` 字段,值为 `"note"`。 + +--- + +## 强制性类型约束 + +### 根节点结构 + +根节点必须包含且仅包含与判断类型对应的字段,类型不可变更。 + +### 字段类型约束 + +| 约束 | 规则 | +|------|------| +| `invoice_type` | 字符串,必须为 6 种类型值之一 | +| 日期字段 | 字符串格式 `YYYY-MM-DD`,无法识别时返回 `""` | +| 金额字段 | 字符串格式(如 `"115.50"`),找不到时返回 `"0"` | +| 文本字段 | 字符串,无法识别时返回 `""` | +| `person_info` | 数组,每人一条记录,无数据时返回 `[]` | + +### 绝对禁止行为 + +- 输出与判断类型不符的字段 +- 将字符串字段赋值为 `null`、数字或对象 +- 将金额字段赋值为数字类型(必须为字符串) +- 省略必填字段 +- 在 JSON 外输出任何解释文字、Markdown 标记或代码块包裹 + +### 正确输出示例 + +**火车票**: +```json +{ + "invoice_type": "train", + "invoice_number": "", + "invoice_date": "2026-06-01", + "ride_date": "2026-06-01", + "departure": "阜阳西", + "arrival": "合肥南", "seat_class": "二等座", - "train_no": "G1967", - "person_name": "王建锋", //必填项 - "total_amount": "115.50" //必填项 + "train_no": "G1234", + "person_name": "张三", + "total_amount": "231.00" } ``` -以下是支付记录的完整示例: + +**支付记录**: ```json { - "invoice_type": "payment",//必填项 - "card_date": "2026-06-01",//必填项 - "card_amount": "231.00",//必填项 - "card_no": "6282****1682" + "invoice_type": "payment", + "card_date": "2026-06-01", + "card_amount": "231.00", + "card_no": "" } ``` -以下是酒店住宿发票的完整示例: + +**易耗品单**: ```json { - "invoice_type": "hotel",//必填项 - "invoice_number": "26342000001715702281", - "invoice_date": "2026-06-03", - "total_amount": "536.00"//必填项 -} -以下是易耗品出入库单的完整示例: -```json -{ - "invoice_type": "note",//必填项且只有这一项 + "invoice_type": "note" } ``` -## 重要规则 -- **必填项**:invoice_type、invoice_date、ride_date、departure、arrival、person_name、total_amount 字段为必填,必须填写。 -- **空值处理**:可选字段如没有对应信息,返回空字符串;金额字段找不到才填`0`,否则尽量填写实际金额。 -**支付记录**:如果是支付截图、银行转账记录、微信/支付宝付款凭证大概率就是支付记录,请返回如下字段(全部必填,无法识别时返回空字符串): -1. invoice_type: "payment" -2. card_date: 支付发生的日期,格式为 YYYY-M-D -3. card_amount: 实际支付金额,只保留数字(如 123.45) -4. card_no: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串 +--- -**出差事前申请单**,如果是**出差事前申请单**请返回如下字段: -1. invoice_type: "application" -2. project_name:通常是(项目编号/项目名称) -2. purpose:一段文本描述 -3. start_date:格式为 YYYY-M-D -4. end_date:格式为 YYYY-M-D -5. person_info,包含: - 1. person_id:字母+数字 - 2. person_name:有编号就肯定由姓名 +## 空值处理规则 -发票需要提取的字段(全部必填,无法识别时返回空字符串): -先判断发票类型,如果是高铁票/或者火车票,返回如下字段: -1. invoice_type: "train" -2. invoice_number: 发票的唯一编号 -3. invoice_date: 格式为 YYYY-M-D -4. ride_date: 格式为 YYYY-M-D -5. departure: 没有留空 -6. arrival: 没有留空 -7. seat_class: 没有留空 -8. train_no: 没有留空 -9. person_name: 没有留空 -10. total_amount:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息 +- **字符串字段**:无法识别时返回 `""`(空字符串),不得返回 `null` +- **金额字段**:找不到金额时返回 `"0"` +- **`person_info` 数组**:无人员信息时返回 `[]` +- **日期字段**:统一使用 `YYYY-MM-DD` 格式 -如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段: -1. invoice_type: "hotel" -2. invoice_number: 发票的唯一编号 -3. invoice_date: 格式为 YYYY-M-D -4. total_amount: 金额数字 +--- -如果是普通发票,返回如下字段: -1. invoice_type: "general" -2. invoice_number: 发票的唯一编号 -3. invoice_date: 格式为 YYYY-M-D -4. item_name: 商品或服务名称,总结的人能看懂 -5. spec_model: 规格描述 -6. total_amount: 金额数字 -7. seller_name: 卖方全称 +## 最终输出要求 -如果是易耗品出入库单,返回如下字段: -1. invoice_type: "note" - -**千万注意!千万注意!**:严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。 \ No newline at end of file +- 严格只输出 JSON 字符串,不包含任何思考过程、解释文字、Markdown 标记或代码块包裹 +- JSON 语法必须正确,无多余逗号、引号或注释 +- 只输出与判断类型对应的字段,不得混入其他类型的字段 +- `invoice_type` 的值必须与实际判断类型一致 \ No newline at end of file diff --git a/src/doc/prompts/normal_info_system.md b/src/doc/prompts/normal_info_system.md index c9d6398..5bc7b29 100644 --- a/src/doc/prompts/normal_info_system.md +++ b/src/doc/prompts/normal_info_system.md @@ -8,7 +8,7 @@ 以下类型规则为最高优先级,任何情况下不得违反。 -### 1. 根节点字段(共 4 个,类型不可变更) +### 1. 根节点字段(共 6 个,类型不可变更) | 字段名 | 强制类型 | 空值处理 | | --- | --- | --- | @@ -16,6 +16,8 @@ | `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含下述 2 个子字段 | | `payment_methods` | 数组 (list) | 必填,无数据时赋值为 `[]` | | `attachments` | 数组 (list) | 必填,无数据时赋值为 `[]` | +| `can_submit` | 布尔 (bool) | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` | +| `suggestion` | 字符串 (str) | 当 `can_submit` 为 `false` 时说明需补充的材料;为 `true` 时为空字符串 | ### 2. `reimbursement_details` 子字段(共 2 个) @@ -37,7 +39,9 @@ "basic_info": {...}, "reimbursement_details": {...}, "payment_methods": [], - "attachments": [{"filename":"发票.pdf", ...}] + "attachments": [{"filename":"发票.pdf", ...}], + "can_submit": true, + "suggestion": "" } ``` @@ -55,6 +59,8 @@ 1. **发票信息**:包含购买物品的发票信息 2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息 +**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。 + 需要提取的信息: 1. `basic_info`:(必填,每一项都必须填,给出合理的猜测) 1. `reimbursement_description`:根据所有信息写一句20字以内的报销说明 @@ -71,6 +77,23 @@ 2. `attachment_type`:从以下两个选项中选择:invoice、other 3. `attachment_desc`:简要描述该文件的基本信息 +## 语义完整性校验 + +提取完成后,需判断信息是否足够支撑填报。根据校验结果设置根节点的 `can_submit`(boolean)和 `suggestion`(string)字段。 + +**校验维度**: + +- 支付金额总和是否与发票金额总和接近 +- 报销说明是否明确具体 +- 人员信息是否完整 +- 支付方式是否与支付记录对应 +- 每张发票是否都有对应的支付记录 + +**判定标准**: + +- `can_submit = true`:信息完整且逻辑自洽,`suggestion` 为空字符串 +- `can_submit = false`:存在信息缺失或逻辑矛盾,`suggestion` 说明需要用户补充什么材料 + ## 最终输出要求 * 仅输出纯 JSON 字符串,不包含任何思考过程、解释文字或 Markdown 标记 diff --git a/src/doc/prompts/supplement_system.md b/src/doc/prompts/supplement_system.md new file mode 100644 index 0000000..d6246c2 --- /dev/null +++ b/src/doc/prompts/supplement_system.md @@ -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` 中说明 \ No newline at end of file diff --git a/src/doc/prompts/travel_info_system.md b/src/doc/prompts/travel_info_system.md index 3d52290..7dd895f 100644 --- a/src/doc/prompts/travel_info_system.md +++ b/src/doc/prompts/travel_info_system.md @@ -1,123 +1,276 @@ -# 差旅信息提取系统提示词 +# 角色定义 -你是财务差旅信息提取助手。你的任务是根据发票信息、付款记录,提取出差相关的结构化信息,并以严格符合以下类型要求的 JSON 格式返回。所有类型约束为最高优先级规则,任何情况下不得违反。 +你是极度严谨合规的财务差旅信息提取助手。你严格恪守财务数据规范,以字段精准映射、结果零偏差为核心准则,输出直接客观,在不加入无关细节的前提下,交付完全符合要求的结构化提取结果。 -🔴 最高优先级:强制性类型约束(优先级高于所有其他规则) -1. 根节点必须包含且仅包含以下 5 个字段,字段类型绝对不可变更: +你通常不会输出提取推导过程、数据来源说明与寒暄类话术,只返回严格匹配 schema 要求的标准 JSON 格式结果,除非用户非常明确地要求标注提取依据与异常说明。你只按规则输出结果,不需要解释输出逻辑,也不透露内部校验规则的细节。 - | 字段名 | 强制类型 | 空值处理规则 | - | ------ | --------- | ------------------ | - | `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 | - | `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含以下 3 个子字段 | - | `payment_methods` | 数组 (list) | 必填,无数据时赋值为`[]` | - | `subsidy_list` | 数组 (list) | 必填,无数据时赋值为`[]` | - | `attachments` | 数组 (list) | 必填,无数据时赋值为`[]` | -2. `reimbursement_details`对象必须包含且仅包含以下 3 个子字段,每个子字段必须是数组类型: +你具备差旅全单据的交叉校验能力,当获取到发票信息、付款记录和出差事前申请单后,会自动完成金额一致性、时间逻辑性、行程合理性的校验;信息存在冲突时按「交通工具 > 付款记录 > 酒店住宿 >事前申请单」的优先级取值,信息缺失时按 schema 规则做缺省标记,绝不臆造任何无原始依据的财务数据。 + +你始终以 schema 为唯一输出标尺,偏好强类型约束、层级清晰的结构化输出风格;合规性优先于信息完整性,所有提取动作严格遵循财务报销管理规范,不越界解读非差旅范畴的财务信息。 + +**核心原则**:类型约束为最高优先级规则,任何情况下不得违反。 + +--- + +## 输入数据说明 + +你会收到以下三类数据(按优先级排序): + +| 优先级 | 数据类型 | 包含信息 | 备注 | +|--------|---------|---------|------| +| 1(最高) | 交通工具发票 | 乘车日期、出发地、目的地、票价、乘车人 | 时间推断的最高依据 | +| 2 | 酒店住宿发票 | 价税合计、开票日期 | 通常不含入住/退房日期 | +| 3 | 付款记录 | 刷卡日期、刷卡金额、公务卡号 | 用于匹配支付信息 | +| 4(最低) | 出差事前申请单(可选) | 项目名称、出差事由、计划时间、出差人员 | 计划时间可能与实际不符 | + +**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。 + +--- + +## 强制性类型约束 + +### 根节点结构 + +根节点必须包含且仅包含以下 7 个字段,类型不可变更: + +| 字段名 | 类型 | 空值处理 | +|--------|------|---------| +| `basic_info` | object | 必填,所有子字段必须存在 | +| `reimbursement_details` | object | 必填,必须且仅含 3 个子字段 | +| `payment_methods` | array | 无数据时返回 `[]` | +| `subsidy_list` | array | 无数据时返回 `[]` | +| `attachments` | array | 无数据时返回 `[]` | +| `can_submit` | boolean | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` | +| `suggestion` | string | 当 `can_submit` 为 `false` 时说明需补充的材料;为 `true` 时为空字符串 | + +### 报销明细节点结构 + +`reimbursement_details` 必须包含且仅包含以下 3 个子字段,均为数组类型: + +| 子字段 | 类型 | 空值处理 | +|--------|------|---------| +| `transport_fee` | array | 无数据时返回 `[]` | +| `hotel_fee` | array | 无数据时返回 `[]` | +| `conference_fee` | array | 无数据时返回 `[]` | + +### 绝对禁止行为 + +- 省略任何根节点字段或报销明细节点 +- 将数组类型赋值为 `null`、字符串、数字或对象 +- 在 `reimbursement_details` 中添加未定义的子字段 +- 合并不同模块的数组数据(如将去程和返程交通费合并为一条) + +### 正确输出骨架 - |字段名|强制类型|空值处理规则| - |---|---|---| - |`transport_fee`|数组 (list)|无数据时赋值为`[]`| - |`hotel_fee`|数组 (list)|无数据时赋值为`[]`| - |`conference_fee`|数组 (list)|无数据时赋值为`[]`| -3. 绝对禁止以下行为: -* 省略上述任何一个根节点字段或报销明细的子字段 -* 将数组类型的字段赋值为null、字符串、数字或对象 -* 在报销明细中添加任何未定义的子字段 -* 合并不同模块的数组数据 -✅ 正确类型示例 ```json { - "basic_info": {...}, + "basic_info": { /* 所有子字段完整存在 */ }, "reimbursement_details": { - "transport_fee": [{"vehicle_type":"train", ...}], + "transport_fee": [ /* 去程一条,返程一条,可以一个人单独一条,也可以多人合并一条 */ ], "hotel_fee": [], "conference_fee": [] }, "payment_methods": [], - "subsidy_list": [{"person_name":"张三", ...}], - "attachments": [{"filename":"发票.pdf", ...}] + "subsidy_list": [], + "attachments": [], + "can_submit": true, + "suggestion": "" } ``` -❌ 错误类型示例(绝对禁止) + +--- + +## 字段 Schema + +### 1. `basic_info`(全部必填,无直接信息时给出合理猜测) + +| 字段 | 类型 | 说明 | 推断优先级 | +|------|------|------|-----------| +| `travel_purpose` | string | 出差事由 | ①申请单事由 → ②根据发票信息总结 | +| `travel_location` | string | 出差目的地 | ①交通工具目的地 → ②申请单说明 | +| `start_date` | string | 出差开始日期,格式 `YYYY-MM-DD` | ①最早乘车日期 → ②申请单时间 → ③开票/付款日期 | +| `end_date` | string | 出差结束日期,格式 `YYYY-MM-DD` | ①最晚乘车日期 → ②申请单时间 → ③开票/付款日期 | + +**注意**:出差一定从阜阳出发。 + +--- + +### 2. `transport_fee` 数组元素(去程和返程分开,各为一条记录) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `vehicle_type` | string | 枚举:`train` / `car` / `ship` / `personal_car` / `official_car` / `plane` / `rental_car` / `self_drive` | +| `start_date` | string | 乘车日期,格式 `YYYY-MM-DD` | +| `end_date` | string | 乘车日期,格式 `YYYY-MM-DD` | +| `departure_place` | string | 出发地(通常为城市名称) | +| `arrival_place` | string | 目的地(通常为城市名称) | +| `amount` | number | 票价金额 | +| `bill_count` | integer | 发票张数 | +| `remark` | string | 基本信息,例:`王建锋和张国庆高铁票` | + +--- + +### 3. `hotel_fee` 数组元素 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `checkin_date` | string | 入住日期,格式 `YYYY-MM-DD` | +| `checkout_date` | string | 退房日期,格式 `YYYY-MM-DD` | +| `days` | integer | `checkout_date - checkin_date`,结果 ≥ 0 | +| `person_count` | integer | 住宿人数 | +| `invoice_amount` | number | 酒店发票价税合计总额 | +| `reimburse_amount` | number | 酒店付款记录合计总额 | +| `remark` | string | 住宿人员姓名,例:`王建锋、张国庆住宿` | + +**日期推断优先级**:①交通工具发票日期(最高)→ ②酒店发票信息 → ③申请单时间(可能不准) + +**默认值**:单张酒店发票且无明确信息时,`days` 和 `person_count` 均默认为 1。 + +--- + +### 4. `conference_fee` 数组元素 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `bill_count` | integer | 会务费/培训费发票张数 | +| `amount` | number | 会务费/培训费总金额 | +| `remark` | string | 会务培训基本信息 | + +--- + +### 5. `payment_methods` 数组元素(多少笔支付就多少条) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `card_date` | string | 刷卡日期,格式 `YYYY-MM-DD` | +| `card_amount` | number | 刷卡金额(元) | +| `merchant` | string | 商户信息(高铁票统一为`中国铁路`) | +| `remark` | string | 关联的发票信息,例:`王建锋和张国庆从阜阳西-合肥南高铁票` | + +--- + +### 6. `subsidy_list` 数组元素(按出差人员数量决定条目数) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `person_id` | string | 人员编号(无直接信息时给出合理编号) | +| `person_name` | string | 出差人员姓名 | +| `start_date` | string | 该人员出差开始日期,格式 `YYYY-MM-DD` | +| `end_date` | string | 该人员出差结束日期,格式 `YYYY-MM-DD` | +| `days` | integer | `end_date - start_date + 1` | + +**日期推断**:优先取该人员个人的来回交通工具发票日期;无个人数据时取 `basic_info` 中的日期。 + +--- + +### 7. `attachments` 数组元素 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `filename` | string | 严格使用原始文件名,不得修改任何字符 | +| `attachment_type` | string | 枚举:`invoice` / `other` | +| `attachment_desc` | string | 文件基本信息描述 | + +**排除规则**:`invoice_type` 为 `payment` 的记录不作为附件。 + +--- + +## 推理规则 + +### 数据推断优先级链 + +``` +日期推断:交通工具发票 > 申请单时间 > 开票/付款日期 +事由推断:申请单事由 > 发票信息总结 +地点推断:交通工具出发/目的地 > 申请单说明 +人员推断:车票姓名 > 住宿发票信息 > 申请单人员 +``` + +### 关键规则 + +1. **去回分开**:交通费的去程和返程必须分两条记录,禁止合并 +2. **住宿天数**:`days = checkout_date - checkin_date`,结果必须 ≥ 0 +3. **补助天数**:`days = end_date - start_date + 1` +4. **支付记录排除**:付款记录不放入 `attachments` +5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null` + +### 语义完整性校验 + +提取完成后,需判断信息是否足够支撑填报。根据校验结果设置根节点的 `can_submit`(boolean)和 `suggestion`(string)字段。 + +**校验维度**: + +- 出差日期范围是否合理(结束日期不早于开始日期) +- 交通费的去程和返程日期是否在出差日期范围内 +- 支付金额总和是否与发票金额总和接近 + - 通常每张发票都要有对应的支付记录 + - 是否缺少发票 + - 是否缺少支付记录 +- 人员信息是否完整 + +**不需要关注的** +- 非必填项没有填写信息,不要提醒补充 +- 酒店住宿有发票就行,不需要别的证明 + + +**一定要关注的** +- `reimbursement_details` 和 `payment_methods` 两个的总金额应该一样,如果不一样,要么是缺发票,要么是缺支付记录,需要提醒用户 +- 用户一定要提供出差事情申请单 + +**判定标准**: + +- `can_submit = true`:信息完整且逻辑自洽,`suggestion` 为空字符串 +- `can_submit = false`:存在信息缺失或逻辑矛盾,`suggestion` 说明需要用户补充什么材料 + +### 示例 + +**补助清单**(2 人出差,6月1日至6月3日): + ```json -{ - "basic_info": {...}, - "reimbursement_details": { - "transport_fee": [{"vehicle_type":"train", ...}] - // 错误:省略了hotel_fee和conference_fee字段 +[ + { + "person_id": "xxxxxxx", + "person_name": "张三", + "start_date": "2026-06-01", + "end_date": "2026-06-03", + "days": 3 }, - "payment_methods": null, // 错误:数组类型不能为null - "subsidy_list": "" // 错误:数组类型不能为字符串 - // 错误:省略了attachments字段 -} + { + "person_id": "2024xxxxx", + "person_name": "李四", + "start_date": "2026-06-01", + "end_date": "2026-06-03", + "days": 3 + } +] ``` -## 输入数据说明 -你会收到以下数据: -1. **发票信息**:包含高铁票(火车/飞机票)和酒店住宿发票的结构化提取数据 -2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息 -3. **出差事前申请单**(可选):包含项目名称、出差事由、出差时间、出差人员等信息 +**支付方式**(高铁票付款): -需要提取的信息: -1. `basic_info`:(必填,每一项都必须填,给出合理的猜测) - 1. `travel_purpose`:如果有出差事前申请单,优先使用申请单中的出差事由;否则根据所有发票信息总结一个合理的出差事由(如"参加XX学术会议"、"前往XX办理公务"等) - 2. `travel_location`:出差目的地,注意一定是从阜阳出发,根据交通工具出发点和目的地也可以推断得到出差地点,出差事前申请单也有说明 - 3. `start_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D - 4. `end_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D -2. `reimbursement_details`:(至少有一项) - 1. `transport_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式) - 1. `vehicle_type`:从以下选项中选择最符合的一个:train、car、ship、personal_car、official_car、plane、rental_car、self_drive - 2. `start_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D - 3. `end_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D - 4. `departure_place`:由交通工具发票的信息填写,通常是城市名称 - 5. `arrival_place`:由交通工具发票的信息填写,通常是城市名称 - 6. `amount`:由交通工具发票的信息填写,通常是城市名称 - 7. `bill_count`:由交通工具发票的信息填写,通常是城市名称 - 8. `remark`:填写基本信息,例如:王建锋和张国庆高铁票 - 2. `hotel_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式) - 1. `checkin_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具离开阜阳时间为最高优先级)综合推断,格式 YYYY-M-D,例如:2026-06-01 - 2. `checkout_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具回阜阳时间为最高优先级)综合推断,格式 YYYY-M-D,例如:2026-06-03 - 3. `days`:结束日期 - 开始日期,整数,例如 2026-06-03 - 2026-06-01,天数为 2 天 - 4. `person_count`:根据发票信息和车票信息综合判断住宿人数,有可能开成一张发票,人数一定是整数 - 5. `invoice_amount`:所有酒店住宿发票的价税合计总额,数字 - 6. `reimburse_amount`:所有酒店住宿付款记录的合计总额,数字 - 7. `remark`:根据所有信息综合判断住宿人员,然后就填写所有人姓名,例如:王建锋、张国庆住宿 - 3. `conference_fee`(如果有,每一项都要必填,给出合理的猜测) - 1. `bill_count`:根据发票信息判断,有几张关于会务费培训费的发票,一定是整数 - 2. `amount`:会务费培训发票的总金额 - 3. `remark`:会务培训的基本信息 +```json +[ + { + "card_date": "2026-06-01", + "card_amount": 231.0, + "merchant": "中国铁路网络有限公司", + "remark": "张国庆和王建锋从阜阳西-合肥南高铁票" + }, + { + "card_date": "2026-06-01", + "card_amount": 167.0, + "merchant": "中国铁路网络有限公司", + "remark": "陈曙光从阜阳西-合肥南高铁票" + } +] +``` -4. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式) - 1. `card_date`:根据付款记录,格式 YYYY-M-D - 2. `card_amount`:根据付款记录填写,单位为元,数字 - 3. `merchant`:根据发票信息推测商户信息(高铁票统一为中国铁路) - 4. `remark`:说明该笔付款关联的发票信息,例如:王建锋和张国庆从阜阳西 - 合肥南高铁票 -5. `subsidy_list`:(必填;多项请采用上述通用 JSON 数组格式) - 1. `person_id`:无直接信息时给出合理编号 - 2. `person_name`:根据车票、住宿等信息推断出差人员姓名 - 3. `start_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D,例如:2026-06-01 - 4. `end_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D,例如:2026-06-03 - 5. `days`:结束日期 - 开始日期 + 1,整数(例如:2026-06-03 - 2026-06-01 + 1,天数为 3 天) -6. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`,"invoice_type": "payment"的不作为附件) - 1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符 - 2. `attachment_type`:从以下两个选项中选择:invoice、other - 3. `attachment_desc`:简要描述该文件的基本信息 - -**推理规则**: -- 补助清单由人员数量决定:例如`[{"person_id": "xxxxxxx", "person_name": "张三", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}, {"person_id": "2024xxxxx", "person_name": "李四", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}]` -- 支付方式示例:`[{"card_date": "2026-06-01","card_amount": 231.0,"merchant": "中国铁路网络有限公司","remark": "张国庆和王建锋从阜阳西-合肥南高铁票"},{"card_date": "2026-06-01","card_amount": 167.0,"merchant": "中国铁路网络有限公司","remark": "陈曙光从阜阳西-合肥南高铁票"}]` -- 交通费,去和回不能放在一起,最好放在两个交通费单里,去时放一个,回时放一个 -- 如果有出差事前申请单,优先使用申请单中的出差事由 -- 出差开始时间优先取最早的交通工具乘车日期,无交通工具发票时参考申请单时间 -- 出差结束时间优先取最晚的交通工具乘车日期,无交通工具发票时参考申请单时间 -- 若无交通工具发票,用开票日期和付款日期综合判断 -- 住宿天数 = checkout_date - checkin_date 结果要大于等于 0 -- 若只有单张酒店发票且无明确天数信息,住宿天数默认为 1 -- 若只有单张酒店发票且无明确人数信息,住宿人数默认为 1 -- 支付记录不放在附件中! +--- ## 最终输出要求 -* 严格只输出符合上述所有要求的 JSON 字符串 -* 不要输出任何思考过程、解释文字、Markdown 标记或其他内容 -* 输出的 JSON 必须语法正确,无多余逗号、引号等语法错误 -* 必须严格遵守所有强制性类型约束,任何违反类型要求的输出均视为无效 \ No newline at end of file + +- 严格只输出 JSON 字符串,不包含任何思考过程、解释文字、Markdown 标记或其他内容 +- JSON 语法必须正确,无多余逗号、引号等错误 +- 严格遵守所有类型约束,任何违反均视为无效输出 +- 日期统一使用 `YYYY-MM-DD` 格式 +- 金额使用数字类型(非字符串) +- 计数使用整数类型 \ No newline at end of file diff --git a/src/doc/prompts/validation_system.md b/src/doc/prompts/validation_system.md new file mode 100644 index 0000000..6b455f0 --- /dev/null +++ b/src/doc/prompts/validation_system.md @@ -0,0 +1,83 @@ +#性格 + +你是财务报销信息完整性校验助手。你的任务是检查已提取的报销信息在语义上是否足够支撑完成报销系统填报。 + +**核心原则**:不仅要检查字段是否存在,还要判断信息在逻辑上是否自洽、是否足以完成填报。 + +--- + +## 输入数据说明 + +你会收到以下数据: +1. **已提取的报销信息**:包含基本信息、报销明细、支付方式、附件清单等 +2. **发票缓存数据**:原始发票提取的结构化数据 +3. **匹配结果**:发票与支付记录的关联关系 + +--- + +## 校验维度 + +### 1. 逻辑一致性检查 + +- 出差日期范围是否合理(结束日期不早于开始日期) +- 交通费的去程和返程日期是否在出差日期范围内 +- 酒店入住/退房日期是否与出差时间匹配 +- 支付金额总和是否与发票金额总和接近(允许小额差异) +- 补助天数计算是否正确 + +### 2. 信息充分性检查 + +- 是否有足够的信息填写所有报销系统必填项 +- 出差事由是否明确具体(不能过于笼统) +- 人员信息是否完整(姓名、工号) +- 支付方式是否与支付记录对应 +- 每张发票应该都有对应的支付记录,如果没有提醒用户补充支付记录 + +### 3. 潜在问题识别 + +- 发票日期与出差日期差异过大 +- 同一笔支付对应多张发票但金额不匹配 +- 缺少关键附件 +- 人员信息不一致(如车票姓名与补助清单姓名不同) + +--- +### 4. 不需询问的问题 +- 出差事前申请单和实际出差时间不一致这是正常的,因为规划是实际可以有差异 + + +## 输出格式 + +严格返回以下 JSON 格式: + +```json +{ + "valid": true或false, + "confidence": 0.0到1.0之间的数字, + "issues": ["问题描述1", "问题描述2"], + "missing_info": ["缺失信息1", "缺失信息2"], + "suggestion": "补充建议" +} +``` + +### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `valid` | boolean | 信息在语义上是否足够支撑填报 | +| `confidence` | number | 置信度,1.0 表示完全确定,0.0 表示完全不确定 | +| `issues` | array | 发现的逻辑问题列表,无问题则为空数组 | +| `missing_info` | array | 语义上缺失的关键信息列表 | +| `suggestion` | string | 补充建议,说明需要用户上传什么材料 | + +### 判定标准 + +- **valid = true**:信息完整且逻辑自洽,可以直接填报 +- **valid = false**:存在信息缺失或逻辑矛盾,需要补充材料 + +--- + +## 最终输出要求 + +- 严格只输出 JSON 字符串 +- JSON 语法必须正确 +- 不要包含任何思考过程或解释文字 \ No newline at end of file diff --git a/src/doc/validator.py b/src/doc/validator.py new file mode 100644 index 0000000..0643a6d --- /dev/null +++ b/src/doc/validator.py @@ -0,0 +1,429 @@ +"""信息完整性校验器 + +对 LLM 提取的报销信息进行规则级校验,判断是否满足填报要求。 + +校验规则基于两个 schema: + - 差旅报销:basic_info + reimbursement_details + payment_methods + subsidy_list + attachments + - 普通报销:basic_info + reimbursement_details + payment_methods + attachments + +校验结果包含缺失字段列表、缺失材料推断和建议信息。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +from .. import get_logger + +log = get_logger("validator") + +# ------------------------------------------------------------------ +# 日期格式 +# ------------------------------------------------------------------ + +DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _is_valid_date(value: str) -> bool: + return bool(DATE_PATTERN.match(value)) + + +# ------------------------------------------------------------------ +# 数据模型 +# ------------------------------------------------------------------ + + +@dataclass +class ValidationReport: + """校验结果报告""" + + valid: bool + missing_fields: list[str] = field(default_factory=list) + missing_materials: list[str] = field(default_factory=list) + confidence: float = 0.0 + suggestion: str = "" + + +# ------------------------------------------------------------------ +# 校验辅助 +# ------------------------------------------------------------------ + + +def _check_field( + data: dict[str, Any], + path: list[str], + required: bool = True, + check_empty: bool = True, + custom_check: Any = None, +) -> tuple[bool, str]: + """沿路径检查字段是否存在且有效。 + + Returns: + (通过, 字段路径字符串) + """ + current = data + for key in path: + if not isinstance(current, dict): + return (False, ".".join(path)) + if key not in current: + return (False, ".".join(path)) + current = current[key] + + if check_empty and isinstance(current, str) and not current.strip(): + return (False, ".".join(path)) + + if custom_check is not None and not custom_check(current): + return (False, ".".join(path)) + + return (True, ".".join(path)) + + +def _check_array_min( + data: dict[str, Any], + path: list[str], + min_items: int = 1, +) -> tuple[bool, str]: + """检查数组字段是否存在且至少有 min_items 项。""" + current = data + for key in path: + if not isinstance(current, dict): + return (False, ".".join(path)) + if key not in current: + return (False, ".".join(path)) + current = current[key] + + if not isinstance(current, list) or len(current) < min_items: + return (False, ".".join(path)) + + return (True, ".".join(path)) + + +def _check_array_element_fields( + items: Any, + required_fields: list[str], + field_path_prefix: str, +) -> list[str]: + """检查数组中每个元素是否包含必填字段。""" + missing: list[str] = [] + if not isinstance(items, list): + return [field_path_prefix] + + for i, item in enumerate(items): + if not isinstance(item, dict): + missing.append(f"{field_path_prefix}[{i}]") + continue + for fld in required_fields: + if fld not in item or (isinstance(item[fld], str) and not item[fld].strip()): + missing.append(f"{field_path_prefix}[{i}].{fld}") + + return missing + + +def _is_positive_number(value: Any) -> bool: + """检查值是否为正数。""" + return isinstance(value, int | float) and value > 0 + + +# ------------------------------------------------------------------ +# 差旅报销校验 +# ------------------------------------------------------------------ + + +def validate_travel_info(data: dict[str, Any]) -> ValidationReport: + """校验差旅报销信息的完整性。""" + missing: list[str] = [] + total_checks = 0 + passed_checks = 0 + + # basic_info 必填字段 + basic_fields = [ + "travel_purpose", + "travel_location", + "start_date", + "end_date", + ] + + for fld in basic_fields: + total_checks += 1 + path = ["basic_info", fld] + if fld in ("start_date", "end_date"): + ok, _ = _check_field(data, path, custom_check=_is_valid_date) + else: + ok, _ = _check_field(data, path) + if ok: + passed_checks += 1 + else: + missing.append(".".join(path)) + + # reimbursement_details.transport_fee (至少一条) + total_checks += 1 + ok, _ = _check_array_min(data, ["reimbursement_details", "transport_fee"], min_items=1) + if ok: + passed_checks += 1 + else: + missing.append("reimbursement_details.transport_fee") + + # 检查 transport_fee 元素字段 + transport = data.get("reimbursement_details", {}).get("transport_fee", []) + transport_fields = [ + "vehicle_type", + "start_date", + "end_date", + "departure_place", + "arrival_place", + "amount", + "bill_count", + "remark", + ] + missing.extend( + _check_array_element_fields( + transport, + transport_fields, + "reimbursement_details.transport_fee", + ) + ) + + # payment_methods (至少一条) + total_checks += 1 + ok, _ = _check_array_min(data, ["payment_methods"], min_items=1) + if ok: + passed_checks += 1 + else: + missing.append("payment_methods") + + # 检查 payment_methods 元素 + payments = data.get("payment_methods", []) + payment_fields = ["card_date", "card_amount", "merchant", "remark"] + missing.extend( + _check_array_element_fields( + payments, + payment_fields, + "payment_methods", + ) + ) + + # subsidy_list (至少一条) + total_checks += 1 + ok, _ = _check_array_min(data, ["subsidy_list"], min_items=1) + if ok: + passed_checks += 1 + else: + missing.append("subsidy_list") + + # 检查 subsidy_list 元素 + subsidies = data.get("subsidy_list", []) + subsidy_fields = ["person_id", "person_name", "start_date", "end_date", "days"] + missing.extend( + _check_array_element_fields( + subsidies, + subsidy_fields, + "subsidy_list", + ) + ) + + # attachments (可选,有数据时检查) + attachments = data.get("attachments", []) + if attachments: + attach_fields = ["filename", "attachment_type"] + missing.extend( + _check_array_element_fields( + attachments, + attach_fields, + "attachments", + ) + ) + + # 计算置信度 + confidence = passed_checks / total_checks if total_checks > 0 else 0.0 + + # 推断缺失材料 + missing_materials = _infer_missing_materials(missing, data) + + # 生成建议 + suggestion = _build_suggestion(missing, missing_materials) + + return ValidationReport( + valid=len(missing) == 0, + missing_fields=missing, + missing_materials=missing_materials, + confidence=round(confidence, 2), + suggestion=suggestion, + ) + + +# ------------------------------------------------------------------ +# 普通报销校验 +# ------------------------------------------------------------------ + + +def validate_normal_info(data: dict[str, Any]) -> ValidationReport: + """校验普通报销信息的完整性。""" + missing: list[str] = [] + total_checks = 0 + passed_checks = 0 + + # basic_info.reimbursement_description + total_checks += 1 + ok, _ = _check_field(data, ["basic_info", "reimbursement_description"]) + if ok: + passed_checks += 1 + else: + missing.append("basic_info.reimbursement_description") + + # reimbursement_details.total_invoices + total_checks += 1 + ok, _ = _check_field( + data, + ["reimbursement_details", "total_invoices"], + custom_check=lambda v: isinstance(v, int) and v > 0, + ) + if ok: + passed_checks += 1 + else: + missing.append("reimbursement_details.total_invoices") + + # reimbursement_details.total_amount + total_checks += 1 + ok, _ = _check_field( + data, + ["reimbursement_details", "total_amount"], + custom_check=_is_positive_number, + ) + if ok: + passed_checks += 1 + else: + missing.append("reimbursement_details.total_amount") + + # payment_methods (至少一条) + total_checks += 1 + ok, _ = _check_array_min(data, ["payment_methods"], min_items=1) + if ok: + passed_checks += 1 + else: + missing.append("payment_methods") + + payments = data.get("payment_methods", []) + payment_fields = ["card_date", "card_amount", "merchant", "remark"] + missing.extend( + _check_array_element_fields( + payments, + payment_fields, + "payment_methods", + ) + ) + + # attachments + attachments = data.get("attachments", []) + if attachments: + attach_fields = ["filename", "attachment_type"] + missing.extend( + _check_array_element_fields( + attachments, + attach_fields, + "attachments", + ) + ) + + confidence = passed_checks / total_checks if total_checks > 0 else 0.0 + missing_materials = _infer_missing_materials(missing, data) + suggestion = _build_suggestion(missing, missing_materials) + + return ValidationReport( + valid=len(missing) == 0, + missing_fields=missing, + missing_materials=missing_materials, + confidence=round(confidence, 2), + suggestion=suggestion, + ) + + +# ------------------------------------------------------------------ +# 缺失材料推断 +# ------------------------------------------------------------------ + + +def _infer_missing_materials( + missing_fields: list[str], + data: dict[str, Any], +) -> list[str]: + """根据缺失字段推断可能需要补充的材料类型。""" + materials: list[str] = [] + field_set = set(missing_fields) + + if any("start_date" in f or "end_date" in f for f in field_set): + if "basic_info.start_date" in field_set or "basic_info.end_date" in field_set: + materials.append("出差事前申请单") + + if "basic_info.travel_purpose" in field_set: + materials.append("出差事前申请单") + + if "basic_info.travel_location" in field_set: + materials.append("交通工具发票") + + if "payment_methods" in field_set or any("payment_methods[" in f for f in field_set): + materials.append("支付记录截图") + + if any("transport_fee" in f for f in field_set): + materials.append("交通工具发票") + + if any("subsidy_list" in f for f in field_set): + materials.append("出差事前申请单") + + if "basic_info.reimbursement_description" in field_set: + materials.append("发票或支付记录") + + # 去重 + return list(dict.fromkeys(materials)) + + +def _build_suggestion( + missing_fields: list[str], + missing_materials: list[str], +) -> str: + """生成用户友好的建议信息。""" + if not missing_fields: + return "" + + if missing_materials: + material_names = "、".join(missing_materials) + return f"信息不完整,请补充上传:{material_names}" + + return f"信息不完整,缺少 {len(missing_fields)} 个字段" + + +# ------------------------------------------------------------------ +# 统一入口 +# ------------------------------------------------------------------ + + +def validate_extracted_info( + data: dict[str, Any], + invoice_type: str = "travel", +) -> ValidationReport: + """校验提取信息的完整性。 + + Args: + data: LLM 提取的结构化信息。 + invoice_type: 发票类型,'travel' 或 'normal'。 + + Returns: + 校验报告。 + """ + log.info("开始校验 %s 报销信息完整性", invoice_type) + + if invoice_type == "travel": + report = validate_travel_info(data) + else: + report = validate_normal_info(data) + + status = "通过" if report.valid else "未通过" + log.info( + "校验结果: %s (置信度: %.0f%%, 缺失字段: %d)", + status, + report.confidence * 100, + len(report.missing_fields), + ) + + return report diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 0000000..3acfd16 --- /dev/null +++ b/src/exceptions.py @@ -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 [] diff --git a/src/pipeline.py b/src/pipeline.py index 900f0a8..5c33294 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -22,6 +22,7 @@ from . import get_logger from .config import load_config from .doc.extractor import extract_invoices from .doc.invoice import ( + classify_invoice_batch, save_application_json, save_invoice_csv, ) @@ -35,25 +36,6 @@ from .doc.llm_extractor import ( load_cache, ) - -def _classify_invoice_batch( - invoices: list[dict[str, str]], -) -> dict[str, list[dict[str, str]]]: - """按发票类型分组""" - travel: list[dict[str, str]] = [] - general: list[dict[str, str]] = [] - application: list[dict[str, str]] = [] - for inv in invoices: - inv_type = inv.get("invoice_type", "general") - if inv_type == "application": - application.append(inv) - elif inv_type in ("train", "hotel"): - travel.append(inv) - else: - general.append(inv) - return {"travel": travel, "general": general, "application": application} - - log = get_logger("pipeline") @@ -63,7 +45,7 @@ def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]: cache_map = load_cache(cache_path) invoices = [data for data in cache_map.values() if data.get("invoice_type") not in ("application", "payment")] - return _classify_invoice_batch(invoices) + return classify_invoice_batch(invoices) def _extract_travel_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None: diff --git a/src/web/README.md b/src/web/README.md index 5c9913d..bea9705 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -1,5 +1,5 @@ --- -last_reviewed: 2026-06-11 +last_reviewed: 2026-06-13 --- # src/web 模块设计说明 @@ -11,20 +11,47 @@ last_reviewed: 2026-06-11 - **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads//` 目录下,避免并发冲突。 - **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。 - **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。 +- **统一文件上传**:2026-06-12 改造,将 PDF 和图片上传入口合并为单一上传区,用户通过一个入口上传所有文件类型。 + +## 变更历史 + +| 日期 | 变更 | +|------|------| +| 2026-06-13 | LLM 流式思考过程展示:后端 SSE 推送 `llm_stream` 事件(start/chunk/end/error),前端聊天气泡实时展示 AI 思考过程 | +| 2026-06-12 | 文件进度实时反馈:后端 SSE 推送 `file_progress` 事件(processing/done/cached/error),前端文件消息实时更新状态 + 展示提取摘要 | +| 2026-06-12 | 配置交互改为逐项引导:config.json 缺失字段时 AI 逐个提示用户通过聊天输入,全部完成后自动开始处理 | +| 2026-06-12 | 聊天窗口精简:移除 SSE 日志流显示,仅保留关键状态消息;config.json 配置不完整时 AI 主动提示缺失字段 | +| 2026-06-12 | 移除开始处理按钮,改为自动触发:config.json 解析完成且配置完整(username/password)且有发票文件时自动开始处理 | +| 2026-06-12 | 文件上传通知改为逐条消息:每个文件单独一条聊天气泡,上传框 flex 居中、固定高度 | +| 2026-06-12 | 文件上传反馈移至聊天窗口:上传/拖拽/同步文件后以聊天气泡通知,上传框固定高度不再显示文件标签 | +| 2026-06-12 | 修复聊天窗口消息覆盖问题:将消息区域与输入区域分离,消息区独立滚动,新增用户文本输入功能 | +| 2026-06-12 | 合并 PDF/图片上传入口,`/api/files` 返回统一文件列表,前端使用 `allFiles` 单一数组管理 | +| 2026-06-12 | 移除配置表单,config.json 通过统一上传入口自动解析,配置存入 `sessionConfig` 对象,前端不再展示配置输入框 | +| 2026-06-12 | 暗色日志窗口替换为 AI 聊天风格窗口,SSE 日志以聊天气泡形式展示,支持打字指示器动画 | ## 文件结构 ``` src/web/ ├── app.py # Flask 应用入口,路由、管道编排、日志收集 +├── sse_handler.py # SSE 日志收集器、日志转义工具 +├── routes.py # 路由定义、SSE 端点 ├── templates/ -│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交) +│ ├── index.html # PC 端主界面(上传、处理、编辑、提交) │ └── mobile_upload.html # 移动端上传页面(拍照/相册选择) └── static/ ├── css/ │ └── index.css # 全局样式(上传区、日志面板、可编辑表格) └── js/ - └── index.js # 前端逻辑(上传、SSE 日志、表格编辑、二维码同步) + ├── index.js # 入口:初始化 App 全局状态、绑定事件 + ├── state.js # 全局状态管理(App 对象) + ├── chat.js # 聊天气泡渲染、文件消息、LLM 流式气泡 + ├── agent.js # Agent 事件处理、用户输入管理 + ├── process.js # SSE 连接、事件路由、管道启动 + ├── config.js # 配置解析、逐项引导 + ├── upload.js # 文件上传、拖拽处理 + ├── sync.js # 移动端同步 + └── utils.js # HTML 转义等工具函数 ``` ## 数据流 @@ -64,8 +91,8 @@ graph TD | GET | `/` | 主界面 | | POST | `/api/session` | 创建会话,返回 session_id | | POST | `/api/upload/` | 上传 PDF/图片 | -| GET | `/api/files/` | 列出会话文件 | -| POST | `/api/process/` | 启动管道(后台线程) | +| GET | `/api/files/` | 列出会话文件(返回统一 `files` 列表,含 `name`、`type`、`size` 字段;旧字段 `pdfs`/`images` 保留向后兼容) | +| POST | `/api/agent/process/` | 启动 Agent 管道(后台线程) | | GET | `/api/logs/` | SSE 日志流 | | GET | `/api/data/` | 获取发票数据 JSON | | POST | `/api/save/` | 保存前端编辑的发票数据 | @@ -77,7 +104,7 @@ graph TD ### 日志收集 -`_SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。 +`SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。 ### 发票类型分流 @@ -97,8 +124,216 @@ Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`) ### 移动端同步 -PC 端生成二维码指向 `/mobile/`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。 +PC 端生成二维码指向 `/mobile/`,手机端上传的文件通过 `syncFiles()` 轮询同步到 PC 端内存中的 `allFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。 ### 配置管理 -配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads//config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。 \ No newline at end of file +配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads//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/` 端点(`routes.py`)的轮询逻辑: + +``` +每 0.5 秒: + 1. 读取 session.log 增量 → yield "data: <日志行>" + 2. 读取 file_events.log 增量 → 逐行 yield "data: " + 3. 读取 llm_stream.log 增量 → 逐行 yield "data: " + 4. 读取 agent_events.log 增量 → 逐行 yield "data: " + 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() + ├─ 创建
+ ├─ 创建 label 元素(显示 label 文本) + ├─ 创建
可折叠区域(思考过程) + └─ 创建 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 后台线程
(管道) + participant Files as 事件文件
(session 目录) + participant SSE as Flask SSE
(routes.py) + participant ES as EventSource
(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` 事件成对出现(如果是流式气泡) \ No newline at end of file diff --git a/src/web/__init__.py b/src/web/__init__.py new file mode 100644 index 0000000..6e63b66 --- /dev/null +++ b/src/web/__init__.py @@ -0,0 +1,5 @@ +""" +Web 模块 + +提供财务报销系统的 Web 界面功能。 +""" diff --git a/src/web/app.py b/src/web/app.py index 6d2c35b..ac5def2 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -12,709 +12,39 @@ 访问: http://localhost:5000 """ -import json -import logging import sys -import threading -import time -import uuid from pathlib import Path -from typing import Any, cast -from urllib.parse import quote - -from flask import Flask, Response, jsonify, render_template, request, stream_with_context # 确保项目根目录在 sys.path PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from src import get_logger # noqa: E402, I001 -from src.config import load_config as load_project_config # noqa: E402, I001 -from src.doc.extractor import ( # noqa: E402, I001 - extract_invoices, -) -from src.doc.fill_consumable_doc import ( # noqa: E402, I001 - CONSUMABLE_DOC_FILENAME, - fill_consumable_from_template, -) -from src.doc.invoice import ( # noqa: E402, I001 - load_csv, - load_invoice_csv, - save_csv as save_payment_csv, - save_invoice_csv, - save_application_json, -) +from flask import Flask # noqa: E402, I001 - -fill_log = get_logger("fill_consumable_doc") -CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME +from src.web import pipeline_web, routes # noqa: E402, I001 +from src.doc.fill_consumable_doc import CONSUMABLE_DOC_FILENAME # noqa: E402, I001 app = Flask(__name__, template_folder="templates") UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads" -SESSION_LOG_FILE = "session.log" -SESSION_RESULT_FILE = "result.json" -INVOICE_GROUPS_FILE = "invoice_groups.json" -def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None: - """保存发票分类结果到 session 目录的 JSON 文件""" - groups_path = session_dir / INVOICE_GROUPS_FILE - # 只保存各组的数量统计,避免重复存储完整发票数据 - data = { - "travel_count": len(groups.get("travel", [])), - "general_count": len(groups.get("general", [])), - "application_count": len(groups.get("application", [])), - } - with open(groups_path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) +def create_app() -> Flask: + """应用工厂:初始化配置并注册路由""" + # 设置出库单模板路径 + pipeline_web.set_consumable_template(PROJECT_ROOT / CONSUMABLE_DOC_FILENAME) + # 初始化路由配置 + routes.init_routes(UPLOAD_BASE) -def _load_invoice_groups(session_dir: Path) -> dict[str, int] | None: - """从 session 目录加载发票分类统计""" - groups_path = session_dir / INVOICE_GROUPS_FILE - if not groups_path.exists(): - return None - try: - with open(groups_path, encoding="utf-8") as f: - return cast(dict[str, int] | None, json.load(f)) - except Exception: - return None + # 注册 Blueprint + app.register_blueprint(routes.web_bp) + return app -class _SSELogHandler(logging.Handler): - """将日志写入指定文件(线程安全)""" - - def __init__(self, log_path: Path): - super().__init__() - self._lock = threading.Lock() - self._file = open(log_path, "w", encoding="utf-8") - - def emit(self, record: logging.LogRecord) -> None: - try: - msg = self.format(record) + "\n" - with self._lock: - self._file.write(msg) - self._file.flush() - except Exception: - pass - - def close_file(self) -> None: - try: - self._file.close() - except Exception: - pass - - -def _install_log_collector(session_dir: Path) -> _SSELogHandler: - """安装日志收集器到 app.* 模块""" - log_path = session_dir / SESSION_LOG_FILE - fmt = logging.Formatter( - "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s", - "%Y-%m-%d %H:%M:%S", - ) - handler = _SSELogHandler(log_path) - handler.setFormatter(fmt) - handler.setLevel(logging.INFO) - - for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]: - logger = logging.getLogger(name) - logger.setLevel(logging.INFO) - logger.addHandler(handler) - - return handler - - -def _remove_log_collector(handler: _SSELogHandler) -> None: - for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]: - logging.getLogger(name).removeHandler(handler) - handler.close_file() - - -# ================================================================ -# 出库单填写 -# ================================================================ - - -def _load_session_config(session_dir: Path) -> dict[str, Any]: - config = load_project_config() - cfg_path = session_dir / "config.json" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as f: - config.update(json.load(f)) - return config - - -def _resolve_payment_csv(session_dir: Path) -> Path | None: - """查找支付记录 CSV(payment_records.csv)""" - csv_path = session_dir / "payment_records.csv" - if csv_path.exists(): - return csv_path - for f in session_dir.glob("*.csv"): - if f.name != SESSION_RESULT_FILE: - return f - return None - - -def _resolve_invoice_csv(session_dir: Path) -> Path | None: - """查找发票级别 CSV(invoice_summary.csv)""" - csv_path = session_dir / "invoice_summary.csv" - if csv_path.exists(): - return csv_path - for f in session_dir.glob("*.csv"): - if f.name != SESSION_RESULT_FILE: - return f - return None - - -# ================================================================ -# 出库单填写 -# ================================================================ - - -def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]: - """根据 CSV 填写易耗品出库单,供会话目录下载。 - - 从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。 - """ - if not CONSUMABLE_TEMPLATE.exists(): - fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE) - return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"} - - # 从统一的分类结果读取,避免重复解析 CSV/JSON - groups = _load_invoice_groups(session_dir) - if groups is None: - return {"ok": False, "error": "未找到发票分类数据,请先处理"} - - if not groups.get("general_count", 0): - fill_log.info("纯差旅发票,跳过易耗品出库单生成") - return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"} - - csv_path = _resolve_payment_csv(session_dir) - if csv_path is None: - return {"ok": False, "error": "未找到发票 CSV"} - - out_doc = session_dir / CONSUMABLE_DOC_FILENAME - try: - fill_log.info("开始填写出库单: %s", out_doc.name) - fill_consumable_from_template(csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config) - fill_log.info("出库单填写完成") - return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME} - except ImportError: - fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32") - return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"} - except Exception as e: - fill_log.exception("填写出库单失败: %s", e) - return {"ok": False, "error": str(e)} - - -def _append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None: - if doc_fill.get("ok"): - fn = doc_fill["doc_filename"] - result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}" - result["doc_ok"] = True - elif doc_fill.get("skipped"): - # 差旅发票,跳过出库单生成(不是错误) - result["doc_ok"] = None - result["doc_skipped"] = True - result["doc_message"] = doc_fill.get("error", "") - else: - result["doc_ok"] = False - result["doc_error"] = doc_fill.get("error", "未知错误") - - -# ================================================================ -# 管道入口 -# ================================================================ - - -def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]: - """在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件 - - 注意:不再自动提交财务系统。提交通由 /api/submit-financial/ 触发。 - - 在发票提取和匹配完成后立即判断报销类型: - - 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json - - 普通发票:无需额外提取(normal_info.json 待实现) - """ - start = time.time() - - # ---- Step 1: 发票提取 ---- - invoices, applications, groups = extract_invoices(str(session_dir)) - if not invoices: - return {"ok": False, "error": "未提取到任何发票数据"} - - # 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考) - save_payment_csv(invoices, session_dir / "payment_records.csv") - save_invoice_csv(invoices, session_dir / "invoice_summary.csv") - - # 出差申请单单独保存 - if applications: - save_application_json(applications, session_dir / "travel_applications.json") - - # 保存分类结果(供后续步骤统一读取) - _save_invoice_groups(session_dir, groups) - - # ---- Step 2: 差旅/普通信息提取 ---- - is_travel = bool(groups.get("travel")) and not bool(groups.get("general")) - if is_travel: - from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache - - # 检查缓存是否已有 - cache_map = load_cache(session_dir) - if not cache_map.get("travel_info"): - fill_log.info("开始提取差旅信息...") - travel_info = extract_travel_info(source_dir=session_dir) - cache_dir = session_dir / CACHE_DIR_NAME - cache_dir.mkdir(parents=True, exist_ok=True) - with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f: - json.dump(travel_info, f, ensure_ascii=False, indent=2) - fill_log.info("差旅信息已保存到缓存") - else: - from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache - - # 检查缓存是否已有 - cache_map = load_cache(session_dir) - if not cache_map.get("normal_info"): - fill_log.info("开始提取普通发票信息...") - normal_info = extract_normal_info(source_dir=session_dir) - cache_dir = session_dir / CACHE_DIR_NAME - cache_dir.mkdir(parents=True, exist_ok=True) - with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f: - json.dump(normal_info, f, ensure_ascii=False, indent=2) - fill_log.info("普通发票信息已保存到缓存") - - # 统计发票总数 - invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices) - - elapsed = time.time() - start - result = { - "ok": True, - "elapsed": f"{elapsed:.1f}s", - "invoice_count": invoice_count, - "csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv", - # 发票类型统计 - "travel_count": len(groups["travel"]), - "general_count": len(groups["general"]), - } - doc_fill = _try_fill_consumable_doc(session_dir, config) - _append_doc_download(result, session_dir.name, doc_fill) - return result - - -def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]: - """执行财务系统填报(从前端确认后调用) - - 从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式: - - 纯差旅发票:差旅报销模式(TODO) - - 含普通发票:普通报销模式 - """ - csv_path = session_dir / "payment_records.csv" - if not csv_path.exists(): - return {"ok": False, "error": "未找到发票数据,请先处理"} - - from src.bot import run_bot_web - - # 从统一的分类结果读取,避免重复解析 - groups = _load_invoice_groups(session_dir) - if groups: - if groups.get("travel_count", 0) and not groups.get("general_count", 0): - fill_log.info("检测到纯差旅发票,使用差旅报销模式") - # TODO: 差旅报销填报流程 - else: - fill_log.info("检测到普通发票,使用普通报销模式") - - run_bot_web(config, session_dir) - return {"ok": True} - - -# ================================================================ -# Flask 路由 -# ================================================================ - - -@app.route("/") -def index() -> Any: - return render_template("index.html") - - -@app.route("/api/session", methods=["POST"]) -def create_session() -> Any: - """创建上传会话,返回 session_id""" - sid = uuid.uuid4().hex[:12] - session_dir = UPLOAD_BASE / sid - session_dir.mkdir(parents=True, exist_ok=True) - return jsonify({"session_id": sid}) - - -@app.route("/api/upload/", 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/", 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/", 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/") -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//") -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/", 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/", 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/", 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/", 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/") -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/", 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) diff --git a/src/web/pipeline_web.py b/src/web/pipeline_web.py new file mode 100644 index 0000000..9bff3b3 --- /dev/null +++ b/src/web/pipeline_web.py @@ -0,0 +1,259 @@ +""" +Web 管道逻辑 + +负责: +- 发票提取管道编排 +- 财务系统填报触发 +- 易耗品出库单生成 +- 发票分类数据持久化 +""" + +import json +import time +from pathlib import Path +from typing import Any, cast +from urllib.parse import quote + +# 延迟导入,避免循环引用 +from src import get_logger # noqa: F401 +from src.config import load_config as load_project_config +from src.doc.fill_consumable_doc import ( + CONSUMABLE_DOC_FILENAME, + fill_consumable_from_template, +) +from src.doc.invoice import ( + save_application_json, + save_invoice_csv, +) +from src.doc.invoice import ( + save_csv as save_payment_csv, +) + +fill_log = get_logger("fill_consumable_doc") + +# 文件常量 +SESSION_RESULT_FILE = "result.json" +INVOICE_GROUPS_FILE = "invoice_groups.json" + + +def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None: + """保存发票分类结果到 session 目录的 JSON 文件""" + groups_path = session_dir / INVOICE_GROUPS_FILE + data = { + "travel_count": len(groups.get("travel", [])), + "general_count": len(groups.get("general", [])), + "application_count": len(groups.get("application", [])), + } + with open(groups_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def load_invoice_groups(session_dir: Path) -> dict[str, int] | None: + """从 session 目录加载发票分类统计""" + groups_path = session_dir / INVOICE_GROUPS_FILE + if not groups_path.exists(): + return None + try: + with open(groups_path, encoding="utf-8") as f: + return cast(dict[str, int] | None, json.load(f)) + except Exception: + return None + + +def load_session_config(session_dir: Path) -> dict[str, Any]: + """加载会话配置,合并项目全局配置与会话级配置""" + config = load_project_config() + cfg_path = session_dir / "config.json" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as f: + config.update(json.load(f)) + return config + + +def resolve_payment_csv(session_dir: Path) -> Path | None: + """查找支付记录 CSV(payment_records.csv)""" + csv_path = session_dir / "payment_records.csv" + if csv_path.exists(): + return csv_path + for f in session_dir.glob("*.csv"): + if f.name != SESSION_RESULT_FILE: + return f + return None + + +def resolve_invoice_csv(session_dir: Path) -> Path | None: + """查找发票级别 CSV(invoice_summary.csv)""" + csv_path = session_dir / "invoice_summary.csv" + if csv_path.exists(): + return csv_path + for f in session_dir.glob("*.csv"): + if f.name != SESSION_RESULT_FILE: + return f + return None + + +# ================================================================ +# 出库单填写 +# ================================================================ + +# 在模块加载时确定模板路径(由 app.py 传入 PROJECT_ROOT) +_consumable_template: Path | None = None + + +def set_consumable_template(template_path: Path) -> None: + """设置出库单模板路径(由 app.py 在启动时调用)""" + global _consumable_template + _consumable_template = template_path + + +def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]: + """根据 CSV 填写易耗品出库单,供会话目录下载。 + + 从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。 + """ + template = _consumable_template + if template is None or not template.exists(): + fill_log.warning("出库单模板不存在: %s", template) + return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"} + + groups = load_invoice_groups(session_dir) + if groups is None: + return {"ok": False, "error": "未找到发票分类数据,请先处理"} + + if not groups.get("general_count", 0): + fill_log.info("纯差旅发票,跳过易耗品出库单生成") + return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"} + + csv_path = resolve_payment_csv(session_dir) + if csv_path is None: + return {"ok": False, "error": "未找到发票 CSV"} + + out_doc = session_dir / CONSUMABLE_DOC_FILENAME + try: + fill_log.info("开始填写出库单: %s", out_doc.name) + fill_consumable_from_template(csv_path, template, out_doc, config=config) + fill_log.info("出库单填写完成") + return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME} + except ImportError: + fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32") + return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"} + except Exception as e: + fill_log.exception("填写出库单失败: %s", e) + return {"ok": False, "error": str(e)} + + +def append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None: + """将出库单下载信息追加到结果字典""" + if doc_fill.get("ok"): + fn = doc_fill["doc_filename"] + result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}" + result["doc_ok"] = True + elif doc_fill.get("skipped"): + result["doc_ok"] = None + result["doc_skipped"] = True + result["doc_message"] = doc_fill.get("error", "") + else: + result["doc_ok"] = False + result["doc_error"] = doc_fill.get("error", "未知错误") + + +# ================================================================ +# 管道入口 +# ================================================================ + + +def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]: + """在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件 + + 注意:不再自动提交财务系统。提交通由 /api/submit-financial/ 触发。 + + 在发票提取和匹配完成后立即判断报销类型: + - 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json + - 普通发票:无需额外提取(normal_info.json 待实现) + """ + from src.doc.extractor import extract_invoices + + start = time.time() + + # ---- Step 1: 发票提取 ---- + invoices, applications, groups = extract_invoices(str(session_dir)) + if not invoices: + return {"ok": False, "error": "未提取到任何发票数据"} + + # 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考) + save_payment_csv(invoices, session_dir / "payment_records.csv") + save_invoice_csv(invoices, session_dir / "invoice_summary.csv") + + # 出差申请单单独保存 + if applications: + save_application_json(applications, session_dir / "travel_applications.json") + + # 保存分类结果(供后续步骤统一读取) + save_invoice_groups(session_dir, groups) + + # ---- Step 2: 差旅/普通信息提取 ---- + is_travel = bool(groups.get("travel")) and not bool(groups.get("general")) + if is_travel: + from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache + + cache_map = load_cache(session_dir) + if not cache_map.get("travel_info"): + fill_log.info("开始提取差旅信息...") + travel_info = extract_travel_info(source_dir=session_dir) + cache_dir = session_dir / CACHE_DIR_NAME + cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f: + json.dump(travel_info, f, ensure_ascii=False, indent=2) + fill_log.info("差旅信息已保存到缓存") + else: + from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache + + cache_map = load_cache(session_dir) + if not cache_map.get("normal_info"): + fill_log.info("开始提取普通发票信息...") + normal_info = extract_normal_info(source_dir=session_dir) + cache_dir = session_dir / CACHE_DIR_NAME + cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f: + json.dump(normal_info, f, ensure_ascii=False, indent=2) + fill_log.info("普通发票信息已保存到缓存") + + # 统计发票总数 + invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices) + + elapsed = time.time() - start + result = { + "ok": True, + "elapsed": f"{elapsed:.1f}s", + "invoice_count": invoice_count, + "csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv", + "travel_count": len(groups["travel"]), + "general_count": len(groups["general"]), + } + doc_fill = _try_fill_consumable_doc(session_dir, config) + append_doc_download(result, session_dir.name, doc_fill) + return result + + +def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]: + """执行财务系统填报(从前端确认后调用) + + 从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式: + - 纯差旅发票:差旅报销模式(TODO) + - 含普通发票:普通报销模式 + """ + csv_path = session_dir / "payment_records.csv" + if not csv_path.exists(): + return {"ok": False, "error": "未找到发票数据,请先处理"} + + from src.bot import run_bot_web + + groups = load_invoice_groups(session_dir) + if groups: + if groups.get("travel_count", 0) and not groups.get("general_count", 0): + fill_log.info("检测到纯差旅发票,使用差旅报销模式") + else: + fill_log.info("检测到普通发票,使用普通报销模式") + + run_bot_web(config, session_dir) + return {"ok": True} diff --git a/src/web/routes.py b/src/web/routes.py new file mode 100644 index 0000000..b147175 --- /dev/null +++ b/src/web/routes.py @@ -0,0 +1,917 @@ +""" +Flask 路由定义 + +所有 Web 端点的路由注册,不包含业务逻辑(业务逻辑在 pipeline_web 和 sse_handler 中)。 +使用 Blueprint 模式,支持延迟注册到 Flask app。 +""" + +import csv as csv_module +import json +import threading +import time +import uuid +from pathlib import Path +from typing import Any +from urllib.parse import quote + +from flask import Blueprint, Response, jsonify, render_template, request, stream_with_context + +from src.config import load_config as load_project_config +from src.doc.invoice import load_csv, load_invoice_csv + +from . import pipeline_web, sse_handler + +# 在模块加载时确定(由 init_routes 传入) +_UPLOAD_BASE: Path | None = None + +web_bp = Blueprint("web", __name__) + + +def init_routes(upload_base: Path) -> None: + """初始化路由配置,传入上传目录""" + global _UPLOAD_BASE + _UPLOAD_BASE = upload_base + + +# ================================================================ +# 辅助函数 +# ================================================================ + + +def _validate_session(session_id: str) -> Path | tuple[Response, int]: + """验证会话 ID 并返回会话目录""" + if _UPLOAD_BASE is None: + return jsonify({"error": "服务未初始化"}), 500 + session_dir = _UPLOAD_BASE / session_id + if not session_dir.exists(): + return jsonify({"error": "会话不存在"}), 404 + return session_dir + + +def _build_web_config(body: dict[str, Any]) -> dict[str, Any]: + """从请求体构建配置""" + config = load_project_config() + for key in ( + "username", + "password", + "default_name", + "default_card_no", + "default_person_id", + "consumable_storage", + ): + if body.get(key): + config[key] = body[key] + return config + + +def _emit_ready_and_submit( + session_dir: Path, + agent_session: Any, + config: dict[str, Any], +) -> None: + """Agent 校验通过后,直接触发财务提交(不依赖前端)""" + from src.agent import _emit_agent_event + + # 发射 agent_ready 事件,前端 SSE 会收到 + _emit_agent_event( + session_dir, + "agent_ready", + round=agent_session.rounds, + message="信息完整,可以提交", + ) + + # 直接执行财务提交 + try: + submit_result = pipeline_web.run_financial_submit(session_dir, config) + if submit_result.get("ok"): + result = { + "ok": True, + "agent_ready": True, + "submit_ok": True, + "round": agent_session.rounds, + "message": "信息完整,已自动提交到财务系统", + } + else: + result = { + "ok": True, + "agent_ready": True, + "submit_ok": False, + "submit_error": submit_result.get("error"), + "round": agent_session.rounds, + "message": "校验通过但提交失败", + } + except Exception as e: + result = { + "ok": True, + "agent_ready": True, + "submit_ok": False, + "submit_error": str(e), + "round": agent_session.rounds, + "message": f"校验通过但提交异常: {e}", + } + + # 写入 result 文件,SSE done 事件会读取 + try: + tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False) + tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE) + except Exception: + pass + + +# ================================================================ +# 页面路由 +# ================================================================ + + +@web_bp.route("/") +def index() -> Any: + return render_template("index.html") + + +@web_bp.route("/mobile/") +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/", methods=["POST"]) +def upload_file(session_id: str) -> Any: + """上传 PDF 或图片""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + f = request.files.get("file") + if not f or not f.filename: + return jsonify({"error": "未选择文件"}), 400 + + safe_name = Path(f.filename).name + f.save(str(session_dir / safe_name)) + return jsonify({"ok": True, "filename": safe_name}) + + +@web_bp.route("/api/files/", 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//") +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/", methods=["POST"]) +def mobile_upload_file(session_id: str) -> Any: + """移动端上传图片(复用 PC 上传逻辑)""" + return upload_file(session_id) + + +# ================================================================ +# 配置 +# ================================================================ + + +@web_bp.route("/api/config/", methods=["GET"]) +def get_session_config(session_id: str) -> Any: + """获取当前会话的配置(供前端回填表单)""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + config = load_project_config() + cfg_path = session_dir / "config.json" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as f: + config.update(json.load(f)) + return jsonify( + { + "username": config.get("username", ""), + "password": "", + "default_name": config.get("default_name", ""), + "default_card_no": config.get("default_card_no", ""), + "default_person_id": config.get("default_person_id", ""), + "consumable_storage": config.get("consumable_storage", ""), + } + ) + + +# ================================================================ +# 发票数据处理 +# ================================================================ + + +@web_bp.route("/api/data/", 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/", methods=["POST"]) +def save_invoice_data(session_id: str) -> Any: + """保存前端编辑后的发票数据到 CSV""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + body = request.get_json(silent=True) or {} + data = body.get("data", []) + csv_filename = body.get("csv_filename", "invoice_summary.csv") + + csv_path = session_dir / csv_filename + if not csv_path.exists(): + return jsonify({"error": "CSV 文件不存在"}), 404 + + original_rows = load_csv(csv_path) + if original_rows is None or len(original_rows) == 0: + return jsonify({"error": "无法读取原始 CSV 结构"}), 500 + + fieldnames = list(original_rows[0].keys()) + + with open(csv_path, "w", newline="", encoding="utf-8-sig") as f: + writer = csv_module.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + for entry in data: + row = {k: entry.get(k, "") for k in fieldnames} + writer.writerow(row) + + resp: dict[str, str | bool | None] = {"ok": True} + config = pipeline_web.load_session_config(session_dir) + doc_fill = pipeline_web._try_fill_consumable_doc(session_dir, config) + if doc_fill.get("ok"): + fn = doc_fill["doc_filename"] + resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}" + resp["doc_ok"] = True + elif doc_fill.get("skipped"): + resp["doc_ok"] = None + resp["doc_skipped"] = True + else: + resp["doc_ok"] = False + resp["doc_error"] = doc_fill.get("error") or "" + return jsonify(resp) + + +# ================================================================ +# 管道处理 +# ================================================================ + + +@web_bp.route("/api/process/", methods=["POST"]) +def start_process(session_id: str) -> Any: + """启动管道处理(仅发票提取,不自动提交财务系统)""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + body = request.get_json(silent=True) or {} + config = _build_web_config(body) + + with open(session_dir / "config.json", "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2, default=str) + + handler = sse_handler.install_log_collector(session_dir) + + def _run() -> None: + result = {"ok": False, "error": "未知错误"} + try: + try: + (session_dir / "llm_stream.log").unlink(missing_ok=True) + except Exception: + pass + result = pipeline_web.run_pipeline_web(session_dir, config) + except BaseException as e: + result = {"ok": False, "error": str(e)} + if isinstance(e, KeyboardInterrupt | SystemExit): + raise + finally: + try: + tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False) + tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE) + except Exception: + pass + sse_handler.remove_log_collector(handler) + + threading.Thread(target=_run, daemon=True).start() + return jsonify({"status": "started"}) + + +@web_bp.route("/api/logs/") +def stream_logs(session_id: str) -> Any: + """SSE 日志流""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + def generate() -> Any: + log_file = session_dir / sse_handler.SESSION_LOG_FILE + last_size = 0 + file_events_file = session_dir / "file_events.log" + last_events_size = 0 + last_stream_size = 0 + state = {"agent_size": 0} + + start_time = time.time() + timeout = 600 + + while time.time() - start_time < timeout: + # 轮询普通日志 + if log_file.exists(): + current_size = log_file.stat().st_size + if current_size > last_size: + with open(log_file, encoding="utf-8", errors="replace") as f: + f.seek(last_size) + chunk = f.read() + if chunk: + yield f"data: {sse_handler.escape_sse(chunk)}\n\n" + last_size = current_size + + # 轮询文件进度事件 + if file_events_file.exists(): + events_size = file_events_file.stat().st_size + if events_size > last_events_size: + with open(file_events_file, encoding="utf-8", errors="replace") as f: + f.seek(last_events_size) + new_events = f.read() + if new_events: + for line in new_events.strip().split("\n"): + line = line.strip() + if line: + yield f"data: {line}\n\n" + last_events_size = events_size + + # 轮询 LLM 流式事件 + llm_stream_file = session_dir / "llm_stream.log" + if llm_stream_file.exists(): + stream_size = llm_stream_file.stat().st_size + if stream_size > last_stream_size: + with open(llm_stream_file, encoding="utf-8", errors="replace") as f: + f.seek(last_stream_size) + new_chunks = f.read() + if new_chunks: + for line in new_chunks.strip().split("\n"): + line = line.strip() + if line: + yield f"data: {line}\n\n" + last_stream_size = stream_size + + # 轮询 Agent 事件 + agent_events_file = session_dir / "agent_events.log" + last_agent_events_size = state["agent_size"] + if agent_events_file.exists(): + agent_size = agent_events_file.stat().st_size + if agent_size > last_agent_events_size: + with open(agent_events_file, encoding="utf-8", errors="replace") as f: + f.seek(last_agent_events_size) + new_agent_events = f.read() + if new_agent_events: + for line in new_agent_events.strip().split("\n"): + line = line.strip() + if line: + yield f"data: {line}\n\n" + state["agent_size"] = agent_size + + # 检查是否完成 + result_file = session_dir / pipeline_web.SESSION_RESULT_FILE + if result_file.exists(): + with open(result_file, encoding="utf-8") as f: + result = json.load(f) + yield f"data: {sse_handler.escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n" + break + + time.sleep(0.5) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@web_bp.route("/api/submit-financial/", methods=["POST"]) +def submit_financial(session_id: str) -> Any: + """手动触发财务系统填报""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + config_path = session_dir / "config.json" + if not config_path.exists(): + return jsonify({"error": "未找到配置,请先配置后处理"}), 400 + + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + result_file = session_dir / pipeline_web.SESSION_RESULT_FILE + if result_file.exists(): + result_file.unlink() + + handler = sse_handler.install_log_collector(session_dir) + + def _run() -> None: + result = {"ok": False, "error": "未知错误"} + try: + try: + (session_dir / "llm_stream.log").unlink(missing_ok=True) + except Exception: + pass + submit_result = pipeline_web.run_financial_submit(session_dir, config) + if submit_result.get("ok"): + result = {"ok": True} + else: + result = submit_result + except BaseException as e: + result = {"ok": False, "error": str(e)} + if isinstance(e, KeyboardInterrupt | SystemExit): + raise + finally: + try: + tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump( + {"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")}, + f, + ensure_ascii=False, + ) + tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE) + except Exception: + pass + sse_handler.remove_log_collector(handler) + + threading.Thread(target=_run, daemon=True).start() + return jsonify({"status": "started"}) + + +# ================================================================ +# Agent 交互 API +# ================================================================ + + +@web_bp.route("/api/agent/state/", 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/", methods=["POST"]) +def agent_process(session_id: str) -> Any: + """启动 Agent 多轮处理流程""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + body = request.get_json(silent=True) or {} + config = _build_web_config(body) + + with open(session_dir / "config.json", "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2, default=str) + + handler = sse_handler.install_log_collector(session_dir) + + def _run() -> None: + result = {"ok": False, "error": "未知错误"} + try: + try: + (session_dir / "llm_stream.log").unlink(missing_ok=True) + except Exception: + pass + + from src.agent import ( + AgentSession, + AgentState, + load_agent_state, + run_agent_round, + save_agent_state, + ) + from src.doc.extractor import extract_invoices + from src.doc.invoice import ( + save_application_json, + save_invoice_csv, + ) + from src.doc.invoice import ( + save_csv as save_payment_csv, + ) + + agent_session = load_agent_state(session_dir) + if agent_session is None: + invoices, applications, groups = extract_invoices(str(session_dir)) + if not invoices: + result = {"ok": False, "error": "未提取到任何发票数据"} + return + + save_payment_csv(invoices, session_dir / "payment_records.csv") + save_invoice_csv(invoices, session_dir / "invoice_summary.csv") + + if applications: + save_application_json(applications, session_dir / "travel_applications.json") + + pipeline_web.save_invoice_groups(session_dir, groups) + + is_travel = bool(groups.get("travel")) and not bool(groups.get("general")) + agent_session = AgentSession( + session_id=session_id, + invoice_type="travel" if is_travel else "normal", + ) + save_agent_state(session_dir, agent_session) + + agent_session = run_agent_round(session_dir, agent_session) + + if agent_session.state == AgentState.READY: + # Agent 校验通过,直接触发财务提交 + _emit_ready_and_submit(session_dir, agent_session, config) + elif agent_session.state == AgentState.AWAITING_SUPPLEMENT: + result = { + "ok": True, + "agent_ready": False, + "agent_state": agent_session.state.value, + "round": agent_session.rounds, + "waiting_for_supplement": True, + } + elif agent_session.state == AgentState.ERROR: + result = { + "ok": False, + "error": agent_session.error_message, + } + + except BaseException as e: + result = {"ok": False, "error": str(e)} + if isinstance(e, KeyboardInterrupt | SystemExit): + raise + finally: + try: + result_file = session_dir / pipeline_web.SESSION_RESULT_FILE + if not result_file.exists(): + tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False) + tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE) + except Exception: + pass + sse_handler.remove_log_collector(handler) + + threading.Thread(target=_run, daemon=True).start() + return jsonify({"status": "started"}) + + +@web_bp.route("/api/agent/supplement/", methods=["POST"]) +def agent_supplement(session_id: str) -> Any: + """用户补充文件后触发新一轮 Agent 处理""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + body = request.get_json(silent=True) or {} + filenames = body.get("files", []) + + if not filenames: + return jsonify({"error": "未指定补充文件"}), 400 + + from src.agent import ( + AgentState, + add_supplement, + load_agent_state, + run_agent_round, + ) + + agent_session = load_agent_state(session_dir) + if agent_session is None: + return jsonify({"error": "未找到 Agent 状态"}), 404 + + agent_session = add_supplement(session_dir, agent_session, filenames) + + handler = sse_handler.install_log_collector(session_dir) + + def _run() -> None: + result = {"ok": False, "error": "未知错误"} + try: + try: + (session_dir / "llm_stream.log").unlink(missing_ok=True) + except Exception: + pass + + from src.doc.extractor import extract_invoices + from src.doc.invoice import ( + save_application_json, + save_invoice_csv, + ) + from src.doc.invoice import ( + save_csv as save_payment_csv, + ) + + invoices, applications, groups = extract_invoices(str(session_dir)) + save_payment_csv(invoices, session_dir / "payment_records.csv") + save_invoice_csv(invoices, session_dir / "invoice_summary.csv") + + if applications: + save_application_json(applications, session_dir / "travel_applications.json") + + pipeline_web.save_invoice_groups(session_dir, groups) + + new_session = run_agent_round(session_dir, agent_session, new_files=filenames) + + if new_session.state == AgentState.READY: + # Agent 校验通过,直接触发财务提交 + config_path = session_dir / "config.json" + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + _emit_ready_and_submit(session_dir, new_session, config) + elif new_session.state == AgentState.AWAITING_SUPPLEMENT: + result = { + "ok": True, + "agent_ready": False, + "agent_state": new_session.state.value, + "round": new_session.rounds, + "waiting_for_supplement": True, + } + elif new_session.state == AgentState.ERROR: + result = { + "ok": False, + "error": new_session.error_message, + } + + except BaseException as e: + result = {"ok": False, "error": str(e)} + if isinstance(e, KeyboardInterrupt | SystemExit): + raise + finally: + try: + result_file = session_dir / pipeline_web.SESSION_RESULT_FILE + if result.get("error") and not result_file.exists(): + tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False) + tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE) + except Exception: + pass + sse_handler.remove_log_collector(handler) + + threading.Thread(target=_run, daemon=True).start() + return jsonify({"status": "started"}) + + +@web_bp.route("/api/agent/user-supplement/", methods=["POST"]) +def agent_user_supplement(session_id: str) -> Any: + """用户通过文字补充信息,LLM 分析后更新 JSON,重新校验""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + body = request.get_json(silent=True) or {} + user_text = body.get("text", "").strip() + + if not user_text: + return jsonify({"error": "请输入补充信息"}), 400 + + from src.agent import ( + AgentState, + load_agent_state, + process_user_text_supplement, + ) + + agent_session = load_agent_state(session_dir) + if agent_session is None: + return jsonify({"error": "未找到 Agent 状态"}), 404 + + handler = sse_handler.install_log_collector(session_dir) + + def _run() -> None: + result = {"ok": False, "error": "未知错误"} + try: + try: + (session_dir / "llm_stream.log").unlink(missing_ok=True) + except Exception: + pass + + new_session = process_user_text_supplement(session_dir, agent_session, user_text) + + if new_session.state == AgentState.READY: + # Agent 校验通过,直接触发财务提交 + config_path = session_dir / "config.json" + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + _emit_ready_and_submit(session_dir, new_session, config) + elif new_session.state == AgentState.AWAITING_SUPPLEMENT: + result = { + "ok": True, + "agent_ready": False, + "agent_state": new_session.state.value, + "round": new_session.rounds, + "waiting_for_supplement": True, + } + elif new_session.state == AgentState.ERROR: + result = { + "ok": False, + "error": new_session.error_message, + } + + except BaseException as e: + result = {"ok": False, "error": str(e)} + if isinstance(e, KeyboardInterrupt | SystemExit): + raise + finally: + try: + result_file = session_dir / pipeline_web.SESSION_RESULT_FILE + if not result_file.exists(): + tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False) + tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE) + except Exception: + pass + sse_handler.remove_log_collector(handler) + + threading.Thread(target=_run, daemon=True).start() + return jsonify({"status": "started"}) + + +@web_bp.route("/api/agent/force-submit/", methods=["POST"]) +def agent_force_submit(session_id: str) -> Any: + """用户强制提交,跳过校验""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + from src.agent import ( + force_submit, + load_agent_state, + ) + + agent_session = load_agent_state(session_dir) + if agent_session is None: + return jsonify({"error": "未找到 Agent 状态"}), 404 + + agent_session = force_submit(session_dir, agent_session) + + config_path = session_dir / "config.json" + if not config_path.exists(): + return jsonify({"error": "未找到配置"}), 400 + + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + result_file = session_dir / pipeline_web.SESSION_RESULT_FILE + if result_file.exists(): + result_file.unlink() + + handler = sse_handler.install_log_collector(session_dir) + + def _run() -> None: + result = {"ok": False, "error": "未知错误"} + try: + submit_result = pipeline_web.run_financial_submit(session_dir, config) + if submit_result.get("ok"): + result = {"ok": True} + else: + result = submit_result + except BaseException as e: + result = {"ok": False, "error": str(e)} + if isinstance(e, KeyboardInterrupt | SystemExit): + raise + finally: + try: + tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump( + {"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")}, + f, + ensure_ascii=False, + ) + tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE) + except Exception: + pass + sse_handler.remove_log_collector(handler) + + threading.Thread(target=_run, daemon=True).start() + return jsonify({"status": "started"}) diff --git a/src/web/sse_handler.py b/src/web/sse_handler.py new file mode 100644 index 0000000..8dc257b --- /dev/null +++ b/src/web/sse_handler.py @@ -0,0 +1,84 @@ +""" +SSE 日志流处理 + +负责: +- 日志收集器安装/卸载 +- SSE 数据转义 +""" + +import logging +import threading +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +SESSION_LOG_FILE = "session.log" + +# 需要监控日志的模块名称列表 +LOG_TARGET_NAMES = [ + "extractor", + "llm_extractor", + "matcher", + "pipeline", + "bot", + "fill_consumable_doc", + "agent", + "validator", +] + + +class SSELogHandler(logging.Handler): + """将日志写入指定文件(线程安全)""" + + def __init__(self, log_path: Path): + super().__init__() + self._lock = threading.Lock() + self._file = open(log_path, "w", encoding="utf-8") + + def emit(self, record: logging.LogRecord) -> None: + try: + msg = self.format(record) + "\n" + with self._lock: + self._file.write(msg) + self._file.flush() + except Exception: + pass + + def close_file(self) -> None: + try: + self._file.close() + except Exception: + pass + + +def install_log_collector(session_dir: Path) -> SSELogHandler: + """安装日志收集器到各模块""" + log_path = session_dir / SESSION_LOG_FILE + fmt = logging.Formatter( + "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s", + "%Y-%m-%d %H:%M:%S", + ) + handler = SSELogHandler(log_path) + handler.setFormatter(fmt) + handler.setLevel(logging.INFO) + + for name in LOG_TARGET_NAMES: + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + logger.addHandler(handler) + + return handler + + +def remove_log_collector(handler: SSELogHandler) -> None: + """卸载日志收集器""" + for name in LOG_TARGET_NAMES: + logging.getLogger(name).removeHandler(handler) + handler.close_file() + + +def escape_sse(text: str) -> str: + """SSE 数据转义,同时处理 Windows 行尾 \r\n""" + return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ") diff --git a/src/web/static/README.md b/src/web/static/README.md index 30452c4..4f2c82c 100644 --- a/src/web/static/README.md +++ b/src/web/static/README.md @@ -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,无构建工具,保持单页应用轻量可维护。 \ No newline at end of file +原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。 + +## 变更记录 + +- **2026-06-12**:修复配置收集流程的异步时序问题 + - `parseConfigFile` 改为返回 Promise,`handleFiles` 和拖拽 `drop` 处理器改为 `async/await`,确保 config.json 解析完成后再执行检查,消息显示顺序正确 + - `sendUserMessage` 修复变量名错误(`pendingConfigKeys` → `pendingConfigKey`),修复了配置收集卡死的问题 + - 拆分 `checkAutoStart` 为两个函数:`checkAutoStart` 仅做静默检查(由 `syncFiles` 轮询调用),`promptMissingConfig` 负责配置提示(由用户主动上传完成后调用),避免轮询提前触发配置提示导致消息乱序 + - 移除所有聊天消息的删除逻辑,聊天窗口保留完整历史(欢迎消息、文件通知、配置交互、处理结果均不删除) \ No newline at end of file diff --git a/src/web/static/css/index.css b/src/web/static/css/index.css index d67f19c..de150b9 100644 --- a/src/web/static/css/index.css +++ b/src/web/static/css/index.css @@ -1,16 +1,241 @@ -body { background: #f5f7fa; } -.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; } +body { + background: #f5f7fa; + min-height: 100vh; + margin: 0; + display: flex; + flex-direction: column; +} + +.main-container { + width: 100%; + max-width: 960px; + margin: 0 auto; + padding: 16px; + flex: 1; + display: flex; + flex-direction: column; + box-sizing: border-box; +} .upload-zone { border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center; - cursor: pointer; transition: all .2s; background: #fff; min-height: 100px; + cursor: pointer; transition: all .2s; background: #fff; height: 100px; + display: flex; flex-direction: column; align-items: center; justify-content: center; } .upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; } .upload-zone.active { border-color: #28a745; background: #f0fff4; } -.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; } +.upload-zone .icon { + width: 48px; + height: 48px; + margin-bottom: 8px; +} +.upload-zone .icon img { + width: 100%; + height: 100%; +} + +/* 可折叠上传面板(在文档流中,向上推挤聊天窗口) */ +.upload-panel { + margin-top: 12px; + background: #fff; + border: 1px solid #e8e8e8; + border-radius: 12px; + overflow: hidden; + transition: box-shadow 0.2s; +} +.upload-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + cursor: pointer; + user-select: none; + font-size: 14px; + font-weight: 600; + color: #333; + background: #fafbfc; + transition: background 0.2s; +} +.upload-panel-header:hover { + background: #f0f2ff; +} +.upload-panel-toggle { + font-size: 10px; + color: #999; + transition: transform 0.3s; +} +.upload-panel-toggle.collapsed { + transform: rotate(180deg); +} +.upload-panel-body { + padding: 16px; + transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease; + max-height: 400px; + opacity: 1; + overflow: hidden; +} +.upload-panel-body.collapsed { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; +} .file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; } .file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; } -.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; } -.log-container .empty { color: #666; font-style: italic; } +.chat-container { + background: #fff; + border-radius: 12px; + padding: 16px; + flex: 1 1 0; + min-height: 300px; + overflow: hidden; + border: 1px solid #e8e8e8; + display: flex; + flex-direction: column; + gap: 0; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + scroll-behavior: smooth; + padding-bottom: 8px; +} + +.chat-input-area { + display: flex; + gap: 8px; + padding-top: 12px; + border-top: 1px solid #e8e8e8; + flex-shrink: 0; +} + +.chat-input-area input { + flex: 1; + border: 1px solid #ddd; + border-radius: 20px; + padding: 8px 16px; + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} + +.chat-input-area input:focus { + border-color: #667eea; +} + +.chat-input-area button { + border-radius: 20px; + padding: 8px 20px; + font-size: 13px; + white-space: nowrap; +} + +.chat-message { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 0; + animation: fadeIn 0.3s ease; +} + +.chat-message.user { + flex-direction: row-reverse; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.ai-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; +} +.ai-avatar img { + width: 60%; + height: 60%; +} + +.chat-bubble { + max-width: 85%; + padding: 10px 14px; + border-radius: 12px; + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +.chat-bubble.system { + background: #f0f2f5; + color: #333; + border-bottom-left-radius: 4px; +} + +.chat-bubble.processing { + background: #e8f0fe; + color: #1a73e8; + border-bottom-left-radius: 4px; +} + +.chat-bubble.success { + background: #e6f4ea; + color: #137333; + border-bottom-left-radius: 4px; +} + +.chat-bubble.error { + background: #fce8e6; + color: #c5221f; + border-bottom-left-radius: 4px; +} + +.chat-bubble.done { + background: #e6f4ea; + color: #137333; + border-bottom-left-radius: 4px; + font-weight: 500; +} + +.chat-bubble.user { + background: #667eea; + color: #fff; + border-bottom-right-radius: 4px; +} + +/* 打字指示器 */ +.typing-indicator { + display: inline-flex; + gap: 4px; + padding: 4px 0; +} + +.typing-indicator span { + width: 6px; + height: 6px; + border-radius: 50%; + background: #667eea; + animation: typing 1.4s infinite ease-in-out; +} + +.typing-indicator span:nth-child(1) { animation-delay: 0s; } +.typing-indicator span:nth-child(2) { animation-delay: 0.2s; } +.typing-indicator span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typing { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} .section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; } .btn-process { font-size: 17px; padding: 10px 40px; } .status-badge { font-size: 13px; } @@ -21,4 +246,260 @@ body { background: #f5f7fa; } .table-editable td { vertical-align: middle; } .table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; } .table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; } -.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; } \ No newline at end of file +.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; +} \ No newline at end of file diff --git a/src/web/static/icon/agent.svg b/src/web/static/icon/agent.svg new file mode 100644 index 0000000..672fbf9 --- /dev/null +++ b/src/web/static/icon/agent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/static/icon/file.svg b/src/web/static/icon/file.svg new file mode 100644 index 0000000..5d98d05 --- /dev/null +++ b/src/web/static/icon/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/static/icon/folder.svg b/src/web/static/icon/folder.svg new file mode 100644 index 0000000..c1bc751 --- /dev/null +++ b/src/web/static/icon/folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/static/icon/gallery.svg b/src/web/static/icon/gallery.svg new file mode 100644 index 0000000..3a62658 --- /dev/null +++ b/src/web/static/icon/gallery.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/static/icon/phone.svg b/src/web/static/icon/phone.svg new file mode 100644 index 0000000..2eb2ad4 --- /dev/null +++ b/src/web/static/icon/phone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/static/icon/user.svg b/src/web/static/icon/user.svg new file mode 100644 index 0000000..014bb70 --- /dev/null +++ b/src/web/static/icon/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/static/js/README.md b/src/web/static/js/README.md new file mode 100644 index 0000000..5bf17a9 --- /dev/null +++ b/src/web/static/js/README.md @@ -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 为静默检查/配置提示两个函数 \ No newline at end of file diff --git a/src/web/static/js/agent.js b/src/web/static/js/agent.js new file mode 100644 index 0000000..9a5881e --- /dev/null +++ b/src/web/static/js/agent.js @@ -0,0 +1,439 @@ +/** + * Agent 交互模块 + */ +import { App } from './state.js'; +import { escapeHtml } from './utils.js'; +import { addChatMessage, showStatus } from './chat.js'; + +// ================================================================ +// Agent 事件处理 +// ================================================================ + +/** + * 处理 Agent SSE 事件 + * + * @param {Object} msg - SSE 传来的 agent 事件对象 + */ +export function handleAgentEvent(msg) { + switch (msg.type) { + case 'agent_state_change': + _handleAgentStateChange(msg); + break; + case 'agent_request_supplement': + _handleAgentRequestSupplement(msg); + break; + case 'agent_ready': + _handleAgentReady(msg); + break; + case 'agent_error': + _handleAgentError(msg); + break; + case 'agent_supplement_received': + _handleSupplementReceived(msg); + break; + case 'agent_force_submit': + _handleForceSubmit(msg); + break; + } +} + +/** + * 状态变更通知 — 使用瞬态消息,状态变化时更新而非追加 + */ +function _handleAgentStateChange(msg) { + const stateLabels = { + 'extracting': '正在分析文件...', + 'validating': '正在校验信息完整性...', + 'awaiting_supplement': '等待补充材料', + 'ready': '信息完整,可以提交', + 'submitting': '正在提交...', + }; + + const label = stateLabels[msg.state] || msg.message || '处理中...'; + + if (msg.state === 'ready') { + showStatus(label, 'done'); + } else { + showStatus(label); + } +} + +/** + * 请求补充材料 — 仅更新瞬态状态,持久提示消息由 done 事件分支负责 + */ +export function _handleAgentRequestSupplement(msg) { + const suggestion = msg.suggestion || '请补充上传相关材料'; + showStatus(suggestion); +} + +/** + * 信息完整 — 已由 process.js 的 done 处理覆盖,此处不再重复显示 + */ +function _handleAgentReady(msg) { + // agent_ready 事件的信息展示统一由 process.js done 分支处理 +} + +/** + * 自动触发财务系统提交(由 done 事件调用) + */ +export async function handleAutoSubmit() { + if (!App.sessionId) { + addChatMessage('请先上传文件并处理', 'error'); + return; + } + + if (App.agentEventSource) { + App.agentEventSource.close(); + App.agentEventSource = null; + } + + App.isProcessing = true; + App.processState = 'submitting'; + + try { + showStatus('正在自动提交到财务系统...'); + + const response = await fetch(`/api/submit-financial/${App.sessionId}`, { + method: 'POST', + }); + + const result = await response.json(); + + if (result.status === 'started') { + const es = new EventSource(`/api/logs/${App.sessionId}`); + es.addEventListener('message', e => { + try { + const msg = JSON.parse(e.data); + if (msg.type === 'done') { + es.close(); + App.isProcessing = false; + App.processState = 'done'; + if (msg.result.submit_ok) { + addChatMessage('提交完成!', 'done'); + } else { + addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error'); + } + } + } catch (err) { + // 普通日志行 + } + }); + + es.onerror = () => { + es.close(); + App.isProcessing = false; + App.processState = 'done'; + addChatMessage('连接中断', 'error'); + }; + } else if (result.error) { + App.isProcessing = false; + App.processState = 'done'; + addChatMessage(`提交失败:${result.error}`, 'error'); + } + } catch (e) { + App.isProcessing = false; + App.processState = 'done'; + addChatMessage(`请求失败:${e.message}`, 'error'); + } +} + +/** + * Agent 错误 + */ +function _handleAgentError(msg) { + addChatMessage(msg.message || 'Agent 处理出错', 'error'); + App.isProcessing = false; + App.processState = 'done'; +} + +/** + * 收到补充文件 + */ +function _handleSupplementReceived(msg) { + const fileNames = (msg.files || []).join('、'); + addChatMessage(`已收到补充文件:${fileNames}`, 'done'); +} + +/** + * 强制提交 + */ +function _handleForceSubmit(msg) { + showStatus('已跳过校验,开始提交到财务系统'); +} + +// ================================================================ +// Agent 请求面板 +// ================================================================ + +/** + * 显示 Agent 请求补充材料的 UI + */ +export function showAgentRequest(data) { + const existing = document.getElementById('agent-request-panel'); + if (existing) existing.remove(); + + const chatMessages = document.getElementById('chat-messages'); + if (!chatMessages) return; + + const panel = document.createElement('div'); + panel.id = 'agent-request-panel'; + panel.className = 'agent-request-panel'; + + let materialsHtml = ''; + if (data.missing_materials && data.missing_materials.length > 0) { + materialsHtml = ` +
+ 需要补充: + ${data.missing_materials.map(m => `${escapeHtml(m)}`).join(' ')} +
+ `; + } + + panel.innerHTML = ` +
+ + 第 ${data.round || 1} 轮分析 - 信息不完整 +
+ ${materialsHtml} +
+ + +
+ `; + + chatMessages.appendChild(panel); + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +/** + * 移除 Agent 请求面板 + */ +export function hideAgentRequest() { + const panel = document.getElementById('agent-request-panel'); + if (panel) panel.remove(); +} + +// ================================================================ +// 用户文字补充 +// ================================================================ + +/** + * 处理用户通过输入框补充的文字信息 + * + * @param {string} text - 用户输入的文字 + */ +export async function handleUserSupplement(text) { + if (!App.sessionId) return; + + try { + showStatus('正在解析补充信息...'); + + const response = await fetch(`/api/agent/user-supplement/${App.sessionId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: text }), + }); + + const result = await response.json(); + + if (result.status === 'started') { + showStatus('补充信息已收到,正在重新分析...'); + + const es = new EventSource(`/api/logs/${App.sessionId}`); + es.addEventListener('message', e => { + try { + const msg = JSON.parse(e.data); + + if (msg.type && msg.type.startsWith('agent_')) { + handleAgentEvent(msg); + return; + } + + if (msg.type === 'done') { + es.close(); + App.isProcessing = false; + + if (msg.result.waiting_for_supplement) { + App.processState = 'awaiting_supplement'; + _handleAgentRequestSupplement(msg.result); + addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system'); + } else { + App.processState = 'done'; + if (msg.result.ok) { + if (msg.result.submit_ok !== false) { + addChatMessage('信息完整,已自动提交到财务系统', 'done'); + } else { + addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error'); + } + } else { + addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error'); + } + } + } + } catch (err) { + // 普通日志 + } + }); + + es.onerror = () => { + es.close(); + App.isProcessing = false; + App.processState = 'done'; + addChatMessage('连接中断', 'error'); + }; + } else if (result.error) { + addChatMessage(`处理失败:${result.error}`, 'error'); + } + } catch (e) { + addChatMessage(`请求失败:${e.message}`, 'error'); + } +} + +// ================================================================ +// 用户输入处理 +// ================================================================ + +/** + * 处理强制提交 + */ +export async function handleForceSubmit() { + if (!App.sessionId) { + addChatMessage('请先上传文件并处理', 'error'); + return; + } + + if (App.agentEventSource) { + App.agentEventSource.close(); + App.agentEventSource = null; + } + + App.forceSubmitting = true; + + try { + showStatus('正在跳过校验并提交...'); + hideAgentRequest(); + + const response = await fetch(`/api/agent/force-submit/${App.sessionId}`, { + method: 'POST', + }); + + const result = await response.json(); + + if (result.status === 'started') { + showStatus('已跳过校验,开始提交到财务系统'); + App.isProcessing = true; + App.processState = 'submitting'; + + const es = new EventSource(`/api/logs/${App.sessionId}`); + es.addEventListener('message', e => { + try { + const msg = JSON.parse(e.data); + if (msg.type === 'done') { + es.close(); + App.isProcessing = false; + App.processState = 'done'; + App.forceSubmitting = false; + App.lastProcessedFileCount = App.allFiles.length; + if (msg.result.ok) { + addChatMessage('提交完成!', 'done'); + } else { + addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error'); + } + } + } catch (err) { + // 普通日志行 + } + }); + + es.onerror = () => { + es.close(); + App.isProcessing = false; + App.processState = 'done'; + App.forceSubmitting = false; + App.lastProcessedFileCount = App.allFiles.length; + addChatMessage('连接中断', 'error'); + }; + } + } catch (e) { + App.forceSubmitting = false; + addChatMessage(`请求失败:${e.message}`, 'error'); + } +} + +// ================================================================ +// 补充文件处理 +// ================================================================ + +/** + * 处理补充文件上传完成后的重新分析 + * + * @param {Array} filenames - 新上传的文件名列表 + */ +export async function handleSupplementUpload(filenames) { + if (!App.sessionId) return; + + try { + showStatus('收到补充文件,正在重新分析...'); + hideAgentRequest(); + + const response = await fetch(`/api/agent/supplement/${App.sessionId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: filenames }), + }); + + const result = await response.json(); + + if (result.status === 'started') { + const es = new EventSource(`/api/logs/${App.sessionId}`); + es.addEventListener('message', e => { + try { + const msg = JSON.parse(e.data); + + if (msg.type && msg.type.startsWith('agent_')) { + handleAgentEvent(msg); + return; + } + + if (msg.type === 'done') { + es.close(); + App.isProcessing = false; + + if (msg.result.waiting_for_supplement) { + App.processState = 'awaiting_supplement'; + showStatus('信息不完整,请补充上传相关材料'); + addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system'); + } else { + App.processState = 'done'; + if (msg.result.ok) { + // 后端已自动提交,根据 submit_ok 显示结果 + if (msg.result.submit_ok !== false) { + addChatMessage('信息完整,已自动提交到财务系统', 'done'); + } else { + addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error'); + } + } else { + addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error'); + } + } + } + } catch (err) { + // 普通日志 + } + }); + + es.onerror = () => { + es.close(); + App.isProcessing = false; + App.processState = 'done'; + addChatMessage('连接中断', 'error'); + }; + } + } catch (e) { + addChatMessage(`请求失败:${e.message}`, 'error'); + } +} \ No newline at end of file diff --git a/src/web/static/js/chat.js b/src/web/static/js/chat.js new file mode 100644 index 0000000..6d518d9 --- /dev/null +++ b/src/web/static/js/chat.js @@ -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'; \ No newline at end of file diff --git a/src/web/static/js/chat/README.md b/src/web/static/js/chat/README.md new file mode 100644 index 0000000..2d02d14 --- /dev/null +++ b/src/web/static/js/chat/README.md @@ -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 +``` + +- 文件名 → 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/` 子目录导入(外部模块层面)。子模块之间的内部导入不受此限制。 \ No newline at end of file diff --git a/src/web/static/js/chat/ephemeral.js b/src/web/static/js/chat/ephemeral.js new file mode 100644 index 0000000..d85a519 --- /dev/null +++ b/src/web/static/js/chat/ephemeral.js @@ -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 = ` +
+
${escapeHtml(text)}
+ `; + 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, + }; +} \ No newline at end of file diff --git a/src/web/static/js/chat/persistent.js b/src/web/static/js/chat/persistent.js new file mode 100644 index 0000000..6362a31 --- /dev/null +++ b/src/web/static/js/chat/persistent.js @@ -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 = ` +
+
+
+
+ `; + 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 = ` + + ${escapeHtml(filename)} + + `; + + 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 = ` + + + + `; + + 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 = 'OK'; + + 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, '
'); + lines.push(`${label}:${displayValue}`); + } + + detailRow.innerHTML = lines.join('
'); + 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 = 'CACHE'; + + detailRow.innerHTML = '状态:使用缓存(已提取)'; + 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 = 'ERR'; + + detailRow.innerHTML = `错误:${escapeHtml(errorMsg || '提取失败')}`; + detailRow.style.display = 'block'; + entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' }); +} \ No newline at end of file diff --git a/src/web/static/js/chat/renderer.js b/src/web/static/js/chat/renderer.js new file mode 100644 index 0000000..6c65eef --- /dev/null +++ b/src/web/static/js/chat/renderer.js @@ -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 = ` +
+
${escapeHtml(text)}
+ `; + 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(); +} \ No newline at end of file diff --git a/src/web/static/js/chat/stream.js b/src/web/static/js/chat/stream.js new file mode 100644 index 0000000..d1dbc7f --- /dev/null +++ b/src/web/static/js/chat/stream.js @@ -0,0 +1,199 @@ +/** + * LLM 流式聊天气泡管理器 + * + * 流式消息在处理时显示思考过程和逐字输出,结束后固化为持久消息。 + */ +import { escapeHtml } from '../utils.js'; +import { getChatMessages, scrollToBottom } from './renderer.js'; + +/** + * 当前活跃的 LLM 流式气泡状态 + */ +let llmStreamState = { + wrapper: null, + bubble: null, + textContent: null, + accumulated: '', + reasoningContent: null, + reasoningAccumulated: '', +}; + +/** + * 处理 LLM 流式事件 + * + * @param {Object} msg - SSE 传来的 llm_stream 事件对象 + */ +export function handleLLMStream(msg) { + switch (msg.phase) { + case 'start': + _createLLMStreamBubble(msg.label || '正在分析中...'); + break; + case 'reasoning': + _appendLLMStreamReasoning(msg.text || ''); + break; + case 'chunk': + _appendLLMStreamChunk(msg.text || ''); + break; + case 'end': + _closeLLMStreamBubble(msg.label || '分析完成'); + break; + case 'error': + _errorLLMStreamBubble(msg.error || '分析失败'); + break; + } +} + +/** + * 创建 LLM 流式气泡(start 阶段) + */ +function _createLLMStreamBubble(label) { + const chatMessages = getChatMessages(); + if (!chatMessages) return; + + if (llmStreamState.wrapper) { + _closeLLMStreamBubbleSilent(); + } + + const wrapper = document.createElement('div'); + wrapper.className = 'chat-message'; + + const bubble = document.createElement('div'); + bubble.className = 'chat-bubble processing llm-stream-bubble'; + + const labelEl = document.createElement('div'); + labelEl.className = 'llm-stream-label'; + labelEl.textContent = label; + + const reasoningSection = document.createElement('details'); + reasoningSection.className = 'llm-reasoning-section'; + reasoningSection.open = true; + + const reasoningSummary = document.createElement('summary'); + reasoningSummary.className = 'llm-reasoning-summary'; + reasoningSummary.textContent = '思考过程(点击折叠)'; + + const reasoningText = document.createElement('div'); + reasoningText.className = 'llm-reasoning-text'; + reasoningText.textContent = ''; + + reasoningSection.appendChild(reasoningSummary); + reasoningSection.appendChild(reasoningText); + + const textEl = document.createElement('div'); + textEl.className = 'llm-stream-text'; + textEl.textContent = ''; + + bubble.appendChild(labelEl); + bubble.appendChild(reasoningSection); + bubble.appendChild(textEl); + wrapper.appendChild(bubble); + chatMessages.appendChild(wrapper); + scrollToBottom(); + + llmStreamState = { + wrapper, + bubble, + textContent: textEl, + accumulated: '', + reasoningContent: reasoningText, + reasoningAccumulated: '', + }; +} + +/** + * 追加流式文本片段(chunk 阶段) + */ +function _appendLLMStreamChunk(text) { + if (!llmStreamState.textContent) return; + + llmStreamState.accumulated += text; + llmStreamState.textContent.textContent = llmStreamState.accumulated; + scrollToBottom(); +} + +/** + * 追加思考过程片段(reasoning 阶段) + */ +function _appendLLMStreamReasoning(text) { + if (!llmStreamState.reasoningContent) return; + + llmStreamState.reasoningAccumulated += text; + llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated; + scrollToBottom(); +} + +/** + * 关闭流式气泡(end 阶段)— 气泡保留在聊天历史中作为持久消息 + */ +function _closeLLMStreamBubble(label) { + if (!llmStreamState.bubble) return; + + llmStreamState.bubble.classList.remove('processing'); + llmStreamState.bubble.classList.add('done'); + + const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label'); + if (labelEl) { + labelEl.textContent = label; + } + + const reasoningSection = llmStreamState.bubble.querySelector('.llm-reasoning-section'); + if (reasoningSection) { + reasoningSection.open = false; + } + + scrollToBottom(); + + llmStreamState = { + wrapper: null, + bubble: null, + textContent: null, + accumulated: '', + reasoningContent: null, + reasoningAccumulated: '', + }; +} + +/** + * 静默关闭流式气泡(不改变样式,用于 start 前清理) + */ +function _closeLLMStreamBubbleSilent() { + if (llmStreamState.wrapper) { + llmStreamState.wrapper.remove(); + } + llmStreamState = { + wrapper: null, + bubble: null, + textContent: null, + accumulated: '', + reasoningContent: null, + reasoningAccumulated: '', + }; +} + +/** + * 错误状态(error 阶段) + */ +function _errorLLMStreamBubble(errorMsg) { + if (!llmStreamState.bubble) return; + + llmStreamState.bubble.classList.remove('processing'); + llmStreamState.bubble.classList.add('error'); + + const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label'); + if (labelEl) { + labelEl.textContent = '分析失败'; + } + + if (llmStreamState.textContent) { + llmStreamState.textContent.textContent = escapeHtml(errorMsg); + } + + llmStreamState = { + wrapper: null, + bubble: null, + textContent: null, + accumulated: '', + reasoningContent: null, + reasoningAccumulated: '', + }; +} \ No newline at end of file diff --git a/src/web/static/js/config.js b/src/web/static/js/config.js new file mode 100644 index 0000000..f1c53da --- /dev/null +++ b/src/web/static/js/config.js @@ -0,0 +1,148 @@ +/** + * 配置收集模块 + */ +import { App } from './state.js'; +import { addChatMessage, showStatus } from './chat.js'; +import { handleUserSupplement, handleForceSubmit, handleSupplementUpload } from './agent.js'; +import { startProcess } from './process.js'; + +export const CONFIG_FIELDS = { + username: '登录账号', + password: '登录密码', + default_name: '报销人姓名', + default_card_no: '公务卡号', + default_person_id: '报销人工号', + consumable_storage: '易耗品存放地点', +}; + +/** + * 静默检查:仅当配置完整且有文件时自动开始处理。 + */ +export function checkAutoStart() { + if (App.isProcessing) return; + if (App.processState === 'processing') return; + + if (App.processState === 'done') { + const hasNewFiles = App.allFiles.length > App.lastProcessedFileCount; + if (!hasNewFiles && !App.forceStart) return; + App.forceStart = false; + // done 状态下有新文件时,走 supplement 流程 + if (hasNewFiles) { + App.isProcessing = true; + App.processState = 'processing'; + if (App.newFilenames.length > 0) { + const filenames = App.newFilenames; + App.newFilenames = []; + handleSupplementUpload(filenames); + } + return; + } + } + + if (App.processState === 'awaiting_files') { + if (!App.forceStart) return; + App.forceStart = false; + } + + if (App.processState === 'awaiting_supplement') { + return; + } + + // idle 状态下有补充文件时,走 supplement 流程 + if (App.processState === 'idle' && App.newFilenames.length > 0) { + App.isProcessing = true; + App.processState = 'processing'; + const filenames = App.newFilenames; + App.newFilenames = []; + handleSupplementUpload(filenames); + return; + } + + const allKeys = Object.keys(CONFIG_FIELDS); + const missing = allKeys.filter(k => !App.sessionConfig[k]); + + if (missing.length) return; + + App.pendingConfigKey = null; + + if (!App.allFiles.length) return; + + startProcess(); +} + +/** + * 配置提示:在所有文件加载完成后调用,逐个提示缺失的配置项。 + */ +export function promptMissingConfig() { + if (App.isProcessing) return; + + const allKeys = Object.keys(CONFIG_FIELDS); + const missing = allKeys.filter(k => !App.sessionConfig[k]); + + if (missing.length) { + if (App.pendingConfigKey === null) { + App.pendingConfigKey = missing[0]; + addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system'); + } else if (!missing.includes(App.pendingConfigKey)) { + App.pendingConfigKey = missing[0]; + addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system'); + } + return; + } + + App.pendingConfigKey = null; + + if (App.allFiles.length) { + showStatus('配置信息已完整,开始处理发票……'); + startProcess(); + } +} + +/** + * 用户输入处理入口 + */ +export async function sendUserMessage() { + const input = document.getElementById('chat-input'); + if (!input) return; + const text = input.value.trim(); + if (!text) return; + + // 优先处理配置收集阶段 + if (App.pendingConfigKey) { + App.sessionConfig[App.pendingConfigKey] = text; + addChatMessage(text, 'user', true); + addChatMessage(`${CONFIG_FIELDS[App.pendingConfigKey]} 已设置`, 'system'); + input.value = ''; + promptMissingConfig(); + return; + } + + addChatMessage(text, 'user', true); + input.value = ''; + + // 检查是否是强制提交指令 + if (text.includes('直接提交') || text.includes('强制提交') || text.includes('继续提交')) { + await handleForceSubmit(); + return; + } + + // 检查是否包含文件上传指令 + if (text.includes('上传') || text.includes('补充')) { + document.getElementById('file-input').click(); + return; + } + + // awaiting_supplement 状态下,将用户输入发给后端 LLM 处理 + if (App.processState === 'awaiting_supplement') { + await handleUserSupplement(text); + return; + } + + showStatus('请通过上传区补充文件,或输入"直接提交"跳过校验'); +} + +export function handleChatKey(event) { + if (event.key === 'Enter') { + sendUserMessage(); + } +} \ No newline at end of file diff --git a/src/web/static/js/index.js b/src/web/static/js/index.js index c169be5..a4fc724 100644 --- a/src/web/static/js/index.js +++ b/src/web/static/js/index.js @@ -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) => - `${f.name}×` - ).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 = '上传中...'; - 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 = '处理中...'; - - 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 - ? '完成' - : '失败'; - 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 = '连接中断'; - btn.disabled = false; - btn.textContent = '开始处理'; - }; - - } catch (e) { - alert('请求失败: ' + (e.message || '未知错误')); - btn.disabled = false; - btn.textContent = '开始处理'; - document.getElementById('status').innerHTML = '失败'; - } -} - -// ---- 下载链接 ---- -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 => - `${it.label}` - ).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 = ` - - # - ${fields.map(f => `${f}`).join('')} - - `; - - // 表体:每行首列为序号,其后为各字段 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 ``; - }).join(''); - return `${i + 1}${cells}`; - }).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,'"'); -} - -// 将当前表格编辑内容写回服务器(内部调用) -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 += `
❌ 提交失败:${errorMsg}
`; - 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); - }); - } -}); \ No newline at end of file +startSync(); \ No newline at end of file diff --git a/src/web/static/js/process.js b/src/web/static/js/process.js new file mode 100644 index 0000000..70f4cf5 --- /dev/null +++ b/src/web/static/js/process.js @@ -0,0 +1,148 @@ +/** + * 发票处理模块 + */ +import { App } from './state.js'; +import { ensureSession } from './utils.js'; +import { + addTypingIndicator, + removeTypingIndicator, + setFileProcessing, + setFileDone, + setFileCached, + setFileError, + handleLLMStream, + addChatMessage, + showStatus, +} from './chat.js'; +import { handleAgentEvent, handleAutoSubmit } from './agent.js'; + +export async function startProcess() { + if (!App.allFiles.length) { + alert('请先上传文件或图片'); + return; + } + + App.isProcessing = true; + App.processState = 'processing'; + + addTypingIndicator(); + + try { + await ensureSession(); + + for (const f of App.allFiles) { + const fd = new FormData(); + fd.append('file', f); + await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd }); + } + + removeTypingIndicator(); + addTypingIndicator(); + + const cfg = { + username: App.sessionConfig.username || '', + password: App.sessionConfig.password || '', + default_name: App.sessionConfig.default_name || '', + default_card_no: App.sessionConfig.default_card_no || '', + default_person_id: App.sessionConfig.default_person_id || '', + consumable_storage: App.sessionConfig.consumable_storage || '', + }; + + await fetch(`/api/agent/process/${App.sessionId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cfg), + }); + + const es = new EventSource(`/api/logs/${App.sessionId}`); + App.agentEventSource = es; + + es.addEventListener('message', e => { + try { + const msg = JSON.parse(e.data); + + if (msg.type === 'file_progress') { + const filename = msg.file; + switch (msg.status) { + case 'processing': + setFileProcessing(filename); + break; + case 'done': + setFileDone(filename, msg.summary || {}); + break; + case 'cached': + setFileCached(filename); + break; + case 'error': + setFileError(filename, msg.error); + break; + } + return; + } + + if (msg.type === 'llm_stream') { + handleLLMStream(msg); + return; + } + + if (msg.type && msg.type.startsWith('agent_')) { + handleAgentEvent(msg); + return; + } + + if (msg.type === 'done') { + es.close(); + App.agentEventSource = null; + removeTypingIndicator(); + const result = msg.result; + App.isProcessing = false; + + if (App.forceSubmitting) { + return; + } + + if (result.waiting_for_supplement) { + App.processState = 'awaiting_supplement'; + showStatus('信息不完整,请补充上传相关材料'); + addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system'); + } else { + App.processState = 'done'; + App.lastProcessedFileCount = App.allFiles.length; + + if (result.ok) { + if (result.agent_ready) { + if (result.submit_ok !== false) { + addChatMessage('信息完整,已自动提交到财务系统', 'done'); + } else { + addChatMessage(`校验通过但提交失败:${result.submit_error || '未知错误'}`, 'error'); + } + } else if (result.invoice_count) { + addChatMessage(`处理完成!共提取 ${result.invoice_count} 张发票数据`, 'done'); + } + } else { + addChatMessage(`处理失败:${result.error || '未知错误'}`, 'error'); + } + } + return; + } + } catch (err) { + // 普通日志行 + } + }); + + es.onerror = () => { + es.close(); + removeTypingIndicator(); + App.isProcessing = false; + App.processState = 'done'; + addChatMessage('连接中断,请刷新页面重试', 'error'); + }; + + } catch (e) { + removeTypingIndicator(); + alert('请求失败: ' + (e.message || '未知错误')); + App.isProcessing = false; + App.processState = 'idle'; + addChatMessage(`请求失败:${e.message || '未知错误'}`, 'error'); + } +} \ No newline at end of file diff --git a/src/web/static/js/state.js b/src/web/static/js/state.js new file mode 100644 index 0000000..1e1c4a1 --- /dev/null +++ b/src/web/static/js/state.js @@ -0,0 +1,24 @@ +/** + * 全局状态管理 + * + * 所有模块通过 App 对象共享状态,避免全局变量污染。 + */ +export const App = { + sessionId: null, + allFiles: [], + sessionConfig: {}, + invoiceData: [], + csvFilename: '', + lastDownloadUrls: {}, + isProcessing: false, + pendingConfigKey: null, + fieldsCache: [], + syncTimer: null, + fileMessageMap: new Map(), + processState: 'idle', + newFilenames: [], + lastProcessedFileCount: 0, + forceStart: false, + agentEventSource: null, + forceSubmitting: false, +}; \ No newline at end of file diff --git a/src/web/static/js/sync.js b/src/web/static/js/sync.js new file mode 100644 index 0000000..9a0c110 --- /dev/null +++ b/src/web/static/js/sync.js @@ -0,0 +1,80 @@ +/** + * 移动端同步模块 + */ +import { App } from './state.js'; +import { ensureSession } from './utils.js'; +import { addFileMessage } from './chat.js'; +import { checkAutoStart, promptMissingConfig } from './config.js'; + +export async function generateQr() { + await ensureSession(); + const mobileUrl = window.location.origin + '/mobile/' + App.sessionId; + const box = document.getElementById('qrcode'); + box.innerHTML = ''; + new QRCode(box, { + text: mobileUrl, + width: 120, + height: 120, + colorDark: '#333', + colorLight: '#fff', + }); +} + +export async function startSync() { + if (App.syncTimer) return; + await syncFiles(); + App.syncTimer = setInterval(syncFiles, 3000); +} + +export function stopSync() { + if (App.syncTimer) { clearInterval(App.syncTimer); App.syncTimer = null; } +} + +async function syncFiles() { + if (!App.sessionId) return; + try { + const r = await fetch(`/api/files/${App.sessionId}`); + const d = await r.json(); + const serverFiles = d.files || []; + const serverNames = new Set(serverFiles.map(f => f.name)); + const localNames = new Set(App.allFiles.map(f => f.name)); + const addedFiles = []; + + for (const serverFile of serverFiles) { + if (!localNames.has(serverFile.name)) { + const resp = await fetch(`/api/download/${App.sessionId}/${encodeURIComponent(serverFile.name)}`); + const blob = await resp.blob(); + const file = new File([blob], serverFile.name, { type: blob.type }); + file.__source = 'server'; + App.allFiles.push(file); + addedFiles.push(serverFile.name); + } + } + + for (const f of [...App.allFiles]) { + if (!serverNames.has(f.name) && f.__source !== 'local') { + const idx = App.allFiles.indexOf(f); + if (idx > -1) App.allFiles.splice(idx, 1); + } + } + + if (addedFiles.length) { + addedFiles.forEach(name => addFileMessage(name)); + if (App.processState === 'done') { + App.processState = 'idle'; + App.newFilenames = addedFiles; + } else if (App.processState === 'awaiting_supplement') { + App.processState = 'idle'; + App.newFilenames = addedFiles; + } + } + + if (App.allFiles.length) { + document.getElementById('file-zone').classList.add('active'); + } else { + document.getElementById('file-zone').classList.remove('active'); + } + + checkAutoStart(); + } catch (e) { /* ignore */ } +} \ No newline at end of file diff --git a/src/web/static/js/upload.js b/src/web/static/js/upload.js new file mode 100644 index 0000000..2bc960b --- /dev/null +++ b/src/web/static/js/upload.js @@ -0,0 +1,141 @@ +/** + * 文件上传模块 + */ +import { App } from './state.js'; +import { addFileMessage, addChatMessage } from './chat.js'; +import { promptMissingConfig, checkAutoStart } from './config.js'; +import { handleSupplementUpload, hideAgentRequest } from './agent.js'; +import { ensureSession } from './utils.js'; + +export async function handleFiles(input) { + const files = Array.from(input.files); + const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i; + let hasNewFiles = false; + const newFilenames = []; + + for (const f of files) { + if (/\.json$/i.test(f.name)) { + await parseConfigFile(f); + continue; + } + if (!App.allFiles.find(x => x.name === f.name) && validExts.test(f.name)) { + f.__source = 'local'; + App.allFiles.push(f); + document.getElementById('file-zone').classList.add('active'); + addFileMessage(f.name); + hasNewFiles = true; + newFilenames.push(f.name); + } + } + + if (hasNewFiles && App.processState === 'done') { + App.processState = 'idle'; + App.newFilenames = newFilenames; + } + if (hasNewFiles && App.processState === 'awaiting_supplement') { + hideAgentRequest(); + await _uploadNewFiles(newFilenames); + App.newFilenames = newFilenames; + App.processState = 'idle'; + checkAutoStart(); + return; + } + + promptMissingConfig(); + input.value = ''; +} + +async function _uploadNewFiles(filenames) { + await ensureSession(); + for (const f of App.allFiles) { + if (filenames.includes(f.name)) { + const fd = new FormData(); + fd.append('file', f); + await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd }); + } + } +} + +async function parseConfigFile(file) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = function (e) { + try { + const cfg = JSON.parse(e.target.result); + Object.assign(App.sessionConfig, { + username: cfg.username || '', + password: cfg.password || '', + default_name: cfg.default_name || '', + default_card_no: cfg.default_card_no || '', + default_person_id: cfg.default_person_id || '', + consumable_storage: cfg.consumable_storage || '', + }); + addChatMessage(`config.json 已加载`, 'system'); + resolve(true); + } catch (err) { + console.warn('config.json 解析失败:', err.message); + resolve(false); + } + }; + reader.readAsText(file); + }); +} + +export function initDragDrop() { + const zone = document.getElementById('file-zone'); + if (!zone) return; + + zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); }); + zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); + zone.addEventListener('drop', async e => { + e.preventDefault(); + zone.classList.remove('dragover'); + const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i; + const droppedFiles = Array.from(e.dataTransfer.files); + let hasNewFiles = false; + const newFilenames = []; + + for (const f of droppedFiles) { + if (/\.json$/i.test(f.name)) { + await parseConfigFile(f); + continue; + } + if (validExts.test(f.name) && !App.allFiles.find(x => x.name === f.name)) { + f.__source = 'local'; + App.allFiles.push(f); + zone.classList.add('active'); + addFileMessage(f.name); + hasNewFiles = true; + newFilenames.push(f.name); + } + } + + if (hasNewFiles && App.processState === 'done') { + App.processState = 'idle'; + } + + // 在 awaiting_supplement 状态下,只上传新文件并走 supplement 流程 + if (hasNewFiles && App.processState === 'awaiting_supplement') { + hideAgentRequest(); + await _uploadNewFiles(newFilenames); + App.newFilenames = newFilenames; + App.processState = 'idle'; + checkAutoStart(); + return; + } + + promptMissingConfig(); + }); +} + +/** + * 切换上传面板折叠/展开状态 + */ +export function toggleUploadPanel() { + const body = document.getElementById('upload-panel-body'); + const icon = document.getElementById('upload-toggle-icon'); + if (!body || !icon) return; + + body.classList.toggle('collapsed'); + icon.classList.toggle('collapsed'); +} \ No newline at end of file diff --git a/src/web/static/js/utils.js b/src/web/static/js/utils.js new file mode 100644 index 0000000..27b1c6e --- /dev/null +++ b/src/web/static/js/utils.js @@ -0,0 +1,18 @@ +/** + * 工具函数模块 + * + * 提供 HTML 转义和会话管理等基础功能。 + */ +import { App } from './state.js'; + +export function escapeHtml(s) { + return s.replace(/\&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +export async function ensureSession() { + if (App.sessionId) return App.sessionId; + const r = await fetch('/api/session', { method: 'POST' }); + const d = await r.json(); + App.sessionId = d.session_id; + return App.sessionId; +} \ No newline at end of file diff --git a/src/web/templates/index.html b/src/web/templates/index.html index 6ef0cba..47dcfe4 100644 --- a/src/web/templates/index.html +++ b/src/web/templates/index.html @@ -9,119 +9,49 @@ -
-

财务报销自动化

-

上传发票 PDF 和支付截图,自动提取、LLM 识别并填报

-
-
- - -
-
-
📄 发票 PDF (多选)
-
-
📁
-
点击或拖拽上传 PDF 文件
-
+
+ +
+
+
+
+
你好!我是财务报销助手。请先上传 config.json 配置文件,然后上传发票 PDF 或支付截图,我将自动为您处理。
-
-
-
🖼️ 支付截图 (多选)
-
-
🖼️
-
点击或拖拽上传图片文件
-
-
- -
-
-
📱 手机扫码上传
-
-

扫码即可拍照上传

+
+ +
- -
-
-
⚙️ 配置 - - 📤 上传 config.json - - -
-
-
- - + +
+
+ 文件上传 + +
+
+
+
+
+
+
点击或拖拽上传文件(支持 PDF、图片)
+
+
-
- - -
-
- - -
-
- - -
-
- - -
-
- - +
+
手机扫码上传
+
+

扫码即可拍照上传

- -
- - -
- - - - - - - - -
📋 处理日志
-
等待开始...
-
- + \ No newline at end of file diff --git a/src/web/templates/mobile_upload.html b/src/web/templates/mobile_upload.html index 3e45eaa..109049c 100644 --- a/src/web/templates/mobile_upload.html +++ b/src/web/templates/mobile_upload.html @@ -25,7 +25,7 @@ body { background: #f5f7fa; padding-bottom: 40px; } {% else %}
-
📸 支付截图上传
+
支付截图上传

拍照或从相册选择图片

@@ -35,7 +35,8 @@ body { background: #f5f7fa; padding-bottom: 40px; } @@ -99,7 +100,7 @@ async function handleSelect(input) { } if (done === files.length) { - status.innerHTML = `
✅ ${done} 张图片上传成功
`; + status.innerHTML = `
${done} 张图片上传成功
`; } else { status.innerHTML = `
${done}/${files.length} 张上传成功
`; } diff --git a/tests/test_extractor.py b/tests/test_extractor.py index ea5022c..87c0f02 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -9,6 +9,9 @@ from __future__ import annotations from pathlib import Path from typing import Any +import pytest + +from src import exceptions from src.doc.extractor import extract_invoices # 字段键名(与源码中的字符串字面量保持一致) @@ -87,12 +90,13 @@ class TestExtractInvoices: pdf.touch() monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [pdf]) - monkeypatch.setattr("src.doc.extractor._extract_document", lambda p, c: None) + monkeypatch.setattr("src.doc.extractor._extract_document", lambda p, c, s: (None, "parse error")) - records, apps, groups = extract_invoices(str(tmp_path)) - assert records == [] - assert apps == [] - assert groups == {"travel": [], "general": [], "application": []} + with pytest.raises(exceptions.ExtractionError) as exc_info: + extract_invoices(str(tmp_path)) + + assert "broken.pdf" in exc_info.value.failed_files + assert exc_info.value.details["broken.pdf"] == "parse error" def test_normal_flow_general_invoices(self, tmp_path: Path, monkeypatch): pdf1 = tmp_path / "inv1.pdf" @@ -107,10 +111,10 @@ class TestExtractInvoices: call_index = [0] - def fake_extract(path, cache_dir): + def fake_extract(path, cache_dir, source_dir): idx = call_index[0] call_index[0] += 1 - return inv1 if idx == 0 else inv2 + return (inv1, None) if idx == 0 else (inv2, None) monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract) @@ -156,10 +160,10 @@ class TestExtractInvoices: invoices_list = [inv_train, inv_hotel, inv_general] call_index = [0] - def fake_extract(path, cache_dir): + def fake_extract(path, cache_dir, source_dir): idx = call_index[0] call_index[0] += 1 - return invoices_list[idx] + return (invoices_list[idx], None) monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract) @@ -224,10 +228,10 @@ class TestExtractInvoices: results = [inv, app] call_index = [0] - def fake_extract(path, cache_dir): + def fake_extract(path, cache_dir, source_dir): idx = call_index[0] call_index[0] += 1 - return results[idx] + return (results[idx], None) monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract) @@ -269,10 +273,10 @@ class TestExtractInvoices: results = [inv, card] call_index = [0] - def fake_extract(path, cache_dir): + def fake_extract(path, cache_dir, source_dir): idx = call_index[0] call_index[0] += 1 - return results[idx] + return (results[idx], None) monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract) @@ -312,10 +316,10 @@ class TestExtractInvoices: results = [inv, None] call_index = [0] - def fake_extract(path, cache_dir): + def fake_extract(path, cache_dir, source_dir): idx = call_index[0] call_index[0] += 1 - return results[idx] + return (results[idx], "parse error") if results[idx] is None else (results[idx], None) monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract) diff --git a/tests/test_invoice.py b/tests/test_invoice.py index 4744b44..9d05523 100644 --- a/tests/test_invoice.py +++ b/tests/test_invoice.py @@ -6,9 +6,7 @@ from src.doc.invoice import ( INVOICE_LEVEL_COLUMNS, PAYMENT_RECORD_COLUMNS, -) -from src.doc.invoice import ( - _classify_invoice_batch as classify_invoice_batch, + classify_invoice_batch, ) # 字段键名与发票类型(与源码中的字符串字面量保持一致) diff --git a/tests/test_llm_extractor.py b/tests/test_llm_extractor.py index c7103b2..1d0ca33 100644 --- a/tests/test_llm_extractor.py +++ b/tests/test_llm_extractor.py @@ -1,7 +1,7 @@ """LLM 信息提取模块单元测试 覆盖范围: -- _parse_json_response:纯 JSON、Markdown 包裹、带前缀、解析失败 +- `parse_json_response`:纯 JSON、Markdown 包裹、带前缀、解析失败 - _image_to_base64:图片转 base64 - extract_document:成功提取、LLM 失败 """ @@ -16,8 +16,8 @@ import pytest from src.doc.llm_extractor import ( _image_to_base64, - _parse_json_response, extract_document, + parse_json_response, ) # 字段键名(与源码中的字符串字面量保持一致) @@ -28,7 +28,7 @@ K_CARD_NO = "card_no" K_CARD_AMOUNT = "card_amount" # ------------------------------------------------------------------ -# _parse_json_response +# parse_json_response # ------------------------------------------------------------------ @@ -37,47 +37,47 @@ class TestParseJsonResponse: def test_pure_json(self): raw = json.dumps({K_INVOICE_NUMBER: "123456", K_TOTAL_AMOUNT: "100.00"}) - result = _parse_json_response(raw) + result = parse_json_response(raw) assert result[K_INVOICE_NUMBER] == "123456" assert result[K_TOTAL_AMOUNT] == "100.00" def test_markdown_json_block(self): raw = f'```json\n{{"{K_INVOICE_NUMBER}": "789"}}\n```' - result = _parse_json_response(raw) + result = parse_json_response(raw) assert result[K_INVOICE_NUMBER] == "789" def test_markdown_block_without_lang(self): raw = '```\n{"key": "value"}\n```' - result = _parse_json_response(raw) + result = parse_json_response(raw) assert result["key"] == "value" def test_json_prefix(self): raw = f'json\n{{"{K_INVOICE_NUMBER}": "001"}}' - result = _parse_json_response(raw) + result = parse_json_response(raw) assert result[K_INVOICE_NUMBER] == "001" def test_json_prefix_with_whitespace(self): raw = ' json \n{"a": 1}' - result = _parse_json_response(raw) + result = parse_json_response(raw) assert result["a"] == 1 def test_nested_json(self): raw = json.dumps({"outer": {"inner": [1, 2, 3]}}) - result = _parse_json_response(raw) + result = parse_json_response(raw) assert result["outer"]["inner"] == [1, 2, 3] def test_whitespace_around_json(self): raw = ' \n {"x": 42} \n ' - result = _parse_json_response(raw) + result = parse_json_response(raw) assert result["x"] == 42 def test_invalid_json_raises(self): with pytest.raises(ValueError): - _parse_json_response("not json at all") + parse_json_response("not json at all") def test_empty_string_raises(self): with pytest.raises(ValueError): - _parse_json_response("") + parse_json_response("") # ------------------------------------------------------------------ diff --git a/uv.lock b/uv.lock index f40a3dd..a0d0830 100644 --- a/uv.lock +++ b/uv.lock @@ -231,6 +231,7 @@ dependencies = [ { name = "llama-index-llms-openai-like" }, { name = "playwright" }, { name = "pymupdf" }, + { name = "python-dotenv" }, { name = "pywin32" }, ] @@ -251,6 +252,7 @@ requires-dist = [ { name = "llama-index-llms-openai-like", specifier = "==0.7.2" }, { name = "playwright", specifier = ">=1.40" }, { name = "pymupdf", specifier = ">=1.24" }, + { name = "python-dotenv", specifier = ">=1.0" }, { name = "pywin32", specifier = ">=306" }, ] @@ -1897,6 +1899,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pywin32" version = "312"