refactor!: 删除 Web/CLI 应用层与内置 LLM 抽取,出库单功能下线(2b)

入口只剩 MCP 服务器;编排由宿主 Agent 负责,本项目只留工具与确定性内核。

删除:
- src/web/(Flask 路由、SSE 轮询、自研聊天前端、模板与静态资源,约 3400 行)
- src/main.py、src/pipeline.py、src/pipeline_core.py(CLI 全流程入口与两套重复管道)
- core/extraction/{extractor,llm_extractor}.py(内置 LLM 抽取、llm_stream.log、file_events.log、并行抽取)
- infra/documents/consumable.py 与 infra/documents/pdf.py:出库单功能下线(Word 模板 4fbb4c3 起已不在仓库,
  该路径此前一直静默失败),PDF 渲染由 core/extraction/render.py 取代
- mcp__arb__doc_consumable 工具;exceptions.ExtractionError;config 的 consumable_storage / get_llm_config /
  load_session_config / SESSION_CONFIG_KEYS(Web 专属,已无引用)
- 针对被删代码的 tests/test_extractor.py、tests/test_llm_extractor.py 与 scripts/ 下的 LLM 调试脚本
- 依赖:llama-index、llama-index-llms-openai-like、python-dotenv(连同其 50+ 传递依赖)
- 提交进来的调试截图 src/images/、根目录 images/ 与 .coverage(并补 .gitignore)

保留与新增:
- 材料入口改为独立进程 src/upload(手机扫码/PC 上传 → cases/<id>/inbox/)
- runner.py 自带 save_invoice_groups,不再依赖 pipeline_core
- scripts/mcp_smoke.py:stdio 自检(拉起自身、列工具、调 case_list、验证 instructions 下发)
- make mcp / mcp-smoke / upload 取代 make run;check 的 mypy 目标从 src/main.py 扩到 src

