Files
Auto-Finance/src/web

last_reviewed
last_reviewed
2026-06-13

src/web 模块设计说明

设计思路

src/web 是一个基于 Flask 的轻量级 Web 界面,为财务报销自动化管道提供可视化操作入口。核心设计原则:

  • 会话隔离:每次上传生成独立 session_id,文件、日志、配置、结果各自隔离在 uploads/<session_id>/ 目录下,避免并发冲突。
  • 异步处理:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
  • 前后端分离最小化:前端使用原生 JS + Bootstrap 5不引入构建工具保持单页应用轻量可维护。
  • 统一文件上传2026-06-12 改造,将 PDF 和图片上传入口合并为单一上传区,用户通过一个入口上传所有文件类型。

变更历史

日期 变更
2026-06-13 LLM 流式思考过程展示:后端 SSE 推送 llm_stream 事件start/chunk/end/error前端聊天气泡实时展示 AI 思考过程
2026-06-12 文件进度实时反馈:后端 SSE 推送 file_progress 事件processing/done/cached/error前端文件消息实时更新状态 + 展示提取摘要
2026-06-12 配置交互改为逐项引导config.json 缺失字段时 AI 逐个提示用户通过聊天输入,全部完成后自动开始处理
2026-06-12 聊天窗口精简:移除 SSE 日志流显示仅保留关键状态消息config.json 配置不完整时 AI 主动提示缺失字段
2026-06-12 移除开始处理按钮改为自动触发config.json 解析完成且配置完整username/password且有发票文件时自动开始处理
2026-06-12 文件上传通知改为逐条消息:每个文件单独一条聊天气泡,上传框 flex 居中、固定高度
2026-06-12 文件上传反馈移至聊天窗口:上传/拖拽/同步文件后以聊天气泡通知,上传框固定高度不再显示文件标签
2026-06-12 修复聊天窗口消息覆盖问题:将消息区域与输入区域分离,消息区独立滚动,新增用户文本输入功能
2026-06-12 合并 PDF/图片上传入口,/api/files 返回统一文件列表,前端使用 allFiles 单一数组管理
2026-06-12 移除配置表单config.json 通过统一上传入口自动解析,配置存入 sessionConfig 对象,前端不再展示配置输入框
2026-06-12 暗色日志窗口替换为 AI 聊天风格窗口SSE 日志以聊天气泡形式展示,支持打字指示器动画

文件结构

src/web/
├── app.py                  # Flask 应用入口,路由、管道编排、日志收集
├── sse_handler.py          # SSE 日志收集器、日志转义工具
├── routes.py               # 路由定义、SSE 端点
├── templates/
│   ├── index.html          # PC 端主界面(上传、处理、编辑、提交)
│   └── mobile_upload.html  # 移动端上传页面(拍照/相册选择)
└── static/
    ├── css/
    │   └── index.css       # 全局样式(上传区、日志面板、可编辑表格)
    └── js/
        ├── index.js        # 入口:初始化 App 全局状态、绑定事件
        ├── state.js        # 全局状态管理App 对象)
        ├── chat.js         # 聊天气泡渲染、文件消息、LLM 流式气泡
        ├── agent.js        # Agent 事件处理、用户输入管理
        ├── process.js      # SSE 连接、事件路由、管道启动
        ├── config.js       # 配置解析、逐项引导
        ├── upload.js       # 文件上传、拖拽处理
        ├── sync.js         # 移动端同步
        └── utils.js        # HTML 转义等工具函数

数据流

