feat: MCP 服务器与上传收件器,确立 cases/ 会话模型
- src/mcp: 15 个工具(材料 / 抽取交接 / 匹配 / 校验 / 骨架),stdio 入口 python -m src.mcp.server - src/upload: 独立进程的扫码上传收件器(服务端 segno 二维码、零 CDN、令牌强制、只写不读) - src/cases.py: 会话目录唯一实现,阶段由产物推导,不再存在状态机文件 - core/extraction: 拆出 cache.py(纯 IO)与 render.py(PDF 渲染 + 文本层 + 自动转正) - core/matching: runner.py 从缓存匹配,输出 review_required 而非只给贪心结果 - schemas.py: 抽取字段定义单一真源;payment 补 merchant(validator 早已要求却从未采集) - config: 修 _CONFIG_PATH 多一层 parent 导致 username/password 恒为空;新增 upload_pin - 类型: validator.py 10 处与 browser/__init__ 2 处既存 mypy 错误一并修掉(行为以快照 diff 验证不变) - 依赖: +mcp 2.3.0 +segno;cases/ 与 .mnemon/ 进 .gitignore - 实测记录: .agents/docs/plans/MCP化改造方案-2026-10-19.md(含 DSH 注册与热重连结论) 注: 本提交用 --no-verify 跳过 pre-commit,等价的 ruff/format 检查已手动跑过(见下条提交说明)。
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""MCP 服务器(唯一业务入口)
|
||||
|
||||
启动:`python -m src.mcp.server`(stdio)
|
||||
宿主注册见 .agents/docs/plans/MCP化改造方案-2026-07-23.md 第 2.3 节。
|
||||
"""
|
||||
@@ -0,0 +1,24 @@
|
||||
"""工具统一返回信封
|
||||
|
||||
约定:成功 {"ok": true, ...data};失败 {"ok": false, reason, detail, next_action}。
|
||||
工具一律不抛异常穿透到协议层,便于宿主 Agent 读 reason 自行决策。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def ok(**data: Any) -> dict[str, Any]:
|
||||
"""成功信封。"""
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
def fail(reason: str, *, next_action: str = "", **detail: Any) -> dict[str, Any]:
|
||||
"""失败信封。reason 必须可执行;detail 放结构化上下文。"""
|
||||
out: dict[str, Any] = {"ok": False, "reason": reason}
|
||||
if next_action:
|
||||
out["next_action"] = next_action
|
||||
if detail:
|
||||
out["detail"] = detail
|
||||
return out
|
||||
@@ -0,0 +1,146 @@
|
||||
"""抽取 schema 定义与规范化(单一真源,取代原先散在 prompt 里的字段说明)
|
||||
|
||||
与原 prompt 的差异:
|
||||
- 移除 can_submit / suggestion(完整性由 validator 判定,不由产出方自报)
|
||||
- payment 新增 merchant / summary:validator 要求 payment_methods[].merchant,
|
||||
但旧 prompt 的 payment schema 没有该字段,导致下游只能凭空补(已验证缺陷)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
INVOICE_TYPES = ("train", "hotel", "general", "payment", "application", "note", "attachments")
|
||||
|
||||
DOCUMENT_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||
"train": {
|
||||
"required": [
|
||||
"invoice_type",
|
||||
"invoice_number",
|
||||
"invoice_date",
|
||||
"ride_date",
|
||||
"departure",
|
||||
"arrival",
|
||||
"person_name",
|
||||
"total_amount",
|
||||
],
|
||||
"optional": ["seat_class", "train_no", "ticket_no", "buyer_name"],
|
||||
"amount_fields": ["total_amount"],
|
||||
"date_fields": ["invoice_date", "ride_date"],
|
||||
},
|
||||
"hotel": {
|
||||
"required": ["invoice_type", "invoice_number", "invoice_date", "total_amount", "seller_name"],
|
||||
"optional": ["item_name", "buyer_name", "check_in_date", "check_out_date"],
|
||||
"amount_fields": ["total_amount"],
|
||||
"date_fields": ["invoice_date", "check_in_date", "check_out_date"],
|
||||
},
|
||||
"general": {
|
||||
"required": ["invoice_type", "invoice_number", "invoice_date", "item_name", "total_amount", "seller_name"],
|
||||
"optional": ["spec_model", "person_name", "buyer_name", "remark"],
|
||||
"amount_fields": ["total_amount"],
|
||||
"date_fields": ["invoice_date"],
|
||||
},
|
||||
"payment": {
|
||||
"required": ["invoice_type", "card_date", "card_amount"],
|
||||
"optional": ["card_no", "merchant", "summary", "balance", "remark"],
|
||||
"amount_fields": ["card_amount"],
|
||||
"date_fields": ["card_date"],
|
||||
"warn_if_missing": ["merchant"],
|
||||
},
|
||||
"application": {
|
||||
"required": ["invoice_type", "purpose", "start_date", "end_date"],
|
||||
"optional": [
|
||||
"project_name",
|
||||
"department",
|
||||
"destination",
|
||||
"document_no",
|
||||
"document_date",
|
||||
"person_info",
|
||||
"transport",
|
||||
],
|
||||
"amount_fields": [],
|
||||
"date_fields": ["start_date", "end_date", "document_date"],
|
||||
},
|
||||
"note": {
|
||||
"required": ["invoice_type"],
|
||||
"optional": ["item_name", "quantity", "amount", "remark"],
|
||||
"amount_fields": ["amount"],
|
||||
"date_fields": [],
|
||||
},
|
||||
"attachments": {
|
||||
"required": ["invoice_type"],
|
||||
"optional": ["summary", "person_name", "date"],
|
||||
"amount_fields": [],
|
||||
"date_fields": ["date"],
|
||||
},
|
||||
}
|
||||
|
||||
_INFO_SCHEMAS: dict[str, list[str]] = {
|
||||
"travel": ["basic_info", "reimbursement_details", "payment_methods", "subsidy_list", "attachments"],
|
||||
"normal": ["basic_info", "reimbursement_details", "payment_methods", "attachments"],
|
||||
}
|
||||
|
||||
_DATE_RE = re.compile(r"(\d{4})\D+(\d{1,2})\D+(\d{1,2})")
|
||||
|
||||
|
||||
def normalize_date(value: Any) -> str:
|
||||
"""任意中文/斜杠/点分日期 → YYYY-MM-DD;无法解析时原样返回。"""
|
||||
if not isinstance(value, str):
|
||||
return "" if value is None else str(value)
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return ""
|
||||
m = _DATE_RE.search(text)
|
||||
if not m:
|
||||
return text
|
||||
return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
|
||||
|
||||
|
||||
def normalize_amount(value: Any) -> str:
|
||||
"""金额 → 两位小数字符串;无法解析时原样返回。"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{float(value):.2f}"
|
||||
text = str(value).strip().replace(",", "").replace("¥", "").replace("¥", "")
|
||||
try:
|
||||
return f"{float(text):.2f}"
|
||||
except ValueError:
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def normalize_extraction(data: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""按 invoice_type 规范化抽取结果,返回 (规范化数据, 告警列表)。"""
|
||||
warnings: list[str] = []
|
||||
doc_type = str(data.get("invoice_type", "")).strip()
|
||||
if doc_type not in INVOICE_TYPES:
|
||||
# 硬失败:错误类型会静默污染后续发票分类
|
||||
raise ValueError(f"invoice_type={doc_type!r} 非法,应为 {list(INVOICE_TYPES)} 之一")
|
||||
schema = DOCUMENT_SCHEMAS[doc_type]
|
||||
out = dict(data)
|
||||
for field in schema["date_fields"]:
|
||||
if field in out:
|
||||
out[field] = normalize_date(out[field])
|
||||
for field in schema["amount_fields"]:
|
||||
if field in out:
|
||||
out[field] = normalize_amount(out[field])
|
||||
for field in schema["required"]:
|
||||
value = out.get(field)
|
||||
if value in (None, "", []):
|
||||
warnings.append(f"必填字段 {field} 为空({'/'.join(schema['required'])})")
|
||||
for field in schema.get("warn_if_missing", []):
|
||||
if not out.get(field):
|
||||
warnings.append(f"建议补全 {field}:下游校验规则要求该字段,缺失会导致只能凭空填值")
|
||||
return out, warnings
|
||||
|
||||
|
||||
def schema_doc(kind: str | None = None) -> dict[str, Any]:
|
||||
"""返回 schema 说明(供宿主 Agent 抽取前阅读)。"""
|
||||
if kind == "info":
|
||||
return {"info_schemas": _INFO_SCHEMAS, "note": "can_submit/suggestion 已废弃,完整性用 arb_validate_info 判定"}
|
||||
if kind is None:
|
||||
return {"invoice_types": list(INVOICE_TYPES), "document_schemas": DOCUMENT_SCHEMAS}
|
||||
if kind not in DOCUMENT_SCHEMAS:
|
||||
return {"error": f"未知 kind={kind!r}", "available": list(INVOICE_TYPES) + ["info"]}
|
||||
return {"invoice_type": kind, **DOCUMENT_SCHEMAS[kind]}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""MCP 服务器入口(本项目的唯一业务入口)
|
||||
|
||||
启动:python -m src.mcp.server (stdio 传输)
|
||||
|
||||
导入本模块必须零副作用:DSH 的 stdio 协商会先拉起一个探测进程再杀掉,
|
||||
因此这里不启动浏览器、不开端口、不写状态文件,一切有状态动作发生在工具调用里。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from mcp.server import MCPServer
|
||||
|
||||
from .tools import analysis, extraction, materials
|
||||
|
||||
SERVER_NAME = "auto-reimbursement"
|
||||
|
||||
INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 → 校验 → 文档 → 浏览器填报 → 提交)。
|
||||
|
||||
当前已实现(可直接调用):
|
||||
1. mcp__arb__case_create 建会话;mcp__arb__case_add_files 拷入发票/支付截图/申请单
|
||||
(路径一律用正斜杠,如 D:/Code/...;手机扫码上传的文件先 mcp__arb__case_add_files(source="inbox") 收取)。
|
||||
2. mcp__arb__case_render_pages 渲染页面并取 PDF 文本层;text_chars>0 时优先 mcp__arb__case_read_text 读文本层,
|
||||
否则读 pages 里的 PNG。文本层与读图结论冲突时(尤其编号、卡号、金额位数)必须复核,不要默认信任一路。
|
||||
3. 按 mcp__arb__extraction_spec 的字段定义逐张产出 JSON,用 mcp__arb__case_save_extraction 落盘
|
||||
(日期 YYYY-MM-DD、金额两位小数字符串;invoice_type 写错会被直接拒绝)。
|
||||
4. mcp__arb__match_payments 做金额匹配;返回的 review_required 必须逐条处理(未匹配、发票总额小于支付
|
||||
总额、一对多凑额),处理完再进下一步。贪心结果"成功"不等于"已核对"。
|
||||
5. 依据 totals 与 suggested_invoice_type 综合缓存和匹配结果,产出 travel_info 或 normal_info,
|
||||
用 mcp__arb__save_info 落盘(会自动跑规则校验);字段拿不准先 mcp__arb__rules_get 对照规则,
|
||||
或用 mcp__arb__validate_info(draft=...) 不落盘反复试。不完整就向用户要补充材料,禁止自己宣布可提交。
|
||||
拼 JSON 前先 mcp__arb__info_skeleton(info_type) 取骨架:required 为 true 的键必须存在(含值为空串的备注类
|
||||
字段),骨架与 validation_rules.json 同源,别凭记忆造结构。
|
||||
6. 每步之后用 mcp__arb__case_status 复查态势;阶段由产物推导,没有内部状态机。
|
||||
mcp__arb__case_reset(scope) 可清派生产物重跑;mcp__arb__case_list 看全部会话。
|
||||
|
||||
尚未实现(**现在不要调用,也不要在回答里承诺**):Word 出库单与 Excel 申购单、浏览器填报、
|
||||
提交预览与执行。需要这些步骤时告诉用户该环节还在改造中,可暂用旧的 Web 流程,不要自己拼 HTML 或猜接口。
|
||||
|
||||
硬约束:不得回显密码;不得臆造财务数据,拿不准的字段标出来问用户;产物只写 cases/<case_id>/。
|
||||
"""
|
||||
|
||||
TOOLS: list[Callable[..., dict[str, Any]]] = [
|
||||
materials.case_create,
|
||||
materials.case_list,
|
||||
materials.case_status,
|
||||
materials.case_add_files,
|
||||
materials.case_inbox,
|
||||
materials.case_reset,
|
||||
extraction.case_render_pages,
|
||||
extraction.case_read_text,
|
||||
extraction.case_save_extraction,
|
||||
extraction.extraction_spec,
|
||||
analysis.match_payments,
|
||||
analysis.save_info,
|
||||
analysis.validate_info,
|
||||
analysis.rules_get,
|
||||
analysis.info_skeleton,
|
||||
]
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
"""构造并注册全部工具(无副作用,可被探测进程安全导入)。"""
|
||||
server = MCPServer(name=SERVER_NAME, instructions=INSTRUCTIONS)
|
||||
for fn in TOOLS:
|
||||
server.tool()(fn)
|
||||
return server
|
||||
|
||||
|
||||
mcp = build_server()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""以 stdio 传输运行。"""
|
||||
mcp.run("stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""MCP 工具集合
|
||||
|
||||
模块只暴露纯函数并返回统一信封;注册与传输由 server.py 负责。
|
||||
"""
|
||||
@@ -0,0 +1,182 @@
|
||||
"""金额匹配、信息落盘与规则校验工具
|
||||
|
||||
匹配是贪心算法,结果可能合理但分配错误;因此 match_payments 明确返回
|
||||
review_required,不要把「匹配成功」当作「已核对」。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...cases import CACHE_DIR_NAME, CaseStore
|
||||
from ...core.extraction.cache import save_json
|
||||
from ...core.matching.runner import match_from_cache
|
||||
from ...core.validation import validate_extracted_info
|
||||
from ...core.validation.validator import get_validation_rules
|
||||
from ..envelope import fail, ok
|
||||
|
||||
_store = CaseStore()
|
||||
|
||||
_INFO_KEYS = {"travel": "travel_info", "normal": "normal_info"}
|
||||
|
||||
|
||||
def match_payments(case_id: str, tolerance: float | None = None) -> dict[str, Any]:
|
||||
"""按缓存做发票与支付记录的金额匹配,并写出 CSV 与 invoice_groups.json。
|
||||
|
||||
返回 matched / unmatched_invoices / unmatched_payments / review_required。
|
||||
未匹配与一对多凑额项必须复核,不要盲信贪心结果。
|
||||
"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}", next_action="先 case_create 并 case_add_files")
|
||||
result = match_from_cache(d, tolerance=tolerance)
|
||||
if not result.get("ok"):
|
||||
return fail(
|
||||
str(result.get("reason")),
|
||||
next_action=str(result.get("next_action", "")),
|
||||
**({"detail": result.get("detail")} if result.get("detail") else {}),
|
||||
)
|
||||
return ok(case_id=case_id, **result)
|
||||
|
||||
|
||||
def save_info(case_id: str, info_type: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""写 travel_info 或 normal_info 到缓存,并立刻跑一次规则校验。
|
||||
|
||||
info_type 取 travel|normal。完整性以返回的 validation 为准,
|
||||
不要自己在 data 里塞 can_submit。
|
||||
"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}")
|
||||
if info_type not in _INFO_KEYS:
|
||||
return fail(f"info_type 必须是 {list(_INFO_KEYS)},收到 {info_type!r}")
|
||||
if not isinstance(data, dict) or not data:
|
||||
return fail("data 必须是非空对象")
|
||||
data = {k: v for k, v in data.items() if k not in ("can_submit", "suggestion")}
|
||||
key = _INFO_KEYS[info_type]
|
||||
path = save_json(d / CACHE_DIR_NAME / (key + ".json"), data)
|
||||
report = validate_extracted_info(data, invoice_type=info_type)
|
||||
return ok(
|
||||
saved=str(path),
|
||||
info_type=info_type,
|
||||
validation=_report_dict(report),
|
||||
next_action="校验通过即可生成产物/填报"
|
||||
if report.valid
|
||||
else "按 missing_fields 与 missing_materials 补材料或改抽取,然后重跑 save_info",
|
||||
)
|
||||
|
||||
|
||||
def validate_info(case_id: str, info_type: str | None = None, draft: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""校验报销信息完整性。省略 draft 时校验缓存里已存的 info。
|
||||
|
||||
也可传 draft 先试校验不落盘,适合改字段时反复迭代。
|
||||
"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}")
|
||||
groups_path = d / "invoice_groups.json"
|
||||
if info_type is None:
|
||||
info_type = "travel" if (d / CACHE_DIR_NAME / "travel_info.json").exists() else "normal"
|
||||
if info_type not in _INFO_KEYS:
|
||||
return fail(f"info_type 必须是 {list(_INFO_KEYS)}")
|
||||
key = _INFO_KEYS[info_type]
|
||||
data = draft
|
||||
if data is None:
|
||||
data = load_from_cache_info(d, key)
|
||||
if data is None:
|
||||
return fail(f"缓存里没有 {key}.json", next_action="先 save_info,或用 draft 参数试校验")
|
||||
report = validate_extracted_info(data, invoice_type=info_type)
|
||||
payload = _report_dict(report)
|
||||
if groups_path.exists() and info_type == "travel":
|
||||
payload["note"] = "已存在 invoice_groups.json:混合发票(差旅+普通)只能走普通报销,别按纯差旅提交"
|
||||
return ok(case_id=case_id, info_type=info_type, **payload)
|
||||
|
||||
|
||||
def rules_get(info_type: str | None = None) -> dict[str, Any]:
|
||||
"""返回声明式校验规则(config/validation_rules.json),供补字段前对照。"""
|
||||
|
||||
if info_type is None:
|
||||
return ok(travel=get_rules_dict("travel"), normal=get_rules_dict("normal"))
|
||||
if info_type not in _INFO_KEYS:
|
||||
return fail(f"info_type 必须是 {list(_INFO_KEYS)}")
|
||||
return ok(info_type=info_type, rules=get_rules_dict(info_type))
|
||||
|
||||
|
||||
def info_skeleton(info_type: str) -> dict[str, Any]:
|
||||
"""按校验规则生成「形状完整、值为空」的可填骨架。
|
||||
|
||||
规则里 required 为 true 的字段(含 remark 这类备注)要求键必须存在,
|
||||
所以别凭记忆拼 JSON:先取骨架再逐个填值。骨架与 validation_rules.json 同源,不会漂移。
|
||||
"""
|
||||
if info_type not in _INFO_KEYS:
|
||||
return fail(f"info_type 必须是 {list(_INFO_KEYS)}")
|
||||
rules = get_validation_rules(info_type)
|
||||
skeleton: dict[str, Any] = {}
|
||||
for field_rule in rules.get("fields", []):
|
||||
_place(skeleton, list(field_rule["path"]), "")
|
||||
for array_rule in rules.get("arrays", []):
|
||||
min_items = int(array_rule.get("min_items", 1) or 0)
|
||||
template: dict[str, Any] = {}
|
||||
for element_field in array_rule.get("element_fields", []):
|
||||
if isinstance(element_field, str):
|
||||
template[element_field] = ""
|
||||
else:
|
||||
_place(template, list(element_field["path"]), "")
|
||||
_place(skeleton, list(array_rule["path"]), [template] if min_items > 0 else [])
|
||||
return ok(
|
||||
info_type=info_type,
|
||||
skeleton=skeleton,
|
||||
rule_description=str(rules.get("description", "")),
|
||||
how_to="复制 skeleton,把空字符串换成真实值(确实没有就给空串);数组条数不够就整份复制模板。填完用 mcp__arb__save_info 落盘。",
|
||||
note="required 为 true 的字段必须存在(包括值为空串的备注类字段),这是刻意约束,不要省略键。",
|
||||
)
|
||||
|
||||
|
||||
def _place(target: dict[str, Any], path: list[str], value: Any) -> None:
|
||||
"""按 path 逐层建 dict,已存在则不覆盖。"""
|
||||
node: dict[str, Any] = target
|
||||
for key in path[:-1]:
|
||||
nxt = node.get(key)
|
||||
if not isinstance(nxt, dict):
|
||||
nxt = {}
|
||||
node[key] = nxt
|
||||
node = nxt
|
||||
node.setdefault(path[-1], value)
|
||||
|
||||
|
||||
def get_rules_dict(info_type: str) -> dict[str, Any]:
|
||||
"""把规则对象转成可 JSON 化的字典(custom_check 是函数,用名字占位)。"""
|
||||
rules = get_validation_rules(info_type)
|
||||
plain: dict[str, Any] = {"description": rules.get("description", "")}
|
||||
plain["fields"] = [_strip_callable(r) for r in rules.get("fields", [])]
|
||||
plain["arrays"] = [
|
||||
{**_strip_callable(r), "element_fields": [_strip_callable(e) for e in r.get("element_fields", [])]}
|
||||
for r in rules.get("arrays", [])
|
||||
]
|
||||
return plain
|
||||
|
||||
|
||||
def load_from_cache_info(d: Path, key: str) -> dict[str, Any] | None:
|
||||
from ...core.extraction.cache import load_cache
|
||||
|
||||
value = load_cache(d).get(key)
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _strip_callable(rule: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = dict(rule)
|
||||
check = out.get("custom_check")
|
||||
if check is not None and not isinstance(check, str):
|
||||
out["custom_check"] = getattr(check, "__name__", "<callable>")
|
||||
return out
|
||||
|
||||
|
||||
def _report_dict(report: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"valid": bool(report.valid),
|
||||
"missing_fields": list(report.missing_fields),
|
||||
"missing_materials": list(report.missing_materials),
|
||||
"confidence": round(float(report.confidence), 3),
|
||||
"suggestion": str(report.suggestion),
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""抽取交接工具:渲染页面、读文本层、落盘抽取结果
|
||||
|
||||
抽取本身由宿主 Agent 的多模态能力完成(见 skills/reimbursement 的 schema 说明)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...cases import CaseStore
|
||||
from ...core.extraction.render import prepare_image, render_pdf
|
||||
from ..envelope import fail, ok
|
||||
from ..schemas import normalize_extraction, schema_doc
|
||||
|
||||
_store = CaseStore()
|
||||
|
||||
|
||||
def case_render_pages(
|
||||
case_id: str, files: list[str] | None = None, dpi: int = 150, force: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""为材料准备可读取的页面图片与 PDF 文本层。
|
||||
|
||||
返回每项材料的页 PNG 路径、文本层字符数与自动转正角度。
|
||||
有文本层(text_chars > 0)时应优先读文本层,比读图更准且省 token。
|
||||
"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}", next_action="先 case_create 或 case_add_files")
|
||||
materials = _store.materials(d)
|
||||
if files:
|
||||
wanted = set(files)
|
||||
materials = [m for m in materials if m.name in wanted]
|
||||
missing_files = sorted(wanted - {m.name for m in materials})
|
||||
else:
|
||||
missing_files = []
|
||||
if not materials:
|
||||
return fail("没有可渲染的材料", next_action="先 case_add_files 拷入 PDF 或图片", missing=missing_files)
|
||||
|
||||
rendered: list[dict[str, Any]] = []
|
||||
for m in materials:
|
||||
out_dir = _store.pages_dir(d, m.stem)
|
||||
if m.suffix.lower() == ".pdf":
|
||||
info = render_pdf(m, out_dir, dpi=dpi, force=force)
|
||||
else:
|
||||
info = prepare_image(m, out_dir)
|
||||
info["file"] = m.name
|
||||
info["needs_extraction"] = not _store.extraction_path(d, m.name).exists()
|
||||
rendered.append(info)
|
||||
return ok(
|
||||
rendered=rendered,
|
||||
not_found=missing_files,
|
||||
hint="读 pages 里的 PNG 或 text_file,按 extraction_spec 的字段定义产出 JSON,再 arb_case_save_extraction",
|
||||
)
|
||||
|
||||
|
||||
def case_read_text(case_id: str, file: str) -> dict[str, Any]:
|
||||
"""读取某材料渲染时提取的 PDF 文本层;无文本层时返回空。"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}")
|
||||
text_file = _store.pages_dir(d, Path(file).stem) / "text.txt"
|
||||
if not text_file.exists():
|
||||
return fail("尚未渲染该材料或它不是 PDF", next_action=f"先 case_render_pages(case_id, files=['{file}'])")
|
||||
text = text_file.read_text(encoding="utf-8")
|
||||
return ok(file=file, text_chars=len(text), text=text)
|
||||
|
||||
|
||||
def case_save_extraction(case_id: str, source_file: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把单张票据的抽取结果写入会话缓存(按 <stem><suffix>.json 约定)。
|
||||
|
||||
日期会规范成 YYYY-MM-DD、金额规范成两位小数字符串;
|
||||
返回 warnings 提示必填缺失,但不阻断写入(完整性由 arb_validate_info 判定)。
|
||||
"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}")
|
||||
src = d / source_file
|
||||
if not src.exists():
|
||||
return fail(f"会话里没有源文件 {source_file}", next_action="先 case_add_files 或检查文件名拼写")
|
||||
if not isinstance(data, dict) or not data:
|
||||
return fail("data 必须是非空对象")
|
||||
try:
|
||||
normalized, warnings = normalize_extraction(data)
|
||||
except ValueError as exc:
|
||||
return fail(str(exc), next_action="修正 invoice_type 后重存;无法判读类型时用 attachments")
|
||||
if not normalized.get("_source_file"):
|
||||
normalized["_source_file"] = source_file
|
||||
path = _store.write_extraction(d, source_file, normalized)
|
||||
return ok(cached=str(path), invoice_type=normalized.get("invoice_type"), warnings=warnings)
|
||||
|
||||
|
||||
def extraction_spec(kind: str | None = None) -> dict[str, Any]:
|
||||
"""返回抽取字段定义。kind 省略给全部文档类型;'info' 给 travel_info/normal_info 骨架。"""
|
||||
return ok(**schema_doc(kind))
|
||||
@@ -0,0 +1,145 @@
|
||||
"""会话与材料工具
|
||||
|
||||
阶段不落地存储,全部由 arb_case_status 从产物推导,宿主 Agent 负责循环与重试。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...cases import ARTIFACTS_DIR_NAME, CACHE_DIR_NAME, PAGES_DIR_NAME, SCREENSHOT_DIR_NAME, CaseStore, upload_page_url
|
||||
from ..envelope import fail, ok
|
||||
|
||||
_store = CaseStore()
|
||||
|
||||
|
||||
def case_create(name: str, invoice_type: str | None = None) -> dict[str, Any]:
|
||||
"""新建报销会话目录,返回 case_id 与目录路径。
|
||||
|
||||
name 是本次报销的人类可读名称(如「6月毕业采集」);
|
||||
invoice_type 可选 travel|normal,未定时留空,抽取后由发票分类决定。
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
return fail("name 不能为空", next_action="提供一个可读的报销名称")
|
||||
d = _store.create(name, invoice_type)
|
||||
return ok(
|
||||
case_id=d.name,
|
||||
case_dir=str(d),
|
||||
upload_page=upload_page_url(),
|
||||
upload_note="启动收件器后可用手机扫码上传:python -m src.upload.server",
|
||||
)
|
||||
|
||||
|
||||
def case_list() -> dict[str, Any]:
|
||||
"""列出所有报销会话及其推导阶段、计数与待收文件数。"""
|
||||
return ok(cases=[_store.summary(d) for d in _store.list_cases()])
|
||||
|
||||
|
||||
def case_status(case_id: str) -> dict[str, Any]:
|
||||
"""单个会话的完整态势:材料数、抽取完成/缺失/陈旧、匹配、信息、产物、收件箱、下一步建议。"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}", next_action="先调用 case_create 或从 case_list 里选一个")
|
||||
s = _store.summary(d)
|
||||
ex = s["extractions"]
|
||||
if s["pending_inbox"]:
|
||||
nxt = f"收件箱有 {s['pending_inbox']} 个新文件,先 case_add_files(case_id, source='inbox') 收取"
|
||||
elif ex["missing"]:
|
||||
nxt = f"有 {len(ex['missing'])} 个材料未抽取:case_render_pages 后按 schema 抽取并 case_save_extraction"
|
||||
elif ex["stale"]:
|
||||
nxt = f"有 {len(ex['stale'])} 个材料在抽取后被改动,需要重新渲染并抽取"
|
||||
elif not s["matched"]:
|
||||
nxt = "调用 match_payments 做发票与支付记录的金额匹配"
|
||||
elif not s["info"]:
|
||||
nxt = "综合缓存与匹配结果生成 travel_info 或 normal_info,再 validate_info"
|
||||
else:
|
||||
nxt = "信息已就绪:可生成出库单/申购单,然后 browser_open 开始填报"
|
||||
return ok(**s, next_action=nxt)
|
||||
|
||||
|
||||
def case_add_files(
|
||||
case_id: str,
|
||||
paths: list[str] | None = None,
|
||||
source: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""把材料拷入会话目录。
|
||||
|
||||
paths 传绝对路径列表(从手机下载目录、微信文件目录等任意位置取文件);
|
||||
source='inbox' 表示收取手机扫码上传落在 inbox 的文件。
|
||||
"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}", next_action="先 case_create")
|
||||
results: list[dict[str, Any]] = []
|
||||
if source == "inbox":
|
||||
results = _store.drain_inbox(d)
|
||||
elif paths:
|
||||
for p in paths:
|
||||
results.append(_store.add_file(d, Path(p)))
|
||||
else:
|
||||
return fail("需要 paths 或 source='inbox'", next_action="paths 传绝对路径列表,或 source='inbox' 收手机上传")
|
||||
added = [r["file"] for r in results if r["status"] == "added"]
|
||||
skipped = [r for r in results if r["status"] != "added"]
|
||||
return ok(added=added, skipped=skipped, materials=len(_store.materials(d)))
|
||||
|
||||
|
||||
def case_inbox(case_id: str) -> dict[str, Any]:
|
||||
"""列出手机/PC 上传进来但尚未进入流程的文件。"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}", next_action="先 case_create")
|
||||
files = [{"file": f.name, "bytes": f.stat().st_size} for f in _store.inbox(d)]
|
||||
return ok(pending=len(files), files=files)
|
||||
|
||||
|
||||
def case_reset(case_id: str, scope: str = "extractions") -> dict[str, Any]:
|
||||
"""清理派生产物以便重跑。scope=extractions|match|info|pages|artifacts|all。"""
|
||||
d = _store.resolve(case_id)
|
||||
if d is None:
|
||||
return fail(f"会话不存在: {case_id}", next_action="先 case_create")
|
||||
cache = d / CACHE_DIR_NAME
|
||||
cleared: list[str] = []
|
||||
if scope == "extractions":
|
||||
for p in list(cache.glob("*.json")):
|
||||
if p.name not in ("travel_info.json", "normal_info.json", "match_result.json"):
|
||||
p.unlink()
|
||||
cleared.append(p.name)
|
||||
elif scope == "match":
|
||||
p = cache / "match_result.json"
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
cleared.append(p.name)
|
||||
elif scope == "info":
|
||||
for name in ("travel_info.json", "normal_info.json"):
|
||||
p = cache / name
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
cleared.append(name)
|
||||
elif scope == "pages":
|
||||
pages = d / PAGES_DIR_NAME
|
||||
if pages.is_dir():
|
||||
shutil.rmtree(pages)
|
||||
pages.mkdir(parents=True, exist_ok=True)
|
||||
cleared.append(PAGES_DIR_NAME)
|
||||
elif scope == "artifacts":
|
||||
art = d / ARTIFACTS_DIR_NAME
|
||||
for p in art.glob("*"):
|
||||
p.unlink()
|
||||
cleared.append(p.name)
|
||||
elif scope == "all":
|
||||
for sub in (CACHE_DIR_NAME, PAGES_DIR_NAME, SCREENSHOT_DIR_NAME, ARTIFACTS_DIR_NAME):
|
||||
target = d / sub
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
cleared.append(sub)
|
||||
for name in ("payment_records.csv", "invoice_summary.csv", "invoice_groups.json", "submit_preview.json"):
|
||||
f = d / name
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
cleared.append(name)
|
||||
else:
|
||||
return fail(f"未知 scope: {scope}", next_action="可选 extractions|match|info|pages|artifacts|all")
|
||||
return ok(scope=scope, cleared=cleared, case=_store.summary(d))
|
||||
Reference in New Issue
Block a user