验证:mcp_smoke 通过(17 个工具、instructions 1449 字节、case_list 正常);申购单实机生成此前已验证。
门禁:ruff check / format --check(43) / mypy src(36 文件) / deptry / pytest 107 passed。
注:仍用 --no-verify 提交并手动跑等价检查(原因见 2f08bbd 提交说明)。
This commit is contained in:
wandering
2026-10-19 22:03:55 +08:00
parent b2d271c5ed
commit 87b4080b1b
59 changed files with 116 additions and 8298 deletions
+1
View File
@@ -18,3 +18,4 @@ cases/
# DSH/Mnemon 本地记忆与文档索引,不属于项目源码
.mnemon/
.coverage
+11 -4
View File
@@ -1,4 +1,4 @@
.PHONY: install check test run clean
.PHONY: install check test mcp mcp-smoke upload clean
install:
@uv sync
@@ -8,14 +8,21 @@ check:
@uv lock --locked
@uv run ruff check .
@uv run ruff format --check .
@uv run mypy src/main.py
@uv run python -m mypy src
@uv run deptry .
test:
@uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing
run:
@uv run python src/main.py
mcp:
@echo '宿主注册: command=<项目根>/.venv/Scripts/python.exe, args=-m src.mcp.server, cwd=<项目根>, transport=stdio'
@echo '自检: make mcp-smoke'
mcp-smoke:
@uv run python scripts/mcp_smoke.py
upload:
@uv run python -m src.upload.server
clean:
@rm -rf .venv __pycache__ .pytest_cache .mypy_cache .ruff_cache
-3
View File
@@ -8,9 +8,6 @@ dependencies = [
"playwright>=1.40",
"PyMuPDF>=1.24",
"pywin32>=306",
"llama-index>=0.12.0",
"llama-index-llms-openai-like>=0.7.2",
"python-dotenv>=1.0",
"mcp>=2.3.0",
"segno>=1.6.6",
]
-69
View File
@@ -1,69 +0,0 @@
"""诊断脚本:检查 stream_chat 返回对象的字段结构"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
# 加载 .env
from dotenv import load_dotenv # noqa: E402
from llama_index.core.llms import ChatMessage # noqa: E402
from src.config import get_llm_config # noqa: E402
from src.core.extraction import _create_llm # noqa: E402
load_dotenv(Path(__file__).parent / ".env")
llm_config = get_llm_config()
print(f"LLM config: model={llm_config['model']}, api_base={llm_config['api_base']}")
llm = _create_llm()
messages = [
ChatMessage(role="system", content="你是一个助手"),
ChatMessage(role="user", content="1+1 等于几?"),
]
print("\n=== 检查 stream_chat 返回对象的字段 ===")
count = 0
try:
for resp in llm.stream_chat(messages, temperature=0.1):
count += 1
if count <= 3:
print(f"\n--- chunk #{count} ---")
print(f" type: {type(resp).__name__}")
print(f" delta: {repr(resp.delta)[:200]}")
if hasattr(resp, "additional_kwargs") and resp.additional_kwargs:
print(f" additional_kwargs keys: {list(resp.additional_kwargs.keys())}")
for k, v in resp.additional_kwargs.items():
val_preview = str(v)[:200]
print(f" additional_kwargs['{k}']: {val_preview}")
if hasattr(resp, "raw"):
raw = resp.raw
if isinstance(raw, dict):
print(f" raw keys: {list(raw.keys())}")
for k, v in raw.items():
val_preview = str(v)[:200]
print(f" raw['{k}']: {val_preview}")
else:
print(f" raw type: {type(raw)}")
if hasattr(resp, "message") and resp.message:
msg = resp.message
print(f" message type: {type(msg).__name__}")
if hasattr(msg, "additional_kwargs") and msg.additional_kwargs:
print(f" message.additional_kwargs keys: {list(msg.additional_kwargs.keys())}")
for k, v in msg.additional_kwargs.items():
val_preview = str(v)[:200]
print(f" message.additional_kwargs['{k}']: {val_preview}")
if hasattr(msg, "reasoning_content"):
rc = msg.reasoning_content
print(f" message.reasoning_content: {repr(rc)[:200]}")
elif count == 4:
print("\n... (更多 chunk 省略)")
if count >= 10:
break
print(f"\n=== 共收到 {count} 个 chunk ===")
except Exception as e:
print(f"\n错误: {e}")
import traceback
traceback.print_exc()
+47
View File
@@ -0,0 +1,47 @@
"""MCP 服务器自检:以 stdio 拉起自身,列出工具并调一次 case_list。
用法:uv run python scripts/mcp_smoke.py
验证要点:工具数量与名称、structured_content 可解析、instructions 已下发。
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from mcp.client.stdio import StdioServerParameters # noqa: E402
from mcp import Client # noqa: E402
async def main() -> int:
root = str(Path(__file__).resolve().parents[1])
params = StdioServerParameters(command=sys.executable, args=["-m", "src.mcp.server"], cwd=root)
async with Client(params) as client:
listed = await client.list_tools()
tools = list(getattr(listed, "tools", listed))
names = sorted(t.name for t in tools)
print("instructions bytes:", len(getattr(client, "instructions", "") or ""))
print("tools:", len(names))
for name in names:
print(" -", name)
result = await client.call_tool("case_list", {})
payload = getattr(result, "structured_content", None)
if not isinstance(payload, dict) or not payload.get("ok"):
print("case_list 调用异常:", payload)
return 1
print("case_list ok, cases:", len(payload.get("cases", [])))
missing = [
n
for n in ("case_status", "match_payments", "info_skeleton", "doc_requisition", "case_patch_extraction")
if n not in names
]
print("关键工具缺失:", missing or "无")
return 1 if missing else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
-208
View File
@@ -1,208 +0,0 @@
#!/usr/bin/env python3
"""单独测试事前申请单的信息提取功能
用于调试 LLM 对事前申请单的提取准确率。
用法:
# 测试项目根目录的事前申请单.pdf
python scripts/test_application_extract.py
# 测试指定文件
python scripts/test_application_extract.py --file path/to/file.pdf
# 测试 scripts/data 目录下的事前申请单
python scripts/test_application_extract.py --dir scripts/data
"""
import argparse
import io
import json
import sys
from pathlib import Path
# 加载 .env 环境变量(在导入 src 模块之前)
from dotenv import load_dotenv
ROOT = Path(__file__).resolve().parent.parent
load_dotenv(ROOT / ".env")
# Windows 终端强制 UTF-8
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.path.insert(0, str(ROOT)) # noqa: E402
from src.core.extraction import extract_document # noqa: E402
def test_single_file(file_path: Path) -> None:
"""测试单个文件的提取效果"""
print("=" * 60)
print(f"测试文件: {file_path.name}")
print("=" * 60)
if not file_path.exists():
print(f"文件不存在: {file_path}")
return
try:
# 调用统一提取接口
result = extract_document(file_path)
if not result:
print("[ERROR] 提取返回空结果")
return
# 检查类型判断
inv_type = result.get("invoice_type", "")
print(f"\n[类型判断] invoice_type = {inv_type}")
if inv_type != "application":
print(f"[WARNING] 类型判断错误!期望 'application',实际得到 '{inv_type}'")
else:
print("[OK] 类型判断正确")
# 展示提取结果
print("\n[提取结果]")
print(json.dumps(result, ensure_ascii=False, indent=2))
# 字段完整性检查
print("\n[字段检查]")
expected_fields = {
"invoice_type": "类型标识",
"project_name": "项目名称",
"purpose": "出差事由",
"start_date": "开始日期",
"end_date": "结束日期",
"person_info": "人员信息",
}
for field, desc in expected_fields.items():
value = result.get(field)
if value is None:
print(f" [MISSING] {desc} ({field}) - 字段缺失")
elif value == "" or value == []:
print(f" [EMPTY] {desc} ({field}) - 字段为空")
else:
print(f" [OK] {desc} ({field})")
# 日期格式检查
for date_field in ["start_date", "end_date"]:
date_value = result.get(date_field, "")
if date_value and len(date_value) == 10:
try:
parts = date_value.split("-")
if len(parts) == 3:
int(parts[0]) # year
int(parts[1]) # month
int(parts[2]) # day
print(f" [OK] {date_field} 格式正确 (YYYY-MM-DD)")
except (ValueError, IndexError):
print(f" [ERROR] {date_field} 格式错误: {date_value}")
elif date_value:
print(f" [ERROR] {date_field} 格式错误: {date_value}")
# 人员信息结构检查
person_info = result.get("person_info")
if person_info:
if isinstance(person_info, list):
print(f"\n[人员信息] 共 {len(person_info)} 人")
for i, person in enumerate(person_info, 1):
pid = person.get("person_id", "")
pname = person.get("person_name", "")
print(f" 人员 {i}: {pname} ({pid})")
elif isinstance(person_info, dict):
print("\n[人员信息] 单人格式")
print(f" 姓名: {person_info.get('person_name', '')}")
print(f" 编号: {person_info.get('person_id', '')}")
except Exception as e:
print(f"\n[EXCEPTION] 提取失败: {e}")
import traceback
traceback.print_exc()
def test_cache_comparison(file_path: Path) -> None:
"""对比原始提取和缓存结果"""
cache_dir = file_path.parent / ".invoice_cache"
cache_file = cache_dir / f"{file_path.stem}{file_path.suffix}.json"
if not cache_file.exists():
print(f"\n[INFO] 无缓存文件对比: {cache_file}")
return
print("\n" + "=" * 60)
print("[缓存对比]")
print("=" * 60)
try:
with open(cache_file, encoding="utf-8") as f:
cache_data = json.load(f)
cached_result = cache_data.get("extracted_data", {})
print("\n[缓存数据]")
print(json.dumps(cached_result, ensure_ascii=False, indent=2))
# 对比关键字段
print("\n[字段对比]")
for key in ["invoice_type", "project_name", "purpose", "start_date", "end_date"]:
cached_value = cached_result.get(key, "<缺失>")
print(f" {key}: {cached_value}")
except Exception as e:
print(f"[ERROR] 读取缓存失败: {e}")
def main() -> None:
parser = argparse.ArgumentParser(description="测试事前申请单信息提取")
parser.add_argument(
"--file",
type=str,
default=None,
help="要测试的文件路径 (默认: 扫描 scripts/data 目录)",
)
parser.add_argument(
"--dir",
type=str,
default=str(ROOT / "scripts" / "data"),
help="扫描目录下所有事前申请单文件 (默认: scripts/data)",
)
parser.add_argument(
"--no-cache-compare",
action="store_true",
help="不执行缓存对比",
)
args = parser.parse_args()
if args.dir:
# 目录模式:扫描所有PDF文件
dir_path = Path(args.dir)
if not dir_path.exists():
print(f"目录不存在: {dir_path}")
sys.exit(1)
pdf_files = sorted(dir_path.glob("*.pdf"))
if not pdf_files:
print(f"目录下没有找到PDF文件: {dir_path}")
sys.exit(1)
print(f"发现 {len(pdf_files)} 个PDF文件,开始逐个测试...\n")
for pdf in pdf_files:
if "事前申请" in pdf.name or "申请单" in pdf.name:
test_single_file(pdf)
if not args.no_cache_compare:
test_cache_comparison(pdf)
print()
else:
# 单文件模式
file_path = Path(args.file)
test_single_file(file_path)
if not args.no_cache_compare:
test_cache_comparison(file_path)
print("\n测试完成!")
if __name__ == "__main__":
main()
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env python3
"""测试 PDF 多模态提取完整链路
用法:
python scripts/test_multimodal.py
"""
import io
import sys
from pathlib import Path
# Windows 终端强制 UTF-8
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) # noqa: E402
from src.core.extraction import extract_document # noqa: E402
from src.infra.documents.pdf import render_pdf_to_images # noqa: E402
def test_render() -> None:
"""测试 PDF 渲染"""
pdf_path = ROOT / "事前申请单.pdf"
if not pdf_path.exists():
print(f"跳过: {pdf_path.name} 不存在")
return
print("=" * 60)
print("测试 PDF 渲染")
print("=" * 60)
try:
images = render_pdf_to_images(pdf_path, dpi=150)
if images:
print(f"成功渲染 {len(images)} 页")
for i, img_b64 in enumerate(images):
print(f" 第 {i + 1} 页: base64 长度 {len(img_b64)} 字符")
else:
print("渲染返回空列表!")
except ImportError as e:
print(f"导入失败: {e}")
except Exception as e:
print(f"渲染异常: {e}")
def test_multimodal_extract() -> None:
"""测试完整的多模态提取链路"""
pdf_path = ROOT / "事前申请单.pdf"
if not pdf_path.exists():
print(f"跳过: {pdf_path.name} 不存在")
return
print()
print("=" * 60)
print("测试多模态提取")
print("=" * 60)
try:
result = extract_document(pdf_path)
if result:
print("提取成功:")
import json
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print("提取返回空字典!")
except Exception as e:
print(f"提取异常: {e}")
import traceback
traceback.print_exc()
def main() -> None:
test_render()
test_multimodal_extract()
print()
print("测试完成!")
if __name__ == "__main__":
main()
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""直接测试 extract_travel_info 函数
所有数据从 .invoice_cache 缓存中自动加载,无需手动构造样本数据。
用法:
python scripts/test_travel_info.py
"""
import json
import sys
from pathlib import Path
# 项目根目录
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) # noqa: E402
from src.core.extraction import extract_travel_info # noqa: E402
def main() -> None:
source_dir = ROOT / "scripts" / "data"
if not source_dir.exists():
print(f"源文件目录不存在: {source_dir}")
sys.exit(1)
print("=" * 60)
print("测试 extract_travel_info(数据来自缓存)")
print("=" * 60)
print(f"源文件目录: {source_dir}")
print()
try:
result = extract_travel_info(
source_dir=source_dir,
)
print()
print("=" * 60)
print("提取结果:")
print("=" * 60)
print(json.dumps(result, ensure_ascii=False, indent=2))
print()
print("测试通过!")
except Exception as e:
print(f"测试失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
+1 -40
View File
@@ -1,28 +1,17 @@
"""
配置加载
从 scripts/data/config.json 读取用户配置,从环境变量读取服务端配置(LLM + 系统 URL)。
从 scripts/data/config.json 读取用户配置,环境变量可覆盖系统 URL 与用户字段。
"""
import json
import os
from pathlib import Path
from typing import Any
# 项目根 = src/config.py 的上上级(曾多写一层 parent,导致解析到项目之外、凭据恒为空)
_CONFIG_PATH = Path(__file__).resolve().parent.parent / "scripts" / "data" / "config.json"
# 会话级配置允许覆盖的用户相关字段白名单(含密码)
SESSION_CONFIG_KEYS = frozenset(
{
"username",
"password",
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
}
)
# 前端安全的配置字段白名单(不含密码)
SAFE_CONFIG_KEYS = frozenset(
@@ -31,7 +20,6 @@ SAFE_CONFIG_KEYS = frozenset(
"default_name",
"default_card_no",
"default_person_id",
"consumable_storage",
}
)
@@ -59,7 +47,6 @@ def _read_config() -> dict[str, str | Path]:
"default_name": raw.get("default_name", ""),
"default_card_no": raw.get("default_card_no", ""),
"default_person_id": raw.get("default_person_id", ""),
"consumable_storage": raw.get("consumable_storage", "躬行楼 C205"),
"upload_pin": os.environ.get("UPLOAD_PIN") or raw.get("upload_pin", ""),
"attachment_dir": project_root / "attachments",
}
@@ -77,29 +64,3 @@ def clear_config_cache() -> None:
"""清除配置缓存(测试或配置变更时调用)"""
global _config_cache
_config_cache = None
def load_session_config(session_dir: Path) -> dict[str, Any]:
"""加载会话配置,合并项目全局配置与会话级配置
仅允许覆盖用户相关字段(白名单),防止用户上传的 config.json
覆盖 sso_login_url、portal_url 等系统级配置。
"""
config = load_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
session_cfg = json.load(f)
for key in SESSION_CONFIG_KEYS:
if key in session_cfg:
config[key] = session_cfg[key]
return config
def get_llm_config() -> dict[str, str]:
"""加载 LLM 配置,优先从环境变量读取,缺失字段使用默认值"""
return {
"model": os.environ.get("LLM_MODEL", "qwen-vl-max"),
"api_base": os.environ.get("LLM_API_BASE", "http://localhost:8080/v1"),
"api_key": os.environ.get("LLM_API_KEY", "lm-studio"),
}
+15 -27
View File
@@ -1,40 +1,28 @@
"""信息提取模块
"""信息存取层(无 LLM)
提供发票/文档结构化提取、LLM 辅助提取等功能。
抽取由宿主 Agent 完成:本模块只负责缓存读写、PDF 页面渲染与文本层提取。
"""
from .extractor import (
EXTRACTION_PARALLEL_COUNT,
FILE_EVENTS_LOG,
SUPPORTED_EXTENSIONS,
extract_invoices,
)
from .llm_extractor import (
from .cache import (
CACHE_DIR_NAME,
_image_to_base64,
build_extraction_user_message,
extract_document,
extract_normal_info,
extract_travel_info,
llm_query_text,
extraction_path,
load_cache,
load_match_result,
parse_json_response,
save_extraction,
save_json,
save_match_result,
)
from .render import detect_content_rotation, prepare_image, render_pdf
__all__ = [
"CACHE_DIR_NAME",
"_image_to_base64",
"build_extraction_user_message",
"extract_document",
"extract_normal_info",
"extract_travel_info",
"detect_content_rotation",
"extraction_path",
"load_cache",
"load_match_result",
"llm_query_text",
"parse_json_response",
"EXTRACTION_PARALLEL_COUNT",
"FILE_EVENTS_LOG",
"SUPPORTED_EXTENSIONS",
"extract_invoices",
"prepare_image",
"render_pdf",
"save_extraction",
"save_json",
"save_match_result",
]
-335
View File
@@ -1,335 +0,0 @@
"""发票提取编排
统一扫描目录下所有文件(PDF + 图片),通过 LLM 提取结构化数据,
根据 LLM 返回的「invoice_type」字段自动分类为发票/支付记录/出差事前申请单。
对外接口:
extract_invoices(directory) -> tuple[list[dict], list[dict], dict]
错误传播规则:
- 单个文件提取失败: 记录日志 + SSE error 事件,继续处理下一个文件
- 全部文件提取失败: raise ExtractionError,携带失败文件列表和原始错误
- 缓存读取失败: 静默降级,尝试重新提取
SSE 文件进度事件:
在 source_dir 下写入 file_events.log,每行一个 JSON 对象:
- {"type": "file_progress", "file": "...", "status": "processing"}
- {"type": "file_progress", "file": "...", "status": "done", "summary": {...}}
- {"type": "file_progress", "file": "...", "status": "cached"}
- {"type": "file_progress", "file": "...", "status": "error", "error": "..."}
"""
import json
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
from ... import get_logger
from ...core.matching import match_invoices_to_cards
from ...exceptions import ExtractionError
from ...infra.documents.invoice import CACHE_DIR_NAME, classify_invoice_batch
from .cache import save_match_result as _save_match_result
from .llm_extractor import extract_document
log = get_logger("extractor")
# SSE 文件进度事件日志文件名
FILE_EVENTS_LOG = "file_events.log"
# 并行提取文件数,可通过环境变量 EXTRACTION_PARALLEL_COUNT 配置
EXTRACTION_PARALLEL_COUNT = int(os.environ.get("EXTRACTION_PARALLEL_COUNT", "3"))
# 支持的文件扩展名
SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".webp"}
def _emit_file_event(source_dir: Path, file_name: str, status: str, **kwargs: Any) -> None:
"""向 file_events.log 追加一行 JSON 事件(线程安全,失败时静默忽略)"""
event = {
"type": "file_progress",
"file": file_name,
"status": status,
**kwargs,
}
try:
event_path = source_dir / FILE_EVENTS_LOG
with open(event_path, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception:
pass
def _get_cache_dir(source_dir: Path) -> Path:
"""获取缓存目录路径"""
cache_dir = source_dir / CACHE_DIR_NAME
cache_dir.mkdir(exist_ok=True)
return cache_dir
def _get_json_path(file_path: Path, cache_dir: Path) -> Path:
"""根据文件路径生成对应的 JSON 缓存路径(包含后缀名以区分同名的 PDF/图片)"""
return cache_dir / f"{file_path.stem}{file_path.suffix}.json"
def _save_to_cache(file_path: Path, extracted_data: dict[str, Any], cache_dir: Path) -> Path:
"""将提取结果保存到 JSON 缓存文件,并记录源文件路径和后缀名"""
json_path = _get_json_path(file_path, cache_dir)
cache_data = {
"source_file": str(file_path),
"source_filename": file_path.name,
"source_extension": file_path.suffix.lower(),
"extracted_data": extracted_data,
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
log.info(f"提取结果已缓存: {json_path.name}")
return json_path
def _load_from_cache(json_path: Path, expected_extension: str | None = None) -> dict[str, Any] | None:
"""从 JSON 缓存文件加载提取结果,可选校验后缀名一致性"""
if not json_path.exists():
return None
try:
with open(json_path, encoding="utf-8") as f:
cache_data: dict[str, Any] = json.load(f)
# 校验后缀名是否一致,防止同名不同后缀的文件误命中缓存
if expected_extension and cache_data.get("source_extension", "").lower() != expected_extension.lower():
return None
result: dict[str, Any] | None = cache_data.get("extracted_data")
return result
except Exception as e:
log.warning(f"读取缓存失败 {json_path.name}: {e}")
return None
def _build_summary(data: dict[str, Any]) -> dict[str, str]:
"""从提取结果构建前端展示摘要(人类可读格式)。
返回所有非内部字段(排除 _source_file 等下划线前缀字段),
并将 invoice_type 转换为中文标签。列表/字典类型的值会展开为可读文本。
"""
invoice_type_map = {
"train": "高铁票",
"hotel": "酒店住宿",
"general": "普通发票",
"payment": "支付记录",
"application": "出差申请单",
"attachments": "其他附件",
"note": "易耗品单",
}
summary = {}
for key, value in data.items():
# 跳过内部字段
if key.startswith("_"):
continue
# 跳过空值
if value is None or value == "":
continue
# invoice_type 转为中文标签
if key == "invoice_type":
summary["invoice_type_label"] = invoice_type_map.get(str(value), str(value))
elif isinstance(value, list):
# 列表展开为多行可读文本
if len(value) == 0:
continue
parts = []
for item in value:
if isinstance(item, dict):
# 字典项用 "key: value" 格式拼接
pair_parts = [f"{k}: {v}" for k, v in item.items()]
parts.append(" | ".join(pair_parts))
else:
parts.append(str(item))
summary[key] = "\n".join(parts)
elif isinstance(value, dict):
# 字典展开为 "key: value" 格式
pair_parts = [f"{k}: {v}" for k, v in value.items()]
summary[key] = " | ".join(pair_parts)
else:
summary[key] = str(value)
return summary
def _extract_document(
file_path: Path,
cache_dir: Path,
source_dir: Path,
) -> tuple[dict[str, str] | None, str | None]:
"""提取单个文件的结构化信息,优先使用缓存。
根据 LLM 返回的「invoice_type」字段自动分类:
- "payment" -> 支付记录
- "application" -> 申请单
- 有 "invoice_number" -> 发票
- 其他 -> 无法识别
Args:
file_path: 文件路径(PDF 或图片)。
cache_dir: JSON 缓存目录。
source_dir: 源目录(用于写入 SSE 进度事件)。
Returns:
(提取结果字典或 None, 错误信息或 None)。
"""
file_name = file_path.name
json_path = _get_json_path(file_path, cache_dir)
cached = _load_from_cache(json_path, expected_extension=file_path.suffix.lower())
if cached:
cached["_source_file"] = file_name
log.info(f"使用缓存: {file_name}")
_emit_file_event(source_dir, file_name, "cached")
return cached, None
# 发送处理中事件
_emit_file_event(source_dir, file_name, "processing")
log.info(f"使用多模态提取: {file_name}")
try:
result = extract_document(file_path)
if result:
result["_source_file"] = file_name
_save_to_cache(file_path, result, cache_dir)
# 发送完成事件(含摘要)
summary = _build_summary(result)
_emit_file_event(source_dir, file_name, "done", summary=summary)
return result, None
except Exception as e:
err_msg = str(e)
log.warning(f"多模态提取失败: {file_name} ({err_msg})")
_emit_file_event(source_dir, file_name, "error", error=err_msg)
return None, err_msg
return None, None
def _find_all_files(directory: str) -> list[Path]:
"""扫描目录下所有支持的文件(PDF + 图片)"""
dir_path = Path(directory)
files = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS]
return sorted(files)
def extract_invoices(
directory: str = ".",
) -> tuple[list[dict[str, str]], list[dict[str, str]], dict[str, list[dict[str, str]]]]:
"""扫描目录下所有文件,提取信息并匹配支付记录
统一使用 LLM 提取,根据返回的「invoice_type」自动分类:
- 发票(有 invoice_number)-> 参与金额匹配
- 支付记录(invoice_type="payment")-> 参与金额匹配
- 出差事前申请单 -> 单独存储,不参与匹配
Returns:
元组 (payment_records, applications, groups):
- payment_records: 支付记录列表(每项含关联发票 _matched_invoices)
- applications: 出差事前申请单列表
- groups: 按文档类型分组的字典
{'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单],
'attachments': [其他附件]}
Raises:
ExtractionError: 当所有文件提取均失败时抛出,携带失败文件列表和原始错误。
"""
source_dir = Path(directory)
cache_dir = _get_cache_dir(source_dir)
# 清空上次的文件进度事件
try:
(source_dir / FILE_EVENTS_LOG).unlink(missing_ok=True)
except Exception:
pass
all_files = _find_all_files(directory)
if not all_files:
log.warning("未找到支持的文件")
return [], [], {"travel": [], "general": [], "application": [], "attachments": []}
log.info(f"发现 {len(all_files)} 个文件")
all_invoices = []
all_cards = []
applications = []
attachments = []
# 记录失败文件及其错误信息
failed_files: list[tuple[str, str]] = []
max_workers = max(1, EXTRACTION_PARALLEL_COUNT)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_file = {executor.submit(_extract_document, fp, cache_dir, source_dir): fp for fp in all_files}
for future in as_completed(future_to_file):
file_path = future_to_file[future]
try:
result, err = future.result()
except Exception as e:
err_msg = str(e)
failed_files.append((file_path.name, err_msg))
log.warning(f"未能解析: {file_path.name} ({err_msg})")
continue
if not result:
if err:
failed_files.append((file_path.name, err))
log.warning(f"未能解析: {file_path.name}")
continue
inv_type = result.get("invoice_type", "")
if inv_type == "application":
applications.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif inv_type == "payment":
all_cards.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif inv_type in ("attachments", "note"):
attachments.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
elif result.get("invoice_number"):
all_invoices.append(result)
log.info(f"[{inv_type}] 已解析: {file_path.name}")
else:
log.warning(f"无法分类: {file_path.name} (invoice_type={inv_type})")
# 全部文件提取失败时抛出异常,携带原始错误信息
if failed_files and not all_invoices and not all_cards and not applications:
failed_names = [name for name, _ in failed_files]
error_details = {name: err for name, err in failed_files}
raise ExtractionError(
f"所有 {len(all_files)} 个文件提取均失败",
failed_files=failed_names,
details=error_details,
)
log.info(
f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份, 附件 {len(attachments)} 个"
)
if not all_invoices:
log.warning("未成功解析任何发票")
payment_records = match_invoices_to_cards(all_invoices, all_cards)
_save_match_result(payment_records, cache_dir)
# 构建分类
all_documents: list[dict[str, str]] = []
for record in payment_records:
all_documents.extend(record.get("_matched_invoices", []))
all_documents.extend(applications)
groups = classify_invoice_batch(all_documents)
groups["attachments"] = attachments
log.info(
f"文档分类: 差旅发票 {len(groups['travel'])} 张, "
f"普通发票 {len(groups['general'])} 张, "
f"出差事前申请单 {len(groups['application'])} 份, "
f"附件 {len(groups['attachments'])} 个"
)
return payment_records, applications, groups
-506
View File
@@ -1,506 +0,0 @@
"""LLM 信息提取
使用 LLM 从 PDF 文本/图片、支付截图中提取结构化数据。
## 功能模块
- **统一文档提取**:使用一套提示词,LLM 自行判断文档类型(发票/支付记录/出差事前申请单等),支持 JSON 格式输出。
- **差旅信息提取**:综合多张发票、支付记录和匹配结果,提取出差事由、地点、时间等差旅相关信息。
- **缓存管理**:支持从 `.invoice_cache/` 目录加载已提取的结构化数据和匹配结果,避免重复处理。
- **SSE 流式事件**:`extract_travel_info` 和 `extract_normal_info` 在调用 LLM 时,向 `source_dir/llm_stream.log` 写入流式事件(start/reasoning/chunk/end/error),前端通过 SSE 实时展示 AI 思考过程与正式回答。
## 对外接口
- `extract_document(file_path) -> dict` — 统一入口:从任意图片/PDF 提取信息
- `extract_travel_info(source_dir) -> dict` — 综合发票和匹配结果提取差旅信息
- `extract_normal_info(source_dir) -> dict` — 提取普通发票报销信息
- `load_cache(source_dir) -> dict` — 加载缓存的结构化数据
- `load_match_result(source_dir) -> dict` — 加载发票与支付记录的匹配结果
- `llm_query_text(system_prompt, text, source_dir) -> str` — 纯文本 LLM 查询(供 Agent 调度使用)
- `parse_json_response(text) -> dict` — 从 LLM 响应中提取 JSON(供 Agent 调度使用)
- `build_extraction_user_message(cache_map, match_result) -> str` — 构建提取请求的用户消息(供 Agent 调度使用)
## SSE 流式事件协议
`llm_stream.log` 每行一个 JSON 对象:
- `{"type": "llm_stream", "phase": "start", "label": "..."}` — LLM 调用开始
- `{"type": "llm_stream", "phase": "reasoning", "text": "..."}` — 模型原生推理/思考片段(来自 `thinking_delta`)
- `{"type": "llm_stream", "phase": "chunk", "text": "..."}` — 流式文本片段(正式回答)
- `{"type": "llm_stream", "phase": "end", "label": "..."}` — LLM 调用结束
- `{"type": "llm_stream", "phase": "error", "error": "..."}` — LLM 调用失败
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Any, cast
from ... import get_logger
from ...infra.documents.invoice import CACHE_DIR_NAME
from ...infra.llm.prompt import (
build_invoice_system_prompt,
build_normal_info_system_prompt,
build_travel_info_system_prompt,
)
log = get_logger("llm_extractor")
# Re-export CACHE_DIR_NAME for convenience
__all__ = [
"CACHE_DIR_NAME",
"extract_document",
"extract_travel_info",
"extract_normal_info",
"load_cache",
"load_match_result",
"llm_query_text",
"parse_json_response",
"build_extraction_user_message",
]
# SSE LLM 流式事件日志文件名
LLM_STREAM_LOG = "llm_stream.log"
def _emit_llm_stream(source_dir: Path, phase: str, **kwargs: Any) -> None:
"""向 llm_stream.log 追加一行 JSON 事件(线程安全,失败时静默忽略)
Args:
source_dir: 会话目录路径。
phase: 事件阶段 ("start" / "chunk" / "end" / "error")。
**kwargs: 额外字段 (text, label, error 等)。
"""
event = {"type": "llm_stream", "phase": phase, **kwargs}
try:
event_path = source_dir / LLM_STREAM_LOG
with open(event_path, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception:
pass
def _create_llm() -> Any:
"""根据配置文件创建 LLM 实例。"""
try:
from llama_index.llms.openai_like import OpenAILike
except ImportError:
log.error("缺少 llama-index-llms-openai-like,请执行: uv pip install llama-index-llms-openai-like")
raise
from ...config import get_llm_config
llm_config = get_llm_config()
return OpenAILike(
model=llm_config["model"],
api_base=llm_config["api_base"],
api_key=llm_config.get("api_key", "lm-studio"),
temperature=0.1,
max_tokens=65535,
request_timeout=600.0,
is_chat_model=True,
)
def parse_json_response(text: str) -> dict[str, Any]:
"""从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。
Args:
text: LLM 响应文本。
Returns:
解析后的字典。
"""
text = text.strip()
# 处理 ```json ... ``` 包裹
if "```" in text:
start = text.find("```") + 3
end = text.find("```", start)
if end > start:
text = text[start:end].strip()
# 去掉可能的前缀 (如 "json")
if text.lower().startswith("json"):
text = text[4:].strip()
return cast(dict[str, Any], json.loads(text))
# ------------------------------------------------------------------
# 统一文档提取(多模态,直接传图片给 LLM)
# ------------------------------------------------------------------
def _image_to_base64(image_path: Path) -> str:
"""将图片文件读取为 base64 字符串。"""
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def _stream_llm_response(
llm: Any,
messages: list[Any],
source_dir: Path | None,
reasoning_effort: str,
log_label: str = "LLM",
) -> str:
"""流式调用 LLM 并写入 SSE 事件(供 llm_query_text 和 _llm_query_multimodal 共用)。
Args:
llm: LLM 实例。
messages: 消息列表。
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
reasoning_effort: 推理努力级别。
log_label: 日志标签(用于区分"纯文本"和"多模态")。
Returns:
LLM 响应文本。
"""
try:
if source_dir:
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
parts = []
for resp in llm.stream_chat(
messages,
temperature=0.1,
extra_body={"reasoning_effort": reasoning_effort},
):
delta = resp.delta
if delta:
parts.append(delta)
if source_dir:
_emit_llm_stream(source_dir, "chunk", text=delta)
thinking = getattr(resp, "additional_kwargs", {}) or {}
thinking_delta = thinking.get("thinking_delta", "")
if thinking_delta and source_dir:
_emit_llm_stream(source_dir, "reasoning", text=thinking_delta)
text = "".join(parts)
log.info("%s请求完成,响应总长度: %d 字符", log_label, len(text))
if source_dir:
_emit_llm_stream(source_dir, "end", label="分析完成")
return text
except Exception as e:
log.error("%s请求失败: %s", log_label, e)
if source_dir:
_emit_llm_stream(source_dir, "error", error=str(e))
raise
def llm_query_text(
system_prompt: str,
text: str,
reasoning_effort: str = "none",
source_dir: Path | None = None,
) -> str:
"""发送纯文本请求到 LLM(供 Agent 调度使用)。
Args:
system_prompt: 系统提示词。
text: 用户文本。
reasoning_effort: 推理努力级别。
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
Returns:
LLM 响应文本。
"""
from llama_index.core.base.llms.types import TextBlock
from llama_index.core.llms import ChatMessage
from ...config import get_llm_config
messages = [
ChatMessage(role="system", content=system_prompt),
ChatMessage(role="user", blocks=[TextBlock(text=text)]),
]
llm_config = get_llm_config()
llm = _create_llm()
log.info(
"开始请求 LLM (model=%s, base=%s)",
llm_config["model"],
llm_config["api_base"],
)
return _stream_llm_response(llm, messages, source_dir, reasoning_effort, log_label="LLM")
def extract_document(file_path: Path) -> dict[str, Any]:
"""统一文档提取入口:从任意图片/PDF 中提取结构化信息。
LLM 会根据统一提示词自行判断文档类型(发票/支付记录/出差事前申请单等)。
Args:
file_path: 文件路径(支持 PDF 和图片格式)。
Returns:
包含提取字段的字典。
"""
from ...infra.documents.pdf import render_pdf_to_images
system_prompt = build_invoice_system_prompt()
user_text = f"请分析以下财务文档并提取信息:\n\n文件名: {file_path.name}"
# PDF 先渲染为图片
suffix = file_path.suffix.lower()
if suffix == ".pdf":
image_b64s = render_pdf_to_images(file_path)
else:
image_b64s = [_image_to_base64(file_path)]
if not image_b64s:
log.warning(f"文件渲染为空: {file_path.name}")
return {}
try:
response = _llm_query_multimodal(system_prompt, user_text, image_b64s)
result = parse_json_response(response)
log.info("LLM 文档提取成功: %s", file_path.name)
return result
except Exception as e:
log.error("LLM 文档提取失败: %s (%s)", file_path.name, e)
raise
def _llm_query_multimodal(
system_prompt: str,
text: str | None = None,
image_b64s: list[str] | None = None,
blocks: list[Any] | None = None,
reasoning_effort: str = "none",
source_dir: Path | None = None,
) -> str:
"""发送多模态请求到 LLM(内部使用)。
Args:
system_prompt: 系统提示词。
text: 用户文本(与 image_b64s 配合使用,文本在前、图片在后)。
image_b64s: base64 编码的图片列表。
blocks: 预构建的内容块列表(TextBlock/ImageBlock),传入时忽略 text 和 image_b64s。
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
Returns:
LLM 响应文本。
"""
from llama_index.core.base.llms.types import ImageBlock, TextBlock
from llama_index.core.llms import ChatMessage
from ...config import get_llm_config
if blocks is not None:
final_blocks = blocks
else:
text = text or ""
image_b64s = image_b64s or []
final_blocks = [TextBlock(text=text)]
for img_b64 in image_b64s:
final_blocks.append(
ImageBlock(
url=f"data:image/jpeg;base64,{img_b64}",
detail="high",
)
)
messages = [
ChatMessage(role="system", content=system_prompt),
ChatMessage(role="user", blocks=final_blocks),
]
llm_config = get_llm_config()
llm = _create_llm()
log.info(
"开始请求 LLM 多模态 (model=%s, base=%s, blocks=%d)",
llm_config["model"],
llm_config["api_base"],
len(final_blocks),
)
return _stream_llm_response(llm, messages, source_dir, reasoning_effort, log_label="LLM多模态")
def load_cache(source_dir: Path) -> dict[str, Any]:
"""从 JSON 缓存目录加载结构化数据,构建 source filename -> 缓存数据的映射。
Args:
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
Returns:
{source_filename: extracted_data} 字典。
额外包含 "travel_info" 键(如果 travel_info.json 存在)。
"""
cache_map: dict[str, Any] = {}
cache_dir = source_dir / CACHE_DIR_NAME
if not cache_dir.exists():
return cache_map
for json_path in sorted(cache_dir.glob("*.json")):
try:
with open(json_path, encoding="utf-8") as f:
cache_data = json.load(f)
if json_path.name in ("travel_info.json", "normal_info.json"):
cache_map[json_path.name.replace(".json", "")] = cache_data
continue
extracted = cache_data.get("extracted_data", {})
src_file = extracted.get("_source_file", "")
if src_file:
cache_map[src_file] = extracted
except Exception as e:
log.warning(f"读取缓存失败 {json_path.name}: {e}")
return cache_map
def load_match_result(source_dir: Path) -> dict[str, list[dict[str, Any]]]:
"""从 JSON 缓存目录加载发票与支付记录的匹配结果。
Args:
source_dir: 源文件目录(包含 .invoice_cache 子目录)。
Returns:
{支付记录源文件 (含金额): [发票信息列表]} 字典。
每个发票信息包含 file, type, amount 字段。
"""
cache_dir = source_dir / CACHE_DIR_NAME
match_path = cache_dir / "match_result.json"
if not match_path.exists():
return {}
try:
with open(match_path, encoding="utf-8") as f:
result: dict[str, list[dict[str, Any]]] = json.load(f)
return result
except Exception as e:
log.warning(f"读取匹配结果缓存失败: {e}")
return {}
def build_extraction_user_message(
cache_map: dict[str, Any],
match_result: dict[str, list[dict[str, Any]]],
previous_analysis: dict[str, Any] | None = None,
user_supplement: str | None = None,
) -> str:
"""构建提取请求的用户消息(供 Agent 调度使用)。
Args:
cache_map: 缓存数据映射。
match_result: 匹配结果。
previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。
user_supplement: 用户文字补充(可选,传入时作为修正上下文)。
Returns:
拼接好的用户消息字符串。
"""
parts = [
"以下是本次报销的所有源文件及其提取出的结构化数据。"
"每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。"
]
if previous_analysis:
parts.append(
"【上一轮分析结果】"
"以下是上一轮 LLM 对已有文件的分析结果。"
"注意:用户可能已补充新文件,请综合所有数据(含新文件)重新分析。"
"如果新文件填补了之前的信息缺失,请相应更新分析结果。\n"
+ json.dumps(previous_analysis, ensure_ascii=False, indent=2)
)
if user_supplement:
parts.append("【用户文字补充】用户补充了以下文字信息,请据此更新你的分析结果。\n" + user_supplement)
if match_result:
parts.append(
"【发票与支付记录匹配结果】"
"以下数据已将发票信息与对应的支付记录进行关联匹配,"
"用于判断每笔支付对应的发票和商户信息。\n" + json.dumps(match_result, ensure_ascii=False, indent=2)
)
for filename, extracted in cache_map.items():
parts.append(
f"【源文件: {filename}】"
"以下为从该文件提取的结构化发票/支付/申请单数据。\n" + json.dumps(extracted, ensure_ascii=False, indent=2)
)
parts.append("\n=== 请返回 JSON 格式结果 ===")
return "\n".join(parts)
def _extract_info(
source_dir: Path | None,
system_prompt: str,
info_type: str,
) -> dict[str, Any]:
"""通用的信息提取函数:加载缓存、构建消息、调用 LLM 并解析 JSON。
extract_travel_info 和 extract_normal_info 的公共实现。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
system_prompt: 系统提示词。
info_type: 信息类型标签("差旅" 或 "普通发票"),用于日志。
Returns:
LLM 提取的结构化信息字典。
"""
if not source_dir:
log.warning("未提供 source_dir,无法加载缓存数据")
return {}
cache_map = load_cache(source_dir)
match_result = load_match_result(source_dir)
user_message = build_extraction_user_message(cache_map, match_result)
log.info("开始构建%s信息提取请求,缓存条目: %d, 匹配结果: %d", info_type, len(cache_map), len(match_result))
try:
response = llm_query_text(
system_prompt=system_prompt,
text=user_message,
reasoning_effort="low",
source_dir=source_dir,
)
result = parse_json_response(response)
log.info("LLM %s信息提取成功", info_type)
return result
except Exception as e:
log.error("LLM %s信息提取失败: %s", info_type, e)
raise
def extract_travel_info(
source_dir: Path | None = None,
) -> dict[str, Any]:
"""根据差旅发票(bot 格式),让 LLM 提取出差相关信息。
纯提取,不包含校验逻辑。校验由 Agent 层调度。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
Returns:
包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。
"""
return _extract_info(source_dir, build_travel_info_system_prompt(), "差旅")
def extract_normal_info(
source_dir: Path | None = None,
) -> dict[str, Any]:
"""根据普通发票(非差旅),让 LLM 提取报销相关信息。
纯提取,不包含校验逻辑。校验由 Agent 层调度。
Args:
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
Returns:
包含报销说明、发票总数、总金额、支付方式、附件清单等字段的字典。
"""
return _extract_info(source_dir, build_normal_info_system_prompt(), "普通发票")
+15 -2
View File
@@ -10,14 +10,13 @@ from pathlib import Path
from typing import Any
from ... import get_logger
from ...core.extraction.cache import load_cache, save_match_result
from ...core.extraction.cache import load_cache, save_json, save_match_result
from ...infra.documents.invoice import (
classify_invoice_batch,
save_application_json,
save_csv,
save_invoice_csv,
)
from ...pipeline_core import save_invoice_groups
from .matcher import match_invoices_to_cards
log = get_logger("matching.runner")
@@ -46,6 +45,20 @@ def split_cache(cache_map: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
return buckets
def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, Any]]]) -> None:
"""写 invoice_groups.json:完整分组 + 计数,供后续阶段与产物判断。"""
data = {
"travel": groups.get("travel", []),
"general": groups.get("general", []),
"application": groups.get("application", []),
"attachments": groups.get("attachments", []),
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
}
save_json(session_dir / "invoice_groups.json", data)
def match_from_cache(case_dir: Path, tolerance: float | None = None) -> dict[str, Any]:
"""按缓存内容执行金额匹配,返回结构化结果。
+1 -15
View File
@@ -2,14 +2,13 @@
异常层次:
ReimbursementError — 所有业务异常的基类
├── ExtractionError — 文档提取失败(单个文件/批量全失败)
├── BrowserError — 浏览器自动化失败
└── ValidationError — 校验失败(规则校验/语义校验)
使用规则:
- 模块内部: 捕获具体异常 → 记录日志 → 截图(如适用) → re-raise
- 模块边界: 不吞异常,向上传播
- 顶层 (routes.py / orchestrator.py): 统一捕获 ReimbursementError
- MCP 工具层: 不把异常抛穿协议层,统一转成 {ok:false, reason, next_action} 信封
- 可恢复场景: 返回结构化结果而非 raise
"""
@@ -26,19 +25,6 @@ class ReimbursementError(Exception):
self.details = details or {}
class ExtractionError(ReimbursementError):
"""文档提取失败"""
def __init__(
self,
message: str,
failed_files: list[str] | None = None,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message, details)
self.failed_files = failed_files or []
class BrowserError(ReimbursementError):
"""浏览器自动化操作失败"""
+12 -17
View File
@@ -1,13 +1,9 @@
"""文档处理基础设施
提供发票数据模型、PDF 渲染、出库单填写等功能。
发票/支付 CSV、PDF 渲染、低值易耗品申购单(Excel COM)。
出库单(Word)已随功能下线删除:见提交记录与 .agents/docs/plans/MCP化改造方案-2026-10-19.md。
"""
from .consumable import (
CONSUMABLE_DOC_FILENAME,
fill_consumable_doc,
fill_consumable_from_template,
)
from .invoice import (
CACHE_DIR_NAME,
INVOICE_LEVEL_COLUMNS,
@@ -24,25 +20,24 @@ from .requisition import (
REQUISITION_FILENAME,
fill_requisition_excel,
generate_requisition,
load_project_desc,
load_requisition_items,
)
__all__ = [
"CACHE_DIR_NAME",
"DEFAULT_PROJECT",
"INVOICE_LEVEL_COLUMNS",
"PAYMENT_RECORD_COLUMNS",
"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",
"REQUISITION_FILENAME",
"DEFAULT_PROJECT",
"load_requisition_items",
"classify_invoice_batch",
"fill_requisition_excel",
"generate_requisition",
"load_csv",
"load_invoice_csv",
"load_project_desc",
"load_requisition_items",
"save_application_json",
"save_csv",
"save_invoice_csv",
]
-262
View File
@@ -1,262 +0,0 @@
"""将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
仅写入表格数据单元格,保留原模板字体、边框与版式。
"""
from __future__ import annotations
import argparse
import re
import shutil
from datetime import date
from pathlib import Path
from typing import Any
from ... import get_logger
from ...config import load_config
from .invoice import load_invoice_csv
log = get_logger("fill_consumable_doc")
CONSUMABLE_DOC_FILENAME = "易耗品、出库单.doc"
# Word COM 常量
WD_CHARACTER = 1
# 表格统一字体:宋体、五号(10.5 磅)
TABLE_FONT_NAME = "宋体"
TABLE_FONT_SIZE = 10.5
def _split_name_spec(left: str) -> tuple[str, str]:
m = re.search(r"(\S+一批)\s*$", left)
if m:
return m.group(1), left[: m.start()].strip()
parts = left.split(" ", 1)
if len(parts) == 2:
return parts[0], parts[1]
return left, ""
def parse_spec_model(spec: str) -> dict[str, str]:
spec = (spec or "").strip()
if " 个 " not in spec:
return {
"product_name": spec,
"spec": "",
"unit": "",
"qty": "",
"unit_price": "",
}
left, right = spec.split(" 个 ", 1)
product_name, model_spec = _split_name_spec(left.strip())
tokens = right.split()
qty = ""
unit_price = ""
if len(tokens) >= 4 and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]):
qty, unit_price = tokens[0], tokens[1]
elif tokens and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]):
qty, unit_price = "1", tokens[0]
return {
"product_name": product_name,
"spec": model_spec,
"unit": "个",
"qty": qty,
"unit_price": unit_price,
}
def _format_money(value: str | float) -> str:
"""单价、金额:固定保留两位小数。"""
if value is None or value == "":
return ""
try:
num = float(value)
except (TypeError, ValueError):
return str(value)
return f"{num:.2f}"
def _today_cn_date() -> str:
"""当前日期,格式:2026年5月26日"""
today = date.today()
return f"{today.year}年{today.month}月{today.day}日"
def _apply_font(rng: Any) -> None:
"""将范围字体设为宋体五号(含数字与英文)。"""
font = rng.Font
font.Name = TABLE_FONT_NAME
font.NameFarEast = TABLE_FONT_NAME
font.NameAscii = TABLE_FONT_NAME
font.NameOther = TABLE_FONT_NAME
font.NameBi = TABLE_FONT_NAME
font.Size = TABLE_FONT_SIZE
def _set_cell_value(cell: Any, text: str) -> None:
"""写入单元格正文(不含末尾单元格标记)。"""
rng = cell.Range
rng.MoveEnd(WD_CHARACTER, -1)
rng.Text = "" if text is None else str(text)
_apply_font(rng)
def _normalize_table_font(tbl: Any) -> None:
"""填写完成后统一整张表的字体。"""
for row in tbl.Rows:
for cell in row.Cells:
rng = cell.Range
rng.MoveEnd(WD_CHARACTER, -1)
_apply_font(rng)
def _replace_date_in_doc(doc: Any, new_date: str) -> None:
"""仅替换表头段落中的日期文字,不改动段落其余部分。"""
if not new_date:
return
try:
para = doc.Paragraphs(3)
except Exception:
return
rng = para.Range
text = rng.Text.replace("\r", "").replace("\x07", "")
m = re.search(r"\d{4}年\d{1,2}月\d{1,2}日", text)
if not m:
return
start = rng.Start + m.start()
end = rng.Start + m.end()
doc.Range(Start=start, End=end).Text = new_date
def fill_consumable_doc(
csv_path: str | Path,
doc_path: str | Path,
config: dict[str, Any] | None = None,
backup: bool = True,
) -> Path:
csv_path = Path(csv_path)
doc_path = Path(doc_path)
if config is None:
config = load_config()
invoices = load_invoice_csv(csv_path.parent / "invoice_summary.csv") or []
if backup:
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
shutil.copy2(doc_path, bak)
import pythoncom
import win32com.client
pythoncom.CoInitialize()
word = None
doc = None
try:
word = win32com.client.Dispatch("Word.Application")
word.Visible = False
word.DisplayAlerts = 0
doc = word.Documents.Open(str(doc_path.resolve()))
try:
_replace_date_in_doc(doc, _today_cn_date())
tbl = doc.Tables(1)
storage = config.get("consumable_storage", "躬行楼 C205")
for i, inv in enumerate(invoices):
row_idx = i + 2
if row_idx > tbl.Rows.Count:
break
parsed = parse_spec_model(str(inv.get("spec_model", "")))
# 当规格型号为空时,从项目名称提取产品信息
if not parsed["product_name"]:
item_name = str(inv.get("item_name", ""))
# 去除 "*分类*" 前缀(如 "*电子工业设备*元件盒" -> "元件盒")
if "*" in item_name:
item_name = item_name.split("*")[-1].strip()
parsed["product_name"] = item_name
card_amount_raw = inv.get("card_amount") or 0
card_amount: float = float(str(card_amount_raw).replace(",", ""))
qty_str = parsed["qty"]
qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0
# 金额填写刷卡金额,单价由刷卡金额反算
amount = _format_money(card_amount)
unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount)
# 数量:去掉前导零;若无数量则默认为 1
qty = str(qty_val) if qty_val > 0 else "1"
values = [
str(inv.get("index", i + 1)),
parsed["product_name"],
parsed["spec"],
parsed["unit"],
qty,
unit_price,
amount,
"", # 购货人签字 — 保持空白
storage,
"", # 领用人签字 — 保持空白
"", # 备注 — 保持空白,避免撑破版式
]
for col_idx, val in enumerate(values, start=1):
_set_cell_value(tbl.Cell(row_idx, col_idx), str(val))
_normalize_table_font(tbl)
doc.Save()
finally:
if doc is not None:
doc.Close()
finally:
if word is not None:
word.Quit()
pythoncom.CoUninitialize()
return doc_path
def fill_consumable_from_template(
csv_path: str | Path,
template_path: str | Path,
output_path: str | Path,
config: dict[str, Any] | None = None,
) -> Path:
"""从模板复制并填写出库单(Web 会话每次从模板重新生成)。"""
template_path = Path(template_path)
output_path = Path(output_path)
if not template_path.exists():
raise FileNotFoundError(f"出库单模板不存在: {template_path}")
shutil.copy2(template_path, output_path)
return fill_consumable_doc(csv_path, output_path, config=config, backup=False)
def main() -> None:
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单")
parser.add_argument("--csv", default=str(root / "invoice_summary.csv"))
parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc"))
parser.add_argument("--config", default=str(root / "scripts" / "data" / "config.json"))
parser.add_argument("--no-backup", action="store_true")
args = parser.parse_args()
cfg = None
if Path(args.config).exists():
import json
with open(args.config, encoding="utf-8") as f:
cfg = {**load_config(), **json.load(f)}
out = fill_consumable_doc(args.csv, args.doc, config=cfg, backup=not args.no_backup)
print(f"已填写并保存: {out}")
if __name__ == "__main__":
main()
-46
View File
@@ -1,46 +0,0 @@
"""PDF 图片渲染
从 PDF 发票文件中渲染为图片供多模态 LLM 使用。
对外接口:
render_pdf_to_images(filepath, dpi) -list[str] 渲染 PDF 为图片字节
"""
import base64
from pathlib import Path
import fitz
from ... import get_logger
log = get_logger("pdf")
def render_pdf_to_images(filepath: Path, dpi: int = 300) -> list[str]:
"""将 PDF 渲染为图片,返回 base64 编码的 JPEG 字符串列表。
Args:
filepath: PDF 文件路径。
dpi: 渲染分辨率(默认 300,平衡质量与速度)。
Returns:
base64 编码的 JPEG 图片字符串列表(每页一个)。
"""
images = []
try:
doc = fitz.open(filepath)
zoom = dpi / 72.0 # 72 DPI 是 fitz 默认
matrix = fitz.Matrix(zoom, zoom)
for page in doc:
pix = page.get_pixmap(matrix=matrix)
jpg_bytes = pix.tobytes("jpg")
b64 = base64.b64encode(jpg_bytes).decode("utf-8")
images.append(b64)
doc.close()
log.info(f"PDF 渲染成功: {filepath.name} ({len(images)} 页, {dpi} DPI)")
except Exception as e:
log.error(f"PDF 渲染失败 {filepath.name}: {e}")
return images
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""
财务报销自动化
依次执行:
1. 发票提取 — 从 PDF 发票提取信息,生成 invoice_summary.csv
2. 报销提交 — 打开浏览器登录财务系统并自动填报
用法:
python run.py # 全流程
python run.py --step invoice # 仅发票提取
python run.py --step submit # 仅浏览器填报
python run.py -u 工号 -p 密码 # 覆盖登录凭据
"""
import argparse
import sys
from pathlib import Path
# 确保项目根目录在 sys.path 中
sys.path.insert(0, str(Path(__file__).parent.resolve().parent))
from src.pipeline import run_pipeline
def main() -> None:
parser = argparse.ArgumentParser(
description="财务报销自动化 - 发票提取 → 浏览器填报",
)
parser.add_argument(
"--step",
choices=["all", "invoice", "submit"],
default="all",
help="执行步骤 (默认: all)",
)
parser.add_argument(
"-u",
"--username",
default=None,
help="信息门户登录账号(覆盖 scripts/config.json)",
)
parser.add_argument(
"-p",
"--password",
default=None,
help="信息门户登录密码(覆盖 scripts/config.json)",
)
parser.add_argument(
"--cache-dir",
default=None,
help="发票缓存目录(包含 .invoice_cache 子目录,默认: scripts/data)",
)
args = parser.parse_args()
exit_code = run_pipeline(
step=args.step,
username=args.username,
password=args.password,
cache_dir=args.cache_dir,
)
sys.exit(exit_code)
if __name__ == "__main__":
main()
+2 -3
View File
@@ -33,8 +33,8 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配
或用 mcp__arb__validate_info(draft=...) 不落盘反复试。不完整就向用户要补充材料,禁止自己宣布可提交。
拼 JSON 前先 mcp__arb__info_skeleton(info_type) 取骨架:required 为 true 的键必须存在(含值为空串的备注类
字段),骨架与 validation_rules.json 同源,别凭记忆造结构。
6. 普通发票用 mcp__arb__doc_consumable 生成 Word 出库单、mcp__arb__doc_requisition 生成 Excel 申购单
(COM 自动化,需本机装有 Word/Excel;纯差旅会返回 skipped,不是错误)。
6. 普通发票用 mcp__arb__doc_requisition 生成 Excel 申购单(COM 自动化,需本机装有 Excel;
exclude 可剔除不参与申购的物品;纯差旅没有申购明细会返回失败提示,不是错误)。
人工核对后要改单张票据的某个字段,用 mcp__arb__case_patch_extraction(只传要改的键,会回显 from/to)。
7. 每步之后用 mcp__arb__case_status 复查态势;阶段由产物推导,没有内部状态机。
mcp__arb__case_reset(scope) 可清派生产物重跑;mcp__arb__case_list 看全部会话。
@@ -62,7 +62,6 @@ TOOLS: list[Callable[..., dict[str, Any]]] = [
analysis.validate_info,
analysis.rules_get,
analysis.info_skeleton,
documents.doc_consumable,
documents.doc_requisition,
]
+4 -59
View File
@@ -1,7 +1,7 @@
"""Word 出库单与 Excel 申购单生成工具
"""Excel 申购单生成工具(Word 出库单已随模板下线删除)
都是 COM 自动化,依赖 Windows + Microsoft Office;统一走 src.infra.com_thread
的单线程 actor,避免两个工具调用同时驱动 Word/Excel。
COM 自动化,依赖 Windows + Microsoft Excel;统一走 src.infra.com_thread 的单线程 actor,
避免多个调用同时驱动 Office。
"""
from __future__ import annotations
@@ -10,68 +10,13 @@ from pathlib import Path
from typing import Any
from ...cases import ARTIFACTS_DIR_NAME, CACHE_DIR_NAME, PROJECT_ROOT, CaseStore
from ...config import SESSION_CONFIG_KEYS, load_config
from ...infra.com_thread import run_com
from ...infra.documents import (
CONSUMABLE_DOC_FILENAME,
REQUISITION_FILENAME,
fill_consumable_from_template,
generate_requisition,
load_requisition_items,
)
from ...infra.documents import REQUISITION_FILENAME, generate_requisition, load_requisition_items
from ..envelope import fail, ok
_store = CaseStore()
def _case_config(d: Path) -> dict[str, Any]:
"""项目配置 + 会话目录里 config.json 的用户字段覆盖。"""
config = load_config()
cfg_path = d / "config.json"
if cfg_path.exists():
import json
try:
raw: dict[str, Any] = json.loads(cfg_path.read_text(encoding="utf-8"))
except Exception:
raw = {}
for key in SESSION_CONFIG_KEYS:
if key in raw:
config[key] = raw[key]
return config
def doc_consumable(case_id: str, output_name: str | None = None) -> dict[str, Any]:
"""生成易耗品出库单(Word,仅普通发票需要)。
取会话里的 payment_records.csv 填入项目根目录模板的副本,输出到
cases/<id>/artifacts/。纯差旅发票会返回 skipped,不是错误。
需要 Windows + 已安装 Microsoft Word。
"""
d = _store.resolve(case_id)
if d is None:
return fail(f"会话不存在: {case_id}")
csv_path = d / "payment_records.csv"
if not csv_path.exists():
return fail("缺少 payment_records.csv", next_action="先 mcp__arb__match_payments 生成汇总")
groups = _groups(d)
if groups is not None and not groups.get("general_count", 0):
return ok(skipped=True, reason="本会话没有普通发票(纯差旅),不需要易耗品出库单")
template = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
if not template.exists():
return fail(f"出库单模板不存在: {template}", next_action="把模板放回项目根目录")
out = d / ARTIFACTS_DIR_NAME / (output_name or CONSUMABLE_DOC_FILENAME)
out.parent.mkdir(parents=True, exist_ok=True)
config = _case_config(d)
try:
run_com(fill_consumable_from_template, csv_path, template, out, config=config)
except ImportError:
return fail("缺少 pywin32,无法驱动 Word", next_action="uv sync 安装项目依赖", needs="pywin32")
except Exception as e:
return fail(f"生成出库单失败: {e}", next_action="检查 Word 是否被其他实例占用或有弹窗未关闭", doc=str(out))
return ok(doc=str(out), filename=out.name, bytes=out.stat().st_size, skipped=False)
def doc_requisition(case_id: str, exclude: list[str] | None = None, project: str | None = None) -> dict[str, Any]:
"""生成低值易耗品申购单(Excel,仅普通发票)。
-119
View File
@@ -1,119 +0,0 @@
"""
报销全流程编排
将发票提取 → 差旅/普通信息提取 → 浏览器填报串联为一条管道,
数据在内存中流转,同时生成 CSV 中间产物。
发票类型区分:
- 差旅发票(train/hotel):不生成易耗品出库单,走差旅报销流程
- 普通发票(general):生成易耗品出库单,走普通报销流程
数据流变更(2026-06-11):
在发票提取和匹配完成后立即判断报销类型(差旅/普通),
差旅调用 LLM 提取 travel_info.json,普通预留 normal_info.json。
Bot 仅负责接收信息并填报,不再承担信息提取职责。
"""
from pathlib import Path
from typing import Any
from . import get_logger
from .config import load_config
from .core.extraction import extract_invoices
from .pipeline_core import (
extract_and_cache_normal_info,
extract_and_cache_travel_info,
extract_info_by_type,
is_travel_invoice,
process_invoices,
)
log = get_logger("pipeline")
def run_pipeline(
step: str = "all",
username: str | None = None,
password: str | None = None,
cache_dir: str | None = None,
) -> int:
"""执行报销流程
Args:
step: all | invoice | submit
username: 覆盖 config.json 中的用户名
password: 覆盖 config.json 中的密码
cache_dir: 发票缓存目录(包含 .invoice_cache 子目录)
"""
config = load_config()
if username:
config["username"] = username
if password:
config["password"] = password
project_dir = Path(__file__).parent.parent
cache_path = Path(cache_dir) if cache_dir else project_dir / "scripts" / "data"
# --------------------------------------------------
# Step 1: 发票提取 + 类型判断 + 信息提取
# --------------------------------------------------
payment_records: list[dict[str, str]] | None = None
groups: dict[str, list[dict[str, str]]] | None = None
travel_info: dict[str, Any] | None = None
normal_info: dict[str, Any] | None = None
if step in ("all", "invoice"):
log.info("=" * 60)
log.info("[1/2] 发票提取")
log.info("=" * 60)
payment_records, applications, groups = extract_invoices(str(cache_path))
if not payment_records:
log.error("未提取到任何发票数据")
return 1
# 使用公共函数处理发票数据
process_invoices(payment_records, applications, groups, cache_path)
# 发票提取完成后立即判断类型并提取信息
travel_info, normal_info = extract_info_by_type(groups, cache_path)
if step == "invoice":
log.info("[1/2] 发票提取 完成")
return 0
# --------------------------------------------------
# Step 2: 浏览器填报
# --------------------------------------------------
if step in ("all", "submit"):
log.info("=" * 60)
log.info("[2/2] 报销提交")
log.info("=" * 60)
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):
if travel_info is None:
travel_info = extract_and_cache_travel_info(groups, cache_path)
log.info("检测到纯差旅发票,使用差旅报销模式")
run_bot(config, work_dir=cache_path, travel_info=travel_info)
else:
if normal_info is None:
normal_info = extract_and_cache_normal_info(groups, cache_path)
log.info("检测到普通发票,使用普通报销模式")
run_bot(config, work_dir=cache_path, normal_info=normal_info)
if step == "submit":
log.info("[2/2] 报销提交 完成")
return 0
log.info("=" * 60)
log.info("全流程执行完毕")
log.info("=" * 60)
return 0
-233
View File
@@ -1,233 +0,0 @@
"""
管道核心逻辑
抽取 pipeline.py(CLI 管道)和 pipeline_web.py(Web 管道)的公共数据流:
发票分类判断 -> 差旅/普通信息提取 -> 缓存读写
两个入口分别传入不同的目录参数,复用此模块。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, cast
from . import get_logger
from .core.extraction import (
CACHE_DIR_NAME,
extract_normal_info,
extract_travel_info,
load_cache,
)
from .infra.documents import (
classify_invoice_batch,
save_application_json,
save_invoice_csv,
)
from .infra.documents import save_csv as save_payment_csv
log = get_logger("pipeline_core")
def is_travel_invoice(groups: dict[str, list[dict[str, Any]]]) -> bool:
"""判断是否为纯差旅发票(有差旅发票且无普通发票)。
注意:系统仅支持「纯差旅」和「普通报销」两种模式。
若同时存在差旅发票和普通发票(混合),则视为普通报销模式处理——差旅发票
对应的费用仍会在普通报销中按项目填报。若需要严格区分,上游应在发票分类
后报错提示用户分开提交。
"""
return bool(groups.get("travel")) and not bool(groups.get("general"))
def save_cache_info(cache_path: Path, info_key: str, info: dict[str, Any]) -> None:
"""将提取结果保存到缓存目录
Args:
cache_path: 会话目录路径。
info_key: 缓存键名("travel_info" 或 "normal_info")。
info: 提取结果字典。
"""
cache_dir = cache_path / CACHE_DIR_NAME
cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_dir / f"{info_key}.json", "w", encoding="utf-8") as f:
json.dump(info, f, ensure_ascii=False, indent=2)
log.info("%s 已保存到缓存", info_key)
def extract_and_cache_travel_info(
groups: dict[str, list[dict[str, Any]]],
cache_path: Path,
) -> dict[str, Any] | None:
"""当存在差旅发票时,调用 LLM 提取差旅信息并缓存。
Returns:
差旅信息字典,非差旅时返回 None。
"""
if not groups.get("travel"):
return None
# 检查缓存是否已有
cache_map = load_cache(cache_path)
travel_info = cache_map.get("travel_info")
if travel_info:
log.info("使用已有差旅信息缓存")
return travel_info # type: ignore[no-any-return]
log.info("开始提取差旅信息...")
travel_info = extract_travel_info(source_dir=cache_path)
save_cache_info(cache_path, "travel_info", travel_info)
return travel_info
def extract_and_cache_normal_info(
groups: dict[str, list[dict[str, Any]]],
cache_path: Path,
) -> dict[str, Any] | None:
"""当存在普通发票时,调用 LLM 提取普通报销信息并缓存。
Returns:
普通报销信息字典,非普通时返回 None。
"""
if not groups.get("general"):
return None
# 检查缓存是否已有
cache_map = load_cache(cache_path)
normal_info = cache_map.get("normal_info")
if normal_info:
log.info("使用已有普通发票信息缓存")
return normal_info # type: ignore[no-any-return]
log.info("开始提取普通发票信息...")
normal_info = extract_normal_info(source_dir=cache_path)
save_cache_info(cache_path, "normal_info", normal_info)
return normal_info
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
"""从缓存目录读取发票数据并按类型分组
注意:load_cache 会加载所有 .invoice_cache/*.json,包括 travel_info 和 normal_info
等提取结果缓存(它们没有 invoice_type 字段),需要显式过滤掉。
"""
cache_map = load_cache(cache_path)
# 过滤掉非发票的缓存条目:travel_info、normal_info 等提取结果
invoices = [
data
for key, data in cache_map.items()
if key not in ("travel_info", "normal_info")
and isinstance(data, dict)
and data.get("invoice_type") not in ("application", "payment", "attachments", "note")
]
groups = classify_invoice_batch(invoices)
# 从缓存重新收集 attachments 类型
attachments = [
data
for key, data in cache_map.items()
if isinstance(data, dict) and data.get("invoice_type") in ("attachments", "note")
]
groups["attachments"] = attachments
return groups
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", []),
"attachments": groups.get("attachments", []),
"travel_count": len(groups.get("travel", [])),
"general_count": len(groups.get("general", [])),
"application_count": len(groups.get("application", [])),
"attachments_count": len(groups.get("attachments", [])),
}
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'])} 张, 附件 {len(groups.get('attachments', []))} 个"
)
return {
"invoice_count": invoice_count,
"travel_count": len(groups["travel"]),
"general_count": len(groups["general"]),
"application_count": len(groups.get("application", [])),
"attachments_count": len(groups.get("attachments", [])),
}
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
-339
View File
@@ -1,339 +0,0 @@
---
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 转义等工具函数
```
## 数据流
```mermaid
graph TD
A[用户上传文件] --> B[创建 session]
B --> C[文件写入 uploads/<sid>/]
C --> D[后台线程执行管道]
D --> E["extract_invoices()"]
E --> F["enrich_with_llm()"]
F --> G["save_csv()"]
G --> H["结果写入 session 目录"]
H --> I["invoice_summary.csv"]
H --> J["invoice_groups.json"]
H --> K["result.json"]
H --> L["session.log"]
E --> M{"判断报销类型"}
M -->|差旅| N["extract_travel_info()"]
M -->|普通| O["extract_normal_info()"]
N --> P["travel_info.json"]
O --> Q["normal_info.json"]
K --> R["前端 SSE 轮询 → 显示完成状态"]
R --> S["前端加载 CSV → 可编辑表格"]
S --> T["用户修改后保存"]
T --> U["用户点击提交"]
U --> V["run_financial_submit()"]
V --> W["bot 自动填报财务系统"]
W --> X["差旅模式: travel_info.json → 填报差旅单 → 上传差旅附件"]
W --> Y["普通模式: normal_info.json → 基本信息 → 录入明细 → 支付信息 → 上传附件"]
```
## API 路由
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/` | 主界面 |
| POST | `/api/session` | 创建会话,返回 session_id |
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
| GET | `/api/files/<sid>` | 列出会话文件(返回统一 `files` 列表,含 `name`、`type`、`size` 字段;旧字段 `pdfs`/`images` 保留向后兼容) |
| POST | `/api/agent/process/<sid>` | 启动 Agent 管道(后台线程) |
| GET | `/api/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.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
### 发票类型分流
- **差旅发票**(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- **普通发票**:生成易耗品出库单(Word 文档),走普通报销流程
`extract_invoices()` 在提取阶段完成分类,结果保存为 `invoice_groups.json`(包含 `travel_count`、`general_count`、`application_count`)。后续步骤(出库单生成、财务填报)统一从该文件读取分类结果,避免重复解析 CSV 和 JSON 字段。
### LLM 信息提取(2026-06-11)
发票提取和匹配完成后,根据类型分流调用 LLM 提取结构化报销信息:
- **差旅发票**:调用 `extract_travel_info()`,提取出差事由、地点、时间、交通/住宿明细、补助清单、支付方式、附件清单,缓存为 `travel_info.json`。
- **普通发票**:调用 `extract_normal_info()`,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`。
Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`),降级时从缓存加载原始发票数据。
### 移动端同步
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的文件通过 `syncFiles()` 轮询同步到 PC 端内存中的 `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, ...)` 写入:
```python
# 开始 LLM 调用(必需)
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
# 流式文本片段(可选,有内容时发)
_emit_llm_stream(source_dir, "chunk", text="让我来分析...")
# 思考过程片段(可选,模型支持时发)
_emit_llm_stream(source_dir, "reasoning", text="根据发票信息...")
# 调用完成(必需)
_emit_llm_stream(source_dir, "end", label="分析完成")
# 调用失败(异常时发)
_emit_llm_stream(source_dir, "error", error="连接超时")
```
**`agent_events.log`** — 由 `_emit_agent_event(session_dir, event_type, ...)` 写入:
```python
# 状态变更
_emit_agent_event(session_dir, "agent_state_change", state="extracting", message="正在分析文件...")
# 请求补充材料
_emit_agent_event(session_dir, "agent_request_supplement", message="请上传返程车票...")
# 信息完整,可以提交
_emit_agent_event(session_dir, "agent_ready")
# 错误
_emit_agent_event(session_dir, "agent_error", message="LLM 提取失败")
```
**`file_events.log`** — 由 `_emit_file_event(source_dir, ...)` 写入:
```python
# 文件开始处理
_emit_file_event(source_dir, filename, "processing")
# 文件处理完成(带摘要)
_emit_file_event(source_dir, filename, "done", summary={"invoice_number": "...", ...})
# 使用缓存
_emit_file_event(source_dir, filename, "cached")
# 处理失败
_emit_file_event(source_dir, filename, "error", error="PDF 解析失败")
```
**重要约束:`start` 和 `end` 事件不可省略。** 前端 `llmStreamState` 状态机依赖 `start` 创建气泡 DOM,没有 `start` 时后续的 `chunk` 和 `reasoning` 会因守卫条件直接返回。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`。
#### SSE 传输层
`/api/logs/<sid>` 端点(`routes.py`)的轮询逻辑:
```
每 0.5 秒:
1. 读取 session.log 增量 → yield "data: <日志行>"
2. 读取 file_events.log 增量 → 逐行 yield "data: <JSON>"
3. 读取 llm_stream.log 增量 → 逐行 yield "data: <JSON>"
4. 读取 agent_events.log 增量 → 逐行 yield "data: <JSON>"
5. 检查 result.json 是否存在 → yield "data: {type: 'done'}" 后退出
```
#### 前端事件路由
`process.js` 的 `EventSource` 监听器按 `msg.type` 分发:
```
msg.type === 'file_progress' → setFileProcessing / setFileDone / setFileCached / setFileError
msg.type === 'llm_stream' → handleLLMStream()
msg.type 以 'agent_' 开头 → handleAgentEvent()
msg.type === 'done' → 关闭 EventSource,展示结果
```
#### 前端聊天气泡渲染
**LLM 流式气泡**(`chat.js`):
```
start → _createLLMStreamBubble()
├─ 创建 <div class="chat-bubble processing llm-stream-bubble">
├─ 创建 label 元素(显示 label 文本)
├─ 创建 <details> 可折叠区域(思考过程)
└─ 创建 textContent 元素(正式回答)
└─ 注册到 llmStreamState
reasoning → _appendLLMStreamReasoning()
└─ 追加到 reasoningContent.textContent
chunk → _appendLLMStreamChunk()
└─ 追加到 textContent.textContent
end → _closeLLMStreamBubble()
├─ 气泡 class 从 processing 变为 done
└─ 清空 llmStreamState
error → _errorLLMStreamBubble()
├─ 气泡 class 变为 error
└─ 清空 llmStreamState
```
**Agent 状态气泡**(`agent.js`):
```
agent_state_change → _handleAgentStateChange()
└─ 更新最后一条状态消息(不追加新气泡)
agent_request_supplement → _handleAgentRequestSupplement()
└─ 追加请求补充的气泡
agent_ready → _handleAgentReady()
└─ 追加完成状态气泡
agent_error → _handleAgentError()
└─ 追加错误气泡
```
**文件进度气泡**(`chat.js`):
```
file_progress (processing) → setFileProcessing() → 三点动画
file_progress (done) → setFileDone() → 提取摘要
file_progress (cached) → setFileCached() → 缓存标识
file_progress (error) → setFileError() → 错误信息
```
#### 完整数据流
```mermaid
sequenceDiagram
participant Pipe as 后台线程<br/>(管道)
participant Files as 事件文件<br/>(session 目录)
participant SSE as Flask SSE<br/>(routes.py)
participant ES as EventSource<br/>(process.js)
participant Chat as chat.js
participant Agent as agent.js
Note over Pipe: 启动管道
Pipe->>Files: 追加 llm_stream start
Pipe->>Files: 追加 agent_events state_change
Note over SSE: 0.5s 轮询
SSE->>Files: seek(last_size) + read()
SSE->>ES: yield "data: start"
SSE->>ES: yield "data: state_change"
ES->>Chat: handleLLMStream({phase:"start"})
Chat->>Chat: 创建流式气泡
ES->>Agent: handleAgentEvent({type:"agent_state_change"})
Agent->>Agent: 显示状态消息
Note over Pipe: LLM 流式输出
Pipe->>Files: 追加 llm_stream reasoning
Pipe->>Files: 追加 llm_stream chunk
SSE->>Files: seek(last_size) + read()
SSE->>ES: yield "data: reasoning"
SSE->>ES: yield "data: chunk"
ES->>Chat: handleLLMStream({phase:"reasoning"})
Chat->>Chat: 追加思考内容
ES->>Chat: handleLLMStream({phase:"chunk"})
Chat->>Chat: 追加正式回答
Note over Pipe: 完成
Pipe->>Files: 追加 llm_stream end
Pipe->>Files: 写入 result.json
SSE->>Files: seek(last_size) + read()
SSE->>ES: yield "data: end"
ES->>Chat: handleLLMStream({phase:"end"})
Chat->>Chat: 气泡变完成状态
SSE->>Files: 检测 result.json
SSE->>ES: yield "data: {type:'done'}"
ES->>ES: 关闭连接
```
#### 添加新消息类型的步骤
1. 在对应模块定义 `_emit_xxx()` 函数,写入 session 目录的 JSON 文件
2. 在 `routes.py` 的 `stream_logs()` 轮询循环中新增对该文件的轮询
3. 在 `process.js` 的 EventSource 监听器中按 `msg.type` 路由到新处理器
4. 在 `chat.js` 或 `agent.js` 中实现渲染逻辑
5. 确保 `start` 和 `end` 事件成对出现(如果是流式气泡)
-5
View File
@@ -1,5 +0,0 @@
"""
Web 模块
提供财务报销系统的 Web 界面功能。
"""
-59
View File
@@ -1,59 +0,0 @@
"""
财务报销自动化 — Web 界面
用户上传 PDF 发票,配置账号信息,自动完成:
1. 发票提取 2. 浏览器填报(可选)
发票类型区分:
- 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程
- 普通发票:生成易耗品出库单,走普通报销流程
启动: uv run python src/web/app.py
访问: http://localhost:5000
"""
import sys
from pathlib import Path
# 确保项目根目录在 sys.path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from flask import Flask # noqa: E402, I001
from src.web import pipeline_web, routes # noqa: E402, I001
from src.infra.documents import CONSUMABLE_DOC_FILENAME # noqa: E402, I001
app = Flask(__name__, template_folder="templates")
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
_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)
# 初始化路由配置
routes.init_routes(UPLOAD_BASE)
# 注册 Blueprint
app.register_blueprint(routes.web_bp)
return app
# 直接使用 app 实例(保持向后兼容)
create_app()
if __name__ == "__main__":
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
print("启动 Web 服务: http://localhost:5000")
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False)
-190
View File
@@ -1,190 +0,0 @@
"""
Web 管道逻辑
负责:
- 发票提取管道编排
- 财务系统填报触发
- 易耗品出库单生成
- 发票分类数据持久化
"""
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote
# 延迟导入,避免循环引用
from src import get_logger # noqa: F401
from src.infra.documents import (
CONSUMABLE_DOC_FILENAME,
fill_consumable_from_template,
)
from src.pipeline_core import (
extract_info_by_type,
load_invoice_groups,
process_invoices,
)
fill_log = get_logger("fill_consumable_doc")
# 文件常量
SESSION_RESULT_FILE = "result.json"
INVOICE_GROUPS_FILE = "invoice_groups.json"
def resolve_payment_csv(session_dir: Path) -> Path | None:
"""查找支付记录 CSV(payment_records.csv)"""
csv_path = session_dir / "payment_records.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
def resolve_invoice_csv(session_dir: Path) -> Path | None:
"""查找发票级别 CSV(invoice_summary.csv)"""
csv_path = session_dir / "invoice_summary.csv"
if csv_path.exists():
return csv_path
for f in session_dir.glob("*.csv"):
if f.name != SESSION_RESULT_FILE:
return f
return None
# ================================================================
# 出库单填写
# ================================================================
# 在模块加载时确定模板路径(由 app.py 传入 PROJECT_ROOT)
_consumable_template: Path | None = None
def set_consumable_template(template_path: Path) -> None:
"""设置出库单模板路径(由 app.py 在启动时调用)"""
global _consumable_template
_consumable_template = template_path
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
"""根据 CSV 填写易耗品出库单,供会话目录下载。
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
"""
template = _consumable_template
if template is None or not template.exists():
fill_log.warning("出库单模板不存在: %s", template)
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
groups = load_invoice_groups(session_dir)
if groups is None:
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
if not groups.get("general_count", 0):
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
csv_path = resolve_payment_csv(session_dir)
if csv_path is None:
return {"ok": False, "error": "未找到发票 CSV"}
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
try:
fill_log.info("开始填写出库单: %s", out_doc.name)
fill_consumable_from_template(csv_path, template, out_doc, config=config)
fill_log.info("出库单填写完成")
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
except ImportError:
fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32")
return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"}
except Exception as e:
fill_log.exception("填写出库单失败: %s", e)
return {"ok": False, "error": str(e)}
def append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
"""将出库单下载信息追加到结果字典"""
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
result["doc_ok"] = True
elif doc_fill.get("skipped"):
result["doc_ok"] = None
result["doc_skipped"] = True
result["doc_message"] = doc_fill.get("error", "")
else:
result["doc_ok"] = False
result["doc_error"] = doc_fill.get("error", "未知错误")
# ================================================================
# 管道入口
# ================================================================
def run_pipeline_web(session_dir: Path, config: dict[str, Any] | None) -> dict[str, Any]:
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
在发票提取和匹配完成后立即判断报销类型:
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
- 普通发票:无需额外提取(normal_info.json 待实现)
"""
from src.core.extraction import extract_invoices
start = time.time()
# ---- Step 1: 发票提取 ----
invoices, applications, groups = extract_invoices(str(session_dir))
if not invoices:
return {"ok": False, "error": "未提取到任何发票数据"}
# 使用公共函数处理发票数据
stats = process_invoices(invoices, applications, groups, session_dir)
# ---- Step 2: 差旅/普通信息提取 ----
extract_info_by_type(groups, session_dir)
elapsed = time.time() - start
result = {
"ok": True,
"elapsed": f"{elapsed:.1f}s",
"invoice_count": stats["invoice_count"],
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
"travel_count": stats["travel_count"],
"general_count": stats["general_count"],
}
doc_fill = _try_fill_consumable_doc(session_dir, config or {})
append_doc_download(result, session_dir.name, doc_fill)
return result
def run_financial_submit(session_dir: Path, config: dict[str, Any] | None) -> dict[str, Any]:
"""执行财务系统填报(从前端确认后调用)
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
- 纯差旅发票:差旅报销模式
- 含普通发票:普通报销模式
"""
csv_path = session_dir / "payment_records.csv"
if not csv_path.exists():
return {"ok": False, "error": "未找到发票数据,请先处理"}
from src.infra.browser import run_bot_web
groups = load_invoice_groups(session_dir)
if groups:
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
else:
fill_log.info("检测到普通发票,使用普通报销模式")
try:
run_bot_web(config or {}, session_dir)
return {"ok": True}
except Exception as e:
fill_log.error("财务填报失败: %s", e)
return {"ok": False, "error": str(e)}
-511
View File
@@ -1,511 +0,0 @@
"""
Flask 路由定义
所有 Web 端点的路由注册,不包含业务逻辑(业务逻辑在 pipeline_web 和 sse_handler 中)。
使用 Blueprint 模式,支持延迟注册到 Flask app。
"""
import csv as csv_module
import json
import threading
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import quote
from flask import Blueprint, Response, jsonify, render_template, request, stream_with_context
from src.config import (
SAFE_CONFIG_KEYS,
SESSION_CONFIG_KEYS,
load_session_config,
)
from src.config import (
load_config as load_project_config,
)
from src.infra.documents import load_csv, load_invoice_csv
from . import pipeline_web, sse_handler
# 在模块加载时确定(由 init_routes 传入)
_UPLOAD_BASE: Path | None = None
web_bp = Blueprint("web", __name__)
def init_routes(upload_base: Path) -> None:
"""初始化路由配置,传入上传目录"""
global _UPLOAD_BASE
_UPLOAD_BASE = upload_base
# ================================================================
# 辅助函数
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
"""验证会话 ID 并返回会话目录"""
if _UPLOAD_BASE is None:
return jsonify({"error": "服务未初始化"}), 500
session_dir = _UPLOAD_BASE / session_id
if not session_dir.exists():
return jsonify({"error": "会话不存在"}), 404
return session_dir
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
"""从请求体构建配置"""
config = load_project_config()
for key in SESSION_CONFIG_KEYS:
if body.get(key):
# 基本类型校验:防止非字符串值写入配置
if not isinstance(body[key], (str, int, float, bool)):
continue
config[key] = body[key]
return config
def _run_session_task(
session_dir: Path,
handler: Any,
task_fn: Any,
config: dict[str, Any] | None = None,
) -> None:
"""后台任务的通用包装器
执行任务并把结果写进 result.json,供 SSE 端点收尾。
Args:
session_dir: 会话目录
handler: SSE 日志收集器句柄
task_fn: 业务逻辑回调,接收 (session_dir, config) 返回 result_dict
config: 配置字典(可选)
"""
result = {"ok": False, "error": "未知错误"}
try:
# 清除上一轮残留文件,避免 SSE 连接立即读到旧数据
for fname in ("llm_stream.log", pipeline_web.SESSION_RESULT_FILE):
try:
(session_dir / fname).unlink(missing_ok=True)
except Exception:
pass
ret = task_fn(session_dir, config)
if isinstance(ret, dict):
result = ret
except Exception as e:
result = {"ok": False, "error": str(e)}
finally:
# 统一写入 result.json,SSE 端点检测到后发射 done 事件
try:
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
except Exception:
pass
sse_handler.remove_log_collector(handler)
# ================================================================
# 页面路由
# ================================================================
@web_bp.route("/")
def index() -> Any:
return render_template("index.html")
@web_bp.route("/mobile/<session_id>")
def mobile_upload(session_id: str) -> Any:
"""移动端上传页面"""
if _UPLOAD_BASE is None:
return render_template("mobile_upload.html", error="服务未初始化"), 500
session_dir = _UPLOAD_BASE / session_id
if not session_dir.exists():
return render_template("mobile_upload.html", error="会话不存在"), 404
return render_template("mobile_upload.html", session_id=session_id)
# ================================================================
# 会话管理
# ================================================================
@web_bp.route("/api/session", methods=["POST"])
def create_session() -> Any:
"""创建上传会话,返回 session_id"""
if _UPLOAD_BASE is None:
return jsonify({"error": "服务未初始化"}), 500
sid = uuid.uuid4().hex[:12]
session_dir = _UPLOAD_BASE / sid
session_dir.mkdir(parents=True, exist_ok=True)
return jsonify({"session_id": sid})
# ================================================================
# 文件上传与下载
# ================================================================
@web_bp.route("/api/upload/<session_id>", methods=["POST"])
def upload_file(session_id: str) -> Any:
"""上传 PDF 或图片"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
safe_name = Path(f.filename).name
# 安全检查:拒绝包含路径穿越字符的文件名
if ".." in safe_name or "/" in safe_name or "\\" in safe_name:
return jsonify({"error": "文件名包含非法字符"}), 400
f.save(str(session_dir / safe_name))
return jsonify({"ok": True, "filename": safe_name})
@web_bp.route("/api/files/<session_id>", methods=["GET"])
def list_files(session_id: str) -> Any:
"""列出会话目录中的文件(统一列表)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
pdf_exts = {".pdf"}
img_exts = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
files = []
for f in sorted(session_dir.iterdir()):
if not f.is_file():
continue
ext = f.suffix.lower()
if ext in pdf_exts:
files.append({"name": f.name, "type": "pdf", "size": f.stat().st_size})
elif ext in img_exts:
files.append({"name": f.name, "type": "image", "size": f.stat().st_size})
return jsonify(
{
"files": files,
"pdfs": [item["name"] for item in files if item["type"] == "pdf"],
"images": [item["name"] for item in files if item["type"] == "image"],
}
)
@web_bp.route("/api/download/<session_id>/<filename>")
def download_file(session_id: str, filename: str) -> Any:
"""下载生成的文件"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
safe_name = Path(filename).name
filepath = session_dir / safe_name
if not filepath.exists():
return jsonify({"error": "文件不存在"}), 404
if safe_name.endswith(".doc"):
mimetype = "application/msword"
elif safe_name.endswith(".csv"):
mimetype = "text/csv; charset=utf-8"
else:
mimetype = "application/octet-stream"
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
return Response(
filepath.read_bytes(),
mimetype=mimetype,
headers={"Content-Disposition": disposition},
)
@web_bp.route("/api/mobile-upload/<session_id>", methods=["POST"])
def mobile_upload_file(session_id: str) -> Any:
"""移动端上传图片(复用 PC 上传逻辑)"""
return upload_file(session_id)
# ================================================================
# 配置
# ================================================================
@web_bp.route("/api/config/<session_id>", methods=["GET"])
def get_session_config(session_id: str) -> Any:
"""获取当前会话的配置(供前端回填表单)
使用白名单过滤会话配置,防止密码等敏感字段被加载到内存。
password 字段始终返回空字符串——密码由前端用户输入,不持久化。
注意:当前为 localhost 服务,密码通过 HTTP 明文传输。如需通过代理暴露服务,
请启用 HTTPS 或使用反向代理加密。
"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
config = load_project_config()
cfg_path = session_dir / "config.json"
if cfg_path.exists():
# 仅允许覆盖前端表单字段(不含密码)
with open(cfg_path, encoding="utf-8") as f:
session_cfg = json.load(f)
for key in SAFE_CONFIG_KEYS:
if key in session_cfg:
config[key] = session_cfg[key]
return jsonify(
{
"username": config.get("username", ""),
"password": "",
"default_name": config.get("default_name", ""),
"default_card_no": config.get("default_card_no", ""),
"default_person_id": config.get("default_person_id", ""),
"consumable_storage": config.get("consumable_storage", ""),
}
)
# ================================================================
# 发票数据处理
# ================================================================
@web_bp.route("/api/data/<session_id>", methods=["GET"])
def get_invoice_data(session_id: str) -> Any:
"""读取发票数据并返回 JSON(供前端表格编辑)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
# 优先读取支付记录 CSV
payment_csv = session_dir / "payment_records.csv"
if payment_csv.exists():
rows = load_csv(payment_csv)
if rows is not None:
data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry: dict[str, Any] = dict(row)
entry["__row"] = i
data.append(entry)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
# 回退到发票级别 CSV
invoice_csv = session_dir / "invoice_summary.csv"
if invoice_csv.exists():
rows = load_invoice_csv(invoice_csv)
if rows is not None:
invoice_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry2: dict[str, Any] = dict(row)
entry2["__row"] = i
invoice_data.append(entry2)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
# 最后尝试任意 CSV
csv_files = list(session_dir.glob("*.csv"))
csv_files = [f for f in csv_files if f.name != pipeline_web.SESSION_RESULT_FILE]
if csv_files:
csv_path = csv_files[0]
rows = load_csv(csv_path)
if rows is None:
rows = load_invoice_csv(csv_path)
if rows is not None:
fallback_data: list[dict[str, Any]] = []
for i, row in enumerate(rows):
entry3: dict[str, Any] = dict(row)
entry3["__row"] = i
fallback_data.append(entry3)
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
return jsonify({"error": "未找到发票数据,请先处理"}), 404
@web_bp.route("/api/save/<session_id>", methods=["POST"])
def save_invoice_data(session_id: str) -> Any:
"""保存前端编辑后的发票数据到 CSV"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
data = body.get("data", [])
csv_filename = body.get("csv_filename", "invoice_summary.csv")
csv_path = session_dir / csv_filename
if not csv_path.exists():
return jsonify({"error": "CSV 文件不存在"}), 404
original_rows = load_csv(csv_path)
if original_rows is None or len(original_rows) == 0:
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
fieldnames = list(original_rows[0].keys())
# 校验前端传入的 data 字段是否与原始 CSV 列匹配
if data:
unknown_keys = set(data[0].keys()) - (set(fieldnames) | {"__row"})
if unknown_keys:
return jsonify({"error": f"数据包含未知字段: {unknown_keys}"}), 400
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for entry in data:
row = {k: entry.get(k, "") for k in fieldnames}
writer.writerow(row)
resp: dict[str, str | bool | None] = {"ok": True}
config = load_session_config(session_dir)
doc_fill = pipeline_web._try_fill_consumable_doc(session_dir, config)
if doc_fill.get("ok"):
fn = doc_fill["doc_filename"]
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
resp["doc_ok"] = True
elif doc_fill.get("skipped"):
resp["doc_ok"] = None
resp["doc_skipped"] = True
else:
resp["doc_ok"] = False
resp["doc_error"] = doc_fill.get("error") or ""
return jsonify(resp)
# ================================================================
# 管道处理
# ================================================================
@web_bp.route("/api/process/<session_id>", methods=["POST"])
def start_process(session_id: str) -> Any:
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
body = request.get_json(silent=True) or {}
config = _build_web_config(body)
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
def _task(sd: Path, cfg: dict[str, Any] | None) -> dict[str, Any]:
return pipeline_web.run_pipeline_web(sd, cfg)
handler = sse_handler.install_log_collector(session_dir)
threading.Thread(
target=_run_session_task,
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
daemon=True,
).start()
return jsonify({"status": "started"})
@web_bp.route("/api/logs/<session_id>")
def stream_logs(session_id: str) -> Any:
"""SSE 日志流"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
def generate() -> Any:
log_file = session_dir / sse_handler.SESSION_LOG_FILE
last_size = 0
file_events_file = session_dir / "file_events.log"
last_events_size = 0
last_stream_size = 0
start_time = time.time()
timeout = 900 # 略大于 LLM 请求超时 (600s),防止 SSE 先断开
while time.time() - start_time < timeout:
# 轮询普通日志
if log_file.exists():
current_size = log_file.stat().st_size
if current_size > last_size:
with open(log_file, encoding="utf-8", errors="replace") as f:
f.seek(last_size)
chunk = f.read()
if chunk:
yield f"data: {sse_handler.escape_sse(chunk)}\n\n"
last_size = current_size
# 轮询文件进度事件
if file_events_file.exists():
events_size = file_events_file.stat().st_size
if events_size > last_events_size:
with open(file_events_file, encoding="utf-8", errors="replace") as f:
f.seek(last_events_size)
new_events = f.read()
if new_events:
for line in new_events.strip().split("\n"):
line = line.strip()
if line:
yield f"data: {line}\n\n"
last_events_size = events_size
# 轮询 LLM 流式事件
llm_stream_file = session_dir / "llm_stream.log"
if llm_stream_file.exists():
stream_size = llm_stream_file.stat().st_size
if stream_size > last_stream_size:
with open(llm_stream_file, encoding="utf-8", errors="replace") as f:
f.seek(last_stream_size)
new_chunks = f.read()
if new_chunks:
for line in new_chunks.strip().split("\n"):
line = line.strip()
if line:
yield f"data: {line}\n\n"
last_stream_size = stream_size
# 检查是否完成
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
if result_file.exists():
with open(result_file, encoding="utf-8") as f:
result = json.load(f)
yield f"data: {sse_handler.escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
break
time.sleep(0.5)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@web_bp.route("/api/submit-financial/<session_id>", methods=["POST"])
def submit_financial(session_id: str) -> Any:
"""手动触发财务系统填报"""
session_dir = _validate_session(session_id)
if isinstance(session_dir, tuple):
return session_dir
config_path = session_dir / "config.json"
if not config_path.exists():
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
def _task(sd: Path, cfg: dict[str, Any] | None) -> dict[str, Any]:
submit_result = pipeline_web.run_financial_submit(sd, cfg)
if submit_result.get("ok"):
return {"ok": True}
return submit_result
handler = sse_handler.install_log_collector(session_dir)
threading.Thread(
target=_run_session_task,
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
daemon=True,
).start()
return jsonify({"status": "started"})
-85
View File
@@ -1,85 +0,0 @@
"""
SSE 日志流处理
负责:
- 日志收集器安装/卸载
- SSE 数据转义
"""
import logging
import threading
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
SESSION_LOG_FILE = "session.log"
# 需要监控日志的模块名称列表
LOG_TARGET_NAMES = [
"extractor",
"llm_extractor",
"matcher",
"pipeline",
"bot",
"fill_consumable_doc",
"agent",
"validator",
]
class SSELogHandler(logging.Handler):
"""将日志写入指定文件(线程安全)"""
def __init__(self, log_path: Path):
super().__init__()
self._lock = threading.Lock()
self._file = open(log_path, "w", encoding="utf-8")
def emit(self, record: logging.LogRecord) -> None:
try:
msg = self.format(record) + "\n"
with self._lock:
self._file.write(msg)
self._file.flush()
except Exception:
pass
def close_file(self) -> None:
with self._lock:
try:
self._file.close()
except Exception:
pass
def install_log_collector(session_dir: Path) -> SSELogHandler:
"""安装日志收集器到各模块"""
log_path = session_dir / SESSION_LOG_FILE
fmt = logging.Formatter(
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
"%Y-%m-%d %H:%M:%S",
)
handler = SSELogHandler(log_path)
handler.setFormatter(fmt)
handler.setLevel(logging.INFO)
for name in LOG_TARGET_NAMES:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
return handler
def remove_log_collector(handler: SSELogHandler) -> None:
"""卸载日志收集器"""
for name in LOG_TARGET_NAMES:
logging.getLogger(name).removeHandler(handler)
handler.close_file()
def escape_sse(text: str) -> str:
"""SSE 数据转义,同时处理 Windows 行尾 \r\n"""
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
-26
View File
@@ -1,26 +0,0 @@
---
last_reviewed: 2026-06-12
---
# src/web/static — 静态资源目录
存放 Web 界面的 CSS 样式表和 JavaScript 前端逻辑。
## 文件结构
| 路径 | 说明 |
|------|------|
| `css/index.css` | 全局样式:上传区域、日志面板、可编辑表格、状态徽章 |
| `js/index.js` | 前端逻辑:文件上传、SSE 日志监听、发票数据编辑、配置同步、移动端扫码上传、财务提交 |
## 技术栈
原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。
## 变更记录
- **2026-06-12**:修复配置收集流程的异步时序问题
- `parseConfigFile` 改为返回 Promise,`handleFiles` 和拖拽 `drop` 处理器改为 `async/await`,确保 config.json 解析完成后再执行检查,消息显示顺序正确
- `sendUserMessage` 修复变量名错误(`pendingConfigKeys` → `pendingConfigKey`),修复了配置收集卡死的问题
- 拆分 `checkAutoStart` 为两个函数:`checkAutoStart` 仅做静默检查(由 `syncFiles` 轮询调用),`promptMissingConfig` 负责配置提示(由用户主动上传完成后调用),避免轮询提前触发配置提示导致消息乱序
- 移除所有聊天消息的删除逻辑,聊天窗口保留完整历史(欢迎消息、文件通知、配置交互、处理结果均不删除)
-592
View File
@@ -1,592 +0,0 @@
body {
background: #f5f7fa;
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
}
.main-container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.upload-zone {
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
cursor: pointer; transition: all .2s; background: #fff; height: 100px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
.upload-zone .icon {
width: 48px;
height: 48px;
margin-bottom: 8px;
}
.upload-zone .icon img {
width: 100%;
height: 100%;
}
/* 可折叠上传面板(在文档流中,向上推挤聊天窗口) */
.upload-panel {
margin-top: 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 12px;
overflow: hidden;
transition: box-shadow 0.2s;
}
.upload-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
cursor: pointer;
user-select: none;
font-size: 14px;
font-weight: 600;
color: #333;
background: #fafbfc;
transition: background 0.2s;
}
.upload-panel-header:hover {
background: #f0f2ff;
}
.upload-panel-toggle {
font-size: 10px;
color: #999;
transition: transform 0.3s;
}
.upload-panel-toggle.collapsed {
transform: rotate(180deg);
}
.upload-panel-body {
padding: 16px;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
max-height: 400px;
opacity: 1;
overflow: hidden;
}
.upload-panel-body.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
.chat-container {
background: #fff;
border-radius: 12px;
padding: 16px;
flex: 1 1 0;
min-height: 300px;
overflow: hidden;
border: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
gap: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
scroll-behavior: smooth;
padding-bottom: 8px;
}
.chat-input-area {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e8e8e8;
flex-shrink: 0;
}
.chat-input-area input {
flex: 1;
border: 1px solid #ddd;
border-radius: 20px;
padding: 8px 16px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.chat-input-area input:focus {
border-color: #667eea;
}
.chat-input-area button {
border-radius: 20px;
padding: 8px 20px;
font-size: 13px;
white-space: nowrap;
}
.chat-message {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
animation: fadeIn 0.3s ease;
}
.chat-message.user {
flex-direction: row-reverse;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.ai-avatar img {
width: 60%;
height: 60%;
}
.chat-bubble {
max-width: 85%;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.chat-bubble.system {
background: #f0f2f5;
color: #333;
border-bottom-left-radius: 4px;
}
.chat-bubble.processing {
background: #e8f0fe;
color: #1a73e8;
border-bottom-left-radius: 4px;
}
.chat-bubble.success {
background: #e6f4ea;
color: #137333;
border-bottom-left-radius: 4px;
}
.chat-bubble.error {
background: #fce8e6;
color: #c5221f;
border-bottom-left-radius: 4px;
}
.chat-bubble.done {
background: #e6f4ea;
color: #137333;
border-bottom-left-radius: 4px;
font-weight: 500;
}
.chat-bubble.user {
background: #667eea;
color: #fff;
border-bottom-right-radius: 4px;
}
/* 打字指示器 */
.typing-indicator {
display: inline-flex;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #667eea;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
.btn-process { font-size: 17px; padding: 10px 40px; }
.status-badge { font-size: 13px; }
/* 可编辑表格 */
.table-editable { font-size: 13px; }
.table-editable th { position: sticky; top: 0; background: #f8f9fa; z-index: 1; font-weight: 600; white-space: nowrap; }
.table-editable td { vertical-align: middle; }
.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; }
.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; }
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
/* ================================================================ */
/* 文件消息样式 */
/* ================================================================ */
.file-bubble {
background: #fafbfc;
border: 1px solid #e8e8e8;
border-bottom-left-radius: 4px;
transition: border-color 0.3s, background 0.3s;
}
.file-bubble.file-processing {
background: #f0f4ff;
border-color: #667eea;
}
.file-bubble.file-done {
background: #f0fff4;
border-color: #28a745;
}
.file-bubble.file-error {
background: #fff5f5;
border-color: #c5221f;
}
.file-name-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.file-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.file-icon img {
width: 100%;
height: 100%;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: #333;
word-break: break-all;
}
.file-status {
display: inline-flex;
align-items: center;
margin-left: 4px;
}
.file-status-icon {
font-size: 14px;
}
/* 文件消息的三点动画(与文件名同一行) */
.file-typing-indicator {
display: inline-flex;
gap: 3px;
align-items: center;
margin-left: 2px;
}
.file-typing-indicator span {
width: 5px;
height: 5px;
border-radius: 50%;
background: #667eea;
animation: fileTyping 1.4s infinite ease-in-out;
}
.file-typing-indicator span:nth-child(1) { animation-delay: 0s; }
.file-typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.file-typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes fileTyping {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* 文件详情区域 */
.file-detail {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #ddd;
font-size: 12px;
line-height: 1.8;
color: #555;
}
.detail-label {
font-weight: 600;
color: #667eea;
}
.detail-value {
color: #333;
}
.detail-value.error-text {
color: #c5221f;
}
/* ================================================================ */
/* LLM 流式聊天气泡样式 */
/* ================================================================ */
.llm-stream-bubble {
background: #f0f4ff;
border: 1px solid #667eea;
border-bottom-left-radius: 4px;
}
.llm-stream-bubble.done {
background: #e6f4ea;
border-color: #28a745;
}
.llm-stream-bubble.error {
background: #fce8e6;
border-color: #c5221f;
}
.llm-stream-label {
font-size: 12px;
font-weight: 600;
color: #667eea;
margin-bottom: 6px;
}
.llm-stream-text {
font-size: 13px;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
/* 可折叠的思考过程区域 */
.llm-reasoning-section {
margin: 6px 0;
padding: 4px 0;
border-top: 1px dashed #ddd;
border-bottom: 1px dashed #ddd;
}
.llm-reasoning-summary {
font-size: 12px;
font-weight: 600;
color: #888;
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 4px;
}
.llm-reasoning-summary::-webkit-details-marker {
display: none;
}
.llm-reasoning-summary::before {
content: '▶';
font-size: 8px;
display: inline-block;
transition: transform 0.2s;
}
.llm-reasoning-section[open] > .llm-reasoning-summary::before {
transform: rotate(90deg);
}
.llm-reasoning-text {
font-size: 12px;
line-height: 1.5;
color: #777;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
padding: 4px 8px;
margin-top: 4px;
background: #fafbfc;
border-radius: 4px;
}
/* ================================================================ */
/* Agent 请求面板样式 */
/* ================================================================ */
.agent-request-panel {
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border: 1px solid #ffc107;
border-radius: 12px;
padding: 14px 16px;
margin: 8px 0;
animation: fadeIn 0.3s ease;
}
.agent-request-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #856404;
margin-bottom: 10px;
}
.agent-request-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.agent-request-icon img {
width: 100%;
height: 100%;
}
.agent-request-materials {
font-size: 13px;
color: #856404;
margin-bottom: 12px;
line-height: 1.6;
}
.material-tag {
display: inline-block;
background: #ffc107;
color: #000;
border-radius: 4px;
padding: 2px 10px;
margin: 2px 4px 2px 0;
font-size: 12px;
font-weight: 500;
}
.agent-request-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.agent-request-actions .btn {
font-size: 13px;
padding: 6px 16px;
}
/* ================================================================ */
/* 提取结果摘要卡片样式 */
/* ================================================================ */
.extraction-summary {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #ccc;
}
.extraction-summary .summary-section {
margin-bottom: 12px;
}
.extraction-summary .summary-section:last-child {
margin-bottom: 0;
}
.extraction-summary .summary-section-title {
font-size: 13px;
font-weight: 700;
color: #333;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid #eee;
}
.extraction-summary .summary-row {
display: flex;
gap: 8px;
padding: 2px 0;
font-size: 12px;
line-height: 1.8;
}
.extraction-summary .summary-label {
color: #888;
min-width: 60px;
flex-shrink: 0;
}
.extraction-summary .summary-value {
color: #333;
flex: 1;
}
.extraction-summary .summary-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 4px;
}
.extraction-summary .summary-table th {
background: #f5f6f8;
color: #555;
font-weight: 600;
padding: 5px 8px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
white-space: nowrap;
}
.extraction-summary .summary-table td {
padding: 4px 8px;
border-bottom: 1px solid #f0f0f0;
color: #333;
vertical-align: top;
}
.extraction-summary .summary-table tbody tr:hover {
background: #fafbfc;
}
.extraction-summary .summary-attachment-list {
margin: 4px 0 0;
padding-left: 20px;
font-size: 12px;
color: #555;
line-height: 1.8;
}
.extraction-summary .summary-attachment-item::marker {
color: #999;
font-size: 10px;
}
-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319741857" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1691" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M690.185 275.766c88.365 0 160 71.634 160 160v239.488c0 88.365-71.635 160-160 160H339.766c-88.366 0-160-71.635-160-160V435.766c0-88.366 71.634-160 160-160h350.419z m-350.419 64c-53.02 0-96 42.98-96 96v239.488c0 53.019 42.98 96 96 96h350.419c53.019 0 96-42.981 96-96V435.766c0-53.02-42.981-96-96-96H339.766z m56.28 120.882c52.056 0.001 94.256 42.201 94.256 94.257 0 52.057-42.2 94.256-94.256 94.256s-94.257-42.199-94.257-94.256c0-52.056 42.2-94.257 94.257-94.257z m240.348 0c52.056 0.001 94.255 42.201 94.255 94.257 0 52.057-42.199 94.256-94.255 94.256-52.057 0-94.257-42.199-94.257-94.256 0-52.056 42.2-94.257 94.257-94.257zM128 481.562c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m769.113 0c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m-501.067 43.086c-16.71 0-30.257 13.547-30.257 30.257s13.547 30.256 30.257 30.256 30.256-13.546 30.256-30.256-13.546-30.256-30.256-30.257z m240.348 0c-16.711 0-30.257 13.547-30.257 30.257s13.546 30.256 30.257 30.256c16.71 0 30.255-13.546 30.255-30.256s-13.545-30.256-30.255-30.257zM556.931 192c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32h-83.908c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32h83.908z" fill="#1296db" p-id="1692"></path></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319878861" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10502" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M716.8 704h-51.2v-25.6h38.4V140.8H320v38.4h-25.6v-51.2a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8zM806.4 614.4h-51.2v-25.6h38.4V230.4h25.6v371.2a12.8 12.8 0 0 1-12.8 12.8zM793.6 179.2h25.6v25.6h-25.6z" fill="#1296db" p-id="10503"></path><path d="M819.2 153.6h-25.6V51.2H409.6v38.4h-25.6V38.4a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v115.2zM627.2 793.6H217.6a12.8 12.8 0 0 1-12.8-12.8V217.6a12.8 12.8 0 0 1 12.8-12.8h243.2v25.6H230.4v537.6h384V230.4h-76.8v-25.6h89.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8z" fill="#1296db" p-id="10504"></path><path d="M486.4 204.8h25.6v25.6h-25.6zM268.8 448h307.2v25.6H268.8zM268.8 524.8h307.2v25.6H268.8zM512 601.6h64v25.6h-64zM460.8 601.6h25.6v25.6h-25.6zM268.8 601.6h166.4v25.6H268.8zM268.8 678.4h307.2v25.6H268.8zM268.8 294.4h307.2v25.6H268.8zM409.6 371.2h166.4v25.6H409.6zM358.4 371.2h25.6v25.6h-25.6zM268.8 371.2h64v25.6h-64z" fill="#1296db" p-id="10505"></path></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319817389" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6095" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M912 208H427.872l-50.368-94.176A63.936 63.936 0 0 0 321.056 80H112c-35.296 0-64 28.704-64 64v736c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-608c0-35.296-28.704-64-64-64z m-800-64h209.056l68.448 128H912v97.984c-0.416 0-0.8-0.128-1.216-0.128H113.248c-0.416 0-0.8 0.128-1.248 0.128V144z m0 736v-96l1.248-350.144 798.752 1.216V784h0.064v96H112z" fill="#1296db" p-id="6096"></path></svg>

Before

Width:  |  Height:  |  Size: 727 B

-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319921047" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14940" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M746.6496 198.4a129.6384 129.6384 0 0 1 129.536 124.0064l0.1024 5.632v367.9232a129.6384 129.6384 0 0 1-124.0064 129.536l-5.632 0.1024H306.9184a129.6384 129.6384 0 0 1-129.536-124.0064l-0.128-5.632v-57.9328c0-7.4752 2.6368-14.6944 7.3728-20.4032l2.5344-2.7136 131.84-126.0544a32 32 0 0 1 35.4304-5.9648l3.1488 1.664 116.0704 69.5808 74.7008-66.0992a32 32 0 0 1 35.072-4.864l3.072 1.6896 177.1008 110.6688a32 32 0 0 1-30.848 55.9616l-3.072-1.6896-156.8256-97.9968-74.3424 65.792a32 32 0 0 1-34.6112 5.12l-3.072-1.6384-115.2512-69.0944-104.32 99.712v44.2624a65.6384 65.6384 0 0 0 61.4912 65.5104l4.1728 0.128h439.7312a65.6384 65.6384 0 0 0 65.5104-61.4912l0.128-4.1472V328.0384a65.6384 65.6384 0 0 0-61.4912-65.5104l-4.1472-0.128H306.9184a65.6384 65.6384 0 0 0-65.536 61.4912l-0.128 4.1472v134.3488a32 32 0 0 1-63.8208 3.2768l-0.1792-3.2768v-134.3488a129.6384 129.6384 0 0 1 124.032-129.536l5.632-0.1024h439.7312z" fill="#FB553C" p-id="14941"></path><path d="M692.5312 398.3104m-47.4112 0a47.4112 47.4112 0 1 0 94.8224 0 47.4112 47.4112 0 1 0-94.8224 0Z" fill="#FB553C" p-id="14942"></path></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319971806" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19425" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M820.409449 797.228346q0 25.19685-10.07874 46.866142t-27.716535 38.299213-41.322835 26.204724-50.897638 9.574803l-357.795276 0q-27.212598 0-50.897638-9.574803t-41.322835-26.204724-27.716535-38.299213-10.07874-46.866142l0-675.275591q0-25.19685 10.07874-47.370079t27.716535-38.80315 41.322835-26.204724 50.897638-9.574803l357.795276 0q27.212598 0 50.897638 9.574803t41.322835 26.204724 27.716535 38.80315 10.07874 47.370079l0 675.275591zM738.771654 170.330709l-455.559055 0 0 577.511811 455.559055 0 0-577.511811zM510.992126 776.062992q-21.165354 0-36.787402 15.11811t-15.622047 37.291339q0 21.165354 15.622047 36.787402t36.787402 15.622047q22.173228 0 37.291339-15.622047t15.11811-36.787402q0-22.173228-15.11811-37.291339t-37.291339-15.11811zM591.622047 84.661417q0-8.062992-5.03937-12.598425t-11.086614-4.535433l-128 0q-5.03937 0-10.582677 4.535433t-5.543307 12.598425 5.03937 12.598425 11.086614 4.535433l128 0q6.047244 0 11.086614-4.535433t5.03937-12.598425z" p-id="19426" fill="#1296db"></path></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.5 KiB

-186
View File
@@ -1,186 +0,0 @@
---
last_reviewed: 2026-06-16
---
# src/web/static/js — 前端逻辑模块
## 概述
财务报销自动化系统的前端入口脚本,按功能拆分为多个模块文件,通过 `App` 共享状态对象通信。采用原生 JavaScript 编写,通过 HTTP 请求与 Flask 后端通信,使用 SSE 接收处理进度。
## 文件结构
| 文件 | 职责 |
|------|------|
| `state.js` | 全局状态管理 (`App` 对象,含处理状态机) |
| `utils.js` | 工具函数 (HTML 转义、会话管理) |
| `chat.js` | 聊天窗口消息管理(含文件消息、处理进度、三点动画、LLM 流式气泡) |
| `upload.js` | 文件上传(含拖拽、config.json 解析) |
| `config.js` | 配置收集与校验(基于状态机的自动触发) |
| `process.js` | 发票处理流程与 SSE 监听(含 file_progress、llm_stream 事件处理) |
| `agent.js` | Agent 交互(含 supplement 流程、强制提交、用户文字补充、自动提交) |
| `sync.js` | 移动端同步轮询(状态感知模式) |
| `index.js` | 入口初始化 |
## 加载顺序
```
state.js → utils.js → chat.js → upload.js → config.js → process.js → sync.js → index.js
```
## 模块依赖关系
```
state.js (无依赖)
│
├── utils.js ────────────────┐
│ │
├── chat.js ─────────────────┐ │
│ │ │
├── upload.js ──────────────┐ │ │
│ │ │ │
├── config.js ─────────────┐ │ │ │
│ │ │ │ │
├── process.js ───────────┐ │ │ │ │
│ │ │ │ │ │
├── sync.js ──────────┐ │ │ │ │ │
│ │ │ │ │ │ │
└── index.js ────────┘ │ │ │ │ │ │
```
## 处理状态机
`App.processState` 控制何时触发自动处理,避免重复执行:
```mermaid
stateDiagram-v2
[*] --> idle
idle --> processing: 配置完整 + 有文件
processing --> done: 处理完成
processing --> awaiting_supplement: Agent 要求补充材料
done --> idle: 有新文件 / forceStart = true
done --> done: 无新文件(保持,不重复)
awaiting_supplement --> idle: 用户补充文件(只上传新文件)
idle --> processing: idle + newFilenames > 0(走 supplement 流程)
idle --> awaiting_files: AI 判断文件不足(未来功能)
awaiting_files --> idle: 用户补充文件
awaiting_files --> processing: 用户说"直接开始" (forceStart = true)
```
状态字段说明:
| 字段 | 类型 | 说明 |
|------|------|------|
| `processState` | string | 当前处理状态:idle / processing / done / awaiting_files / awaiting_supplement |
| `lastProcessedFileCount` | number | 上次处理时的文件数,用于检测是否有新文件 |
| `forceStart` | boolean | 用户强制开始标记(兼容"文件不足也要处理"的场景) |
| `newFilenames` | array | 待补充上传的文件名列表,用于区分首次处理和补充处理 |
## 核心流程
### 1. 文件上传
```
用户上传文件 → addFileMessage() → 注册到 App.fileMessageMap → promptMissingConfig()
→ processState 为 done 时重置为 idle(支持增量上传后重新处理)
```
### 2. 配置收集
```
promptMissingConfig() → 检查 CONFIG_FIELDS → 有缺失则提示 → 用户输入 → sendUserMessage() → 递归直到完整
```
### 3. 自动触发检查 (checkAutoStart)
```
checkAutoStart() → 状态机判断:
- processing → 跳过
- done + 无新文件 → 跳过(修复重复执行问题)
- done + 有新文件 → 走 supplement 流程(不再走 startProcess)
- idle + newFilenames > 0 → 走 supplement 流程
- idle + 配置完整 + 有文件 → 走 startProcess(首次处理)
- awaiting_files + forceStart → 触发
```
### 4. 发票处理(首次)
```
startProcess() → ensureSession → 上传全部文件 → /api/agent/process → SSE 监听
→ 收到 file_progress(processing) → setFileProcessing()(三点动画)
→ 收到 file_progress(done) → setFileDone()(展示提取摘要)
→ 收到 file_progress(cached) → setFileCached()
→ 收到 file_progress(error) → setFileError()
→ 收到 llm_stream(start) → handleLLMStream() 创建流式气泡
→ 收到 llm_stream(chunk) → handleLLMStream() 逐字追加 AI 输出
→ 收到 llm_stream(end) → handleLLMStream() 关闭流式气泡
→ 收到 done → 设置 processState = 'done' + 记录文件数 → 显示汇总结果
```
### 5. 补充文件处理(新增)
当 Agent 返回 `awaiting_supplement` 状态时,用户补充文件的流程:
```
用户上传补充文件
→ handleFiles() 检测 processState === 'awaiting_supplement'
→ _uploadNewFiles() 只上传新文件(不再重新上传所有文件)
→ App.newFilenames = [新文件名列表]
→ App.processState = 'idle'
→ syncFiles() 3 秒后触发 checkAutoStart()
→ checkAutoStart() 检测到 idle + newFilenames > 0
→ handleSupplementUpload(filenames)
→ POST /api/agent/supplement/(后端会重新 extract_invoices,旧文件走缓存)
→ 新文件走 LLM 提取,旧文件命中 JSON 缓存直接返回
→ run_agent_round() 重新分析
→ SSE 返回 done → 根据结果进入 awaiting_supplement 或 done 状态
```
**关键区别**:补充文件走 `/api/agent/supplement/` 而非 `/api/agent/process/`,后者在已有 agent_session 时会跳过提取阶段。
## 数据流
### 配置数据流
```
config.json → parseConfigFile → App.sessionConfig → startProcess → /api/agent/process
```
### 发票数据流(首次处理)
```
发票文件 → startProcess → /api/upload → /api/agent/process → SSE file_events.log → 文件消息实时更新
```
### 发票数据流(补充文件)
```
新发票文件 → _uploadNewFiles() → /api/upload → /api/agent/supplement
→ extract_invoices()(旧文件走缓存,新文件走 LLM)
→ run_agent_round() → SSE file_events.log → 文件消息实时更新
```
### 文件消息状态机
```
addFileMessage(filename)
→ file_progress(processing) → 三点动画(同一行)
→ file_progress(done/cached/error) → 详情展开 + 状态图标
```
## 变更记录
- **2026-06-16**:修复补充文件重复上传问题 — 新增 `newFilenames` 状态字段,补充文件时只上传新文件并走 `/api/agent/supplement/` 接口。`upload.js` 新增 `_uploadNewFiles()` 辅助函数。`config.js` 的 `checkAutoStart()` 在 `done` 和 `idle` 状态下有 `newFilenames` 时走 supplement 流程而非 `startProcess()`
- **2026-06-16**:新增 LLM 流式思考展示 — `chat.js` 新增 `handleLLMStream` 状态机(start/chunk/end/error 四阶段),`process.js` 的 SSE 监听增加 `llm_stream` 事件分支,AI 思考过程以聊天气泡逐字展示
- **2026-06-16**:引入处理状态机 — 新增 `processState` / `lastProcessedFileCount` / `forceStart` 状态字段,修复 `syncFiles` 轮询每 3 秒重复触发处理的 bug。`checkAutoStart` 改为基于状态机判断,done 状态下只有检测到新文件才重新触发。为未来 AI 判断文件是否充足预留了 `awaiting_files` 状态和 `forceStart` 标记
- **2026-06-12**:上传区域可折叠 — 新增 `toggleUploadPanel()` 函数,配合 CSS `max-height` 动画实现面板收起/展开
- **2026-06-12**:文件消息框对齐到用户侧 — `addFileMessage` 的 wrapper 新增 `user` 类,上传的文件消息显示在右侧(用户侧),与 agent 消息区分
- **2026-06-12**:配置完成增加互动提示 — `promptMissingConfig` 在配置完整时发送系统消息告知用户即将开始处理发票
- **2026-06-14**:新增文件进度实时反馈 — 后端 extractor 在每处理一个文件时产生 SSE 进度事件,前端根据文件名定位 DOM 并更新状态(处理中三点动画、完成摘要展示、缓存/错误状态)
- **2026-06-14**:移除发票编辑和财务提交流程 — 删除 table.js、financial.js 及相关 HTML 区域,后续开发不再支持手动编辑发票信息
- **2026-06-12**:模块拆分重构 — 将 index.js (625 行) 拆分为 10 个独立模块,引入 `App` 共享状态对象
- **2026-06-12**:修复配置收集流程的异步时序问题 — parseConfigFile 改为 Promise,拆分 checkAutoStart 为静默检查/配置提示两个函数
-295
View File
@@ -1,295 +0,0 @@
/**
* Agent 交互模块
*/
import { App } from './state.js';
import { escapeHtml } from './utils.js';
import { addChatMessage, showStatus } from './chat.js';
import { createSSEConnection, handleDoneResult } from './sse.js';
// ================================================================
// Agent 事件处理
// ================================================================
/**
* 处理 Agent SSE 事件
*
* @param {Object} msg - SSE 传来的 agent 事件对象
*/
export function handleAgentEvent(msg) {
switch (msg.type) {
case 'agent_state_change':
_handleAgentStateChange(msg);
break;
case 'agent_request_supplement':
_handleAgentRequestSupplement(msg);
break;
case 'agent_ready':
_handleAgentReady(msg);
break;
case 'agent_error':
_handleAgentError(msg);
break;
case 'agent_supplement_received':
_handleSupplementReceived(msg);
break;
case 'agent_force_submit':
_handleForceSubmit(msg);
break;
case 'agent_max_rounds':
_handleAgentError(msg);
break;
case 'agent_extract_status':
_handleAgentStateChange(msg);
break;
}
}
/**
* 状态变更通知 — 使用瞬态消息,状态变化时更新而非追加
*/
function _handleAgentStateChange(msg) {
const stateLabels = {
'extracting': '正在分析文件...',
'validating': '正在校验信息完整性...',
'awaiting_supplement': '等待补充材料',
'ready': '信息完整,可以提交',
'submitting': '正在提交...',
};
const label = stateLabels[msg.state] || msg.message || '处理中...';
if (msg.state === 'ready') {
showStatus(label, 'done');
} else {
showStatus(label);
}
}
/**
* 请求补充材料 — 将 suggestion 以消息气泡形式单独展示
*/
export function _handleAgentRequestSupplement(msg) {
const suggestion = msg.suggestion || '请补充上传相关材料';
showStatus('信息不完整,请补充材料');
addChatMessage(suggestion, 'system');
}
/**
* 信息完整 — 在消息气泡中提醒用户,最终结果由 done 事件处理
*/
function _handleAgentReady(msg) {
addChatMessage(msg.message || '信息完整,正在提交到财务系统', 'done');
}
/**
* Agent 错误
*/
function _handleAgentError(msg) {
addChatMessage(msg.message || 'Agent 处理出错', 'error');
App.isProcessing = false;
App.processState = 'done';
}
/**
* 收到补充文件
*/
function _handleSupplementReceived(msg) {
if (msg.supplement_type === 'text') {
addChatMessage(`已收到补充文字:${msg.text || ''}`, 'done');
} else {
const fileNames = (msg.files || []).join('、');
addChatMessage(`已收到补充文件:${fileNames}`, 'done');
}
}
/**
* 强制提交
*/
function _handleForceSubmit(msg) {
showStatus('已跳过校验,开始提交到财务系统');
}
// ================================================================
// Agent 请求面板
// ================================================================
/**
* 显示 Agent 请求补充材料的 UI
*/
export function showAgentRequest(data) {
const existing = document.getElementById('agent-request-panel');
if (existing) existing.remove();
const chatMessages = document.getElementById('chat-messages');
if (!chatMessages) return;
const panel = document.createElement('div');
panel.id = 'agent-request-panel';
panel.className = 'agent-request-panel';
let materialsHtml = '';
if (data.missing_materials && data.missing_materials.length > 0) {
materialsHtml = `
<div class="agent-request-materials">
<strong>需要补充:</strong>
${data.missing_materials.map(m => `<span class="material-tag">${escapeHtml(m)}</span>`).join(' ')}
</div>
`;
}
panel.innerHTML = `
<div class="agent-request-header">
<span class="agent-request-icon"><img src="/static/icon/file.svg" alt=""></span>
<span>第 ${data.round || 1} 轮分析 - 信息不完整</span>
</div>
${materialsHtml}
<div class="agent-request-actions">
<button class="btn btn-sm btn-primary" onclick="document.getElementById('file-input').click()">
上传补充材料
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="handleForceSubmit()">
直接提交
</button>
</div>
`;
chatMessages.appendChild(panel);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
/**
* 移除 Agent 请求面板
*/
export function hideAgentRequest() {
const panel = document.getElementById('agent-request-panel');
if (panel) panel.remove();
}
// ================================================================
// 用户文字补充
// ================================================================
/**
* 处理用户通过输入框补充的文字信息
*
* @param {string} text - 用户输入的文字
*/
export async function handleUserSupplement(text) {
if (!App.sessionId) return;
try {
showStatus('正在解析补充信息...');
const response = await fetch(`/api/agent/user-supplement/${App.sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text }),
});
const result = await response.json();
if (result.status === 'started') {
showStatus('补充信息已收到,正在重新分析...');
App.isProcessing = true;
createSSEConnection(App.sessionId, {
onDone: (result) => {
handleDoneResult(result);
},
handleFileProgress: false,
handleLLMStream: true,
});
} else if (result.error) {
addChatMessage(`处理失败:${result.error}`, 'error');
}
} catch (e) {
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
// ================================================================
// 用户输入处理
// ================================================================
/**
* 处理强制提交
*/
export async function handleForceSubmit() {
if (!App.sessionId) {
addChatMessage('请先上传文件并处理', 'error');
return;
}
if (App.agentEventSource) {
App.agentEventSource.close();
App.agentEventSource = null;
}
App.forceSubmitting = true;
try {
showStatus('正在跳过校验并提交...');
hideAgentRequest();
const response = await fetch(`/api/agent/force-submit/${App.sessionId}`, {
method: 'POST',
});
const result = await response.json();
if (result.status === 'started') {
showStatus('已跳过校验,开始提交到财务系统');
App.isProcessing = true;
App.processState = 'submitting';
createSSEConnection(App.sessionId, {
onDone: (result) => {
handleDoneResult(result);
},
handleFileProgress: false,
handleLLMStream: false,
});
}
} catch (e) {
App.forceSubmitting = false;
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
// ================================================================
// 补充文件处理
// ================================================================
/**
* 处理补充文件上传完成后的重新分析
*
* @param {Array<string>} filenames - 新上传的文件名列表
*/
export async function handleSupplementUpload(filenames) {
if (!App.sessionId) return;
try {
showStatus('收到补充文件,正在重新分析...');
hideAgentRequest();
const response = await fetch(`/api/agent/supplement/${App.sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: filenames }),
});
const result = await response.json();
if (result.status === 'started') {
App.isProcessing = true;
createSSEConnection(App.sessionId, {
onDone: (result) => {
handleDoneResult(result);
},
});
}
} catch (e) {
addChatMessage(`请求失败:${e.message}`, 'error');
}
}
-28
View File
@@ -1,28 +0,0 @@
/**
* 聊天窗口模块入口
*
* 从 chat/ 子模块重新导出所有公开函数,保持向后兼容。
* 外部模块通过 import 方式使用,HTML 中的 script 标签直接加载本文件。
*/
export {
addChatMessage,
addTypingIndicator,
removeTypingIndicator,
addFileMessage,
setFileProcessing,
setFileDone,
setFileCached,
setFileError,
} from './chat/persistent.js';
// 瞬态消息
export {
showStatus,
clearStatus,
} from './chat/ephemeral.js';
// LLM 流式消息
export {
handleLLMStream,
} from './chat/stream.js';
-198
View File
@@ -1,198 +0,0 @@
# chat/ 模块说明
## 目录
- [架构概览](#架构概览)
- [模块职责](#模块职责)
- [消息类型](#消息类型)
- [数据流](#数据流)
- [状态管理](#状态管理)
- [注意事项](#注意事项)
---
## 架构概览
```
chat/
├── renderer.js # 底层 DOM 渲染工具
├── persistent.js # 持久消息(永久保留在聊天历史中)
├── ephemeral.js # 瞬态消息(只显示最新一条,新状态覆盖旧状态)
└── stream.js # LLM 流式消息(处理中显示思考过程,结束后固化)
chat.js # 入口文件,统一 re-export 所有公开函数
```
设计原则:将**持久消息**和**瞬态消息**分离,避免聊天历史被中间状态消息堆积。
---
## 模块职责
### renderer.js
最底层渲染工具,不维护任何状态。提供:
- `getChatMessages()` — 获取 `#chat-messages` 容器
- `scrollToBottom()` — 滚动到底部
- `createChatBubble(text, type, isUser)` — 创建消息气泡 DOM
- `createSystemBubble(text, type)` — 创建系统气泡 DOM
- `appendMessage(wrapper)` — 将消息追加到容器并滚动
### persistent.js
管理永久保留在聊天历史中的消息:
- `addChatMessage(text, type, isUser)` — 添加普通聊天消息
- `addTypingIndicator()` / `removeTypingIndicator()` — 加载三点动画
- `addFileMessage(filename)` — 添加文件上传消息
- `setFileProcessing(filename)` — 文件处理中状态
- `setFileDone(filename, summary)` — 文件处理完成,展开提取摘要
- `setFileCached(filename)` — 文件使用缓存
- `setFileError(filename, errorMsg)` — 文件处理错误
依赖 `App.fileMessageMap`(定义在 `state.js`)维护文件名到 DOM 元素的映射。
### ephemeral.js
管理瞬态状态消息,同一时刻只显示最新一条:
- `showStatus(text, type)` — 显示/更新状态消息
- `clearStatus()` — 清除当前状态消息
内部维护 `ephemeralState` 对象记录当前活跃的状态气泡引用。重复调用 `showStatus` 时直接更新已有气泡的文本和样式,不创建新 DOM。
### stream.js
管理 LLM 流式响应的气泡生命周期:
- `handleLLMStream(msg)` — 根据 SSE 事件阶段分发处理
支持的阶段:
| phase | 说明 |
|-------|------|
| `start` | 创建流式气泡,初始化思考过程区域(默认展开) |
| `reasoning` | 追加思考过程文本 |
| `chunk` | 追加正式回复文本 |
| `end` | 关闭气泡,切换为 `done` 样式,折叠思考过程区域,气泡保留在历史中 |
| `error` | 切换为 `error` 样式,显示错误信息 |
内部维护 `llmStreamState` 对象记录当前活跃的流式气泡引用。
---
## 消息类型
### 持久消息
永久保留在聊天历史中,不会被自动清除:
- 用户输入的文字
- 文件上传记录及其处理状态
- LLM 流式响应的最终结果(`end` 阶段后固化)
- 系统通知(如"信息完整,可以提交")
- 错误消息
调用 `addChatMessage()` 或 `addFileMessage()` 创建。
### 瞬态消息
只显示最新一条,新状态覆盖旧状态:
- "正在分析文件..."
- "正在校验信息完整性..."
- "请输入登录账号:"
- "配置信息已完整,开始处理发票……"
- "收到补充文件,正在重新分析..."
调用 `showStatus()` 创建/更新,调用 `clearStatus()` 清除。
---
## 数据流
```
外部模块 (agent.js / config.js / process.js / upload.js / sync.js)
│
├── import { addChatMessage, addFileMessage, ... } from './chat.js'
├── import { showStatus, clearStatus } from './chat.js'
└── import { handleLLMStream } from './chat.js'
│
▼
chat.js (re-export)
│
├── → chat/persistent.js ──→ chat/renderer.js
├── → chat/ephemeral.js ──→ chat/renderer.js
└── → chat/stream.js ──→ chat/renderer.js
```
- 外部模块统一从 `chat.js` 导入函数
- `chat.js` 只做 re-export,不引入循环依赖
- 三个子模块通过 `renderer.js` 共享底层 DOM 操作
- `persistent.js` 额外依赖 `state.js` 的 `App.fileMessageMap`
---
## 状态管理
### ephemeralState (ephemeral.js)
```js
{
wrapper: HTMLElement | null, // 状态消息的 wrapper 元素
bubble: HTMLElement | null, // 状态气泡元素
}
```
- 初始为 `null`
- `showStatus` 首次调用时创建并记录引用
- 后续调用直接更新 `bubble.textContent` 和 `bubble.className`
- `clearStatus` 时移除 DOM 并重置为 `null`
### llmStreamState (stream.js)
```js
{
wrapper: HTMLElement | null, // 流式消息 wrapper
bubble: HTMLElement | null, // 流式气泡
textContent: HTMLElement | null, // 正式文本容器
accumulated: string, // 累积的正式文本
reasoningContent: HTMLElement | null, // 思考过程容器
reasoningAccumulated: string, // 累积的思考文本
}
```
- `start` 阶段创建并记录引用
- `chunk` / `reasoning` 阶段追加文本
- `end` / `error` 阶段重置为 `null`(DOM 保留在历史中)
### App.fileMessageMap (state.js)
```js
Map<string, { wrapper, bubble, nameRow, detailRow }>
```
- 文件名 → DOM 元素映射
- `addFileMessage` 创建时写入
- `setFileProcessing` / `setFileDone` / `setFileCached` / `setFileError` 读取并更新对应文件的状态
---
## 注意事项
1. **不要直接操作 `#chat-messages` 容器**。所有消息创建都通过本模块的 API 进行。
2. **瞬态消息和持久消息不要混用**。中间处理状态用 `showStatus`,最终结果用 `addChatMessage`。
3. **`showStatus` 不需要手动清除**。调用 `showStatus` 显示新状态时会自动覆盖旧状态;在处理流程结束时,后续的消息或状态会自然覆盖。
4. **LLM 流式气泡在 `end` 阶段后变为持久消息**。不需要额外调用 `addChatMessage` 来保留结果。
5. **SSE 事件必须包含 `start` 和 `end` 阶段**。缺少 `start` 会导致气泡未创建,缺少 `end` 会导致气泡一直处于处理中状态。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`。
6. **`handleLLMStream` 不处理普通日志行**。SSE 的 `message` 事件中,只有 `type === 'llm_stream'` 的事件才会被转发到此模块。
7. **文件消息的 DOM 生命周期由 `fileMessageMap` 管理**。文件处理完成后,摘要信息会展开显示在文件气泡下方。
8. **所有模块通过 `chat.js` 统一导入**,不要直接从 `chat/` 子目录导入(外部模块层面)。子模块之间的内部导入不受此限制。
-62
View File
@@ -1,62 +0,0 @@
/**
* 瞬态消息管理器
*
* 瞬态消息只保留最新一条,新状态覆盖旧状态,不会堆积在聊天历史中。
*/
import { escapeHtml } from '../utils.js';
import { getChatMessages, scrollToBottom } from './renderer.js';
/**
* 当前活跃的瞬态消息 DOM 引用
*/
let ephemeralState = {
wrapper: null,
bubble: null,
};
/**
* 显示瞬态状态消息
*
* 同一时刻只保留一条状态消息,调用此函数会更新已有消息或创建新消息。
*
* @param {string} text - 状态文本
* @param {string} [type='processing'] - 消息类型
*/
export function showStatus(text, type) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
if (ephemeralState.bubble) {
ephemeralState.bubble.textContent = escapeHtml(text);
ephemeralState.bubble.className = 'chat-bubble ' + (type || 'processing');
scrollToBottom();
return;
}
const wrapper = document.createElement('div');
wrapper.className = 'chat-message';
wrapper.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble ${type || 'processing'}">${escapeHtml(text)}</div>
`;
chatMessages.appendChild(wrapper);
scrollToBottom();
ephemeralState = {
wrapper,
bubble: wrapper.querySelector('.chat-bubble'),
};
}
/**
* 清除瞬态状态消息
*/
export function clearStatus() {
if (ephemeralState.wrapper) {
ephemeralState.wrapper.remove();
}
ephemeralState = {
wrapper: null,
bubble: null,
};
}
-246
View File
@@ -1,246 +0,0 @@
/**
* 持久消息管理器
*
* 持久消息永久保留在聊天历史中,包括:
* - 普通聊天消息
* - 文件上传消息及其状态
* - 加载指示器
*/
import { App } from '../state.js';
import { escapeHtml } from '../utils.js';
import { createChatBubble, appendMessage, getChatMessages, scrollToBottom } from './renderer.js';
// ================================================================
// 普通聊天消息
// ================================================================
/**
* 添加聊天消息
*
* @param {string} text - 消息内容
* @param {string} [type='processing'] - 消息类型
* @param {boolean} [isUser=false] - 是否为用户消息
*/
export function addChatMessage(text, type, isUser) {
const wrapper = createChatBubble(text, type, isUser);
appendMessage(wrapper);
}
// ================================================================
// 加载指示器
// ================================================================
/**
* 显示加载指示器(三点动画)
*/
export function addTypingIndicator() {
const chatMessages = getChatMessages();
if (!chatMessages) return;
const indicator = document.createElement('div');
indicator.className = 'chat-message';
indicator.id = 'typing-indicator';
indicator.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble processing">
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
`;
chatMessages.appendChild(indicator);
scrollToBottom();
}
/**
* 移除加载指示器
*/
export function removeTypingIndicator() {
const indicator = document.getElementById('typing-indicator');
if (indicator) indicator.remove();
}
// ================================================================
// 文件消息管理
// ================================================================
/**
* 添加文件消息(上传文件时调用)
*
* @param {string} filename - 文件名
*/
export function addFileMessage(filename) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
const wrapper = document.createElement('div');
wrapper.className = 'chat-message user file-message';
wrapper.dataset.file = filename;
const bubble = document.createElement('div');
bubble.className = 'chat-bubble file-bubble';
const nameRow = document.createElement('div');
nameRow.className = 'file-name-row';
nameRow.innerHTML = `
<span class="file-icon"><img src="/static/icon/file.svg" alt=""></span>
<span class="file-name">${escapeHtml(filename)}</span>
<span class="file-status" style="display:none"></span>
`;
const detailRow = document.createElement('div');
detailRow.className = 'file-detail';
detailRow.style.display = 'none';
bubble.appendChild(nameRow);
bubble.appendChild(detailRow);
wrapper.appendChild(bubble);
chatMessages.appendChild(wrapper);
scrollToBottom();
App.fileMessageMap.set(filename, { wrapper, bubble, nameRow, detailRow });
}
/**
* 设置文件处理中状态
*
* @param {string} filename - 文件名
*/
export function setFileProcessing(filename) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-done', 'file-error');
bubble.classList.add('file-processing');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline-flex';
statusEl.innerHTML = `
<span class="file-typing-indicator">
<span></span><span></span><span></span>
</span>
`;
detailRow.style.display = 'none';
}
/**
* 更新文件消息为完成状态
*
* @param {string} filename - 文件名
* @param {Object} summary - 提取摘要对象
*/
export function setFileDone(filename, summary) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-done');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">OK</span>';
const labelMap = {
card_date: '刷卡日期',
person_id: '人员编号',
person_name: '人员姓名',
invoice_type_label: '类型',
invoice_number: '发票号码',
invoice_date: '开票日期',
ride_date: '乘车日期',
departure: '出发站',
arrival: '到达站',
seat_class: '座位等级',
train_no: '车次',
total_amount: '金额',
amount: '金额',
seller_name: '销售方',
buyer_name: '购买方',
goods_name: '货物/服务',
remark: '备注',
card_no: '卡号',
card_amount: '刷卡金额',
pay_date: '支付日期',
pay_time: '支付时间',
pay_channel: '支付渠道',
transaction_no: '交易单号',
merchant_name: '商户名称',
order_no: '订单号',
project_name: '项目名称',
purpose: '出差事由',
start_date: '开始日期',
end_date: '结束日期',
person_info: '随行人员',
travel_purpose: '出差事由',
travel_from: '出发地',
travel_to: '目的地',
travel_start: '出发日期',
travel_end: '返回日期',
departure_place: '出发地',
arrival_place: '目的地',
hotel_name: '酒店名称',
checkin_date: '入住日期',
checkout_date: '退房日期',
room_type: '房型',
days: '天数',
attachments: '附件',
subsidy_list: '补助清单',
};
let lines = [];
for (const [key, value] of Object.entries(summary)) {
const label = labelMap[key] || key;
const displayValue = escapeHtml(String(value)).replace(/\n/g, '<br>');
lines.push(`<span class="detail-label">${label}:</span><span class="detail-value">${displayValue}</span>`);
}
detailRow.innerHTML = lines.join('<br>');
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
/**
* 更新文件消息为缓存状态
*
* @param {string} filename - 文件名
*/
export function setFileCached(filename) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-done');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">CACHE</span>';
detailRow.innerHTML = '<span class="detail-label">状态:</span><span class="detail-value">使用缓存(已提取)</span>';
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
/**
* 更新文件消息为错误状态
*
* @param {string} filename - 文件名
* @param {string} [errorMsg] - 错误信息
*/
export function setFileError(filename, errorMsg) {
const entry = App.fileMessageMap.get(filename);
if (!entry) return;
const { bubble, nameRow, detailRow } = entry;
bubble.classList.remove('file-processing');
bubble.classList.add('file-error');
const statusEl = nameRow.querySelector('.file-status');
statusEl.style.display = 'inline';
statusEl.innerHTML = '<span class="file-status-icon">ERR</span>';
detailRow.innerHTML = `<span class="detail-label">错误:</span><span class="detail-value error-text">${escapeHtml(errorMsg || '提取失败')}</span>`;
detailRow.style.display = 'block';
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
-64
View File
@@ -1,64 +0,0 @@
/**
* 聊天消息渲染器
*
* 提供通用的气泡创建、DOM 操作和滚动功能。
*/
import { escapeHtml } from '../utils.js';
/**
* 获取聊天消息容器
*/
export function getChatMessages() {
return document.getElementById('chat-messages');
}
/**
* 滚动聊天容器到底部
*/
export function scrollToBottom() {
const chatMessages = getChatMessages();
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
/**
* 创建聊天消息气泡
*
* @param {string} text - 消息内容
* @param {string} type - 消息类型(影响气泡样式)
* @param {boolean} isUser - 是否为用户消息
* @returns {HTMLElement} wrapper 元素
*/
export function createChatBubble(text, type, isUser) {
const wrapper = document.createElement('div');
wrapper.className = `chat-message${isUser ? ' user' : ''}`;
wrapper.innerHTML = `
<div class="ai-avatar"><img src="/static/icon/${isUser ? 'user' : 'agent'}.svg" alt=""></div>
<div class="chat-bubble ${isUser ? 'user' : (type || 'processing')}">${escapeHtml(text)}</div>
`;
return wrapper;
}
/**
* 创建带系统头像的气泡
*
* @param {string} text - 消息内容
* @param {string} type - 消息类型
* @returns {HTMLElement} wrapper 元素
*/
export function createSystemBubble(text, type) {
return createChatBubble(text, type, false);
}
/**
* 将消息追加到聊天容器
*
* @param {HTMLElement} wrapper - 消息 wrapper 元素
*/
export function appendMessage(wrapper) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
chatMessages.appendChild(wrapper);
scrollToBottom();
}
-445
View File
@@ -1,445 +0,0 @@
/**
* LLM 流式聊天气泡管理器
*
* 流式消息在处理时显示思考过程和逐字输出,结束后固化为持久消息。
*/
import { escapeHtml } from '../utils.js';
import { getChatMessages, scrollToBottom } from './renderer.js';
/**
* 判断用户是否在聊天底部附近(阈值 150px),用于流式传输时决定是否自动滚动
*/
function _isNearBottom() {
const el = getChatMessages();
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < 150;
}
/**
* 当前活跃的 LLM 流式气泡状态
*/
let llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
/**
* 处理 LLM 流式事件
*
* @param {Object} msg - SSE 传来的 llm_stream 事件对象
*/
export function handleLLMStream(msg) {
switch (msg.phase) {
case 'start':
_createLLMStreamBubble(msg.label || '正在分析中...');
break;
case 'reasoning':
_appendLLMStreamReasoning(msg.text || '');
break;
case 'chunk':
_appendLLMStreamChunk(msg.text || '');
break;
case 'end':
_closeLLMStreamBubble(msg.label || '分析完成');
break;
case 'error':
_errorLLMStreamBubble(msg.error || '分析失败');
break;
}
}
/**
* 创建 LLM 流式气泡(start 阶段)
*/
function _createLLMStreamBubble(label) {
const chatMessages = getChatMessages();
if (!chatMessages) return;
if (llmStreamState.wrapper) {
_closeLLMStreamBubbleSilent();
}
const wrapper = document.createElement('div');
wrapper.className = 'chat-message';
const bubble = document.createElement('div');
bubble.className = 'chat-bubble processing llm-stream-bubble';
const labelEl = document.createElement('div');
labelEl.className = 'llm-stream-label';
labelEl.textContent = label;
const reasoningSection = document.createElement('details');
reasoningSection.className = 'llm-reasoning-section';
reasoningSection.open = true;
const reasoningSummary = document.createElement('summary');
reasoningSummary.className = 'llm-reasoning-summary';
reasoningSummary.textContent = '思考过程(点击折叠)';
const reasoningText = document.createElement('div');
reasoningText.className = 'llm-reasoning-text';
reasoningText.textContent = '';
reasoningSection.appendChild(reasoningSummary);
reasoningSection.appendChild(reasoningText);
const textEl = document.createElement('div');
textEl.className = 'llm-stream-text';
textEl.textContent = '';
bubble.appendChild(labelEl);
bubble.appendChild(reasoningSection);
bubble.appendChild(textEl);
wrapper.appendChild(bubble);
chatMessages.appendChild(wrapper);
scrollToBottom();
llmStreamState = {
wrapper,
bubble,
textContent: textEl,
accumulated: '',
reasoningContent: reasoningText,
reasoningAccumulated: '',
};
}
/**
* 追加流式文本片段(chunk 阶段)
*/
function _appendLLMStreamChunk(text) {
if (!llmStreamState.textContent) return;
llmStreamState.accumulated += text;
llmStreamState.textContent.textContent = llmStreamState.accumulated;
if (_isNearBottom()) {
scrollToBottom();
}
}
/**
* 追加思考过程片段(reasoning 阶段)
*/
function _appendLLMStreamReasoning(text) {
if (!llmStreamState.reasoningContent) return;
llmStreamState.reasoningAccumulated += text;
llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated;
if (_isNearBottom()) {
scrollToBottom();
}
}
/**
* 关闭流式气泡(end 阶段)— 气泡保留在聊天历史中作为持久消息。
* 如果累积内容是提取结果 JSON,则渲染为美观的摘要卡片。
*/
function _closeLLMStreamBubble(label) {
if (!llmStreamState.bubble) return;
const accumulated = llmStreamState.accumulated;
llmStreamState.bubble.classList.remove('processing');
llmStreamState.bubble.classList.add('done');
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
if (labelEl) {
labelEl.textContent = label;
}
const reasoningSection = llmStreamState.bubble.querySelector('.llm-reasoning-section');
if (reasoningSection) {
reasoningSection.open = false;
}
// 尝试将累积文本解析为提取结果 JSON,渲染为摘要卡片
const textEl = llmStreamState.bubble.querySelector('.llm-stream-text');
if (textEl && accumulated) {
const parsed = _tryParseExtractionJson(accumulated);
if (parsed) {
textEl.innerHTML = '';
textEl.appendChild(_buildExtractionSummary(parsed));
}
}
scrollToBottom();
llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
}
/**
* 静默关闭流式气泡(不改变样式,用于 start 前清理)
*/
function _closeLLMStreamBubbleSilent() {
if (llmStreamState.wrapper) {
llmStreamState.wrapper.remove();
}
llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
}
/**
* 错误状态(error 阶段)
*/
function _errorLLMStreamBubble(errorMsg) {
if (!llmStreamState.bubble) return;
llmStreamState.bubble.classList.remove('processing');
llmStreamState.bubble.classList.add('error');
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
if (labelEl) {
labelEl.textContent = '分析失败';
}
if (llmStreamState.textContent) {
llmStreamState.textContent.textContent = escapeHtml(errorMsg);
}
llmStreamState = {
wrapper: null,
bubble: null,
textContent: null,
accumulated: '',
reasoningContent: null,
reasoningAccumulated: '',
};
}
/**
* 尝试将 LLM 输出文本解析为提取结果 JSON
* 返回解析后的对象,或 null(非提取结果 JSON)
*/
function _tryParseExtractionJson(text) {
try {
const trimmed = text.trim();
if (!trimmed.startsWith('{')) return null;
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && (parsed.basic_info || parsed.reimbursement_details)) {
return parsed;
}
return null;
} catch {
return null;
}
}
/**
* 将提取结果 JSON 渲染为美观的摘要卡片 DOM 元素
*
* @param {Object} data - 提取结果 JSON
* @returns {HTMLElement}
*/
function _buildExtractionSummary(data) {
const container = document.createElement('div');
container.className = 'extraction-summary';
// 全局状态
const statusSection = _summarySection('状态');
const submitOk = data.can_submit;
_summaryRow(statusSection, '可提交', submitOk ? '是' : '否');
if (data.suggestion) {
_summaryRow(statusSection, '建议', data.suggestion);
}
container.appendChild(statusSection);
// 判断类型:差旅 vs 普通
const isTravel = !!(data.basic_info?.travel_purpose || data.reimbursement_details?.transport_fee);
// 基本信息
if (data.basic_info) {
const bi = data.basic_info;
const section = _summarySection('基本信息');
if (isTravel) {
_summaryRow(section, '出差事由', bi.travel_purpose || '');
_summaryRow(section, '出差地点', bi.travel_location || '');
const dates = bi.start_date && bi.end_date ? `${bi.start_date} 至 ${bi.end_date}` : (bi.start_date || bi.end_date || '');
_summaryRow(section, '出差日期', dates);
} else {
_summaryRow(section, '报销说明', bi.reimbursement_description || '');
}
container.appendChild(section);
}
// 报销汇总(普通模式)
if (!isTravel && data.reimbursement_details) {
const rd = data.reimbursement_details;
const section = _summarySection('报销汇总');
_summaryRow(section, '发票总份数', rd.total_invoices != null ? String(rd.total_invoices) : '');
_summaryRow(section, '总金额', rd.total_amount != null ? `¥${rd.total_amount.toFixed(2)}` : '');
container.appendChild(section);
}
// 交通费(差旅模式)
if (isTravel && data.reimbursement_details?.transport_fee?.length) {
const section = _summarySection('交通费');
const table = _summaryTable(['日期', '出发', '到达', '金额', '备注']);
for (const item of data.reimbursement_details.transport_fee) {
_summaryTableRow(table, [
item.start_date || '',
item.departure_place || '',
item.arrival_place || '',
item.amount != null ? `¥${item.amount.toFixed(2)}` : '',
item.remark || '',
]);
}
section.appendChild(table);
container.appendChild(section);
}
// 住宿费(差旅模式)
if (isTravel && data.reimbursement_details?.hotel_fee?.length) {
const section = _summarySection('住宿费');
const table = _summaryTable(['入住日期', '离店日期', '天数', '发票金额', '报销金额', '备注']);
for (const item of data.reimbursement_details.hotel_fee) {
_summaryTableRow(table, [
item.checkin_date || '',
item.checkout_date || '',
item.days != null ? String(item.days) : '',
item.invoice_amount != null ? `¥${item.invoice_amount.toFixed(2)}` : '',
item.reimburse_amount != null ? `¥${item.reimburse_amount.toFixed(2)}` : '',
item.remark || '',
]);
}
section.appendChild(table);
container.appendChild(section);
}
// 会议/培训费(差旅模式)
if (isTravel && data.reimbursement_details?.conference_fee?.length) {
const section = _summarySection('会议/培训费');
const table = _summaryTable(['日期', '金额', '备注']);
for (const item of data.reimbursement_details.conference_fee) {
_summaryTableRow(table, [
item.start_date || '',
item.amount != null ? `¥${item.amount.toFixed(2)}` : '',
item.remark || '',
]);
}
section.appendChild(table);
container.appendChild(section);
}
// 支付记录
if (data.payment_methods?.length) {
const section = _summarySection('支付记录');
const table = _summaryTable(['日期', '金额', '商户', '备注']);
for (const item of data.payment_methods) {
_summaryTableRow(table, [
item.card_date || '',
item.card_amount != null ? `¥${item.card_amount.toFixed(2)}` : '',
item.merchant || '',
item.remark || '',
]);
}
section.appendChild(table);
container.appendChild(section);
}
// 补助(差旅模式)
if (isTravel && data.subsidy_list?.length) {
const section = _summarySection('补助');
const table = _summaryTable(['姓名', '开始日期', '结束日期', '天数']);
for (const item of data.subsidy_list) {
_summaryTableRow(table, [
item.person_name || '',
item.start_date || '',
item.end_date || '',
item.days != null ? String(item.days) : '',
]);
}
section.appendChild(table);
container.appendChild(section);
}
// 附件
if (data.attachments?.length) {
const section = _summarySection('附件');
const list = document.createElement('ul');
list.className = 'summary-attachment-list';
for (const item of data.attachments) {
const li = document.createElement('li');
li.className = `summary-attachment-item summary-attachment-${item.attachment_type || 'other'}`;
li.textContent = `${item.attachment_desc || item.filename || ''}`;
list.appendChild(li);
}
section.appendChild(list);
container.appendChild(section);
}
return container;
}
/* ---- 提取摘要卡片构建辅助函数 ---- */
function _summarySection(title) {
const section = document.createElement('div');
section.className = 'summary-section';
const titleEl = document.createElement('div');
titleEl.className = 'summary-section-title';
titleEl.textContent = title;
section.appendChild(titleEl);
return section;
}
function _summaryRow(section, label, value) {
if (!value) return;
const row = document.createElement('div');
row.className = 'summary-row';
const labelEl = document.createElement('span');
labelEl.className = 'summary-label';
labelEl.textContent = label;
const valueEl = document.createElement('span');
valueEl.className = 'summary-value';
valueEl.textContent = value;
row.appendChild(labelEl);
row.appendChild(valueEl);
section.appendChild(row);
}
function _summaryTable(headers) {
const table = document.createElement('table');
table.className = 'summary-table';
const thead = document.createElement('thead');
const tr = document.createElement('tr');
for (const h of headers) {
const th = document.createElement('th');
th.textContent = h;
tr.appendChild(th);
}
thead.appendChild(tr);
table.appendChild(thead);
const tbody = document.createElement('tbody');
table.appendChild(tbody);
return table;
}
function _summaryTableRow(table, cells) {
const tbody = table.querySelector('tbody');
if (!tbody) return;
const tr = document.createElement('tr');
for (const cell of cells) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
}
tbody.appendChild(tr);
}
-139
View File
@@ -1,139 +0,0 @@
/**
* 配置收集模块
*/
import { App } from './state.js';
import { addChatMessage, showStatus } from './chat.js';
import { handleUserSupplement, handleForceSubmit, handleSupplementUpload } from './agent.js';
import { startProcess } from './process.js';
export const CONFIG_FIELDS = {
username: '登录账号',
password: '登录密码',
default_name: '报销人姓名',
default_card_no: '公务卡号',
default_person_id: '报销人工号',
consumable_storage: '易耗品存放地点',
};
/**
* 静默检查:仅当配置完整且有文件时自动开始处理。
* 注意:上传文件后不再自动触发,需要用户发送"可以开始"等指令。
*/
export function checkAutoStart() {
if (App.isProcessing) return;
if (App.processState === 'processing') return;
// done 状态下有新文件时,走 supplement 流程(手动触发)
if (App.processState === 'done' && App.forceStart) {
const hasNewFiles = App.allFiles.length > App.lastProcessedFileCount;
if (!hasNewFiles) return;
App.forceStart = false;
App.isProcessing = true;
App.processState = 'processing';
if (App.newFilenames.length > 0) {
const filenames = App.newFilenames;
App.newFilenames = [];
handleSupplementUpload(filenames);
}
return;
}
// awaiting_supplement 状态下不自动处理,等待用户补充文件后手动触发
if (App.processState === 'awaiting_supplement') {
return;
}
// idle 状态下有补充文件时,走 supplement 流程(手动触发)
if (App.processState === 'idle' && App.newFilenames.length > 0) {
App.isProcessing = true;
App.processState = 'processing';
const filenames = App.newFilenames;
App.newFilenames = [];
handleSupplementUpload(filenames);
return;
}
// 不再自动开始处理,需要用户发送"可以开始"等指令
}
/**
* 配置提示:在所有文件加载完成后调用,逐个提示缺失的配置项。
*/
export function promptMissingConfig() {
if (App.isProcessing) return;
const allKeys = Object.keys(CONFIG_FIELDS);
const missing = allKeys.filter(k => !App.sessionConfig[k]);
if (missing.length) {
if (App.pendingConfigKey === null) {
App.pendingConfigKey = missing[0];
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system');
} else if (!missing.includes(App.pendingConfigKey)) {
App.pendingConfigKey = missing[0];
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system');
}
return;
}
App.pendingConfigKey = null;
if (App.allFiles.length) {
showStatus('配置信息已完整,请发送"可以开始"或"处理发票"来启动处理流程');
}
}
/**
* 用户输入处理入口
*/
export async function sendUserMessage() {
const input = document.getElementById('chat-input');
if (!input) return;
const text = input.value.trim();
if (!text) return;
// 优先处理配置收集阶段
if (App.pendingConfigKey) {
App.sessionConfig[App.pendingConfigKey] = text;
addChatMessage(text, 'user', true);
addChatMessage(`${CONFIG_FIELDS[App.pendingConfigKey]} 已设置`, 'system');
input.value = '';
promptMissingConfig();
return;
}
addChatMessage(text, 'user', true);
input.value = '';
// 检查是否是开始处理指令(如"可以开始"、"处理发票"等)
if (text.includes('可以开始') || text.includes('开始处理') || text.includes('处理发票') || text.includes('开始')) {
startProcess();
return;
}
// 检查是否是强制提交指令
if (text.includes('直接提交') || text.includes('强制提交') || text.includes('继续提交')) {
await handleForceSubmit();
return;
}
// 检查是否包含文件上传指令
if (text.includes('上传') || text.includes('补充')) {
document.getElementById('file-input').click();
return;
}
// awaiting_supplement 状态下,将用户输入发给后端 LLM 处理
if (App.processState === 'awaiting_supplement') {
await handleUserSupplement(text);
return;
}
showStatus('请发送"可以开始"或"处理发票"来启动处理流程');
}
export function handleChatKey(event) {
if (event.key === 'Enter') {
sendUserMessage();
}
}
-22
View File
@@ -1,22 +0,0 @@
/**
* 入口文件 — ES Module 入口
*
* 所有模块通过 import 加载,依赖顺序由 import 自动解决。
* 需要 HTML 内联事件调用的函数挂载到 window。
*/
import { initDragDrop, toggleUploadPanel, handleFiles } from './upload.js';
import { sendUserMessage, handleChatKey } from './config.js';
import { generateQr, startSync } from './sync.js';
import { handleForceSubmit } from './agent.js';
// 将需要 HTML 内联事件调用的函数挂载到 window
window.handleChatKey = handleChatKey;
window.sendUserMessage = sendUserMessage;
window.initDragDrop = initDragDrop;
window.toggleUploadPanel = toggleUploadPanel;
window.handleFiles = handleFiles;
window.handleForceSubmit = handleForceSubmit;
initDragDrop();
generateQr();
startSync();
-68
View File
@@ -1,68 +0,0 @@
/**
* 发票处理模块
*/
import { App } from './state.js';
import { ensureSession } from './utils.js';
import {
addTypingIndicator,
removeTypingIndicator,
addChatMessage,
} from './chat.js';
import { createSSEConnection, handleDoneResult } from './sse.js';
export async function startProcess() {
if (!App.allFiles.length) {
alert('请先上传文件或图片');
return;
}
App.isProcessing = true;
App.processState = 'processing';
addTypingIndicator();
try {
await ensureSession();
for (const f of App.allFiles) {
// 跳过服务器同步的文件(已在服务器上)
if (f.__source === 'server') continue;
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
}
removeTypingIndicator();
addTypingIndicator();
const cfg = {
username: App.sessionConfig.username || '',
password: App.sessionConfig.password || '',
default_name: App.sessionConfig.default_name || '',
default_card_no: App.sessionConfig.default_card_no || '',
default_person_id: App.sessionConfig.default_person_id || '',
consumable_storage: App.sessionConfig.consumable_storage || '',
};
await fetch(`/api/agent/process/${App.sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
const es = createSSEConnection(App.sessionId, {
onDone: (result) => {
removeTypingIndicator();
handleDoneResult(result);
},
});
App.agentEventSource = es;
} catch (e) {
removeTypingIndicator();
alert('请求失败: ' + (e.message || '未知错误'));
App.isProcessing = false;
App.processState = 'idle';
addChatMessage(`请求失败:${e.message || '未知错误'}`, 'error');
}
}
-144
View File
@@ -1,144 +0,0 @@
/**
* SSE 连接管理
*
* 封装 EventSource 的创建、事件注册和 done 事件处理,避免重复代码。
*/
import { App } from './state.js';
import {
removeTypingIndicator,
setFileProcessing,
setFileDone,
setFileCached,
setFileError,
handleLLMStream,
addChatMessage,
showStatus,
} from './chat.js';
import { handleAgentEvent } from './agent.js';
/**
* 创建 SSE 连接并返回 EventSource 实例
*
* @param {string} sessionId - 会话 ID
* @param {Object} options - 选项配置
* @param {Function} options.onDone - done 事件处理器
* @param {boolean} options.handleFileProgress - 是否处理文件进度事件 (默认 true)
* @param {boolean} options.handleLLMStream - 是否处理 LLM 流式事件 (默认 true)
* @param {boolean} options.handleAgentEvents - 是否处理 Agent 事件 (默认 true)
* @returns {EventSource}
*/
export function createSSEConnection(sessionId, options = {}) {
const {
onDone,
handleFileProgress = true,
handleLLMStream: handleStream = true,
handleAgentEvents = true,
} = options;
const es = new EventSource(`/api/logs/${sessionId}`);
es.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
// 文件进度事件
if (handleFileProgress && msg.type === 'file_progress') {
const filename = msg.file;
switch (msg.status) {
case 'processing':
setFileProcessing(filename);
break;
case 'done':
setFileDone(filename, msg.summary || {});
break;
case 'cached':
setFileCached(filename);
break;
case 'error':
setFileError(filename, msg.error);
break;
}
return;
}
// LLM 流式事件
if (handleStream && msg.type === 'llm_stream') {
handleLLMStream(msg);
return;
}
// Agent 事件
if (handleAgentEvents && msg.type && msg.type.startsWith('agent_')) {
handleAgentEvent(msg);
return;
}
// 完成事件
if (msg.type === 'done') {
es.close();
if (onDone) {
onDone(msg.result);
}
return;
}
} catch (err) {
// 普通日志行,忽略
}
});
es.onerror = () => {
es.close();
removeTypingIndicator();
App.isProcessing = false;
App.processState = 'done';
addChatMessage('连接中断,请刷新页面重试', 'error');
};
return es;
}
/**
* 处理 done 事件的通用逻辑 (根据 result 内容更新 UI 状态)
*
* @param {Object} result - done 事件的结果对象
*/
export function handleDoneResult(result) {
App.isProcessing = false;
if (App.forceSubmitting) {
App.forceSubmitting = false;
App.lastProcessedFileCount = App.allFiles.length;
if (result.ok) {
addChatMessage('提交完成!', 'done');
} else {
addChatMessage(`提交失败:${result.error || '未知错误'}`, 'error');
}
return;
}
if (result.waiting_for_supplement) {
App.processState = 'awaiting_supplement';
showStatus('信息不完整,请补充上传相关材料');
addChatMessage(
'您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验',
'system'
);
} else {
App.processState = 'done';
App.lastProcessedFileCount = App.allFiles.length;
if (result.ok) {
if (result.agent_ready) {
if (result.submit_ok !== false) {
addChatMessage('信息完整,已自动提交到财务系统', 'done');
} else {
addChatMessage(`校验通过但提交失败:${result.submit_error || '未知错误'}`, 'error');
}
} else if (result.invoice_count) {
addChatMessage(`处理完成!共提取 ${result.invoice_count} 张发票数据`, 'done');
}
} else {
addChatMessage(`处理失败:${result.error || '未知错误'}`, 'error');
}
}
}
-24
View File
@@ -1,24 +0,0 @@
/**
* 全局状态管理
*
* 所有模块通过 App 对象共享状态,避免全局变量污染。
*/
export const App = {
sessionId: null,
allFiles: [],
sessionConfig: {},
invoiceData: [],
csvFilename: '',
lastDownloadUrls: {},
isProcessing: false,
pendingConfigKey: null,
fieldsCache: [],
syncTimer: null,
fileMessageMap: new Map(),
processState: 'idle',
newFilenames: [],
lastProcessedFileCount: 0,
forceStart: false,
agentEventSource: null,
forceSubmitting: false,
};
-76
View File
@@ -1,76 +0,0 @@
/**
* 移动端同步模块
*/
import { App } from './state.js';
import { ensureSession } from './utils.js';
import { addFileMessage } from './chat.js';
import { checkAutoStart, promptMissingConfig } from './config.js';
export async function generateQr() {
await ensureSession();
const mobileUrl = window.location.origin + '/mobile/' + App.sessionId;
const box = document.getElementById('qrcode');
box.innerHTML = '';
new QRCode(box, {
text: mobileUrl,
width: 120,
height: 120,
colorDark: '#333',
colorLight: '#fff',
});
}
export async function startSync() {
if (App.syncTimer) return;
await syncFiles();
App.syncTimer = setInterval(syncFiles, 3000);
}
export function stopSync() {
if (App.syncTimer) { clearInterval(App.syncTimer); App.syncTimer = null; }
}
async function syncFiles() {
if (!App.sessionId) return;
try {
const r = await fetch(`/api/files/${App.sessionId}`);
const d = await r.json();
const serverFiles = d.files || [];
const serverNames = new Set(serverFiles.map(f => f.name));
const localNames = new Set(App.allFiles.map(f => f.name));
const addedFiles = [];
// 只同步文件名元数据,不下载文件内容(文件已在服务器上)
for (const serverFile of serverFiles) {
if (!localNames.has(serverFile.name)) {
const file = { name: serverFile.name, __source: 'server' };
App.allFiles.push(file);
addedFiles.push(serverFile.name);
}
}
for (const f of [...App.allFiles]) {
if (!serverNames.has(f.name) && f.__source !== 'local') {
const idx = App.allFiles.indexOf(f);
if (idx > -1) App.allFiles.splice(idx, 1);
}
}
if (addedFiles.length) {
addedFiles.forEach(name => addFileMessage(name));
if (App.processState === 'done') {
App.processState = 'idle';
App.newFilenames = addedFiles;
} else if (App.processState === 'awaiting_supplement') {
App.processState = 'idle';
App.newFilenames = addedFiles;
}
}
if (App.allFiles.length) {
document.getElementById('file-zone').classList.add('active');
} else {
document.getElementById('file-zone').classList.remove('active');
}
} catch (e) { /* ignore */ }
}
-131
View File
@@ -1,131 +0,0 @@
/**
* 文件上传模块
*/
import { App } from './state.js';
import { addFileMessage, addChatMessage } from './chat.js';
import { promptMissingConfig, checkAutoStart } from './config.js';
import { handleSupplementUpload, hideAgentRequest } from './agent.js';
import { ensureSession } from './utils.js';
export async function handleFiles(input) {
const files = Array.from(input.files);
const result = await _processFiles(files);
if (result.hasNewFiles && App.processState === 'done') {
App.processState = 'idle';
App.newFilenames = result.newFilenames;
}
if (result.hasNewFiles && App.processState === 'awaiting_supplement') {
hideAgentRequest();
await _uploadNewFiles(result.newFilenames);
App.newFilenames = result.newFilenames;
App.processState = 'idle';
return;
}
promptMissingConfig();
input.value = '';
}
/**
* 统一文件处理逻辑(handleFiles 和拖拽共享)
*/
async function _processFiles(files) {
const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i;
let hasNewFiles = false;
const newFilenames = [];
for (const f of files) {
if (/\.json$/i.test(f.name)) {
await parseConfigFile(f);
continue;
}
if (!App.allFiles.find(x => x.name === f.name) && validExts.test(f.name)) {
f.__source = 'local';
App.allFiles.push(f);
document.getElementById('file-zone').classList.add('active');
addFileMessage(f.name);
hasNewFiles = true;
newFilenames.push(f.name);
}
}
return { hasNewFiles, newFilenames };
}
async function _uploadNewFiles(filenames) {
await ensureSession();
for (const f of App.allFiles) {
if (filenames.includes(f.name)) {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
}
}
}
async function parseConfigFile(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = function (e) {
try {
const cfg = JSON.parse(e.target.result);
Object.assign(App.sessionConfig, {
username: cfg.username || '',
password: cfg.password || '',
default_name: cfg.default_name || '',
default_card_no: cfg.default_card_no || '',
default_person_id: cfg.default_person_id || '',
consumable_storage: cfg.consumable_storage || '',
});
addChatMessage(`config.json 已加载`, 'system');
resolve(true);
} catch (err) {
console.warn('config.json 解析失败:', err.message);
resolve(false);
}
};
reader.readAsText(file);
});
}
export function initDragDrop() {
const zone = document.getElementById('file-zone');
if (!zone) return;
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', async e => {
e.preventDefault();
zone.classList.remove('dragover');
const droppedFiles = Array.from(e.dataTransfer.files);
const result = await _processFiles(droppedFiles);
if (result.hasNewFiles && App.processState === 'done') {
App.processState = 'idle';
}
// 在 awaiting_supplement 状态下,只上传新文件并走 supplement 流程(手动触发)
if (result.hasNewFiles && App.processState === 'awaiting_supplement') {
hideAgentRequest();
await _uploadNewFiles(result.newFilenames);
App.newFilenames = result.newFilenames;
App.processState = 'idle';
return;
}
promptMissingConfig();
});
}
/**
* 切换上传面板折叠/展开状态
*/
export function toggleUploadPanel() {
const body = document.getElementById('upload-panel-body');
const icon = document.getElementById('upload-toggle-icon');
if (!body || !icon) return;
body.classList.toggle('collapsed');
icon.classList.toggle('collapsed');
}
-18
View File
@@ -1,18 +0,0 @@
/**
* 工具函数模块
*
* 提供 HTML 转义和会话管理等基础功能。
*/
import { App } from './state.js';
export function escapeHtml(s) {
return s.replace(/\&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export async function ensureSession() {
if (App.sessionId) return App.sessionId;
const r = await fetch('/api/session', { method: 'POST' });
const d = await r.json();
App.sessionId = d.session_id;
return App.sessionId;
}
-14
View File
@@ -1,14 +0,0 @@
---
last_reviewed: 2026-06-11
---
# src/web/templates — HTML 模板目录
存放 Flask 渲染的 HTML 模板文件。
## 模板清单
| 文件 | 说明 |
|------|------|
| `index.html` | PC 端主界面:包含文件上传区、配置表单、处理按钮、SSE 日志面板、可编辑发票表格、下载链接、财务提交按钮、移动端二维码 |
| `mobile_upload.html` | 移动端上传页面:支持拍照/相册选择,上传至当前会话 |
-57
View File
@@ -1,57 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>财务报销自动化</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/index.css" rel="stylesheet">
</head>
<body>
<div class="main-container" style="max-width:960px">
<!-- 聊天窗口 -->
<div class="chat-container" id="chat-box">
<div class="chat-messages" id="chat-messages">
<div class="chat-welcome">
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
<div class="chat-bubble system">你好!我是财务报销助手。请先上传 <b>config.json</b> 配置文件和发票文件,然后发送<b>"可以开始"</b>来启动处理流程。</div>
</div>
</div>
<div class="chat-input-area">
<input type="text" id="chat-input" placeholder="输入消息..." onkeydown="handleChatKey(event)" />
<button class="btn btn-primary" onclick="sendUserMessage()">发送</button>
</div>
</div>
<!-- 上传区域(可折叠) -->
<div class="upload-panel">
<div class="upload-panel-header" onclick="toggleUploadPanel()">
<span><img src="/static/icon/file.svg" alt="" style="width:14px;height:14px;vertical-align:middle;margin-right:4px">文件上传</span>
<span class="upload-panel-toggle" id="upload-toggle-icon">▼</span>
</div>
<div class="upload-panel-body" id="upload-panel-body">
<div class="row g-3">
<div class="col-md-8">
<div class="upload-zone" id="file-zone" onclick="event.stopPropagation(); document.getElementById('file-input').click()">
<div class="icon"><img src="/static/icon/folder.svg" alt=""></div>
<div class="text-muted" style="font-size:13px">点击或拖拽上传文件(支持 PDF、图片)</div>
</div>
<input type="file" id="file-input" accept=".pdf,image/*,.json" multiple hidden onchange="handleFiles(this)">
</div>
<div class="col-md-4 text-center">
<div class="section-title"><img src="/static/icon/phone.svg" alt="" style="width:16px;height:16px;vertical-align:middle;margin-right:4px">手机扫码上传</div>
<div id="qrcode" style="display:inline-block"></div>
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script type="module" src="/static/js/index.js"></script>
</body>
</html>
-125
View File
@@ -1,125 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>支付截图上传</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f5f7fa; padding-bottom: 40px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 20px 16px; text-align: center; }
.upload-btn { width: 100%; padding: 18px; font-size: 16px; border-radius: 12px; margin-top: 16px; }
.img-preview { width: 100px; height: 100px; object-fit: cover; border-radius: 8px; margin: 4px; }
.file-card { background: #fff; border-radius: 8px; padding: 8px; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.file-card img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; }
.file-card .info { flex: 1; min-width: 0; }
.file-card .name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-card .remove { color: #c00; cursor: pointer; padding: 4px 8px; }
.status-msg { text-align: center; padding: 12px; font-size: 14px; }
</style>
</head>
<body>
{% if error %}
<div class="header"><h5>错误</h5><p>{{ error }}</p></div>
{% else %}
<div class="header">
<h5>支付截图上传</h5>
<p class="mb-0 opacity-75" style="font-size:13px">拍照或从相册选择图片</p>
</div>
<div class="container" style="max-width:480px">
<!-- 上传按钮 -->
<input type="file" id="file-input" accept="image/*" multiple hidden onchange="handleSelect(this)">
<button class="btn btn-primary upload-btn" onclick="document.getElementById('file-input').click()">
<img src="/static/icon/gallery.svg" alt="" style="width:20px;height:20px;vertical-align:middle;margin-right:6px">
选择图片 / 拍照
</button>
<!-- 上传进度 -->
<div id="upload-status" class="status-msg mt-3"></div>
<!-- 已上传图片列表 -->
<h6 class="mt-3 mb-2">已上传 ({{ server_count | default(0) }})</h6>
<div id="file-list"></div>
</div>
{% endif %}
<script>
const sessionId = "{{ session_id }}";
// ---- 加载已有文件 ----
async function loadFiles() {
try {
const r = await fetch(`/api/files/${sessionId}`);
const d = await r.json();
renderList(d.images || []);
} catch (e) {
console.error(e);
}
}
function renderList(images) {
const box = document.getElementById('file-list');
if (!images.length) {
box.innerHTML = '<p class="text-muted text-center" style="font-size:13px">暂无图片</p>';
return;
}
box.innerHTML = images.map(name => `
<div class="file-card">
<img src="/api/download/${sessionId}/${encodeURIComponent(name)}#t=image/png" alt="">
<div class="info"><div class="name">${name}</div></div>
<span class="remove" onclick="removeFile('${name}')">&times;</span>
</div>
`).join('');
}
// ---- 上传 ----
async function handleSelect(input) {
const files = Array.from(input.files);
if (!files.length) return;
const status = document.getElementById('upload-status');
let done = 0;
status.innerHTML = `<div class="text-primary">上传中... 0/${files.length}</div>`;
for (const f of files) {
try {
const fd = new FormData();
fd.append('file', f);
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
done++;
status.innerHTML = `<div class="text-primary">上传中... ${done}/${files.length}</div>`;
} catch (e) {
status.innerHTML = `<div class="text-danger">${f.name} 上传失败</div>`;
}
}
if (done === files.length) {
status.innerHTML = `<div class="text-success">${done} 张图片上传成功</div>`;
} else {
status.innerHTML = `<div class="text-warning">${done}/${files.length} 张上传成功</div>`;
}
input.value = '';
loadFiles();
}
// ---- 删除(前端视觉移除,实际保留在服务器)----
function removeFile(name) {
// 移动端暂不支持删除,提示用户回到 PC 端管理
if (confirm('文件已上传到服务器。如需删除请回到电脑端操作。')) {
loadFiles();
}
}
// ---- 定时刷新列表(同步 PC 端新增的文件)----
setInterval(loadFiles, 5000);
loadFiles();
</script>
</body>
</html>
+7 -3
View File
@@ -6,10 +6,14 @@ def run_task(task_name):
os.system(
"uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing"
if task_name == "test"
else "uv run ruff check . && uv run ruff format --check . && uv run mypy src/main.py && uv run deptry ."
else "uv run ruff check . && uv run ruff format --check . && uv run python -m mypy src && uv run deptry ."
if task_name == "check"
else "uv run python src/main.py"
if task_name == "run"
else "uv run python -m src.mcp.server"
if task_name == "mcp"
else "uv run python -m src.upload.server"
if task_name == "upload"
else "uv run python scripts/mcp_smoke.py"
if task_name == "mcp-smoke"
else "uv sync && uv run pre-commit install"
if task_name == "install"
else "rm -rf .venv __pycache__ .pytest_cache .mypy_cache .ruff_cache"
-332
View File
@@ -1,332 +0,0 @@
"""发票提取编排模块单元测试
覆盖范围:
- extract_invoices:空目录、提取失败、正常流程、分类结果、申请单分离
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from src import exceptions
from src.core.extraction import extract_invoices
# 字段键名(与源码中的字符串字面量保持一致)
K_INVOICE_TYPE = "invoice_type"
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_ITEM_NAME = "item_name"
K_PERSON_NAME = "person_name"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
K_MATCHED_INVOICES = "_matched_invoices"
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
K_INVOICE_DETAIL = "invoice_detail"
K_REMARK = "remark"
# 发票类型
INVOICE_TYPE_TRAIN = "train"
INVOICE_TYPE_HOTEL = "hotel"
INVOICE_TYPE_GENERAL = "general"
INVOICE_TYPE_PAYMENT = "payment"
DOCUMENT_TYPE_APPLICATION = "application"
# ------------------------------------------------------------------
# Fixture helpers
# ------------------------------------------------------------------
def _make_invoice(number: str, amount: float, inv_type: str = INVOICE_TYPE_GENERAL) -> dict[str, Any]:
inv: dict[str, Any] = {
K_INVOICE_NUMBER: number,
K_INVOICE_TYPE: inv_type,
K_TOTAL_AMOUNT: str(amount),
}
if inv_type == INVOICE_TYPE_TRAIN:
inv[K_PERSON_NAME] = f"person{number}"
elif inv_type == INVOICE_TYPE_GENERAL:
inv[K_ITEM_NAME] = f"item{number}"
return inv
def _make_application() -> dict[str, Any]:
return {
K_INVOICE_TYPE: DOCUMENT_TYPE_APPLICATION,
"applicant": "张三",
}
def _make_card(amount: float) -> dict[str, Any]:
return {
K_INVOICE_TYPE: INVOICE_TYPE_PAYMENT,
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: f"{amount:.2f}",
}
# ------------------------------------------------------------------
# extract_invoices
# ------------------------------------------------------------------
class TestExtractInvoices:
"""extract_invoices 编排函数"""
def test_empty_directory(self, tmp_path: Path, monkeypatch):
monkeypatch.setattr("src.core.extraction.extractor._find_all_files", lambda d: [])
records, apps, groups = extract_invoices(str(tmp_path))
assert records == []
assert apps == []
assert groups == {"travel": [], "general": [], "application": [], "attachments": []}
def test_extraction_fails(self, tmp_path: Path, monkeypatch):
pdf = tmp_path / "broken.pdf"
pdf.touch()
monkeypatch.setattr("src.core.extraction.extractor._find_all_files", lambda d: [pdf])
monkeypatch.setattr("src.core.extraction.extractor._extract_document", lambda p, c, s: (None, "parse error"))
with pytest.raises(exceptions.ExtractionError) as exc_info:
extract_invoices(str(tmp_path))
assert "broken.pdf" in exc_info.value.failed_files
assert exc_info.value.details["broken.pdf"] == "parse error"
def test_normal_flow_general_invoices(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "inv1.pdf"
pdf2 = tmp_path / "inv2.pdf"
pdf1.touch()
pdf2.touch()
inv1 = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
inv2 = _make_invoice("INV002", 200.0, INVOICE_TYPE_GENERAL)
monkeypatch.setattr("src.core.extraction.extractor._find_all_files", lambda d: [pdf1, pdf2])
path_to_result = {pdf1: inv1, pdf2: inv2}
def fake_extract(path, cache_dir, source_dir):
return (path_to_result[path], None)
monkeypatch.setattr("src.core.extraction.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
K_RELATIVE_INVOICE_COUNT: "2",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv1, inv2],
}
]
monkeypatch.setattr("src.core.extraction.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 1
assert records[0][K_RELATIVE_INVOICE_COUNT] == "2"
assert len(groups["travel"]) == 0
assert len(groups["general"]) == 2
def test_normal_flow_mixed_invoices(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "train.pdf"
pdf2 = tmp_path / "hotel.pdf"
pdf3 = tmp_path / "general.pdf"
pdf1.touch()
pdf2.touch()
pdf3.touch()
inv_train = _make_invoice("TRAIN001", 500.0, INVOICE_TYPE_TRAIN)
inv_hotel = _make_invoice("HOTEL001", 800.0, INVOICE_TYPE_HOTEL)
inv_general = _make_invoice("GEN001", 150.0, INVOICE_TYPE_GENERAL)
monkeypatch.setattr(
"src.core.extraction.extractor._find_all_files",
lambda d: [pdf1, pdf2, pdf3],
)
invoices_list = [inv_train, inv_hotel, inv_general]
path_to_result = dict(zip([pdf1, pdf2, pdf3], invoices_list, strict=True))
def fake_extract(path, cache_dir, source_dir):
return (path_to_result[path], None)
monkeypatch.setattr("src.core.extraction.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv_train],
},
{
K_CARD_DATE: "2026-01-02",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "800.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv_hotel],
},
{
K_CARD_DATE: "",
K_CARD_NO: "",
K_CARD_AMOUNT: "",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "unmatched",
K_MATCHED_INVOICES: [inv_general],
},
]
monkeypatch.setattr("src.core.extraction.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 3
assert len(groups["travel"]) == 2
assert len(groups["general"]) == 1
travel_numbers = {inv[K_INVOICE_NUMBER] for inv in groups["travel"]}
assert "TRAIN001" in travel_numbers
assert "HOTEL001" in travel_numbers
assert groups["general"][0][K_INVOICE_NUMBER] == "GEN001"
def test_application_documents_separated(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "invoice.pdf"
pdf2 = tmp_path / "application.pdf"
pdf1.touch()
pdf2.touch()
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
app = _make_application()
monkeypatch.setattr(
"src.core.extraction.extractor._find_all_files",
lambda d: [pdf1, pdf2],
)
path_to_result = {pdf1: inv, pdf2: app}
def fake_extract(path, cache_dir, source_dir):
return (path_to_result[path], None)
monkeypatch.setattr("src.core.extraction.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "300.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv],
}
]
monkeypatch.setattr("src.core.extraction.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(apps) == 1
assert apps[0]["applicant"] == "张三"
assert len(groups["application"]) == 1
def test_payment_records_included(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "invoice.pdf"
pdf2 = tmp_path / "card.png"
pdf1.touch()
pdf2.touch()
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
card = _make_card(300.0)
monkeypatch.setattr(
"src.core.extraction.extractor._find_all_files",
lambda d: [pdf1, pdf2],
)
path_to_result = {pdf1: inv, pdf2: card}
def fake_extract(path, cache_dir, source_dir):
return (path_to_result[path], None)
monkeypatch.setattr("src.core.extraction.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "300.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv],
}
]
monkeypatch.setattr("src.core.extraction.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 1
assert len(groups["general"]) == 1
def test_partial_extraction_failure(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "good.pdf"
pdf2 = tmp_path / "bad.pdf"
pdf1.touch()
pdf2.touch()
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
monkeypatch.setattr(
"src.core.extraction.extractor._find_all_files",
lambda d: [pdf1, pdf2],
)
path_to_result = {pdf1: inv, pdf2: None}
def fake_extract(path, cache_dir, source_dir):
result = path_to_result[path]
return (result, "parse error") if result is None else (result, None)
monkeypatch.setattr("src.core.extraction.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "300.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv],
}
]
monkeypatch.setattr("src.core.extraction.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 1
assert len(groups["general"]) == 1
-202
View File
@@ -1,202 +0,0 @@
"""LLM 信息提取模块单元测试
覆盖范围:
- `parse_json_response`:纯 JSON、Markdown 包裹、带前缀、解析失败
- _image_to_base64:图片转 base64
- extract_document:成功提取、LLM 失败
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
import pytest
from src.core.extraction import (
_image_to_base64,
extract_document,
parse_json_response,
)
# 字段键名(与源码中的字符串字面量保持一致)
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
# ------------------------------------------------------------------
# parse_json_response
# ------------------------------------------------------------------
class TestParseJsonResponse:
"""JSON 响应解析"""
def test_pure_json(self):
raw = json.dumps({K_INVOICE_NUMBER: "123456", K_TOTAL_AMOUNT: "100.00"})
result = parse_json_response(raw)
assert result[K_INVOICE_NUMBER] == "123456"
assert result[K_TOTAL_AMOUNT] == "100.00"
def test_markdown_json_block(self):
raw = f'```json\n{{"{K_INVOICE_NUMBER}": "789"}}\n```'
result = parse_json_response(raw)
assert result[K_INVOICE_NUMBER] == "789"
def test_markdown_block_without_lang(self):
raw = '```\n{"key": "value"}\n```'
result = parse_json_response(raw)
assert result["key"] == "value"
def test_json_prefix(self):
raw = f'json\n{{"{K_INVOICE_NUMBER}": "001"}}'
result = parse_json_response(raw)
assert result[K_INVOICE_NUMBER] == "001"
def test_json_prefix_with_whitespace(self):
raw = ' json \n{"a": 1}'
result = parse_json_response(raw)
assert result["a"] == 1
def test_nested_json(self):
raw = json.dumps({"outer": {"inner": [1, 2, 3]}})
result = parse_json_response(raw)
assert result["outer"]["inner"] == [1, 2, 3]
def test_whitespace_around_json(self):
raw = ' \n {"x": 42} \n '
result = parse_json_response(raw)
assert result["x"] == 42
def test_invalid_json_raises(self):
with pytest.raises(ValueError):
parse_json_response("not json at all")
def test_empty_string_raises(self):
with pytest.raises(ValueError):
parse_json_response("")
# ------------------------------------------------------------------
# _image_to_base64
# ------------------------------------------------------------------
class TestImageToBase64:
"""图片转 base64"""
def test_png_to_base64(self, tmp_path: Path):
content = b"\x89PNG\r\n\x1a\nfake_png_data"
img_path = tmp_path / "test.png"
img_path.write_bytes(content)
result = _image_to_base64(img_path)
assert isinstance(result, str)
assert base64.b64decode(result) == content
def test_jpg_to_base64(self, tmp_path: Path):
content = b"\xff\xd8\xff\xe0fake_jpg_data"
img_path = tmp_path / "test.jpg"
img_path.write_bytes(content)
result = _image_to_base64(img_path)
assert base64.b64decode(result) == content
def test_file_not_found_raises(self, tmp_path: Path):
img_path = tmp_path / "nonexistent.png"
with pytest.raises(FileNotFoundError):
_image_to_base64(img_path)
def test_returns_utf8_string(self, tmp_path: Path):
content = b"test_image_content"
img_path = tmp_path / "test.png"
img_path.write_bytes(content)
result = _image_to_base64(img_path)
assert type(result) is str
# ------------------------------------------------------------------
# extract_document (mock LLM)
# ------------------------------------------------------------------
class TestExtractDocument:
"""图片提取:通过 mock _llm_query_multimodal 避免真实 LLM 调用"""
def _mock_multimodal(self, monkeypatch, response_text: str):
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
return response_text
monkeypatch.setattr("src.core.extraction.llm_extractor._llm_query_multimodal", fake_query)
def test_success(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
img_path.write_bytes(b"fake_image")
mock_result = {
K_CARD_DATE: "2026-01-10",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
self._mock_multimodal(monkeypatch, json.dumps(mock_result))
result = extract_document(img_path)
assert result[K_CARD_DATE] == "2026-01-10"
assert result[K_CARD_NO] == "6228480000000000"
assert result[K_CARD_AMOUNT] == "500.00"
def test_llm_failure_propagates(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
img_path.write_bytes(b"fake_image")
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
raise RuntimeError("模型不可用")
monkeypatch.setattr("src.core.extraction.llm_extractor._llm_query_multimodal", fake_query)
with pytest.raises(RuntimeError, match="模型不可用"):
extract_document(img_path)
def test_file_not_found_propagates(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "missing.png"
self._mock_multimodal(monkeypatch, "{}")
with pytest.raises(FileNotFoundError):
extract_document(img_path)
def test_markdown_wrapped_json(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
img_path.write_bytes(b"fake_image")
mock_result = {
K_CARD_DATE: "2026-02-01",
K_CARD_NO: "6228481111111111",
K_CARD_AMOUNT: "300.00",
}
wrapped = f"```json\n{json.dumps(mock_result)}\n```"
self._mock_multimodal(monkeypatch, wrapped)
result = extract_document(img_path)
assert result[K_CARD_DATE] == "2026-02-01"
def test_image_encoded_as_base64(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
expected_content = b"test_image_data"
img_path.write_bytes(expected_content)
received_b64 = None
def capture_b64(system_prompt, text, image_b64s, max_tokens=4096):
nonlocal received_b64
received_b64 = image_b64s
return json.dumps({K_CARD_DATE: "2026-01-01", K_CARD_NO: "0000", K_CARD_AMOUNT: "100"})
monkeypatch.setattr("src.core.extraction.llm_extractor._llm_query_multimodal", capture_b64)
extract_document(img_path)
assert received_b64 is not None
assert isinstance(received_b64, list)
assert len(received_b64) >= 1
assert base64.b64decode(received_b64[0]) == expected_content
Generated
-1458
View File
File diff suppressed because it is too large Load Diff