graph TD
    A[用户上传文件] --> B[创建 session]
    B --> C[文件写入 uploads/<sid>/]
    C --> D[后台线程执行管道]
    D --> E["extract_invoices()"]
    E --> F["enrich_with_llm()"]
    F --> G["save_csv()"]
    G --> H["结果写入 session 目录"]
    H --> I["invoice_summary.csv"]
    H --> J["invoice_groups.json"]
    H --> K["result.json"]
    H --> L["session.log"]
    E --> M{"判断报销类型"}
    M -->|差旅| N["extract_travel_info()"]
    M -->|普通| O["extract_normal_info()"]
    N --> P["travel_info.json"]
    O --> Q["normal_info.json"]
    K --> R["前端 SSE 轮询 → 显示完成状态"]
    R --> S["前端加载 CSV → 可编辑表格"]
    S --> T["用户修改后保存"]
    T --> U["用户点击提交"]
    U --> V["run_financial_submit()"]
    V --> W["bot 自动填报财务系统"]
    W --> X["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
    W --> Y["普通模式: normal_info.json → 基本信息 → 录入明细 → 支付信息 → 上传附件"]

API 路由

方法 路径 功能
GET / 主界面
POST /api/session 创建会话,返回 session_id
POST /api/upload/<sid> 上传 PDF/图片
GET /api/files/<sid> 列出会话文件(返回统一 files 列表,含 nametypesize 字段;旧字段 pdfs/images 保留向后兼容)
POST /api/agent/process/<sid> 启动 Agent 管道(后台线程)
GET /api/logs/<sid> SSE 日志流
GET /api/data/<sid> 获取发票数据 JSON
POST /api/save/<sid> 保存前端编辑的发票数据
GET /api/download/<sid>/<filename> 下载生成文件
POST /api/submit-financial/<sid> 手动触发财务系统填报
GET /mobile/<sid> 移动端上传页面

关键机制

日志收集

SSELogHandler 将管道日志写入 session.logSSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。

发票类型分流

  • 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
  • 普通发票生成易耗品出库单Word 文档),走普通报销流程

extract_invoices() 在提取阶段完成分类,结果保存为 invoice_groups.json(包含 travel_countgeneral_countapplication_count)。后续步骤(出库单生成、财务填报)统一从该文件读取分类结果,避免重复解析 CSV 和 JSON 字段。

LLM 信息提取2026-06-11

发票提取和匹配完成后,根据类型分流调用 LLM 提取结构化报销信息:

  • 差旅发票:调用 extract_travel_info(),提取出差事由、地点、时间、交通/住宿明细、补助清单、支付方式、附件清单,缓存为 travel_info.json
  • 普通发票:调用 extract_normal_info(),提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 normal_info.json

Bot 填报时优先使用 LLM 提取的信息(travel_info/normal_info),降级时从缓存加载原始发票数据。

移动端同步

PC 端生成二维码指向 /mobile/<sid>,手机端上传的文件通过 syncFiles() 轮询同步到 PC 端内存中的 allFiles 列表,实现跨设备协作。文件来源标记(__source)区分本地选择和服务器同步,避免重复。

配置管理

配置分两层:项目级 config.json 提供默认值,会话级 uploads/<sid>/config.json 存储当次会话覆盖值。前端通过统一上传入口接收 config.json,自动解析到 sessionConfig 对象,不再展示配置输入表单。

聊天气泡消息系统

聊天窗口的所有消息通过 事件文件 + SSE 轮询 机制传输。后端不直接推送消息,而是将事件追加到 session 目录下的日志文件SSE 端点以 0.5 秒间隔轮询文件增量,再通过 EventSource 推送到前端。

事件文件总览

session 目录下有四个事件文件:

文件 用途 写入方 读取方
llm_stream.log LLM 流式输出(思考过程 + 正式回答) llm_extractor 模块 SSE 端点
file_events.log 文件处理进度 extractor 模块 SSE 端点
agent_events.log Agent 状态变更、请求补充等 orchestrator 模块 SSE 端点
session.log 普通 INFO 日志 SSELogHandler SSE 端点(当前仅保留,前端已不做处理)

后端发送事件

所有事件文件遵循相同的写入协议:每行一个 JSON 对象,写入后 flush。

llm_stream.log — 由 _emit_llm_stream(source_dir, phase, ...) 写入:

# 开始 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, ...) 写入:

# 状态变更
_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, ...) 写入:

# 文件开始处理
_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 解析失败")

重要约束:startend 事件不可省略。 前端 llmStreamState 状态机依赖 start 创建气泡 DOM没有 start 时后续的 chunkreasoning 会因守卫条件直接返回。详见 .agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md

SSE 传输层

/api/logs/<sid> 端点(routes.py)的轮询逻辑:

每 0.5 秒:
  1. 读取 session.log 增量 → yield "data: <日志行>"
  2. 读取 file_events.log 增量 → 逐行 yield "data: <JSON>"
  3. 读取 llm_stream.log 增量 → 逐行 yield "data: <JSON>"
  4. 读取 agent_events.log 增量 → 逐行 yield "data: <JSON>"
  5. 检查 result.json 是否存在 → yield "data: {type: 'done'}" 后退出

