refactor: 架构重组 — doc/bot → core/infra,新增 Agent 调度模块

- src/doc/ 拆分为 src/core/extraction/, matching/, validation/(核心业务逻辑)
- src/bot/ 重命名为 src/infra/browser/(浏览器自动化基础设施)
- fill_consumable_doc.py → src/infra/documents/consumable.py
- 新增 Agent 调度模块:coordinator.py, events.py, session.py,重构 orchestrator.py
- 更新 AGENTS.md、README.md 及所有子目录 README
This commit is contained in:
wandering
2026-07-02 18:36:19 +08:00
parent e9e0252638
commit 9043886b6f
68 changed files with 2965 additions and 1548 deletions
+41 -38
View File
@@ -1,74 +1,77 @@
---
last_reviewed: 2026-06-12
last_reviewed: 2026-06-15
---
# src — 主源码目录
包含财务报销自动化系统的核心模块。
包含财务报销自动化系统的全部源码模块。
## 架构分层
```
src/
├── agent/ Agent 调度层(协调提取-校验-修正循环)
├── core/ 核心业务层(提取、匹配、校验)
├── infra/ 基础设施层(浏览器、文档、LLM 提示词)
├── web/ Web 界面层(Flask + SSE)
├── pipeline.py CLI 流程编排
├── pipeline_core.py CLI/Web 公共管道逻辑
├── main.py CLI 入口
├── config.py 配置加载
└── exceptions.py 异常定义
```
## 模块清单
| 文件/目录 | 说明 |
|-----------|------|
| `__init__.py` | 包初始化:提供 `get_logger()` 日志工厂(支持终端 + 文件双输出,按日期自动分文件) |
| `config.py` | 配置加载:从 `config.json` 读取用户凭据和默认值,从环境变量读取服务端配置(SSO 地址、LLM 参数) |
| `pipeline.py` | 流程编排:串联发票提取 → 类型判断 → 差旅/普通信息提取 → 浏览器填报,支持分步执行 |
| `main.py` | CLI 入口:支持 `--step` 分步执行、`-u/-p` 覆盖凭据、`--cache-dir` 指定缓存目录 |
| `bot/` | 浏览器自动化:Playwright 驱动的财务系统填报机器人(仅负责接收信息并填报) |
| `doc/` | 文档处理模块:PDF 渲染、LLM 提取、支付匹配、发票分类、出库单生成 |
| `web/` | Web 界面模块:Flask 应用、SSE 日志、可编辑表格、移动端上传、会话隔离 |
| `agent/` | Agent 调度:校验-修正循环、状态机管理、SSE 事件发射 |
| `core/` | 核心业务逻辑:信息提取、金额匹配、信息校验 |
| `infra/` | 基础设施:浏览器自动填报、文档处理、LLM 提示词管理 |
| `web/` | Web 界面:Flask 应用、SSE 日志流、可编辑表格、移动端上传、会话隔离 |
| `pipeline.py` | CLI 流程编排:串联提取 → 类型判断 → 信息提取 → 浏览器填报 |
| `pipeline_core.py` | CLI/Web 公共管道逻辑:发票类型判断、缓存提取 |
| `main.py` | CLI 入口:`--step` 分步执行、`-u/-p` 覆盖凭据 |
| `config.py` | 配置加载:`config.json` + 环境变量 |
| `exceptions.py` | 异常层次定义 |
## 数据流
```mermaid
graph TD
A[CLI/Web 入口] --> B["pipeline.py (编排)"]
B --> C["doc/extractor.py (统一提取入口)"]
C --> D["doc/pdf.py (PDF 渲染为图片)"]
C --> E["doc/llm_extractor.py (多模态 LLM 识别)"]
B --> C["core/extraction/extractor.py (统一提取入口)"]
C --> D["infra/documents/pdf.py (PDF 渲染为图片)"]
C --> E["core/extraction/llm_extractor.py (多模态 LLM 识别)"]
E --> F["发票 invoice_type=train/hotel/general"]
E --> G["支付记录 invoice_type=payment"]
E --> H["出差事前申请单 invoice_type=application"]
C --> I["doc/matcher.py (发票与支付记录按金额匹配)"]
C --> I["core/matching/matcher.py (发票与支付记录按金额匹配)"]
I --> J["一对一匹配 发票数 == 刷卡数"]
I --> K["一对多匹配 贪心算法 相对容差 3%"]
C --> L["doc/invoice.py (CSV/JSON 读写)"]
C --> L["infra/documents/invoice.py (CSV/JSON 读写)"]
L --> M["payment_records.csv (支付记录级别)"]
L --> N["invoice_summary.csv (发票级别)"]
L --> O["travel_applications.json (出差申请单)"]
B --> R{"判断报销类型"}
R -->|差旅| T["doc/llm_extractor.py (差旅信息提取)"]
R -->|普通| V["doc/llm_extractor.py (普通发票信息提取)"]
R -->|差旅| T["core/extraction/llm_extractor.py (差旅信息提取)"]
R -->|普通| V["core/extraction/llm_extractor.py (普通发票信息提取)"]
T --> W["travel_info.json (差旅信息: 交通/住宿明细、补贴、附件清单)"]
V --> X["normal_info.json (普通发票信息: 报销说明、发票总数、总金额、支付方式、附件清单)"]
W --> P["bot/ (浏览器填报 - 仅接收信息并填报)"]
W --> P["infra/browser/ (浏览器填报 - 仅接收信息并填报)"]
X --> P
P --> Q["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
P --> S["普通模式: 基本信息 → 录入明细 → 支付信息 → 上传附件"]
```
**数据流变更(2026-06-11):** 差旅信息提取从 `bot.py` 提升到 `pipeline.py` 编排层。在发票提取和匹配完成后立即判断报销类型,差旅发票调用 LLM 提取 `travel_info.json`,普通发票调用 LLM 提取 `normal_info.json`。Bot 仅负责接收信息并填报,不再承担信息提取职责。
## 子模块文档
## 文档处理子模块 (`doc/`)
详见 [`doc/README.md`](doc/README.md)
核心能力:
- **统一文档提取**:LLM 自行判断文档类型(发票/支付记录/出差事前申请单),无需正则回退
- **JSON 缓存**:提取结果缓存于 `.invoice_cache/`,避免重复处理
- **金额匹配**:支持一对多匹配,相对容差 3%,未匹配发票单独列为记录
- **差旅信息提取**:综合发票、支付记录和匹配结果,提取出差事由、地点、时间等
- **普通发票信息提取**:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单
- **出库单生成**:将 CSV 数据填入 Word 模板(pywin32 COM,仅 Windows)
## Web 界面子模块 (`web/`)
详见 [`web/README.md`](web/README.md)
核心能力:
- **会话隔离**:每次上传生成独立 `session_id`,文件/日志/配置/结果各自隔离
- **移动端同步**:PC 端生成二维码指向 `/mobile/<sid>`,跨设备协作上传
- **可编辑表格**:前端加载 CSV 数据,支持在线编辑后保存
| 目录 | 文档 |
|------|------|
| `agent/` | [`agent/README.md`](agent/README.md) |
| `core/` | [`core/README.md`](core/README.md) |
| `infra/` | [`infra/README.md`](infra/README.md) |
| `web/` | [`web/README.md`](web/README.md) |
## 启动方式
+410
View File
@@ -0,0 +1,410 @@
"""Agent 协调器
作为调度中枢,编排信息提取、规则校验的完整流程。
校验-修正循环由 Agent 层调度:
1. Agent 调用 LLM 提取信息
2. Agent 调用 validator.py 校验
3. 校验失败则构建修正提示,再次调用 LLM
4. 重复直到校验通过或达到最大重试次数
5. LLM 在输出中包含 can_submit 和 suggestion 字段,用于判断信息完整性
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .. import get_logger
from ..core.extraction import (
build_extraction_user_message,
llm_query_text,
load_cache,
load_match_result,
merge_supplement_into_info,
parse_json_response,
process_user_supplement,
)
from ..core.validation import validate_extracted_info
from ..infra.llm import (
build_normal_info_system_prompt,
build_travel_info_system_prompt,
)
from ..pipeline_core import save_cache_info
from .events import emit_agent_event
from .session import AgentSession, AgentState, save_agent_state
log = get_logger("agent.coordinator")
# 规则校验-修正循环的最大重试次数
MAX_VALIDATION_RETRIES = 3
# ------------------------------------------------------------------
# 辅助:构建修正提示
# ------------------------------------------------------------------
def _build_correction_prompt(
base_message: str,
report: Any,
) -> str:
"""根据校验报告构建修正提示,追加到原始用户消息后。"""
error_feedback = (
f"\n\n=== 上一次输出的校验结果 ===\n"
f"校验未通过,发现以下问题:\n"
f"缺失字段 ({len(report.missing_fields)} 个):{', '.join(report.missing_fields)}\n"
)
if report.missing_materials:
error_feedback += f"可能需要补充的材料:{', '.join(report.missing_materials)}\n"
if report.suggestion:
error_feedback += f"建议:{report.suggestion}\n"
error_feedback += (
"\n请根据以上校验结果修正你的输出,确保所有必填字段都有值。"
"如果某个字段确实没有数据,请给出合理的猜测值。"
"再次返回完整的 JSON 结果。"
)
return base_message + error_feedback
# ------------------------------------------------------------------
# 核心协调逻辑
# ------------------------------------------------------------------
def _do_extraction_with_validation(
session_dir: Path,
session: AgentSession,
previous_analysis: dict[str, Any] | None = None,
cache_map: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Agent 调度的提取-校验-修正循环。
流程:
1. 加载缓存数据和匹配结果
2. 构建用户消息
3. 调用 LLM 提取
4. 调用 validator 校验
5. 校验失败则构建修正提示,回到步骤 3
6. 最多重试 MAX_VALIDATION_RETRIES 次
LLM 输出的 JSON 中额外包含 can_submit 和 suggestion 字段,
用于判断信息是否完整可提交。
Args:
session_dir: 会话目录。
session: 当前 Agent 会话。
previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。
Returns:
校验通过的结构化数据(或达到重试上限后的最佳结果)。
"""
cache_map = cache_map or load_cache(session_dir)
match_result = load_match_result(session_dir)
# 选择系统提示词
if session.invoice_type == "travel":
system_prompt = build_travel_info_system_prompt()
else:
system_prompt = build_normal_info_system_prompt()
base_message = build_extraction_user_message(cache_map, match_result, previous_analysis=previous_analysis)
current_message = base_message
for attempt in range(1, MAX_VALIDATION_RETRIES + 1):
log.info(
"LLM 提取第 %d/%d 次尝试 (%s)",
attempt,
MAX_VALIDATION_RETRIES,
session.invoice_type,
)
emit_agent_event(
session_dir,
"agent_state_change",
state=AgentState.EXTRACTING,
round=session.rounds,
attempt=attempt,
message=f"正在分析文件... (第{attempt}次)",
)
# Step 1: 调用 LLM 提取
try:
response = llm_query_text(
system_prompt=system_prompt,
text=current_message,
reasoning_effort="low",
source_dir=session_dir,
)
result = parse_json_response(response)
except Exception as e:
log.error("LLM 提取失败: %s", e)
emit_agent_event(
session_dir,
"agent_error",
message=f"LLM 提取失败: {e}",
)
raise
# Step 2: 调用 validator 校验
report = validate_extracted_info(result, invoice_type=session.invoice_type)
if report.valid:
log.info("规则校验通过 (第 %d 次尝试)", attempt)
emit_agent_event(
session_dir,
"agent_extract_status",
state=AgentState.EXTRACTING,
round=session.rounds,
attempt=attempt,
message=f"规则校验通过 (第{attempt}次)",
)
return result
# Step 3: 校验失败,构建修正提示
log.warning(
"规则校验未通过 (第 %d/%d 次): 缺失 %d 个字段 - %s",
attempt,
MAX_VALIDATION_RETRIES,
len(report.missing_fields),
report.missing_fields,
)
emit_agent_event(
session_dir,
"agent_extract_status",
state=AgentState.EXTRACTING,
round=session.rounds,
attempt=attempt,
message=f"规则校验未通过,缺失 {len(report.missing_fields)} 个字段,正在请求 LLM 修正...",
)
current_message = _build_correction_prompt(current_message, report)
# 所有重试都失败,返回最后一次结果
log.error(
"LLM 提取经过 %d 次尝试仍未通过规则校验,返回最后一次结果 (置信度: %.0f%%)",
MAX_VALIDATION_RETRIES,
report.confidence * 100,
)
return result
def run_agent_round(
session_dir: Path,
session: AgentSession,
new_files: list[str] | None = None,
) -> AgentSession:
"""执行一轮 Agent 处理:提取-校验-修正循环。
Args:
session_dir: 会话目录。
session: 当前 Agent 会话。
new_files: 新增的文件列表(可选,补充文件时传入)。
Returns:
更新后的 Agent 会话。
注意:
- 信息提取优先从 .invoice_cache 缓存读取,避免重复调用 LLM。
- Agent 调度提取-校验-修正循环:LLM 提取 -> validator 校验 -> 失败则反馈修正。
- 若缓存缺失则执行提取后立即写回缓存(travel_info.json / normal_info.json)。
- 补充文件时(new_files 非空),加载上一轮分析结果作为历史上下文,强制重新分析。
"""
# 终态保护:会话已提交或已完成时不再重复处理
if session.is_terminal():
log.info("Agent 会话已处于终态 (%s),跳过重复处理", session.state.value)
return session
if session.rounds >= session.max_rounds:
session.state = AgentState.ERROR
session.error_message = f"已达到最大轮次 ({session.max_rounds}),请检查信息或强制提交"
log.warning("Agent 达到最大轮次限制")
emit_agent_event(
session_dir,
"agent_max_rounds",
message=session.error_message,
)
return session
session.rounds += 1
log.info("开始第 %d 轮 Agent 处理", session.rounds)
# ---- Step 1: 信息提取(Agent 调度校验-修正循环) ----
session.state = AgentState.EXTRACTING
# 判断是否为补充文件场景:有新文件传入时,加载上一轮分析结果作为上下文
cache_map = load_cache(session_dir)
is_supplement = bool(new_files)
previous_analysis = None
if is_supplement:
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
previous_analysis = cache_map.get(info_key)
if previous_analysis:
log.info("检测到补充文件,加载上一轮分析结果作为历史上下文")
try:
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
should_reanalyze = not cache_map.get(info_key) or is_supplement
if should_reanalyze:
session.extracted_info = _do_extraction_with_validation(
session_dir, session, previous_analysis=previous_analysis, cache_map=cache_map
)
# 提取后立即写入缓存,后续步骤依赖此数据
save_cache_info(session_dir, info_key, session.extracted_info)
else:
session.extracted_info = cache_map[info_key]
emit_agent_event(
session_dir,
"agent_state_change",
state=session.state,
round=session.rounds,
message="使用缓存数据,无需重新分析",
)
except Exception as e:
session.state = AgentState.ERROR
session.error_message = f"信息提取失败: {e}"
log.error("Agent 信息提取失败: %s", e)
emit_agent_event(
session_dir,
"agent_error",
message=session.error_message,
)
return session
# ---- 判断结果(从 LLM 提取结果中读 can_submit) ----
can_submit = session.extracted_info.get("can_submit", True)
suggestion = session.extracted_info.get("suggestion", "")
if can_submit:
session.state = AgentState.READY
emit_agent_event(
session_dir,
"agent_ready",
round=session.rounds,
message="信息完整,可以提交",
)
log.info("Agent 校验通过,信息完整")
else:
session.state = AgentState.AWAITING_SUPPLEMENT
combined_suggestion = suggestion or "信息不完整,请补充材料"
emit_agent_event(
session_dir,
"agent_request_supplement",
round=session.rounds,
missing_fields=[],
missing_materials=[],
semantic_issues=[],
suggestion=combined_suggestion,
)
log.info("Agent 请求补充: %s", combined_suggestion)
save_agent_state(session_dir, session)
return session
def force_submit(
session_dir: Path,
session: AgentSession,
) -> AgentSession:
"""用户强制提交,跳过校验。"""
session.state = AgentState.READY
log.info("用户强制提交,跳过校验")
emit_agent_event(
session_dir,
"agent_force_submit",
message="用户选择强制提交",
)
save_agent_state(session_dir, session)
return session
def add_supplement(
session_dir: Path,
session: AgentSession,
filenames: list[str],
) -> AgentSession:
"""记录用户补充的文件。"""
session.user_supplements.extend(filenames)
emit_agent_event(
session_dir,
"agent_supplement_received",
files=filenames,
)
log.info("收到用户补充文件: %s", filenames)
save_agent_state(session_dir, session)
return session
def process_user_text_supplement(
session_dir: Path,
session: AgentSession,
user_text: str,
) -> AgentSession:
"""处理用户通过文字补充的信息。
流程:
1. LLM 分析用户文字,提取需要更新的字段
2. 合并到已提取的信息中
3. 保存到缓存
4. 重新执行一轮 Agent 校验
Args:
session_dir: 会话目录。
session: 当前 Agent 会话。
user_text: 用户输入的文字。
Returns:
更新后的 Agent 会话。
"""
log.info("收到用户文字补充: %s", user_text)
emit_agent_event(
session_dir,
"agent_supplement_received",
files=[user_text[:50]], # 简短显示
)
# Step 1: LLM 分析用户文字
supplement_result = process_user_supplement(
user_text=user_text,
extracted_info=session.extracted_info,
invoice_type=session.invoice_type,
source_dir=session_dir,
)
updated_fields = supplement_result.get("updated_fields", {})
unparsed = supplement_result.get("unparsed_info", "")
if updated_fields:
# Step 2: 合并到已提取信息
session.extracted_info = merge_supplement_into_info(
session.extracted_info,
updated_fields,
)
# Step 3: 保存到缓存
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
save_cache_info(session_dir, info_key, session.extracted_info)
log.info("已更新 %s", info_key)
# Step 4: 重新执行 Agent 校验
session.state = AgentState.EXTRACTING
emit_agent_event(
session_dir,
"agent_state_change",
state=session.state,
round=session.rounds,
message="正在重新校验...",
)
session = run_agent_round(session_dir, session)
else:
# 没有可更新的字段
msg = unparsed or "未识别到可更新的报销信息"
emit_agent_event(
session_dir,
"agent_supplement_received",
files=[msg],
)
log.info("用户补充未识别到有效信息: %s", msg)
return session
+75
View File
@@ -0,0 +1,75 @@
"""Agent 事件系统
负责 SSE 事件的发射和管理,用于实时通知前端状态变化。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .. import get_logger
log = get_logger("agent.events")
AGENT_EVENT_LOG = "agent_events.log"
# 去重守卫:记录每个 session 上一次发射的事件类型,防止连续重复发射
# key: str(session_dir), value: 上一次的 event_type
_last_event_type: dict[str, str] = {}
def emit_agent_event(session_dir: Path, event_type: str, **kwargs: Any) -> None:
"""向 agent_events.log 追加一行 JSON 事件。
同一 session 连续发射相同 event_type 时直接抛出 RuntimeError,
强制调用方修复重复发射的代码,而非静默掩盖。
注意:agent_state_change 会在同一轮提取中多次发射不同消息
("正在分析"、"校验通过"、"校验失败,请求修正"),这是合法行为。
去重守卫仅检查 event_type 字符串是否完全相同,不检查 kwargs。
因此不要在同一个 event_type 下连续发射不同消息,应使用不同的事件类型。
"""
session_key = str(session_dir)
prev = _last_event_type.get(session_key)
if prev == event_type:
raise RuntimeError(
f"事件重复发射: session={session_dir.name!r}, event_type={event_type!r}。"
f"请检查调用链,确保每个事件类型只发射一次。"
)
_last_event_type[session_key] = event_type
event = {"type": event_type, **kwargs}
try:
event_path = session_dir / AGENT_EVENT_LOG
with open(event_path, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception:
pass
def clear_event_history(session_dir: Path) -> None:
"""清除指定会话的事件历史。"""
session_key = str(session_dir)
_last_event_type.pop(session_key, None)
event_path = session_dir / AGENT_EVENT_LOG
if event_path.exists():
event_path.unlink()
def read_events(session_dir: Path) -> list[dict[str, Any]]:
"""读取指定会话的所有事件记录。"""
event_path = session_dir / AGENT_EVENT_LOG
if not event_path.exists():
return []
events = []
try:
with open(event_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
events.append(json.loads(line))
except Exception as e:
log.warning("读取事件日志失败: %s", e)
return events
+34 -517
View File
@@ -1,529 +1,46 @@
"""Agent 协调器
"""Agent 协调器(兼容层)
作为调度中枢,编排信息提取、规则校验的完整流程。
此文件为向后兼容而保留,所有功能已迁移到以下子模块:
- session.py: 会话状态定义和持久化
- events.py: SSE 事件发射系统
- coordinator.py: 核心协调逻辑
校验-修正循环由 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 (
build_extraction_user_message,
llm_query_text,
load_cache,
load_match_result,
parse_json_response,
# 从新模块重新导出所有符号,保持向后兼容
from .coordinator import (
add_supplement,
force_submit,
process_user_text_supplement,
run_agent_round,
)
from ..doc.prompt import (
build_normal_info_system_prompt,
build_travel_info_system_prompt,
from .events import emit_agent_event as _emit_agent_event
from .session import (
AgentSession,
AgentState,
load_agent_state,
save_agent_state,
)
from ..doc.validator import validate_extracted_info
from ..pipeline_core import save_cache_info
log = get_logger("agent")
# 为旧代码提供兼容的私有函数
_emit_agent_event = _emit_agent_event
# 规则校验-修正循环的最大重试次数
# 常量保持不变
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"
# 去重守卫:记录每个 session 上一次发射的事件类型,防止连续重复发射
# key: str(session_dir), value: 上一次的 event_type
_last_event_type: dict[str, str] = {}
def _emit_agent_event(session_dir: Path, event_type: str, **kwargs: Any) -> None:
"""向 agent_events.log 追加一行 JSON 事件。
同一 session 连续发射相同 event_type 时直接抛出 RuntimeError,
强制调用方修复重复发射的代码,而非静默掩盖。
"""
session_key = str(session_dir)
prev = _last_event_type.get(session_key)
if prev == event_type:
raise RuntimeError(
f"事件重复发射: session={session_dir.name!r}, event_type={event_type!r}。"
f"请检查调用链,确保每个事件类型只发射一次。"
)
_last_event_type[session_key] = event_type
event = {"type": event_type, **kwargs}
try:
event_path = session_dir / AGENT_EVENT_LOG
with open(event_path, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception:
pass
# ------------------------------------------------------------------
# 辅助:构建修正提示
# ------------------------------------------------------------------
def _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:
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
should_reanalyze = not cache_map.get(info_key) or is_supplement
if should_reanalyze:
session.extracted_info = _do_extraction_with_validation(
session_dir, session, previous_analysis=previous_analysis
)
# 提取后立即写入缓存,后续步骤依赖此数据
save_cache_info(session_dir, info_key, session.extracted_info)
else:
session.extracted_info = cache_map[info_key]
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: 保存到缓存
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
save_cache_info(session_dir, info_key, session.extracted_info)
log.info("已更新 %s", info_key)
# Step 4: 重新执行 Agent 校验
session.state = AgentState.EXTRACTING
_emit_agent_event(
session_dir,
"agent_state_change",
state=session.state,
round=session.rounds,
message="正在重新校验...",
)
session = run_agent_round(session_dir, session)
else:
# 没有可更新的字段
msg = unparsed or "未识别到可更新的报销信息"
_emit_agent_event(
session_dir,
"agent_supplement_received",
files=[msg],
)
log.info("用户补充未识别到有效信息: %s", msg)
return session
__all__ = [
"AgentSession",
"AgentState",
"save_agent_state",
"load_agent_state",
"run_agent_round",
"force_submit",
"add_supplement",
"process_user_text_supplement",
"MAX_VALIDATION_RETRIES",
"AGENT_STATE_FILE",
"AGENT_EVENT_LOG",
]
+101
View File
@@ -0,0 +1,101 @@
"""Agent 会话管理
负责会话状态的定义、序列化和持久化。
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any
from .. import get_logger
log = get_logger("agent.session")
# ------------------------------------------------------------------
# 状态枚举
# ------------------------------------------------------------------
class AgentState(StrEnum):
IDLE = "idle"
EXTRACTING = "extracting"
AWAITING_SUPPLEMENT = "awaiting_supplement"
READY = "ready"
SUBMITTING = "submitting"
DONE = "done"
ERROR = "error"
# ------------------------------------------------------------------
# Agent 会话数据模型
# ------------------------------------------------------------------
@dataclass
class AgentSession:
"""Agent 会话状态"""
session_id: str
state: AgentState = AgentState.IDLE
rounds: int = 0
max_rounds: int = 5
invoice_type: str = "travel" # "travel" 或 "normal"
extracted_info: dict[str, Any] = field(default_factory=dict)
validation_reports: list[dict[str, Any]] = field(default_factory=list)
user_supplements: list[str] = field(default_factory=list)
error_message: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AgentSession:
# 兼容旧版本:state 可能是字符串
if "state" in data and isinstance(data["state"], str):
data["state"] = AgentState(data["state"])
return cls(**data)
def is_terminal(self) -> bool:
"""判断会话是否处于终态(已提交或已完成)"""
return self.state in (AgentState.DONE, AgentState.SUBMITTING, AgentState.READY)
# ------------------------------------------------------------------
# 持久化
# ------------------------------------------------------------------
AGENT_STATE_FILE = "agent_state.json"
def save_agent_state(session_dir: Path, session: AgentSession) -> None:
"""将 Agent 会话状态持久化到 session 目录。"""
state_path = session_dir / AGENT_STATE_FILE
tmp_path = session_dir / (AGENT_STATE_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(session.to_dict(), f, ensure_ascii=False, indent=2)
tmp_path.replace(state_path)
def load_agent_state(session_dir: Path) -> AgentSession | None:
"""从 session 目录加载 Agent 会话状态。"""
state_path = session_dir / AGENT_STATE_FILE
if not state_path.exists():
return None
try:
with open(state_path, encoding="utf-8") as f:
return AgentSession.from_dict(json.load(f))
except Exception as e:
log.warning("加载 Agent 状态失败: %s", e)
return None
def create_agent_session(session_id: str, invoice_type: str = "travel") -> AgentSession:
"""创建新的 Agent 会话。"""
return AgentSession(
session_id=session_id,
invoice_type=invoice_type,
)
-33
View File
@@ -1,33 +0,0 @@
---
last_reviewed: 2026-06-12
---
# bot — 浏览器自动化填报模块
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
## 模块清单
| 文件 | 说明 |
|------|------|
| `__init__.py` | 对外入口:`run_bot()` 和 `run_bot_web()`,负责类型判断和流程路由 |
| `base.py` | `BaseBot` 基类:浏览器生命周期、登录、导航、截图、日期格式化 |
| `travel.py` | 差旅报销填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `normal.py` | 普通发票报销填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
## 架构设计
```
run_bot(config, travel_info, normal_info)
├── 创建 BaseBot,启动浏览器,登录门户
├── travel_info 存在 → travel.run(bot, travel_info)
└── normal_info 存在 → normal.run(bot, normal_info)
```
- **`BaseBot`** 只保留公共操作(launch、login、navigate、create_new_form、close、screenshot)
- **差旅/普通流程** 作为独立函数接受 `bot: BaseBot` 参数,符合函数式编程偏好
- **`__init__.py`** 仅做路由分发,不包含具体填报逻辑
## 变更历史
- **2026-06-12**:从 `bot.py` 单文件重构为 `bot/` 包,分离差旅和普通报销逻辑
-97
View File
@@ -1,97 +0,0 @@
"""
浏览器自动化填报
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
对外接口:
run_bot(config, travel_info, normal_info) 启动浏览器并执行填报流程
run_bot_web(config, work_dir) Web 模式填报(从缓存加载信息)
"""
from pathlib import Path
from typing import Any
from .. import get_logger
from .base import BaseBot
log = get_logger("bot")
def run_bot(
config: dict[str, Any],
headless: bool = False,
work_dir: Path | None = None,
travel_info: dict[str, Any] | None = None,
normal_info: dict[str, Any] | None = None,
) -> None:
"""执行完整的浏览器填报流程,根据发票类型自动路由
Args:
config: 配置字典。
headless: 是否无头模式。
work_dir: 工作目录。
travel_info: 差旅信息(由 pipeline 层提前提取并传入,非差旅时传 None)。
normal_info: 普通发票信息(由 pipeline 层提前提取并传入,非普通时传 None)。
"""
if not config["username"] or not config["password"]:
raise ValueError("缺少用户名或密码")
if not work_dir:
raise ValueError("缺少工作目录")
bot = BaseBot(config, headless=headless)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
if travel_info is not None:
log.info("处理差旅发票...")
bot.navigate_to_reimburse(page_key="travel_page")
bot.create_new_form()
from . import travel
travel.run(bot, travel_info)
elif normal_info is not None:
log.info("处理普通发票...")
bot.navigate_to_reimburse(page_key="reimburse_page")
bot.create_new_form()
from . import normal
normal.run(bot, normal_info)
else:
raise ValueError("缺少差旅信息(travel_info)和普通发票信息(normal_info),无法继续填报")
except Exception as e:
log.error(f"操作失败: {e}")
try:
bot._screenshot("error")
except Exception:
pass
raise
finally:
bot.close()
def run_bot_web(config: dict[str, Any], work_dir: Path) -> None:
"""Web 模式填报 — headless,附件从指定目录读取
Web 端的信息提取由 app.py 的管道负责,此处从缓存加载。
"""
from ..doc.llm_extractor import load_cache
cache_map = load_cache(work_dir)
travel_info = cache_map.get("travel_info")
normal_info = cache_map.get("normal_info")
if travel_info is None and normal_info is None:
raise ValueError(f"缓存中未找到 travel_info 或 normal_info,请先执行发票提取流程。工作目录: {work_dir}")
run_bot(
config,
headless=True,
work_dir=work_dir,
travel_info=travel_info,
normal_info=normal_info,
)
+21
View File
@@ -0,0 +1,21 @@
---
last_reviewed: 2026-06-15
---
# src/core — 核心业务逻辑
项目的核心业务层,负责信息提取、金额匹配和信息校验。此层不依赖 Web 框架或浏览器自动化等基础设施。
## 子模块
| 目录 | 说明 |
|------|------|
| `extraction/` | 文档信息提取:PDF/图片 → LLM 多模态识别 → 结构化数据 |
| `matching/` | 发票与支付记录按金额匹配(一对一 / 一对多) |
| `validation/` | 声明式信息完整性校验,规则从 JSON 配置文件加载 |
## 设计原则
- **零外部依赖**:不依赖 Flask、Playwright 等框架
- **接口契约**:每个子模块通过 `__init__.py` 导出稳定的对外接口
- **错误传播**:明确的异常层次,便于上层统一处理
+4
View File
@@ -0,0 +1,4 @@
"""核心业务逻辑模块
提供信息提取、规则校验和发票匹配功能。
"""
+29
View File
@@ -0,0 +1,29 @@
---
last_reviewed: 2026-06-15
---
# src/core/extraction — 信息提取
从 PDF 发票和图片中提取结构化数据,是系统数据流的起点。
## 文件
| 文件 | 职责 |
|------|------|
| `extractor.py` | 编排入口:扫描目录 → 逐文件提取 → 分类(发票/支付记录/申请单)→ 金额匹配 |
| `llm_extractor.py` | LLM 多模态提取核心:统一文档提取、差旅/普通信息提取、缓存管理、SSE 流式事件 |
## 对外接口
| 函数 | 说明 |
|------|------|
| `extract_invoices(directory)` | 统一提取入口,返回 `(payment_records, applications, groups)` |
| `extract_document(file_path)` | 从单个图片/PDF 提取信息 |
| `extract_travel_info(source_dir)` | 综合发票和匹配结果提取差旅信息 |
| `extract_normal_info(source_dir)` | 提取普通发票报销信息 |
| `load_cache(source_dir)` | 加载缓存的结构化数据 |
| `llm_query_text(...)` | 纯文本 LLM 查询(供 Agent 调度使用) |
## 缓存机制
提取结果缓存在 `.invoice_cache/` 目录中,文件名与源文件同名(`发票1.pdf` → `.invoice_cache/发票1.json`),避免重复调用 LLM。
+42
View File
@@ -0,0 +1,42 @@
"""信息提取模块
提供发票/文档结构化提取、LLM 辅助提取等功能。
"""
from .extractor import (
EXTRACTION_PARALLEL_COUNT,
FILE_EVENTS_LOG,
SUPPORTED_EXTENSIONS,
extract_invoices,
)
from .llm_extractor import (
CACHE_DIR_NAME,
build_extraction_user_message,
extract_document,
extract_normal_info,
extract_travel_info,
llm_query_text,
load_cache,
load_match_result,
merge_supplement_into_info,
parse_json_response,
process_user_supplement,
)
__all__ = [
"CACHE_DIR_NAME",
"build_extraction_user_message",
"extract_document",
"extract_normal_info",
"extract_travel_info",
"load_cache",
"load_match_result",
"llm_query_text",
"merge_supplement_into_info",
"parse_json_response",
"process_user_supplement",
"EXTRACTION_PARALLEL_COUNT",
"FILE_EVENTS_LOG",
"SUPPORTED_EXTENSIONS",
"extract_invoices",
]
@@ -20,20 +20,25 @@ SSE 文件进度事件:
"""
import json
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
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 ... import get_logger
from ...core.matching import match_invoices_to_cards
from ...exceptions import ExtractionError
from ...infra.documents.invoice import CACHE_DIR_NAME, classify_invoice_batch
from .llm_extractor import extract_document
from .matcher import match_invoices_to_cards
log = get_logger("extractor")
# SSE 文件进度事件日志文件名
FILE_EVENTS_LOG = "file_events.log"
# 并行提取文件数,可通过环境变量 EXTRACTION_PARALLEL_COUNT 配置
EXTRACTION_PARALLEL_COUNT = int(os.environ.get("EXTRACTION_PARALLEL_COUNT", "3"))
# 支持的文件扩展名
SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".webp"}
@@ -285,28 +290,39 @@ def extract_invoices(
# 记录失败文件及其错误信息
failed_files: list[tuple[str, str]] = []
for file_path in all_files:
result, err = _extract_document(file_path, cache_dir, source_dir)
max_workers = max(1, EXTRACTION_PARALLEL_COUNT)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_file = {executor.submit(_extract_document, fp, cache_dir, source_dir): fp for fp in all_files}
if not result:
if err:
failed_files.append((file_path.name, err))
log.warning(f"未能解析: {file_path.name}")
continue
for future in as_completed(future_to_file):
file_path = future_to_file[future]
try:
result, err = future.result()
except Exception as e:
err_msg = str(e)
failed_files.append((file_path.name, err_msg))
log.warning(f"未能解析: {file_path.name} ({err_msg})")
continue
inv_type = result.get("invoice_type", "")
if not result:
if err:
failed_files.append((file_path.name, err))
log.warning(f"未能解析: {file_path.name}")
continue
if inv_type == "application":
applications.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif inv_type == "payment":
all_cards.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif result.get("invoice_number"):
all_invoices.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
else:
log.warning(f"无法分类: {file_path.name} (invoice_type={inv_type})")
inv_type = result.get("invoice_type", "")
if inv_type == "application":
applications.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif inv_type == "payment":
all_cards.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif result.get("invoice_number"):
all_invoices.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
else:
log.warning(f"无法分类: {file_path.name} (invoice_type={inv_type})")
# 全部文件提取失败时抛出异常,携带原始错误信息
if failed_files and not all_invoices and not all_cards and not applications:
@@ -38,9 +38,9 @@ import json
from pathlib import Path
from typing import Any, cast
from .. import get_logger
from .invoice import CACHE_DIR_NAME
from .prompt import (
from ... import get_logger
from ...infra.documents.invoice import CACHE_DIR_NAME
from ...infra.llm.prompt import (
build_invoice_system_prompt,
build_normal_info_system_prompt,
build_supplement_system_prompt,
@@ -91,7 +91,7 @@ def _create_llm() -> Any:
log.error("缺少 llama-index-llms-openai-like,请执行: uv pip install llama-index-llms-openai-like")
raise
from ..config import get_llm_config
from ...config import get_llm_config
llm_config = get_llm_config()
return OpenAILike(
@@ -213,7 +213,7 @@ def llm_query_text(
from llama_index.core.base.llms.types import TextBlock
from llama_index.core.llms import ChatMessage
from ..config import get_llm_config
from ...config import get_llm_config
messages = [
ChatMessage(role="system", content=system_prompt),
@@ -242,7 +242,7 @@ def extract_document(file_path: Path) -> dict[str, Any]:
Returns:
包含提取字段的字典。
"""
from .pdf import render_pdf_to_images
from ...infra.documents.pdf import render_pdf_to_images
system_prompt = build_invoice_system_prompt()
user_text = f"请分析以下财务文档并提取信息:\n\n文件名: {file_path.name}"
@@ -291,7 +291,7 @@ def _llm_query_multimodal(
from llama_index.core.base.llms.types import ImageBlock, TextBlock
from llama_index.core.llms import ChatMessage
from ..config import get_llm_config
from ...config import get_llm_config
if blocks is not None:
final_blocks = blocks
+27
View File
@@ -0,0 +1,27 @@
---
last_reviewed: 2026-06-15
---
# src/core/matching — 金额匹配
将提取到的发票数据与支付记录(刷卡截图)按金额进行匹配。
## 文件
| 文件 | 职责 |
|------|------|
| `matcher.py` | 匹配引擎:一对一匹配、一对多贪心匹配、未匹配发票处理 |
## 匹配策略
| 场景 | 策略 |
|------|------|
| 发票数 == 支付记录数 | 一对一匹配:按金额降序配对,相对容差内即匹配 |
| 发票数 > 支付记录数 | 一对多匹配:贪心算法凑金额,相对容差 3% |
| 文件名匹配 | 最高优先级:文件名(不含后缀)一致时直接匹配 |
| 未匹配发票 | 单独列为一条支付记录,`remark` 标记为 `"unmatched"` |
## 业务约束
- 发票总金额 >= 支付总金额
- 输出以支付记录为主键的结果列表
+8
View File
@@ -0,0 +1,8 @@
"""匹配模块
提供发票与支付记录的金额匹配功能。
"""
from .matcher import match_invoices_to_cards
__all__ = ["match_invoices_to_cards"]
@@ -49,7 +49,7 @@
from pathlib import Path
from typing import Any
from .. import get_logger
from ... import get_logger
log = get_logger("matcher")
+34
View File
@@ -0,0 +1,34 @@
---
last_reviewed: 2026-06-15
---
# src/core/validation — 信息校验
对 LLM 提取的报销信息进行声明式规则校验,判断是否满足填报要求。
## 文件
| 文件 | 职责 |
|------|------|
| `validator.py` | 校验引擎:加载 JSON 规则配置 → 遍历字段/数组 → 输出校验报告 |
## 设计特点
- **规则与引擎分离**:校验规则存储在 `config/validation_rules.json`,引擎只负责执行
- **统一路径定位**:使用 `path` 列表定位嵌套字段,如 `["basic_info", "travel_purpose"]`
- **自定义校验**:支持 `custom_check` 函数(日期格式、正数检查等)
- **数组元素校验**:支持 `min_items` 最小数量 + 每个元素的必填字段
## 校验规则类型
| 类型 | 用途 | 配置项 |
|------|------|--------|
| `fields` | 顶层单值字段 | `path`, `required`, `custom_check`, `check_empty` |
| `arrays` | 数组字段 | `path`, `min_items`, `element_fields` |
## 对外接口
| 函数 | 说明 |
|------|------|
| `validate(info, invoice_type)` | 执行校验,返回 `ValidationReport` |
| `get_missing_fields(report)` | 提取缺失字段列表 |
+28
View File
@@ -0,0 +1,28 @@
"""校验模块
提供报销信息的规则级校验功能。
"""
from .validator import (
ArrayRule,
FieldRule,
ValidationReport,
ValidationRules,
get_validation_rules,
reload_validation_rules,
validate_extracted_info,
validate_normal_info,
validate_travel_info,
)
__all__ = [
"validate_extracted_info",
"validate_travel_info",
"validate_normal_info",
"ValidationReport",
"FieldRule",
"ArrayRule",
"ValidationRules",
"get_validation_rules",
"reload_validation_rules",
]
+537
View File
@@ -0,0 +1,537 @@
"""信息完整性校验器
对 LLM 提取的报销信息进行规则级校验,判断是否满足填报要求。
校验规则从 JSON 配置文件加载,支持声明式配置。
设计理念:
使用声明式规则配置,将校验规则与校验逻辑分离,提高可读性和可维护性。
"""
from __future__ import annotations
import json
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, TypedDict
from ... import get_logger
log = get_logger("validator")
# ------------------------------------------------------------------
# 日期格式
# ------------------------------------------------------------------
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _is_valid_date(value: str) -> bool:
"""检查日期格式是否为 YYYY-MM-DD。"""
return bool(DATE_PATTERN.match(value))
def _is_positive_number(value: Any) -> bool:
"""检查值是否为正数(整数或浮点数)。"""
return isinstance(value, int | float) and value > 0
def _is_positive_integer(value: Any) -> bool:
"""检查值是否为正整数。"""
return isinstance(value, int) and value > 0
# 自定义校验函数注册表
_CUSTOM_CHECKS: dict[str, Callable[[Any], bool]] = {
"is_valid_date": _is_valid_date,
"is_positive_number": _is_positive_number,
"is_positive_integer": _is_positive_integer,
}
# ------------------------------------------------------------------
# 规则定义
# ------------------------------------------------------------------
class FieldRule(TypedDict, total=False):
"""字段校验规则(统一使用 path 定位)"""
path: list[str] # 字段路径(统一定位方式)
required: bool = True # 是否必填(默认必填)
check_empty: bool = True # 是否检查空字符串(默认检查)
custom_check: str | Callable[[Any], bool] | None = None # 自定义校验函数(名称或函数)
description: str = "" # 字段描述(用于生成友好提示)
class ArrayRule(TypedDict, total=False):
"""数组校验规则"""
path: list[str] # 数组路径
min_items: int = 1 # 最小元素数量
element_fields: list[str | FieldRule] = [] # 元素字段规则
description: str = "" # 数组描述
class ValidationRules(TypedDict):
"""校验规则集合"""
fields: list[FieldRule] # 字段规则列表
arrays: list[ArrayRule] # 数组规则列表
class ValidationConfig(TypedDict):
"""校验配置结构"""
version: str
custom_checks: dict[str, str]
travel: ValidationRules
normal: ValidationRules
# ------------------------------------------------------------------
# 配置加载
# ------------------------------------------------------------------
_CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / "validation_rules.json"
_cached_rules: ValidationConfig | None = None
def _load_validation_config() -> ValidationConfig:
"""加载校验规则配置文件。"""
global _cached_rules
if _cached_rules is not None:
return _cached_rules
if not _CONFIG_PATH.exists():
log.warning("校验规则配置文件不存在: %s,使用内置默认规则", _CONFIG_PATH)
return _load_default_rules()
try:
with open(_CONFIG_PATH, encoding="utf-8") as f:
config = json.load(f)
_cached_rules = _resolve_custom_checks(config)
log.info("校验规则配置加载成功")
return _cached_rules
except Exception as e:
log.error("加载校验规则配置失败: %s,使用内置默认规则", e)
return _load_default_rules()
def _resolve_custom_checks(config: dict[str, Any]) -> ValidationConfig:
"""解析配置中的自定义校验函数名称,替换为实际函数引用。"""
def resolve_rule(rule: dict[str, Any]) -> dict[str, Any]:
if "custom_check" in rule and isinstance(rule["custom_check"], str):
check_name = rule["custom_check"]
if check_name in _CUSTOM_CHECKS:
rule["custom_check"] = _CUSTOM_CHECKS[check_name]
else:
log.warning("未知的自定义校验函数: %s", check_name)
rule["custom_check"] = None
return rule
# 解析 travel 规则的 fields
for field_rule in config.get("travel", {}).get("fields", []):
resolve_rule(field_rule)
# 解析 element_fields
for array_rule in config.get("travel", {}).get("arrays", []):
for elem_field in array_rule.get("element_fields", []):
if isinstance(elem_field, dict):
resolve_rule(elem_field)
# 解析 normal 规则的 fields
for field_rule in config.get("normal", {}).get("fields", []):
resolve_rule(field_rule)
# 解析 element_fields
for array_rule in config.get("normal", {}).get("arrays", []):
for elem_field in array_rule.get("element_fields", []):
if isinstance(elem_field, dict):
resolve_rule(elem_field)
return config # type: ignore[return-value]
def _load_default_rules() -> ValidationConfig:
"""返回内置的默认校验规则(当配置文件不存在时使用)。"""
return {
"version": "1.0",
"custom_checks": {},
"travel": {
"fields": [
{"path": ["basic_info", "travel_purpose"], "description": "出差事由"},
{"path": ["basic_info", "travel_location"], "description": "出差地点"},
{"path": ["basic_info", "start_date"], "custom_check": _is_valid_date, "description": "出差开始日期"},
{"path": ["basic_info", "end_date"], "custom_check": _is_valid_date, "description": "出差结束日期"},
],
"arrays": [
{
"path": ["reimbursement_details", "transport_fee"],
"min_items": 1,
"element_fields": [
{"path": ["vehicle_type"], "description": "交通工具类型"},
{"path": ["start_date"], "custom_check": _is_valid_date, "description": "出发日期"},
{"path": ["end_date"], "custom_check": _is_valid_date, "description": "到达日期"},
{"path": ["departure_place"], "description": "出发地"},
{"path": ["arrival_place"], "description": "目的地"},
{"path": ["amount"], "custom_check": _is_positive_number, "description": "金额"},
{"path": ["bill_count"], "custom_check": _is_positive_integer, "description": "票据张数"},
{"path": ["remark"], "check_empty": False, "description": "备注说明"},
],
"description": "交通费用明细",
},
{
"path": ["payment_methods"],
"min_items": 1,
"element_fields": [
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
{"path": ["merchant"], "description": "商户名称"},
{"path": ["remark"], "check_empty": False, "description": "备注"},
],
"description": "支付方式记录",
},
{
"path": ["subsidy_list"],
"min_items": 1,
"element_fields": [
{"path": ["person_id"], "description": "人员工号"},
{"path": ["person_name"], "description": "人员姓名"},
{"path": ["start_date"], "custom_check": _is_valid_date, "description": "补助开始日期"},
{"path": ["end_date"], "custom_check": _is_valid_date, "description": "补助结束日期"},
{"path": ["days"], "custom_check": _is_positive_integer, "description": "补助天数"},
],
"description": "补助清单",
},
{
"path": ["attachments"],
"min_items": 0,
"element_fields": [
{"path": ["filename"], "description": "文件名"},
{"path": ["attachment_type"], "description": "附件类型"},
],
"description": "附件列表",
},
],
},
"normal": {
"fields": [
{"path": ["basic_info", "reimbursement_description"], "description": "报销事由"},
{
"path": ["reimbursement_details", "total_invoices"],
"custom_check": _is_positive_integer,
"description": "发票总数",
},
{
"path": ["reimbursement_details", "total_amount"],
"custom_check": _is_positive_number,
"description": "总金额",
},
],
"arrays": [
{
"path": ["payment_methods"],
"min_items": 1,
"element_fields": [
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
{"path": ["merchant"], "description": "商户名称"},
{"path": ["remark"], "check_empty": False, "description": "备注"},
],
"description": "支付方式记录",
},
{
"path": ["attachments"],
"min_items": 0,
"element_fields": [
{"path": ["filename"], "description": "文件名"},
{"path": ["attachment_type"], "description": "附件类型"},
],
"description": "附件列表",
},
],
},
}
def get_validation_rules(invoice_type: str) -> ValidationRules:
"""获取指定发票类型的校验规则。
Args:
invoice_type: 发票类型,'travel' 或 'normal'。
Returns:
对应的校验规则。
"""
config = _load_validation_config()
return config.get(invoice_type, config.get("travel", {})) # type: ignore[return-value]
# ------------------------------------------------------------------
# 数据模型
# ------------------------------------------------------------------
@dataclass
class ValidationReport:
"""校验结果报告"""
valid: bool
missing_fields: list[str] = field(default_factory=list)
missing_materials: list[str] = field(default_factory=list)
confidence: float = 0.0
suggestion: str = ""
# ------------------------------------------------------------------
# 通用校验引擎
# ------------------------------------------------------------------
def _check_field(
data: dict[str, Any],
rule: FieldRule,
) -> tuple[bool, str]:
"""根据字段规则检查字段。"""
path = rule["path"]
check_empty = rule.get("check_empty", True)
custom_check = rule.get("custom_check")
current = data
for key in path:
if not isinstance(current, dict):
return (False, ".".join(path))
if key not in current:
return (False, ".".join(path))
current = current[key]
if check_empty and isinstance(current, str) and not current.strip():
return (False, ".".join(path))
if custom_check is not None and not custom_check(current):
return (False, ".".join(path))
return (True, ".".join(path))
def _check_array(
data: dict[str, Any],
rule: ArrayRule,
) -> tuple[list[str], int, int]:
"""根据数组规则检查数组。
Returns:
(缺失字段列表, 总检查数, 通过检查数)
"""
missing: list[str] = []
path = rule["path"]
min_items = rule.get("min_items", 1)
element_fields = rule.get("element_fields", [])
path_str = ".".join(path)
total_checks = 1 # 数组存在性和最小数量检查
passed_checks = 0
# 遍历路径获取数组
current = data
for key in path:
if not isinstance(current, dict) or key not in current:
return ([path_str], total_checks, passed_checks)
current = current[key]
# 检查数组是否满足最小数量要求
if not isinstance(current, list) or len(current) < min_items:
return ([path_str], total_checks, passed_checks)
passed_checks += 1 # 数组检查通过
# 检查数组元素的字段
if element_fields:
for i, item in enumerate(current):
if not isinstance(item, dict):
missing.append(f"{path_str}[{i}]")
total_checks += len(element_fields)
continue
for field_rule in element_fields:
total_checks += 1
# 支持两种格式:简单字符串格式 和 详细规则格式
if isinstance(field_rule, str):
field_rule_dict: FieldRule = {"path": [field_rule]}
else:
field_rule_dict = field_rule
# 复用 _check_field 函数检查元素字段
ok, _ = _check_field(item, field_rule_dict)
if ok:
passed_checks += 1
else:
field_path_str = ".".join(field_rule_dict["path"])
missing.append(f"{path_str}[{i}].{field_path_str}")
return missing, total_checks, passed_checks
def _validate_with_rules(data: dict[str, Any], rules: ValidationRules) -> tuple[list[str], int, int]:
"""使用规则配置进行校验。"""
missing: list[str] = []
total_checks = 0
passed_checks = 0
# 校验字段规则
for rule in rules.get("fields", []):
total_checks += 1
ok, field_path = _check_field(data, rule)
if ok:
passed_checks += 1
else:
missing.append(field_path)
# 校验数组规则
for rule in rules.get("arrays", []):
array_missing, array_total, array_passed = _check_array(data, rule)
total_checks += array_total
passed_checks += array_passed
missing.extend(array_missing)
return missing, total_checks, passed_checks
# ------------------------------------------------------------------
# 校验入口
# ------------------------------------------------------------------
def validate_travel_info(data: dict[str, Any]) -> ValidationReport:
"""校验差旅报销信息的完整性。"""
rules = get_validation_rules("travel")
missing, total_checks, passed_checks = _validate_with_rules(data, rules)
confidence = passed_checks / total_checks if total_checks > 0 else 0.0
missing_materials = _infer_missing_materials(missing, data)
suggestion = _build_suggestion(missing, missing_materials)
return ValidationReport(
valid=len(missing) == 0,
missing_fields=missing,
missing_materials=missing_materials,
confidence=round(confidence, 2),
suggestion=suggestion,
)
def validate_normal_info(data: dict[str, Any]) -> ValidationReport:
"""校验普通报销信息的完整性。"""
rules = get_validation_rules("normal")
missing, total_checks, passed_checks = _validate_with_rules(data, rules)
confidence = passed_checks / total_checks if total_checks > 0 else 0.0
missing_materials = _infer_missing_materials(missing, data)
suggestion = _build_suggestion(missing, missing_materials)
return ValidationReport(
valid=len(missing) == 0,
missing_fields=missing,
missing_materials=missing_materials,
confidence=round(confidence, 2),
suggestion=suggestion,
)
# ------------------------------------------------------------------
# 缺失材料推断
# ------------------------------------------------------------------
def _infer_missing_materials(
missing_fields: list[str],
data: dict[str, Any],
) -> list[str]:
"""根据缺失字段推断可能需要补充的材料类型。"""
materials: list[str] = []
field_set = set(missing_fields)
if any("start_date" in f or "end_date" in f for f in field_set):
if "basic_info.start_date" in field_set or "basic_info.end_date" in field_set:
materials.append("出差事前申请单")
if "basic_info.travel_purpose" in field_set:
materials.append("出差事前申请单")
if "basic_info.travel_location" in field_set:
materials.append("交通工具发票")
if "payment_methods" in field_set or any("payment_methods[" in f for f in field_set):
materials.append("支付记录截图")
if any("transport_fee" in f for f in field_set):
materials.append("交通工具发票")
if any("subsidy_list" in f for f in field_set):
materials.append("出差事前申请单")
if "basic_info.reimbursement_description" in field_set:
materials.append("发票或支付记录")
return list(dict.fromkeys(materials))
def _build_suggestion(
missing_fields: list[str],
missing_materials: list[str],
) -> str:
"""生成用户友好的建议信息。"""
if not missing_fields:
return ""
if missing_materials:
material_names = "、".join(missing_materials)
return f"信息不完整,请补充上传:{material_names}"
return f"信息不完整,缺少 {len(missing_fields)} 个字段"
# ------------------------------------------------------------------
# 统一入口
# ------------------------------------------------------------------
def validate_extracted_info(
data: dict[str, Any],
invoice_type: str = "travel",
) -> ValidationReport:
"""校验提取信息的完整性。
Args:
data: LLM 提取的结构化信息。
invoice_type: 发票类型,'travel' 或 'normal'。
Returns:
校验报告。
"""
log.info("开始校验 %s 报销信息完整性", invoice_type)
if invoice_type == "travel":
report = validate_travel_info(data)
else:
report = validate_normal_info(data)
status = "通过" if report.valid else "未通过"
log.info(
"校验结果: %s (置信度: %.0f%%, 缺失字段: %d)",
status,
report.confidence * 100,
len(report.missing_fields),
)
return report
def reload_validation_rules() -> None:
"""重新加载校验规则配置(用于运行时热更新)。"""
global _cached_rules
_cached_rules = None
_load_validation_config()
log.info("校验规则已重新加载")
-69
View File
@@ -1,69 +0,0 @@
---
## last_reviewed: 2026-06-12
# src/doc — 文档处理模块
负责发票信息提取、基于 LLM 的支付截图信息识别、差旅/普通报销信息提取、以及将数据填入 Word 出库单模板。
## 模块清单
| 文件 | 作用 |
| ------------------------ | ---------------------------------------------------------------------------- |
| `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 |
| `pdf.py` | PDF 图片渲染(PyMuPDF) |
| `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态 + 差旅/普通报销信息综合提取) |
| `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 |
| `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 |
| `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) |
| `prompt.py` | LLM 提示词模板加载 |
| `prompts/` | 提示词模板文件(`invoice_system.md`、`travel_info_system.md`、`normal_info_system.md`) |
## 数据流
```mermaid
flowchart TD
A["PDF 发票"] --> B["pdf.py<br/>PDF 图片渲染"]
B --> C["llm_extractor.py<br/>发票文本提取"]
C --> D["(发票列表)"]
E["支付截图"] --> F["llm_extractor.py<br/>多模态提取"]
F --> G["(刷卡记录)"]
D --> H["matcher.py<br/>金额贪心匹配 / 相对容差 3%"]
G --> H
H --> I["invoice.py<br/>分类 + CSV 回填"]
I --> J["CSV<br/>刷卡日期/卡号/金额"]
J --> K["fill_consumable_doc<br/>易耗品出库单"]
K --> L["易耗品出库单.doc"]
D --> M["llm_extractor.py<br/>差旅/普通信息综合提取"]
H --> M
M --> N{"发票类型判断"}
N -->|差旅发票| O["extract_travel_info()"]
N -->|普通发票| P["extract_normal_info()"]
O --> Q["travel_info.json"]
P --> R["normal_info.json"]
```
## 依赖说明
- **PyMuPDF (pymupdf)** — PDF 图片渲染
- **pywin32** — Word COM 自动化(仅 Windows)
- **llama-index** — LLM 信息提取
## 注意事项
- `fill_consumable_doc.py` 依赖 Microsoft Word + COM,仅 Windows 可用
- LLM 提取不会覆盖 CSV 中已有非空字段
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
- LLM 提取失败时直接报错,无正则回退
-4
View File
@@ -1,4 +0,0 @@
"""文档处理模块
包含发票提取、LLM 信息提取、出库单填写等功能。
"""
-83
View File
@@ -1,83 +0,0 @@
#性格
你是财务报销信息完整性校验助手。你的任务是检查已提取的报销信息在语义上是否足够支撑完成报销系统填报。
**核心原则**:不仅要检查字段是否存在,还要判断信息在逻辑上是否自洽、是否足以完成填报。
---
## 输入数据说明
你会收到以下数据:
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 语法必须正确
- 不要包含任何思考过程或解释文字
-430
View File
@@ -1,430 +0,0 @@
"""信息完整性校验器
对 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],
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
+21
View File
@@ -0,0 +1,21 @@
---
last_reviewed: 2026-06-15
---
# src/infra — 基础设施层
提供浏览器自动化、文档处理和 LLM 接口等底层能力。此层不包含业务逻辑,只提供工具和平台能力。
## 子模块
| 目录 | 说明 |
|------|------|
| `browser/` | Playwright 驱动的财务系统自动填报 |
| `documents/` | 发票数据模型、PDF 渲染、Word 出库单填写 |
| `llm/` | LLM 提示词模板加载与管理 |
## 设计原则
- **无业务逻辑**:只提供工具能力,不包含业务流程判断
- **可替换性**:每个子模块通过 `__init__.py` 导出接口,便于替换实现
- **与 core 层解耦**:infra 不依赖 core,core 可通过接口调用 infra
+4
View File
@@ -0,0 +1,4 @@
"""基础设施模块
提供浏览器自动化、文档处理和 LLM 接口功能。
"""
+44
View File
@@ -0,0 +1,44 @@
---
last_reviewed: 2026-06-15
---
# src/infra/browser — 浏览器自动化
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
## 文件
| 文件 | 职责 |
|------|------|
| `base.py` | `BaseBot` 基类:浏览器生命周期、登录信息门户、导航到报销系统、创建新单据、截图 |
| `travel.py` | 差旅报销填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
| `normal.py` | 普通报销填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
| `__init__.py` | 入口函数:`run_bot()` / `run_bot_web()`,负责类型路由和流程调度 |
## 对外接口
| 函数 | 说明 |
|------|------|
| `run_bot(config, travel_info, normal_info)` | CLI 模式:根据传入信息判断差旅/普通报销 |
| `run_bot_web(config, work_dir)` | Web 模式:从缓存加载信息后执行填报 |
## 填报流程
### 差旅报销(travel)
1. 填写基本信息(事由、地点、日期、项目编号)
2. 添加差旅明细(交通费用逐条录入)
3. 填写支付方式(公务卡刷卡记录)
4. 填写补助清单(按天计算交通补助 + 伙食补助)
5. 上传附件(发票、申请单等)
### 普通报销(normal)
1. 填写基本信息(报销事由、金额)
2. 填写发票明细(总数、总金额)
3. 填写支付方式
4. 上传附件
## 注意事项
- 浏览器填报会启动 Chromium,请勿手动干扰自动化流程
- 调试截图保存在 `images/` 目录
- Web 模式以无头模式运行
+100
View File
@@ -0,0 +1,100 @@
"""浏览器自动化填报
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
对外接口:
run_bot(config, travel_info, normal_info) 启动浏览器并执行填报流程
run_bot_web(config, work_dir) Web 模式填报(从缓存加载信息)
"""
from pathlib import Path
from typing import Any
from ... import get_logger
from .base import BaseBot
log = get_logger("bot")
def run_bot(
config: dict[str, Any],
headless: bool = False,
work_dir: Path | None = None,
travel_info: dict[str, Any] | None = None,
normal_info: dict[str, Any] | None = None,
) -> None:
"""启动浏览器并执行填报流程。
根据传入的报销信息判断执行差旅报销还是普通报销流程。
Args:
config: 财务系统配置(含 URL、账号密码等)。
headless: 是否无头模式。
work_dir: 工作目录。
travel_info: 差旅报销信息(可选)。
normal_info: 普通报销信息(可选)。
"""
invoice_type = ""
if travel_info and normal_info:
invoice_type = "mixed"
elif travel_info:
invoice_type = "travel"
elif normal_info:
invoice_type = "normal"
else:
log.error("未提供任何报销信息")
return
log.info(f"启动填报流程: {invoice_type}")
if invoice_type == "travel":
from .travel import run as run_travel
bot = BaseBot(config, headless=headless)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
bot.navigate_to_reimburse(page_key="travel_page")
bot.create_new_form()
run_travel(bot, travel_info)
finally:
bot.close()
elif invoice_type == "normal":
from .normal import run as run_normal
bot = BaseBot(config, headless=headless)
bot.work_dir = work_dir
try:
bot.launch()
bot.login_portal()
bot.navigate_to_reimburse(page_key="reimburse_page")
bot.create_new_form()
run_normal(bot, normal_info)
finally:
bot.close()
else:
log.warning("暂不支持混合报销流程")
def run_bot_web(config: dict[str, Any], work_dir: str | Path) -> None:
"""Web 模式填报(从缓存加载信息)。
根据 work_dir 下的 .invoice_cache 目录中已提取的信息,
自动判断执行差旅报销还是普通报销流程。
Args:
config: 财务系统配置(含 URL、账号密码等)。
work_dir: 工作目录(包含 .invoice_cache 子目录)。
"""
from ...core.extraction import load_cache
work_dir = Path(work_dir)
cache = load_cache(work_dir)
travel_info = cache.get("travel_info")
normal_info = cache.get("normal_info")
run_bot(config, headless=True, work_dir=work_dir, travel_info=travel_info, normal_info=normal_info)
@@ -1,5 +1,4 @@
"""
浏览器自动化填报 — 公共基类
"""浏览器自动化填报 — 公共基类
提供浏览器生命周期管理、登录、导航、截图等公共操作。
"""
@@ -7,7 +6,7 @@
from pathlib import Path
from typing import Any
from .. import get_logger
from ... import get_logger
log = get_logger("bot")
@@ -1,5 +1,4 @@
"""
普通报销填报流程
"""普通报销填报流程
负责普通发票报销的完整填报步骤:
基本信息 → 总明细 → 支付方式 → 附件上传
@@ -7,7 +6,7 @@
from typing import Any
from .. import get_logger
from ... import get_logger
from .base import BaseBot, format_date
log = get_logger("bot.normal")
@@ -1,5 +1,4 @@
"""
差旅报销填报流程
"""差旅报销填报流程
负责差旅报销的完整填报步骤:
基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传
@@ -7,7 +6,7 @@
from typing import Any
from .. import get_logger
from ... import get_logger
from .base import BaseBot, format_date
log = get_logger("bot.travel")
@@ -294,7 +293,7 @@ def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]
bot.page.select_option("#fjlx", "1")
else:
bot.page.select_option("#fjlx", "2")
bot.page.fill("#fpsmxx", info["attachment_desc"])
bot.page.fill("#fpsmxx", info.get("attachment_desc", ""))
if attachment_file and attachment_file.exists():
bot.page.set_input_files("#file", str(attachment_file))
bot.page.wait_for_timeout(1000)
@@ -304,4 +303,5 @@ def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]
log.error(f"差旅附件上传失败: {e}")
bot._screenshot("travel_attachment_error")
raise
bot._screenshot("travel_attachment_done")
+36
View File
@@ -0,0 +1,36 @@
---
last_reviewed: 2026-06-15
---
# src/infra/documents — 文档处理
提供发票数据模型、PDF 渲染和 Word 出库单填写功能。
## 文件
| 文件 | 职责 |
|------|------|
| `invoice.py` | 发票数据模型:类型常量、CSV 列定义、CSV/JSON 读写工具、发票分类 |
| `pdf.py` | PDF 渲染为图片(PyMuPDF),供多模态 LLM 识别使用 |
| `consumable.py` | 易耗品出库单填写:读取 CSV → 填入 Word 模板(pywin32 COM,仅 Windows) |
## 对外接口
| 函数 | 说明 |
|------|------|
| `load_csv(path)` | 读取支付记录 CSV |
| `save_csv(payment_records, path)` | 保存支付记录 CSV |
| `save_invoice_csv(payment_records, path)` | 保存发票级别 CSV |
| `classify_invoice_batch(cache_map)` | 按类型批量分类发票 |
| `render_pdf_to_images(pdf_path)` | PDF → 图片列表 |
| `fill_consumable_doc(csv_path, doc_path)` | 将 CSV 数据填入 Word 模板 |
## 缓存目录
`.invoice_cache/` 是系统级缓存目录名常量,定义在 `invoice.py` 中,被提取和匹配模块统一引用。
## 易耗品出库单
- 需要 **Windows + Microsoft Word + pywin32**
- 模板文件为项目根目录的 `易耗品、出库单.doc`
- 填写规则:日期用当天日期,品名/规格/数量/单价从 CSV 解析,字体统一宋体五号
+32
View File
@@ -0,0 +1,32 @@
"""文档处理基础设施
提供发票数据模型、PDF 渲染、出库单填写等功能。
"""
from .consumable import (
CONSUMABLE_DOC_FILENAME,
fill_consumable_doc,
fill_consumable_from_template,
)
from .invoice import (
CACHE_DIR_NAME,
classify_invoice_batch,
load_csv,
load_invoice_csv,
save_application_json,
save_csv,
save_invoice_csv,
)
__all__ = [
"CACHE_DIR_NAME",
"classify_invoice_batch",
"load_csv",
"load_invoice_csv",
"save_csv",
"save_invoice_csv",
"save_application_json",
"CONSUMABLE_DOC_FILENAME",
"fill_consumable_doc",
"fill_consumable_from_template",
]
@@ -1,5 +1,4 @@
"""
将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
"""将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
仅写入表格数据单元格,保留原模板字体、边框与版式。
"""
@@ -13,9 +12,9 @@ from datetime import date
from pathlib import Path
from typing import Any
from .. import get_logger
from ..config import load_config
from ..doc.invoice import load_invoice_csv
from ... import get_logger
from ...config import load_config
from .invoice import load_invoice_csv
log = get_logger("fill_consumable_doc")
@@ -13,7 +13,7 @@ import csv
import json
from pathlib import Path
from .. import get_logger
from ... import get_logger
log = get_logger("invoice")
@@ -11,7 +11,7 @@ from pathlib import Path
import fitz
from .. import get_logger
from ... import get_logger
log = get_logger("pdf")
+32
View File
@@ -0,0 +1,32 @@
---
last_reviewed: 2026-06-15
---
# src/infra/llm — LLM 提示词管理
管理 LLM 提示词模板的加载,供 `core/extraction/llm_extractor.py` 调用。
## 文件
| 文件 | 职责 |
|------|------|
| `prompt.py` | 提示词加载:从 `prompts/` 目录读取 `.md` 模板文件 |
| `prompts/` | 提示词模板目录(Markdown 格式) |
## 提示词模板
| 文件 | 用途 |
|------|------|
| `invoice_system.md` | 发票提取系统提示词 |
| `travel_info_system.md` | 差旅信息提取系统提示词 |
| `normal_info_system.md` | 普通发票信息提取系统提示词 |
| `supplement_system.md` | 用户补充信息后的二次提取提示词 |
| `validation_system.md` | 校验修正提示词 |
## 对外接口
| 函数 | 说明 |
|------|------|
| `build_invoice_system_prompt()` | 构建发票提取系统提示词 |
| `build_travel_info_system_prompt()` | 构建差旅信息提取系统提示词 |
| `build_normal_info_system_prompt()` | 构建普通发票信息提取系统提示词 |
+18
View File
@@ -0,0 +1,18 @@
"""LLM 接口模块
提供 LLM 提示词模板加载功能。
"""
from .prompt import (
build_invoice_system_prompt,
build_normal_info_system_prompt,
build_supplement_system_prompt,
build_travel_info_system_prompt,
)
__all__ = [
"build_invoice_system_prompt",
"build_normal_info_system_prompt",
"build_supplement_system_prompt",
"build_travel_info_system_prompt",
]
@@ -1,7 +1,6 @@
"""
LLM 提示词模板
"""LLM 提示词模板
从 src/prompts/ 目录加载 .md 文件作为提示词模板。
从 infra/llm/prompts/ 目录加载 .md 文件作为提示词模板。
"""
import os
@@ -34,8 +33,3 @@ def build_normal_info_system_prompt() -> str:
def build_supplement_system_prompt() -> str:
"""构建用户补充信息分析系统提示词。"""
return _load_prompt("supplement_system.md")
def build_validation_system_prompt() -> str:
"""构建语义校验系统提示词。"""
return _load_prompt("validation_system.md")
@@ -2,9 +2,9 @@
last_reviewed: 2026-06-12
---
# src/doc/prompts — LLM 提示词模板
# src/infra/llm/prompts — LLM 提示词模板
存放 LLM 信息提取使用的系统提示词模板文件,由 `src/doc/prompt.py` 动态加载。
存放 LLM 信息提取使用的系统提示词模板文件,由 `src/infra/llm/prompt.py` 动态加载。
## 模板清单
@@ -16,5 +16,5 @@ last_reviewed: 2026-06-12
## 加载方式
```python
from src.doc.prompt import build_invoice_system_prompt, build_travel_info_system_prompt
from src.infra.llm import build_invoice_system_prompt, build_travel_info_system_prompt
```
+11 -42
View File
@@ -19,44 +19,18 @@ from typing import Any
from . import get_logger
from .config import load_config
from .doc.extractor import extract_invoices
from .doc.invoice import (
classify_invoice_batch,
save_application_json,
save_invoice_csv,
)
from .doc.invoice import (
save_csv as save_payment_csv,
)
from .core.extraction import extract_invoices
from .pipeline_core import (
extract_and_cache_normal_info,
extract_and_cache_travel_info,
extract_info_by_type,
is_travel_invoice,
process_invoices,
)
log = get_logger("pipeline")
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从缓存目录读取发票数据并按类型分组
注意:load_cache 会加载所有 .invoice_cache/*.json,包括 travel_info 和 normal_info
等提取结果缓存(它们没有 invoice_type 字段),需要显式过滤掉。
"""
from .doc.llm_extractor import load_cache
cache_map = load_cache(cache_path)
# 过滤掉非发票的缓存条目:travel_info、normal_info 等提取结果
invoices = [
data
for key, data in cache_map.items()
if key not in ("travel_info", "normal_info")
and isinstance(data, dict)
and data.get("invoice_type") not in ("application", "payment")
]
return classify_invoice_batch(invoices)
def run_pipeline(
step: str = "all",
username: str | None = None,
@@ -98,19 +72,11 @@ def run_pipeline(
log.error("未提取到任何发票数据")
return 1
save_payment_csv(payment_records, cache_path / "payment_records.csv")
save_invoice_csv(payment_records, cache_path / "invoice_summary.csv")
# 使用公共函数处理发票数据
process_invoices(payment_records, applications, groups, cache_path)
if applications:
save_application_json(applications, cache_path / "travel_applications.json")
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张")
# 发票提取完成后立即判断类型
if is_travel_invoice(groups):
travel_info = extract_and_cache_travel_info(groups, cache_path)
else:
normal_info = extract_and_cache_normal_info(groups, cache_path)
# 发票提取完成后立即判断类型并提取信息
travel_info, normal_info = extract_info_by_type(groups, cache_path)
if step == "invoice":
log.info("[1/2] 发票提取 完成")
@@ -124,9 +90,12 @@ def run_pipeline(
log.info("[2/2] 报销提交")
log.info("=" * 60)
from .bot import run_bot
from .infra.browser import run_bot
if groups is None:
# 从缓存重新分类(仅 submit 阶段需要)
from .pipeline_core import _classify_from_cache
groups = _classify_from_cache(cache_path)
if is_travel_invoice(groups):
+122 -2
View File
@@ -11,15 +11,21 @@ from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from typing import Any, cast
from . import get_logger
from .doc.llm_extractor import (
from .core.extraction import (
CACHE_DIR_NAME,
extract_normal_info,
extract_travel_info,
load_cache,
)
from .infra.documents import (
classify_invoice_batch,
save_application_json,
save_invoice_csv,
)
from .infra.documents import save_csv as save_payment_csv
log = get_logger("pipeline_core")
@@ -98,3 +104,117 @@ def extract_and_cache_normal_info(
normal_info = extract_normal_info(source_dir=cache_path)
save_cache_info(cache_path, "normal_info", normal_info)
return normal_info
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从缓存目录读取发票数据并按类型分组
注意:load_cache 会加载所有 .invoice_cache/*.json,包括 travel_info 和 normal_info
等提取结果缓存(它们没有 invoice_type 字段),需要显式过滤掉。
"""
cache_map = load_cache(cache_path)
# 过滤掉非发票的缓存条目:travel_info、normal_info 等提取结果
invoices = [
data
for key, data in cache_map.items()
if key not in ("travel_info", "normal_info")
and isinstance(data, dict)
and data.get("invoice_type") not in ("application", "payment")
]
return classify_invoice_batch(invoices)
def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
"""保存发票分类结果到目录的 JSON 文件
保存完整发票分组数据(供 is_travel_invoice/extract_and_cache_* 使用),
同时保留计数字段(供快速统计使用)。
"""
groups_path = session_dir / "invoice_groups.json"
data = {
"travel": groups.get("travel", []),
"general": groups.get("general", []),
"application": groups.get("application", []),
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_invoice_groups(session_dir: Path) -> dict[str, Any] | None:
"""从目录加载发票分类结果
返回包含完整发票分组数据和计数字段的字典。
"""
groups_path = session_dir / "invoice_groups.json"
if not groups_path.exists():
return None
try:
with open(groups_path, encoding="utf-8") as f:
return cast(dict[str, Any] | None, json.load(f))
except Exception:
return None
def process_invoices(
payment_records: list[dict[str, Any]],
applications: list[dict[str, Any]],
groups: dict[str, list[dict[str, Any]]],
output_dir: Path,
) -> dict[str, Any]:
"""处理提取的发票数据:保存 CSV、申请单和分类结果
Args:
payment_records: 支付记录列表。
applications: 申请单列表。
groups: 发票分类结果。
output_dir: 输出目录。
Returns:
包含发票统计信息的字典。
"""
# 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
save_payment_csv(payment_records, output_dir / "payment_records.csv")
save_invoice_csv(payment_records, output_dir / "invoice_summary.csv")
# 出差申请单单独保存
if applications:
save_application_json(applications, output_dir / "travel_applications.json")
# 保存分类结果(供后续步骤统一读取)
save_invoice_groups(output_dir, groups)
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in payment_records)
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张")
return {
"invoice_count": invoice_count,
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
"application_count": len(groups.get("application", [])),
}
def extract_info_by_type(
groups: dict[str, list[dict[str, Any]]],
cache_path: Path,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""根据发票类型提取差旅或普通报销信息
Args:
groups: 发票分类结果。
cache_path: 缓存目录路径。
Returns:
(travel_info, normal_info) 元组,根据发票类型返回对应信息。
"""
if is_travel_invoice(groups):
travel_info = extract_and_cache_travel_info(groups, cache_path)
return travel_info, None
else:
normal_info = extract_and_cache_normal_info(groups, cache_path)
return None, normal_info
+8 -1
View File
@@ -22,15 +22,22 @@ sys.path.insert(0, str(PROJECT_ROOT))
from flask import Flask # noqa: E402, I001
from src.web import pipeline_web, routes # noqa: E402, I001
from src.doc.fill_consumable_doc import CONSUMABLE_DOC_FILENAME # noqa: E402, I001
from src.infra.documents import CONSUMABLE_DOC_FILENAME # noqa: E402, I001
app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
_app_initialized = False
def create_app() -> Flask:
"""应用工厂:初始化配置并注册路由"""
global _app_initialized
if _app_initialized:
return app
_app_initialized = True
# 设置出库单模板路径
pipeline_web.set_consumable_template(PROJECT_ROOT / CONSUMABLE_DOC_FILENAME)
+13 -69
View File
@@ -8,29 +8,21 @@ Web 管道逻辑
- 发票分类数据持久化
"""
import json
import time
from pathlib import Path
from typing import Any, cast
from typing import Any
from urllib.parse import quote
# 延迟导入,避免循环引用
from src import get_logger # noqa: F401
from src.doc.fill_consumable_doc import (
from src.infra.documents 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,
)
from src.pipeline_core import (
extract_and_cache_normal_info,
extract_and_cache_travel_info,
is_travel_invoice,
extract_info_by_type,
load_invoice_groups,
process_invoices,
)
fill_log = get_logger("fill_consumable_doc")
@@ -40,40 +32,6 @@ 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 文件
保存完整发票分组数据(供 is_travel_invoice/extract_and_cache_* 使用),
同时保留计数字段(供快速统计使用)。
"""
groups_path = session_dir / INVOICE_GROUPS_FILE
data = {
"travel": groups.get("travel", []),
"general": groups.get("general", []),
"application": groups.get("application", []),
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
with open(groups_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_invoice_groups(session_dir: Path) -> dict[str, Any] | None:
"""从 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, Any] | None, json.load(f))
except Exception:
return None
def resolve_payment_csv(session_dir: Path) -> Path | None:
"""查找支付记录 CSV(payment_records.csv)"""
csv_path = session_dir / "payment_records.csv"
@@ -175,7 +133,7 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any] | None) -> dict[s
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票:无需额外提取(normal_info.json 待实现)
"""
from src.doc.extractor import extract_invoices
from src.core.extraction import extract_invoices
start = time.time()
@@ -184,34 +142,20 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any] | None) -> dict[s
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)
# 使用公共函数处理发票数据
stats = process_invoices(invoices, applications, groups, session_dir)
# ---- Step 2: 差旅/普通信息提取 ----
if is_travel_invoice(groups):
extract_and_cache_travel_info(groups, session_dir)
else:
extract_and_cache_normal_info(groups, session_dir)
# 统计发票总数
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
extract_info_by_type(groups, session_dir)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": invoice_count,
"invoice_count": stats["invoice_count"],
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
"travel_count": stats["travel_count"],
"general_count": stats["general_count"],
}
doc_fill = _try_fill_consumable_doc(session_dir, config or {})
append_doc_download(result, session_dir.name, doc_fill)
@@ -229,7 +173,7 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any] | None) -> di
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.bot import run_bot_web
from src.infra.browser import run_bot_web
groups = load_invoice_groups(session_dir)
if groups:
+7 -7
View File
@@ -24,7 +24,7 @@ from src.config import (
from src.config import (
load_config as load_project_config,
)
from src.doc.invoice import load_csv, load_invoice_csv
from src.infra.documents import load_csv, load_invoice_csv
from . import pipeline_web, sse_handler
@@ -634,12 +634,12 @@ def agent_process(session_id: str) -> Any:
run_agent_round,
save_agent_state,
)
from src.doc.extractor import extract_invoices
from src.doc.invoice import (
from src.core.extraction import extract_invoices
from src.infra.documents import (
save_application_json,
save_invoice_csv,
)
from src.doc.invoice import (
from src.infra.documents import (
save_csv as save_payment_csv,
)
@@ -702,12 +702,12 @@ def agent_supplement(session_id: str) -> Any:
agent_session = add_supplement(session_dir, agent_session, filenames)
# 重新提取发票并保存
from src.doc.extractor import extract_invoices
from src.doc.invoice import (
from src.core.extraction import extract_invoices
from src.infra.documents import (
save_application_json,
save_invoice_csv,
)
from src.doc.invoice import (
from src.infra.documents import (
save_csv as save_payment_csv,
)
+6
View File
@@ -35,6 +35,12 @@ export function handleAgentEvent(msg) {
case 'agent_force_submit':
_handleForceSubmit(msg);
break;
case 'agent_max_rounds':
_handleAgentError(msg);
break;
case 'agent_extract_status':
_handleAgentStateChange(msg);
break;
}
}