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:
@@ -18,3 +18,4 @@ cases/
|
||||
|
||||
# DSH/Mnemon 本地记忆与文档索引,不属于项目源码
|
||||
.mnemon/
|
||||
.coverage
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
@@ -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()))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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(), "普通发票")
|
||||
@@ -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
@@ -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):
|
||||
"""浏览器自动化操作失败"""
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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` 事件成对出现(如果是流式气泡)
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
Web 模块
|
||||
|
||||
提供财务报销系统的 Web 界面功能。
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)}
|
||||
@@ -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"})
|
||||
@@ -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: ")
|
||||
@@ -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` 负责配置提示(由用户主动上传完成后调用),避免轮询提前触发配置提示导致消息乱序
|
||||
- 移除所有聊天消息的删除逻辑,聊天窗口保留完整历史(欢迎消息、文件通知、配置交互、处理结果均不删除)
|
||||
@@ -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 +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 +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 +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 +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 +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 |
@@ -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 为静默检查/配置提示两个函数
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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/` 子目录导入(外部模块层面)。子模块之间的内部导入不受此限制。
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 工具函数模块
|
||||
*
|
||||
* 提供 HTML 转义和会话管理等基础功能。
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
|
||||
export function escapeHtml(s) {
|
||||
return s.replace(/\&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export async function ensureSession() {
|
||||
if (App.sessionId) return App.sessionId;
|
||||
const r = await fetch('/api/session', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
App.sessionId = d.session_id;
|
||||
return App.sessionId;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
---
|
||||
|
||||
# src/web/templates — HTML 模板目录
|
||||
|
||||
存放 Flask 渲染的 HTML 模板文件。
|
||||
|
||||
## 模板清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.html` | PC 端主界面:包含文件上传区、配置表单、处理按钮、SSE 日志面板、可编辑发票表格、下载链接、财务提交按钮、移动端二维码 |
|
||||
| `mobile_upload.html` | 移动端上传页面:支持拍照/相册选择,上传至当前会话 |
|
||||
@@ -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>
|
||||
@@ -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}')">×</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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user