前端事件路由

process.jsEventSource 监听器按 msg.type 分发:

msg.type === 'file_progress'   → setFileProcessing / setFileDone / setFileCached / setFileError
msg.type === 'llm_stream'      → handleLLMStream()
msg.type 以 'agent_' 开头      → handleAgentEvent()
msg.type === 'done'            → 关闭 EventSource展示结果

前端聊天气泡渲染

LLM 流式气泡chat.js

start  → _createLLMStreamBubble()
         ├─ 创建 <div class="chat-bubble processing llm-stream-bubble">
         ├─ 创建 label 元素(显示 label 文本)
         ├─ 创建 <details> 可折叠区域(思考过程)
         └─ 创建 textContent 元素(正式回答)
         └─ 注册到 llmStreamState

reasoning → _appendLLMStreamReasoning()
            └─ 追加到 reasoningContent.textContent

chunk   → _appendLLMStreamChunk()
          └─ 追加到 textContent.textContent

end     → _closeLLMStreamBubble()
          ├─ 气泡 class 从 processing 变为 done
          └─ 清空 llmStreamState

error   → _errorLLMStreamBubble()
          ├─ 气泡 class 变为 error
          └─ 清空 llmStreamState

Agent 状态气泡agent.js

agent_state_change → _handleAgentStateChange()
                     └─ 更新最后一条状态消息(不追加新气泡)

agent_request_supplement → _handleAgentRequestSupplement()
                           └─ 追加请求补充的气泡

agent_ready → _handleAgentReady()
              └─ 追加完成状态气泡

agent_error → _handleAgentError()
              └─ 追加错误气泡

文件进度气泡chat.js

file_progress (processing) → setFileProcessing() → 三点动画
file_progress (done)       → setFileDone()        → 提取摘要
file_progress (cached)     → setFileCached()      → 缓存标识
file_progress (error)      → setFileError()       → 错误信息

完整数据流

sequenceDiagram
    participant Pipe as 后台线程<br/>(管道)
    participant Files as 事件文件<br/>(session 目录)
    participant SSE as Flask SSE<br/>(routes.py)
    participant ES as EventSource<br/>(process.js)
    participant Chat as chat.js
    participant Agent as agent.js

    Note over Pipe: 启动管道
    Pipe->>Files: 追加 llm_stream start
    Pipe->>Files: 追加 agent_events state_change

    Note over SSE: 0.5s 轮询
    SSE->>Files: seek(last_size) + read()
    SSE->>ES: yield "data: start"
    SSE->>ES: yield "data: state_change"

    ES->>Chat: handleLLMStream({phase:"start"})
    Chat->>Chat: 创建流式气泡
    ES->>Agent: handleAgentEvent({type:"agent_state_change"})
    Agent->>Agent: 显示状态消息

    Note over Pipe: LLM 流式输出
    Pipe->>Files: 追加 llm_stream reasoning
    Pipe->>Files: 追加 llm_stream chunk

    SSE->>Files: seek(last_size) + read()
    SSE->>ES: yield "data: reasoning"
    SSE->>ES: yield "data: chunk"

    ES->>Chat: handleLLMStream({phase:"reasoning"})
    Chat->>Chat: 追加思考内容
    ES->>Chat: handleLLMStream({phase:"chunk"})
    Chat->>Chat: 追加正式回答

    Note over Pipe: 完成
    Pipe->>Files: 追加 llm_stream end
    Pipe->>Files: 写入 result.json

    SSE->>Files: seek(last_size) + read()
    SSE->>ES: yield "data: end"
    ES->>Chat: handleLLMStream({phase:"end"})
    Chat->>Chat: 气泡变完成状态

    SSE->>Files: 检测 result.json
    SSE->>ES: yield "data: {type:'done'}"
    ES->>ES: 关闭连接

添加新消息类型的步骤

  1. 在对应模块定义 _emit_xxx() 函数,写入 session 目录的 JSON 文件
  2. routes.pystream_logs() 轮询循环中新增对该文件的轮询
  3. process.js 的 EventSource 监听器中按 msg.type 路由到新处理器
  4. chat.jsagent.js 中实现渲染逻辑
  5. 确保 startend 事件成对出现(如果是流式气泡)