From 4fbb4c36ed235a5ce8e75430357867f3ae0bc588 Mon Sep 17 00:00:00 2001 From: wandering Date: Mon, 31 Aug 2026 18:25:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=9F=E6=88=90=E5=AD=A6?= =?UTF-8?q?=E6=A0=A1=E6=98=93=E8=80=97=E5=93=81=E5=A1=AB=E5=86=99=E7=9A=84?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 37 ++- src/agent/coordinator.py | 72 ++---- src/core/extraction/__init__.py | 4 - src/core/extraction/extractor.py | 25 +- src/core/extraction/llm_extractor.py | 95 +------- src/core/validation/validator.py | 4 +- src/infra/documents/__init__.py | 12 + src/infra/documents/requisition.py | 254 ++++++++++++++++++++ src/infra/llm/__init__.py | 2 - src/infra/llm/prompt.py | 5 - src/infra/llm/prompts/invoice_system.md | 29 ++- src/infra/llm/prompts/normal_info_system.md | 239 +++++++++++++----- src/infra/llm/prompts/supplement_system.md | 114 --------- src/pipeline_core.py | 19 +- src/web/routes.py | 5 +- src/web/static/js/agent.js | 10 +- src/web/static/js/chat/stream.js | 66 +++-- tests/test_extractor.py | 2 +- tests/test_requisition.py | 148 ++++++++++++ 19 files changed, 777 insertions(+), 365 deletions(-) create mode 100644 src/infra/documents/requisition.py delete mode 100644 src/infra/llm/prompts/supplement_system.md create mode 100644 tests/test_requisition.py diff --git a/README.md b/README.md index c6d64e9..5d0fdd4 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ │ │ ├── documents/ # 文档处理 │ │ │ ├── invoice.py # 发票数据模型 + CSV 工具 │ │ │ ├── pdf.py # PDF 图片渲染 -│ │ │ └── consumable.py # 易耗品出库单填写(Word COM) +│ │ │ ├── consumable.py # 易耗品出库单填写(Word COM) +│ │ │ └── requisition.py # 低值易耗品申购单填写(Excel COM) │ │ └── llm/ # LLM 接口 │ │ ├── prompt.py # 提示词加载 │ │ └── prompts/ # 提示词模板文件 @@ -293,6 +294,40 @@ uv run python -m src.infra.documents.consumable --no-backup # 不生成 .doc.b - 单价/金额保留两位小数;表格内统一为 **宋体五号(10.5 磅)** - 存放地点取自 `consumable_storage`(默认: `躬行楼 C205`);购货人/领用人签字、备注保持空白 +### 6. 生成低值易耗品申购单(CLI) + +从 Web 会话目录的发票缓存读取普通发票,自动填入「低值易耗品申购」Excel 模板。 +需本机已安装 **Microsoft Excel**(通过 COM 操作,仅 Windows 可用)。 + +```bash +# 传入 Web 会话目录(含 .invoice_cache/ 与 agent_state.json) +uv run python -m src.infra.documents.requisition --session src/web/uploads/35c63986686b + +# 排除物品名称含指定关键词的发票(逗号分隔多个) +uv run python -m src.infra.documents.requisition --session src/web/uploads/0c9b7e7ef1ac --exclude 千斤顶,台钻 + +# 指定模板 / 输出路径 / 实验实训项目 +uv run python -m src.infra.documents.requisition --session <会话目录> --template 模板.xls --output 输出.xls --project "项目名称" +``` + +参数说明: + +| 参数 | 说明 | +|------|------| +| `--session` | Web 会话目录,脚本自动读取其中的 `.invoice_cache/` 与 `agent_state.json` | +| `--template` | 申购单 Excel 模板路径;默认取项目根目录下现有的 `低值易耗品申购*.xls` | +| `--output` | 输出文件路径;默认同目录生成带时间戳的新文件,不覆盖模板 | +| `--project` | 「对应实验实训项目」列内容;默认读 `agent_state.json` 的 `reimbursement_description` | +| `--exclude` | 排除物品名称含这些关键词的发票,逗号分隔 | + +填写规则概要: + +- 仅收录 `invoice_type=general` 的普通发票(支付截图/备注文件自动排除),按发票号去重 +- 物品名称去除 `*分类*` 前缀(如 `*半导体*半导体分立器件` → `半导体分立器件`) +- 规格:优先用发票提取的型号;无型号或为泛化名称(集成电路/电阻/连接器等)时,从名称提取封装/型号(如 `SOP-8`、`TO-220`、`STM32F103C8T6`),仍无法确定则填 `多品种` +- 数量固定 `1`,单价(含税) = 金额 = 发票价税合计 +- 末尾自动追加「合计」行 + ## 执行步骤说明 | 步骤 | 命令 | 说明 | diff --git a/src/agent/coordinator.py b/src/agent/coordinator.py index bade9f4..1e37387 100644 --- a/src/agent/coordinator.py +++ b/src/agent/coordinator.py @@ -21,9 +21,7 @@ from ..core.extraction import ( llm_query_text, load_cache, load_match_result, - merge_supplement_into_info, parse_json_response, - process_user_supplement, ) from ..core.validation import validate_extracted_info from ..infra.llm import ( @@ -77,6 +75,7 @@ def _do_extraction_with_validation( session: AgentSession, previous_analysis: dict[str, Any] | None = None, cache_map: dict[str, Any] | None = None, + user_supplement: str | None = None, ) -> dict[str, Any]: """Agent 调度的提取-校验-修正循环。 @@ -95,6 +94,7 @@ def _do_extraction_with_validation( session_dir: 会话目录。 session: 当前 Agent 会话。 previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。 + user_supplement: 用户文字补充(可选,传入时作为补充上下文)。 Returns: 校验通过的结构化数据(或达到重试上限后的最佳结果)。 @@ -108,7 +108,12 @@ def _do_extraction_with_validation( else: system_prompt = build_normal_info_system_prompt() - base_message = build_extraction_user_message(cache_map, match_result, previous_analysis=previous_analysis) + base_message = build_extraction_user_message( + cache_map, + match_result, + previous_analysis=previous_analysis, + user_supplement=user_supplement, + ) current_message = base_message for attempt in range(1, MAX_VALIDATION_RETRIES + 1): @@ -191,6 +196,7 @@ def run_agent_round( session_dir: Path, session: AgentSession, new_files: list[str] | None = None, + user_supplement: str | None = None, ) -> AgentSession: """执行一轮 Agent 处理:提取-校验-修正循环。 @@ -198,6 +204,7 @@ def run_agent_round( session_dir: 会话目录。 session: 当前 Agent 会话。 new_files: 新增的文件列表(可选,补充文件时传入)。 + user_supplement: 用户文字补充(可选,传入时作为补充上下文,强制重新分析)。 Returns: 更新后的 Agent 会话。 @@ -230,22 +237,26 @@ def run_agent_round( # ---- Step 1: 信息提取(Agent 调度校验-修正循环) ---- session.state = AgentState.EXTRACTING - # 判断是否为补充文件场景:有新文件传入时,加载上一轮分析结果作为上下文 + # 判断是否为补充场景:文件补充或文字补充,加载上一轮分析结果作为上下文 cache_map = load_cache(session_dir) - is_supplement = bool(new_files) + is_supplement = bool(new_files) or bool(user_supplement) previous_analysis = None if is_supplement: info_key = "travel_info" if session.invoice_type == "travel" else "normal_info" previous_analysis = cache_map.get(info_key) if previous_analysis: - log.info("检测到补充文件,加载上一轮分析结果作为历史上下文") + log.info("检测到补充内容,加载上一轮分析结果作为历史上下文") try: info_key = "travel_info" if session.invoice_type == "travel" else "normal_info" should_reanalyze = not cache_map.get(info_key) or is_supplement if should_reanalyze: session.extracted_info = _do_extraction_with_validation( - session_dir, session, previous_analysis=previous_analysis, cache_map=cache_map + session_dir, + session, + previous_analysis=previous_analysis, + cache_map=cache_map, + user_supplement=user_supplement, ) # 提取后立即写入缓存,后续步骤依赖此数据 save_cache_info(session_dir, info_key, session.extracted_info) @@ -361,50 +372,11 @@ def process_user_text_supplement( emit_agent_event( session_dir, "agent_supplement_received", - files=[user_text[:50]], # 简短显示 + supplement_type="text", + text=user_text[:50], ) - # Step 1: LLM 分析用户文字 - supplement_result = process_user_supplement( - user_text=user_text, - extracted_info=session.extracted_info, - invoice_type=session.invoice_type, - source_dir=session_dir, - ) - - updated_fields = supplement_result.get("updated_fields", {}) - unparsed = supplement_result.get("unparsed_info", "") - - if updated_fields: - # Step 2: 合并到已提取信息 - session.extracted_info = merge_supplement_into_info( - session.extracted_info, - updated_fields, - ) - - # Step 3: 保存到缓存 - info_key = "travel_info" if session.invoice_type == "travel" else "normal_info" - save_cache_info(session_dir, info_key, session.extracted_info) - log.info("已更新 %s", info_key) - - # Step 4: 重新执行 Agent 校验 - session.state = AgentState.EXTRACTING - emit_agent_event( - session_dir, - "agent_state_change", - state=session.state, - round=session.rounds, - message="正在重新校验...", - ) - session = run_agent_round(session_dir, session) - else: - # 没有可更新的字段 - msg = unparsed or "未识别到可更新的报销信息" - emit_agent_event( - session_dir, - "agent_supplement_received", - files=[msg], - ) - log.info("用户补充未识别到有效信息: %s", msg) + # 将用户文字补充作为上下文传给主提取流程,LLM 输出完整 JSON + session = run_agent_round(session_dir, session, user_supplement=user_text) return session diff --git a/src/core/extraction/__init__.py b/src/core/extraction/__init__.py index 166edc6..22cc17a 100644 --- a/src/core/extraction/__init__.py +++ b/src/core/extraction/__init__.py @@ -19,9 +19,7 @@ from .llm_extractor import ( llm_query_text, load_cache, load_match_result, - merge_supplement_into_info, parse_json_response, - process_user_supplement, ) __all__ = [ @@ -34,9 +32,7 @@ __all__ = [ "load_cache", "load_match_result", "llm_query_text", - "merge_supplement_into_info", "parse_json_response", - "process_user_supplement", "EXTRACTION_PARALLEL_COUNT", "FILE_EVENTS_LOG", "SUPPORTED_EXTENSIONS", diff --git a/src/core/extraction/extractor.py b/src/core/extraction/extractor.py index 6b9b109..d3cb27b 100644 --- a/src/core/extraction/extractor.py +++ b/src/core/extraction/extractor.py @@ -115,6 +115,8 @@ def _build_summary(data: dict[str, Any]) -> dict[str, str]: "general": "普通发票", "payment": "支付记录", "application": "出差申请单", + "attachments": "其他附件", + "note": "易耗品单", } summary = {} @@ -259,11 +261,12 @@ def extract_invoices( - 出差事前申请单 -> 单独存储,不参与匹配 Returns: - (payment_records, applications, groups): - - payment_records: 支付记录列表(仅包含真实发票,不含申请单) - - applications: 出差事前申请单列表(单独存储,不参与支付匹配) + 元组 (payment_records, applications, groups): + - payment_records: 支付记录列表(每项含关联发票 _matched_invoices) + - applications: 出差事前申请单列表 - groups: 按文档类型分组的字典 - {'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单]} + {'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单], + 'attachments': [其他附件]} Raises: ExtractionError: 当所有文件提取均失败时抛出,携带失败文件列表和原始错误。 @@ -280,13 +283,14 @@ def extract_invoices( all_files = _find_all_files(directory) if not all_files: log.warning("未找到支持的文件") - return [], [], {"travel": [], "general": [], "application": []} + return [], [], {"travel": [], "general": [], "application": [], "attachments": []} log.info(f"发现 {len(all_files)} 个文件") all_invoices = [] all_cards = [] applications = [] + attachments = [] # 记录失败文件及其错误信息 failed_files: list[tuple[str, str]] = [] @@ -318,6 +322,9 @@ def extract_invoices( 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}") @@ -334,7 +341,9 @@ def extract_invoices( details=error_details, ) - log.info(f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份") + log.info( + f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份, 附件 {len(attachments)} 个" + ) if not all_invoices: log.warning("未成功解析任何发票") @@ -349,10 +358,12 @@ def extract_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['application'])} 份, " + f"附件 {len(groups['attachments'])} 个" ) return payment_records, applications, groups diff --git a/src/core/extraction/llm_extractor.py b/src/core/extraction/llm_extractor.py index a200198..de2c74d 100644 --- a/src/core/extraction/llm_extractor.py +++ b/src/core/extraction/llm_extractor.py @@ -43,7 +43,6 @@ from ...infra.documents.invoice import CACHE_DIR_NAME from ...infra.llm.prompt import ( build_invoice_system_prompt, build_normal_info_system_prompt, - build_supplement_system_prompt, build_travel_info_system_prompt, ) @@ -386,6 +385,7 @@ 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 调度使用)。 @@ -393,6 +393,7 @@ def build_extraction_user_message( cache_map: 缓存数据映射。 match_result: 匹配结果。 previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。 + user_supplement: 用户文字补充(可选,传入时作为修正上下文)。 Returns: 拼接好的用户消息字符串。 @@ -411,6 +412,9 @@ def build_extraction_user_message( + json.dumps(previous_analysis, ensure_ascii=False, indent=2) ) + if user_supplement: + parts.append("【用户文字补充】用户补充了以下文字信息,请据此更新你的分析结果。\n" + user_supplement) + if match_result: parts.append( "【发票与支付记录匹配结果】" @@ -500,92 +504,3 @@ def extract_normal_info( 包含报销说明、发票总数、总金额、支付方式、附件清单等字段的字典。 """ return _extract_info(source_dir, build_normal_info_system_prompt(), "普通发票") - - -# ------------------------------------------------------------------ -# 用户补充信息处理 -# ------------------------------------------------------------------ - - -def process_user_supplement( - user_text: str, - extracted_info: dict[str, Any], - invoice_type: str, - source_dir: Path | None = None, -) -> dict[str, Any]: - """让用户补充的文字信息通过 LLM 分析,返回需要更新的字段。 - - Args: - user_text: 用户输入的文字。 - extracted_info: 当前已提取的报销信息。 - invoice_type: "travel" 或 "normal"。 - source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。 - - Returns: - 包含 updated_fields, changes, confidence, unparsed_info 的字典。 - """ - system_prompt = build_supplement_system_prompt() - - parts = [ - f"发票类型: {'差旅报销' if invoice_type == 'travel' else '普通报销'}", - "", - "以下是当前已提取的报销信息:", - json.dumps(extracted_info, ensure_ascii=False, indent=2), - "", - f"用户补充信息:{user_text}", - "", - "=== 请分析用户输入并返回需要更新的字段 ===", - ] - user_message = "\n".join(parts) - - 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 补充信息分析完成") - - return result - except Exception as e: - log.error("LLM 补充信息分析失败: %s", e) - return { - "updated_fields": {}, - "changes": [], - "confidence": 0.0, - "unparsed_info": f"分析失败: {e}", - } - - -def merge_supplement_into_info( - extracted_info: dict[str, Any], - updated_fields: dict[str, Any], -) -> dict[str, Any]: - """将 LLM 返回的更新字段合并到已提取的信息中。 - - 支持点号路径(如 basic_info.travel_purpose)表示嵌套更新。 - - Args: - extracted_info: 当前已提取的报销信息。 - updated_fields: LLM 返回的需要更新的字段。 - - Returns: - 更新后的报销信息。 - """ - import copy - - result = copy.deepcopy(extracted_info) - - for field_path, value in updated_fields.items(): - parts = field_path.split(".") - current = result - for part in parts[:-1]: - if part not in current: - current[part] = {} - current = current[part] - current[parts[-1]] = value - log.info("更新字段 %s = %s", field_path, value) - - return result diff --git a/src/core/validation/validator.py b/src/core/validation/validator.py index 6e82088..f23fcb6 100644 --- a/src/core/validation/validator.py +++ b/src/core/validation/validator.py @@ -25,7 +25,7 @@ log = get_logger("validator") # 日期格式 # ------------------------------------------------------------------ -DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") +DATE_PATTERN = re.compile(r"^\d{4}-(?:0?[1-9]|1[0-2])-(?:0?[1-9]|[12]\d|3[01])$") def _is_valid_date(value: str) -> bool: @@ -95,7 +95,7 @@ class ValidationConfig(TypedDict): # 配置加载 # ------------------------------------------------------------------ -_CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / "validation_rules.json" +_CONFIG_PATH = Path(__file__).parent.parent.parent.parent / "config" / "validation_rules.json" _cached_rules: ValidationConfig | None = None diff --git a/src/infra/documents/__init__.py b/src/infra/documents/__init__.py index 4598a09..b9cef34 100644 --- a/src/infra/documents/__init__.py +++ b/src/infra/documents/__init__.py @@ -19,6 +19,13 @@ from .invoice import ( save_csv, save_invoice_csv, ) +from .requisition import ( + DEFAULT_PROJECT, + REQUISITION_FILENAME, + fill_requisition_excel, + generate_requisition, + load_requisition_items, +) __all__ = [ "CACHE_DIR_NAME", @@ -33,4 +40,9 @@ __all__ = [ "CONSUMABLE_DOC_FILENAME", "fill_consumable_doc", "fill_consumable_from_template", + "REQUISITION_FILENAME", + "DEFAULT_PROJECT", + "load_requisition_items", + "fill_requisition_excel", + "generate_requisition", ] diff --git a/src/infra/documents/requisition.py b/src/infra/documents/requisition.py new file mode 100644 index 0000000..2654d96 --- /dev/null +++ b/src/infra/documents/requisition.py @@ -0,0 +1,254 @@ +"""将发票缓存数据填入「低值易耗品申购」Excel 模板。 + +从会话目录的 .invoice_cache/*.json 读取普通发票(invoice_type=general), +按发票号去重后填入 Excel 申购单模板(通过 Excel COM 操作,仅 Windows 可用)。 + +数据规则: +- 仅收录 invoice_type=general 的普通发票(支付截图/备注文件自动排除) +- 按发票号去重(同发票号的重复文件只保留一条) +- 物品名称去除 "*分类*" 前缀(如 "*半导体*半导体分立器件" -> "半导体分立器件") +- 规格:优先用提取的型号;无型号或为泛化名称(集成电路/电阻/连接器等)时填"多品种" +- 数量固定 1,单价(含税)=金额=发票价税合计 +- 对应实验实训项目:默认取 agent_state.json 的 reimbursement_description + +用法: + uv run python -m src.infra.documents.requisition --session src/web/uploads/35c63986686b + uv run python -m src.infra.documents.requisition --session src/web/uploads/0c9b7e7ef1ac --exclude 千斤顶 +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import time +from pathlib import Path +from typing import Any + +from ... import get_logger + +log = get_logger("fill_requisition") + +REQUISITION_FILENAME = "低值易耗品申购.xls" +CACHE_DIR_NAME = ".invoice_cache" +AGENT_STATE_FILENAME = "agent_state.json" + +TITLE_ROW = 1 +HEADER_ROW = 2 +DATA_START_ROW = 3 + +COL_INDEX = 1 +COL_NAME = 2 +COL_SPEC = 3 +COL_QTY = 4 +COL_UNIT_PRICE = 5 +COL_AMOUNT = 6 +COL_PROJECT = 7 +COL_DATE = 8 + +DEFAULT_PROJECT = "电子元器件及工具耗材采购" + +# 泛化规格名称:这类值对申购单无意义,统一替换为"多品种" +GENERIC_SPECS = {"集成电路", "电阻", "连接器", "电子元件", "二极管", "芯片"} + +# 从物品名称中提取型号/封装的正则(按顺序匹配,须含第 1 捕获组) +SPEC_PATTERNS = [ + re.compile(r"(?:封装\s*)?(SOP-\d+|SOIC-\d+|SOT-\d+|DIP-\d+|QFP-\d+|QFN-\d+)", re.IGNORECASE), + re.compile(r"(STM32[A-Z0-9]+)", re.IGNORECASE), + re.compile(r"(TO-\d+)", re.IGNORECASE), + re.compile(r"(7\.5\*7\.5\*5)"), +] + + +def clean_item_name(raw_name: str) -> str: + """去除物品名称中的 "*分类*" 前缀与首尾标点。""" + cleaned = re.sub(r"\*[\u4e00-\u9fff]+\*", "", raw_name) + cleaned = re.sub(r"^[、,,\s]+", "", cleaned) + return cleaned + + +def guess_spec(extracted_spec: str, item_name: str) -> str: + """确定规格:优先用提取的型号,其次从名称提取,否则"多品种"。""" + spec = (extracted_spec or "").strip() + if spec and spec not in GENERIC_SPECS: + return spec + for pattern in SPEC_PATTERNS: + m = pattern.search(item_name) + if m: + return m.group(1).upper() + return "多品种" + + +def load_requisition_items( + cache_dir: str | Path, + exclude_keywords: set[str] | None = None, +) -> list[dict[str, Any]]: + """读取发票缓存,返回去重后的申购明细。 + + 每个元素: {"name", "spec", "total"},total 为 float 金额。 + exclude_keywords: 物品名称包含任一关键词则排除(如 {"千斤顶"})。 + """ + cache_dir = Path(cache_dir) + exclude_keywords = exclude_keywords or set() + seen_invoices: set[str] = set() + items: list[dict[str, Any]] = [] + + for fname in sorted(cache_dir.iterdir()): + if fname.suffix != ".json" or fname.name in ("match_result.json", "normal_info.json"): + continue + with open(fname, encoding="utf-8") as f: + data = json.load(f) + ex = data.get("extracted_data", {}) + if ex.get("invoice_type") != "general": + continue + invoice_no = ex.get("invoice_number", "") + if invoice_no and invoice_no in seen_invoices: + continue + if invoice_no: + seen_invoices.add(invoice_no) + + item_name = clean_item_name(ex.get("item_name", "")) + if not item_name: + continue + raw_name = ex.get("item_name", "") + if any(kw in raw_name for kw in exclude_keywords): + continue + try: + total = float(ex.get("total_amount", 0)) + except (TypeError, ValueError): + total = 0.0 + items.append( + { + "name": item_name, + "spec": guess_spec(ex.get("spec_model", ""), item_name), + "total": total, + } + ) + return items + + +def load_project_desc(session_dir: str | Path, fallback: str = DEFAULT_PROJECT) -> str: + """从 agent_state.json 读取报销描述作为实验实训项目,缺失时用回退值。""" + state_path = Path(session_dir) / AGENT_STATE_FILENAME + try: + with open(state_path, encoding="utf-8") as f: + state = json.load(f) + desc = state.get("extracted_info", {}).get("basic_info", {}).get("reimbursement_description") + return desc or fallback + except (OSError, ValueError): + return fallback + + +def fill_requisition_excel( + template_path: str | Path, + output_path: str | Path, + items: list[dict[str, Any]], + project_desc: str = DEFAULT_PROJECT, +) -> Path: + """将申购明细填入 Excel 模板并另存为新文件。 + + 模板须含标题行与表头行(表头: 序号|物品名称|规格|数量|单价(含税)|金额|对应实验实训项目|数据采集时间), + 数据从第 3 行开始填写,末尾追加合计行。 + """ + template_path = Path(template_path) + output_path = Path(output_path) + shutil.copy2(template_path, output_path) + + import pythoncom + import win32com.client + + pythoncom.CoInitialize() + excel: Any = None + wb: Any = None + try: + excel = win32com.client.Dispatch("Excel.Application") + excel.Visible = False + excel.DisplayAlerts = False + wb = excel.Workbooks.Open(str(output_path.resolve())) + ws = wb.Sheets(1) + + used = ws.UsedRange.Rows.Count + if used > HEADER_ROW: + ws.Rows(f"{DATA_START_ROW}:{used}").Delete() + + for i, item in enumerate(items): + row = DATA_START_ROW + i + ws.Cells(row, COL_INDEX).Value = i + 1 + ws.Cells(row, COL_NAME).Value = item["name"] + ws.Cells(row, COL_SPEC).Value = item["spec"] + ws.Cells(row, COL_QTY).Value = 1 + ws.Cells(row, COL_UNIT_PRICE).Value = item["total"] + ws.Cells(row, COL_AMOUNT).Value = item["total"] + ws.Cells(row, COL_PROJECT).Value = project_desc + ws.Cells(row, COL_DATE).Value = "" + + total_row = DATA_START_ROW + len(items) + ws.Cells(total_row, COL_NAME).Value = "合计" + ws.Cells(total_row, COL_AMOUNT).Value = sum(item["total"] for item in items) + + wb.Save() + log.info( + "已生成低值易耗品申购单: %s(%d 项,合计 %.2f 元)", + output_path.name, + len(items), + sum(item["total"] for item in items), + ) + finally: + if wb is not None: + wb.Close() + if excel is not None: + excel.Quit() + pythoncom.CoUninitialize() + return output_path + + +def generate_requisition( + session_dir: str | Path, + template_path: str | Path | None = None, + output_path: str | Path | None = None, + project_desc: str | None = None, + exclude_keywords: set[str] | None = None, +) -> Path: + """一站式生成申购单:读取缓存 -> 去重 -> 填表。 + + 默认以项目根目录下已存在的 `低值易耗品申购*.xls` 为模板, + 输出为同目录下带时间戳的新文件。 + """ + session_dir = Path(session_dir) + cache_dir = session_dir / CACHE_DIR_NAME + template = Path(template_path) if template_path is not None else None + + if template is None: + root = Path(__file__).resolve().parents[3] + candidates = sorted(root.glob(f"{REQUISITION_FILENAME.replace('.xls', '')}*.xls")) + if not candidates: + raise FileNotFoundError(f"未找到申购单模板: {root / REQUISITION_FILENAME}") + template = candidates[-1] + if output_path is None: + ts = int(time.time() * 1000) + output_path = template.parent / f"低值易耗品申购{ts}.xls" + + items = load_requisition_items(cache_dir, exclude_keywords=exclude_keywords) + desc = project_desc or load_project_desc(session_dir) + return fill_requisition_excel(template, output_path, items, project_desc=desc) + + +def main() -> None: + parser = argparse.ArgumentParser(description="生成低值易耗品申购单 Excel") + parser.add_argument("--session", default=".", help="Web 会话目录(含 .invoice_cache/ 与 agent_state.json)") + parser.add_argument("--template", default=None, help="申购单模板 xls 路径(默认取项目根目录现有模板)") + parser.add_argument("--output", default=None, help="输出 xls 路径(默认同目录带时间戳新文件)") + parser.add_argument("--project", default=None, help="对应实验实训项目(默认读 agent_state.json)") + parser.add_argument("--exclude", default="", help="排除物品名称含这些关键词的发票,逗号分隔(如: 千斤顶,台钻)") + args = parser.parse_args() + + exclude = {kw.strip() for kw in args.exclude.split(",") if kw.strip()} + out = generate_requisition( + args.session, args.template, args.output, project_desc=args.project, exclude_keywords=exclude + ) + print(f"已生成: {out}") + + +if __name__ == "__main__": + main() diff --git a/src/infra/llm/__init__.py b/src/infra/llm/__init__.py index f435c51..4e0891c 100644 --- a/src/infra/llm/__init__.py +++ b/src/infra/llm/__init__.py @@ -6,13 +6,11 @@ from .prompt import ( build_invoice_system_prompt, build_normal_info_system_prompt, - build_supplement_system_prompt, build_travel_info_system_prompt, ) __all__ = [ "build_invoice_system_prompt", "build_normal_info_system_prompt", - "build_supplement_system_prompt", "build_travel_info_system_prompt", ] diff --git a/src/infra/llm/prompt.py b/src/infra/llm/prompt.py index eb87f2d..b98fe3e 100644 --- a/src/infra/llm/prompt.py +++ b/src/infra/llm/prompt.py @@ -28,8 +28,3 @@ def build_travel_info_system_prompt() -> str: def build_normal_info_system_prompt() -> str: """构建普通发票信息提取系统提示词。""" return _load_prompt("normal_info_system.md") - - -def build_supplement_system_prompt() -> str: - """构建用户补充信息分析系统提示词。""" - return _load_prompt("supplement_system.md") diff --git a/src/infra/llm/prompts/invoice_system.md b/src/infra/llm/prompts/invoice_system.md index a79d47f..0cfe359 100644 --- a/src/infra/llm/prompts/invoice_system.md +++ b/src/infra/llm/prompts/invoice_system.md @@ -12,7 +12,7 @@ ## 第一步:类型判断 -收到图片后,首先判断文档类型。类型共有以下 6 种: +收到图片后,首先判断文档类型。类型共有以下 7 种: | 类型值 | 文档类别 | 识别特征 | |--------|---------|---------| @@ -22,6 +22,7 @@ | `general` | 普通发票 | 不属于以上类别的其他发票 | | `application` | 出差事前申请单 | 含项目名称、出差事由、计划时间、出差人员等信息 | | `note` | 易耗品/出入库单 | 易耗品出入库单、出库单等 | +| `attachments` | 其他附件 | 不属于以上任何类别的文件,如学习材料、合同、说明文档等 | --- @@ -168,6 +169,22 @@ --- +### 7. `attachments`(其他附件) + +```json +{ + "invoice_type": "attachments", + "description": "" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `invoice_type` | 是 | 固定为 `"attachments"` | +| `description` | 是 | 文件内容简要描述(总结为人类可读的描述) | + +--- + ## 强制性类型约束 ### 根节点结构 @@ -178,7 +195,7 @@ | 约束 | 规则 | |------|------| -| `invoice_type` | 字符串,必须为 6 种类型值之一 | +| `invoice_type` | 字符串,必须为 7 种类型值之一 | | 日期字段 | 字符串格式 `YYYY-MM-DD`,无法识别时返回 `""` | | 金额字段 | 字符串格式(如 `"115.50"`),找不到时返回 `"0"` | | 文本字段 | 字符串,无法识别时返回 `""` | @@ -227,6 +244,14 @@ } ``` +**其他附件**: +```json +{ + "invoice_type": "attachments", + "description": "安徽开放大学专业技术人员在线培训学习视频" +} +``` + --- ## 空值处理规则 diff --git a/src/infra/llm/prompts/normal_info_system.md b/src/infra/llm/prompts/normal_info_system.md index 5bc7b29..b0494c1 100644 --- a/src/infra/llm/prompts/normal_info_system.md +++ b/src/infra/llm/prompts/normal_info_system.md @@ -1,101 +1,210 @@ -# 普通报销信息提取系统提示词 +# 角色定义 -你是财务报销信息提取助手。根据发票信息和付款记录,提取报销相关的结构化数据,并以严格符合类型要求的 JSON 格式返回。 +你是极度严谨合规的财务报销信息提取助手。你严格恪守财务数据规范,以字段精准映射、结果零偏差为核心准则,输出直接客观,在不加入无关细节的前提下,交付完全符合要求的结构化提取结果。 -> **核心原则**:类型约束为最高优先级规则,任何情况下不得违反。 +你通常不会输出提取推导过程、数据来源说明与寒暄类话术,只返回严格匹配 schema 要求的标准 JSON 格式结果,除非用户非常明确地要求标注提取依据与异常说明。你只按规则输出结果,不需要解释输出逻辑,也不透露内部校验规则的细节。 -## 强制类型约束 +你具备普通报销全单据的交叉校验能力,当获取到发票信息和付款记录后,会自动完成金额一致性、信息完整性的校验;信息缺失时按 schema 规则做缺省标记,绝不臆造任何无原始依据的财务数据。 -以下类型规则为最高优先级,任何情况下不得违反。 +你始终以 schema 为唯一输出标尺,偏好强类型约束、层级清晰的结构化输出风格;合规性优先于信息完整性,所有提取动作严格遵循财务报销管理规范。 -### 1. 根节点字段(共 6 个,类型不可变更) +**核心原则**:类型约束为最高优先级规则,任何情况下不得违反。 -| 字段名 | 强制类型 | 空值处理 | -| --- | --- | --- | -| `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 | -| `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含下述 2 个子字段 | -| `payment_methods` | 数组 (list) | 必填,无数据时赋值为 `[]` | -| `attachments` | 数组 (list) | 必填,无数据时赋值为 `[]` | -| `can_submit` | 布尔 (bool) | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` | -| `suggestion` | 字符串 (str) | 当 `can_submit` 为 `false` 时说明需补充的材料;为 `true` 时为空字符串 | +--- -### 2. `reimbursement_details` 子字段(共 2 个) +## 输入数据说明 -| 字段名 | 强制类型 | 空值处理 | -| --- | --- | --- | -| `total_invoices` | 数字 (int) | 必填 | -| `total_amount` | 数字 (float) | 必填 | +你会收到以下两类数据: -### 3. 禁止行为 +| 优先级 | 数据类型 | 包含信息 | 备注 | +|--------|---------|---------|------| +| 1(最高) | 发票信息 | 发票号码、开票日期、商品/服务名称、金额、卖方名称 | 金额推断的依据 | +| 2 | 付款记录 | 刷卡日期、刷卡金额、公务卡号 | 金额推断的依据 | -* 省略任何根节点字段或 `reimbursement_details` 的子字段 -* 将 `payment_methods` 或 `attachments` 赋值为 `null`、字符串、数字或对象 -* 在 `reimbursement_details` 中添加未定义的子字段 -* 合并不同模块的数组数据 +**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。 + +--- + +## 强制性类型约束 + +### 根节点结构 + +根节点必须包含且仅包含以下 6 个字段,类型不可变更: + +| 字段名 | 类型 | 空值处理 | +|--------|------|---------| +| `basic_info` | object | 必填,所有子字段必须存在 | +| `reimbursement_details` | object | 必填,必须且仅含 2 个子字段 | +| `payment_methods` | array | 无数据时返回 `[]` | +| `attachments` | array | 无数据时返回 `[]` | +| `can_submit` | boolean | 必填,信息完整且逻辑自洽时为 `true`,否则为 `false` | +| `suggestion` | string | 当 `can_submit` 为 `false` 时说明需补充的材料;为 `true` 时为空字符串 | + +### 报销明细节点结构 + +`reimbursement_details` 必须包含且仅包含以下 2 个子字段: + +| 子字段 | 类型 | 空值处理 | +|--------|------|---------| +| `total_invoices` | integer | 必填,发票总份数 | +| `total_amount` | number | 必填,付款记录总金额 | + +### 绝对禁止行为 + +- 省略任何根节点字段或报销明细节点 +- 将数组类型赋值为 `null`、字符串、数字或对象 +- 在 `reimbursement_details` 中添加未定义的子字段 + +### 正确输出骨架 -**正确示例**: ```json { - "basic_info": {...}, - "reimbursement_details": {...}, + "basic_info": { /* 所有子字段完整存在 */ }, + "reimbursement_details": { + "total_invoices": 0, + "total_amount": 0 + }, "payment_methods": [], - "attachments": [{"filename":"发票.pdf", ...}], + "attachments": [], "can_submit": true, "suggestion": "" } ``` -**错误示例**: -```json -{ - "basic_info": {...}, - "reimbursement_details": {...}, - "payment_methods": null, -} +--- + +## 字段 Schema + +### 1. `basic_info`(全部必填,无直接信息时给出合理猜测) + +| 字段 | 类型 | 说明 | 推断优先级 | +|------|------|------|-----------| +| `reimbursement_description` | string | 报销说明,20 字以内 | ①发票商品/服务名称 → ②根据所有信息总结 | + +--- + +### 2. `reimbursement_details` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `total_invoices` | integer | 发票总份数(不包含支付记录),根据所有发票文件统计 | +| `total_amount` | number | 付款记录总金额,各笔 `card_amount` 之和 | + +--- + +### 3. `payment_methods` 数组元素(多少笔支付就多少条) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `card_date` | string | 刷卡日期,格式 `YYYY-MM-DD` | +| `card_amount` | number | 刷卡金额(元) | +| `merchant` | string | 根据发票信息推测的商户名称 | +| `remark` | string | 说明该笔付款关联的发票信息 | + +--- + +### 4. `attachments` 数组元素 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `filename` | string | 严格使用原始文件名,不得修改任何字符 | +| `attachment_type` | string | 枚举:`invoice` / `other` | +| `attachment_desc` | string | 文件基本内容描述 | + +**排除规则**:`invoice_type` 为 `payment` 的记录不作为附件。 + +--- + +## 推理规则 + +### 数据推断优先级链 + +``` +金额推断:发票金额 > 付款记录金额 +事由推断:发票商品/服务名称 > 文件内容总结 +商户推断:发票卖方名称 > 根据商品类型合理推测 ``` -## 输入数据说明 -你会收到以下数据: -1. **发票信息**:包含购买物品的发票信息 -2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息 +### 关键规则 -**补充分析场景**:如果你收到「上一轮分析结果」,说明用户可能已补充新文件。请综合所有数据(含新文件和历史分析结果)重新分析,不要仅依赖上一轮的结果。如果新文件填补了之前的信息缺失,请相应更新分析结果。 +1. **金额一致性**:`reimbursement_details.total_amount` 必须等于各笔 `payment_methods.card_amount` 之和 +2. **发票统计**:`total_invoices` 统计所有 `invoice_type` 不为 `payment` 的文件数量 +3. **支付记录排除**:付款记录不放入 `attachments` +4. **附件全覆盖**:所有源文件(除支付记录外)都应在 `attachments` 中有一项 +5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null` -需要提取的信息: -1. `basic_info`:(必填,每一项都必须填,给出合理的猜测) - 1. `reimbursement_description`:根据所有信息写一句20字以内的报销说明 -2. `reimbursement_details`:(至少有一项) - 1. `total_invoices`: 发票的总份数 - 2. `total_amount`:填写付款记录总金额 -3. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式) - 1. `card_date`:根据付款记录,格式 YYYY-M-D - 2. `card_amount`:根据付款记录填写,单位为元,数字 - 3. `merchant`:根据发票信息推测商户信息 - 4. `remark`:说明该笔付款关联的发票信息 -4. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`,"invoice_type": "payment"的不作为附件) - 1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符 - 2. `attachment_type`:从以下两个选项中选择:invoice、other - 3. `attachment_desc`:简要描述该文件的基本信息 - -## 语义完整性校验 +### 语义完整性校验 提取完成后,需判断信息是否足够支撑填报。根据校验结果设置根节点的 `can_submit`(boolean)和 `suggestion`(string)字段。 **校验维度**: -- 支付金额总和是否与发票金额总和接近 -- 报销说明是否明确具体 -- 人员信息是否完整 -- 支付方式是否与支付记录对应 -- 每张发票是否都有对应的支付记录 +- `reimbursement_details.total_amount` 与 `payment_methods` 各笔之和是否一致 + - 不一致时说明缺发票或缺支付记录 +- 每张发票是否都有对应的 `payment_methods` 项 +- 报销说明是否清晰明确 + +**不需要关注的** +- 非必填项没有填写信息,不要提醒补充 + +**一定要关注的** +- `reimbursement_details` 和 `payment_methods` 两个的总金额应该一样,如果不一样,要么是缺发票,要么是缺支付记录,需要提醒用户 **判定标准**: - `can_submit = true`:信息完整且逻辑自洽,`suggestion` 为空字符串 - `can_submit = false`:存在信息缺失或逻辑矛盾,`suggestion` 说明需要用户补充什么材料 +### 示例 + +**支付方式**(2 笔支付): + +```json +[ + { + "card_date": "2026-06-30", + "card_amount": 180.0, + "merchant": "安徽开放大学", + "remark": "关联发票: 26342000002051980306.pdf (专业技术人员在线培训费)" + }, + { + "card_date": "2026-06-08", + "card_amount": 921.0, + "merchant": "合肥京东嘉远贸易有限公司", + "remark": "关联发票: digital_26347000000158262080.pdf (电器电子产品及配件)" + } +] +``` + +**附件清单**(3 个文件,排除支付记录): + +```json +[ + { + "filename": "26342000002051980306.pdf", + "attachment_type": "invoice", + "attachment_desc": "安徽开放大学专业技术人员在线培训费发票" + }, + { + "filename": "digital_26347000000158262080.pdf", + "attachment_type": "invoice", + "attachment_desc": "合肥京东嘉远贸易有限公司电器电子产品及配件发票" + }, + { + "filename": "安徽开放大学学习视频180.pdf", + "attachment_type": "other", + "attachment_desc": "安徽开放大学专业技术人员在线培训学习视频" + } +] +``` + +--- + ## 最终输出要求 -* 仅输出纯 JSON 字符串,不包含任何思考过程、解释文字或 Markdown 标记 -* JSON 语法必须正确,无多余逗号、引号等错误 -* 严格遵守所有强制性类型约束,违反类型要求的输出视为无效 \ No newline at end of file +- 严格只输出 JSON 字符串,不包含任何思考过程、解释文字、Markdown 标记或其他内容 +- JSON 语法必须正确,无多余逗号、引号等错误 +- 严格遵守所有类型约束,任何违反均视为无效输出 +- 日期统一使用 `YYYY-MM-DD` 格式 +- 金额使用数字类型(非字符串) +- 计数使用整数类型 diff --git a/src/infra/llm/prompts/supplement_system.md b/src/infra/llm/prompts/supplement_system.md deleted file mode 100644 index d6246c2..0000000 --- a/src/infra/llm/prompts/supplement_system.md +++ /dev/null @@ -1,114 +0,0 @@ -## 角色定义 - -你是财务报销信息补充助手。你的任务是根据用户输入的文字信息,分析并更新已提取的报销信息 JSON。 - -**核心原则**:从用户输入中提取与报销相关的信息,智能合并到现有 JSON 中,不破坏已有数据。 - ---- - -## 输入数据说明 - -你会收到以下数据: -1. **用户输入的文字**:用户补充的信息说明 -2. **当前已提取的报销信息 JSON**:包含基本信息、报销明细、支付方式等 -3. **发票类型**:差旅报销或普通报销 - ---- - -## 处理规则 - -### 1. 信息提取 - -从用户文字中提取以下类型的信息: -- **差旅信息**:出差事由、出发地、目的地、出差日期、随行人员 -- **支付信息**:支付方式、支付金额、支付渠道 -- **发票信息**:发票号码、开票日期、金额 -- **人员信息**:姓名、工号、卡号 -- **其他**:报销说明、备注信息 - -### 2. 合并策略 - -- 如果用户提供的信息对应 JSON 中已存在的字段,则**更新**该字段 -- 如果用户提供的信息是新增内容,则**添加**到合适的字段 -- 如果用户信息模糊,尽量推断最可能的字段 -- **不要删除**已有的信息,除非用户明确说"删除"或"修改为" - -### 3. 差旅报销字段映射 - -| 用户可能说的内容 | 对应 JSON 字段 | -|----------------|--------------| -| 出差原因/目的 | `basic_info.travel_purpose` | -| 去哪里出差 | `basic_info.travel_location` | -| 出发日期 | `basic_info.start_date` | -| 返回日期 | `basic_info.end_date` | -| 同行人员 | `subsidy_list` 数组 | -| 交通方式 | `reimbursement_details.transport_fee` | -| 酒店信息 | `reimbursement_details.accommodation` | - -### 4. 普通报销字段映射 - -| 用户可能说的内容 | 对应 JSON 字段 | -|----------------|--------------| -| 报销说明 | `basic_info.reimbursement_description` | -| 总金额 | `reimbursement_details.total_amount` | -| 支付方式 | `payment_methods` | -| 附件说明 | `attachments` | - ---- - -## 输出格式 - -严格返回以下 JSON 格式: - -```json -{ - "updated_fields": { - "field_path": "新值", - "another_field": "新值" - }, - "changes": ["修改说明1", "修改说明2"], - "confidence": 0.0到1.0之间的数字, - "unparsed_info": "无法解析的信息(如果有)" -} -``` - -### 字段说明 - -| 字段 | 类型 | 说明 | -|------|------|------| -| `updated_fields` | object | 需要更新的字段路径和值,使用点号表示嵌套路径 | -| `changes` | array | 人类可读的修改说明列表 | -| `confidence` | number | 解析置信度,1.0 表示完全确定 | -| `unparsed_info` | string | 无法解析的信息,为空字符串表示全部解析成功 | - -### 示例 - -用户输入:"出差去北京开学术会议,时间是6月10日到6月15日" - -输出: -```json -{ - "updated_fields": { - "basic_info.travel_purpose": "参加学术会议", - "basic_info.travel_location": "北京", - "basic_info.start_date": "2026-06-10", - "basic_info.end_date": "2026-06-15" - }, - "changes": [ - "设置出差事由为参加学术会议", - "设置目的地为北京", - "设置出差时间为6月10日至6月15日" - ], - "confidence": 0.95, - "unparsed_info": "" -} -``` - ---- - -## 最终输出要求 - -- 严格只输出 JSON 字符串 -- JSON 语法必须正确 -- 不要包含任何思考过程或解释文字 -- 如果用户输入与报销无关,返回空的 `updated_fields` 并在 `unparsed_info` 中说明 \ No newline at end of file diff --git a/src/pipeline_core.py b/src/pipeline_core.py index 64ba4fd..ef60717 100644 --- a/src/pipeline_core.py +++ b/src/pipeline_core.py @@ -119,9 +119,17 @@ def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]: 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") + and data.get("invoice_type") not in ("application", "payment", "attachments", "note") ] - return classify_invoice_batch(invoices) + 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: @@ -135,9 +143,11 @@ def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str] "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) @@ -189,13 +199,16 @@ def process_invoices( # 统计发票总数 invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in payment_records) - log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张") + 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", [])), } diff --git a/src/web/routes.py b/src/web/routes.py index 03f86cf..8d6ee56 100644 --- a/src/web/routes.py +++ b/src/web/routes.py @@ -25,6 +25,7 @@ from src.config import ( load_config as load_project_config, ) from src.infra.documents import load_csv, load_invoice_csv +from src.pipeline_core import save_invoice_groups from . import pipeline_web, sse_handler @@ -655,7 +656,7 @@ def agent_process(session_id: str) -> Any: if applications: save_application_json(applications, sd / "travel_applications.json") - pipeline_web.save_invoice_groups(sd, groups) + save_invoice_groups(sd, groups) from src.pipeline_core import is_travel_invoice @@ -718,7 +719,7 @@ def agent_supplement(session_id: str) -> Any: if applications: save_application_json(applications, session_dir / "travel_applications.json") - pipeline_web.save_invoice_groups(session_dir, groups) + save_invoice_groups(session_dir, groups) config_path = session_dir / "config.json" config = {} diff --git a/src/web/static/js/agent.js b/src/web/static/js/agent.js index 891df55..b851d34 100644 --- a/src/web/static/js/agent.js +++ b/src/web/static/js/agent.js @@ -94,8 +94,12 @@ function _handleAgentError(msg) { * 收到补充文件 */ function _handleSupplementReceived(msg) { - const fileNames = (msg.files || []).join('、'); - addChatMessage(`已收到补充文件:${fileNames}`, 'done'); + if (msg.supplement_type === 'text') { + addChatMessage(`已收到补充文字:${msg.text || ''}`, 'done'); + } else { + const fileNames = (msg.files || []).join('、'); + addChatMessage(`已收到补充文件:${fileNames}`, 'done'); + } } /** @@ -193,7 +197,7 @@ export async function handleUserSupplement(text) { handleDoneResult(result); }, handleFileProgress: false, - handleLLMStream: false, + handleLLMStream: true, }); } else if (result.error) { addChatMessage(`处理失败:${result.error}`, 'error'); diff --git a/src/web/static/js/chat/stream.js b/src/web/static/js/chat/stream.js index 44ca84a..1e2e441 100644 --- a/src/web/static/js/chat/stream.js +++ b/src/web/static/js/chat/stream.js @@ -6,6 +6,15 @@ 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 流式气泡状态 */ @@ -108,7 +117,9 @@ function _appendLLMStreamChunk(text) { llmStreamState.accumulated += text; llmStreamState.textContent.textContent = llmStreamState.accumulated; - scrollToBottom(); + if (_isNearBottom()) { + scrollToBottom(); + } } /** @@ -119,7 +130,9 @@ function _appendLLMStreamReasoning(text) { llmStreamState.reasoningAccumulated += text; llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated; - scrollToBottom(); + if (_isNearBottom()) { + scrollToBottom(); + } } /** @@ -239,19 +252,44 @@ 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('基本信息'); - _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); + 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 (data.reimbursement_details?.transport_fee?.length) { + // 报销汇总(普通模式) + 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) { @@ -267,8 +305,8 @@ function _buildExtractionSummary(data) { container.appendChild(section); } - // 住宿费 - if (data.reimbursement_details?.hotel_fee?.length) { + // 住宿费(差旅模式) + if (isTravel && data.reimbursement_details?.hotel_fee?.length) { const section = _summarySection('住宿费'); const table = _summaryTable(['入住日期', '离店日期', '天数', '发票金额', '报销金额', '备注']); for (const item of data.reimbursement_details.hotel_fee) { @@ -285,8 +323,8 @@ function _buildExtractionSummary(data) { container.appendChild(section); } - // 会议/培训费 - if (data.reimbursement_details?.conference_fee?.length) { + // 会议/培训费(差旅模式) + if (isTravel && data.reimbursement_details?.conference_fee?.length) { const section = _summarySection('会议/培训费'); const table = _summaryTable(['日期', '金额', '备注']); for (const item of data.reimbursement_details.conference_fee) { @@ -316,8 +354,8 @@ function _buildExtractionSummary(data) { container.appendChild(section); } - // 补助 - if (data.subsidy_list?.length) { + // 补助(差旅模式) + if (isTravel && data.subsidy_list?.length) { const section = _summarySection('补助'); const table = _summaryTable(['姓名', '开始日期', '结束日期', '天数']); for (const item of data.subsidy_list) { diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 6283323..b01d56c 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -83,7 +83,7 @@ class TestExtractInvoices: records, apps, groups = extract_invoices(str(tmp_path)) assert records == [] assert apps == [] - assert groups == {"travel": [], "general": [], "application": []} + assert groups == {"travel": [], "general": [], "application": [], "attachments": []} def test_extraction_fails(self, tmp_path: Path, monkeypatch): pdf = tmp_path / "broken.pdf" diff --git a/tests/test_requisition.py b/tests/test_requisition.py new file mode 100644 index 0000000..9040041 --- /dev/null +++ b/tests/test_requisition.py @@ -0,0 +1,148 @@ +"""低值易耗品申购单模块单元测试 + +覆盖发票加载去重、规格推测、物品名称清洗。 +""" + +import json +from pathlib import Path +from typing import Any + +from src.infra.documents.requisition import ( + clean_item_name, + guess_spec, + load_project_desc, + load_requisition_items, +) + + +def _write_cache(tmp_path: Path, files: dict[str, dict[str, Any]]) -> None: + cache = tmp_path / ".invoice_cache" + cache.mkdir() + for name, extracted in files.items(): + (cache / f"{name}.json").write_text( + json.dumps({"extracted_data": extracted}, ensure_ascii=False), + encoding="utf-8", + ) + + +class TestCleanItemName: + def test_strips_category_prefix(self) -> None: + assert clean_item_name("*半导体*半导体分立器件") == "半导体分立器件" + + def test_strips_nested_categories(self) -> None: + assert clean_item_name("*半导体*场效应管、*微电子组件*线性稳压器") == "场效应管、线性稳压器" + + def test_removes_leading_punctuation(self) -> None: + assert clean_item_name("、*电子元件*电阻") == "电阻" + + def test_keeps_plain_name(self) -> None: + assert clean_item_name("热熔胶棒") == "热熔胶棒" + + +class TestGuessSpec: + def test_uses_extracted_spec(self) -> None: + assert guess_spec("HYG032N08NS1P", "半导体分立器件") == "HYG032N08NS1P" + + def test_falls_back_to_multiple(self) -> None: + assert guess_spec("", "热熔胶棒") == "多品种" + + def test_ignores_generic_spec(self) -> None: + assert guess_spec("集成电路", "集成电路") == "多品种" + + def test_extracts_package_from_name(self) -> None: + assert guess_spec("", "原装正品 EG3013S 封装 SOP-8 驱动芯片") == "SOP-8" + + def test_extracts_stm32_from_name(self) -> None: + assert guess_spec("", "STM32F103C8T6开发板") == "STM32F103C8T6" + + def test_extracts_tool_from_name(self) -> None: + assert guess_spec("", "IRF5305PBF TO-220 60V 场效应管") == "TO-220" + + +class TestLoadRequisitionItems: + def test_filters_by_invoice_type(self, tmp_path: Path) -> None: + _write_cache( + tmp_path, + { + "invoice1": { + "invoice_type": "general", + "invoice_number": "001", + "item_name": "*电子元件*电阻", + "total_amount": "10.00", + }, + "invoice2": {"invoice_type": "payment", "item_name": "", "total_amount": ""}, + "note": {"invoice_type": "note", "item_name": "", "total_amount": ""}, + }, + ) + items = load_requisition_items(tmp_path / ".invoice_cache") + assert len(items) == 1 + assert items[0]["name"] == "电阻" + + def test_deduplicates_by_invoice_number(self, tmp_path: Path) -> None: + common = { + "invoice_type": "general", + "invoice_number": "999", + "item_name": "*电子元件*电阻", + "total_amount": "10.00", + } + _write_cache(tmp_path, {"a": common, "b": dict(common, item_name="*电子元件*电容")}) + items = load_requisition_items(tmp_path / ".invoice_cache") + assert len(items) == 1 + + def test_exclude_keywords(self, tmp_path: Path) -> None: + _write_cache( + tmp_path, + { + "a": { + "invoice_type": "general", + "invoice_number": "001", + "item_name": "*小型起重设备*千斤顶", + "total_amount": "63.45", + }, + "b": { + "invoice_type": "general", + "invoice_number": "002", + "item_name": "*电子元件*电阻", + "total_amount": "10.00", + }, + }, + ) + items = load_requisition_items(tmp_path / ".invoice_cache", exclude_keywords={"千斤顶"}) + assert len(items) == 1 + assert items[0]["name"] == "电阻" + + def test_skips_control_json(self, tmp_path: Path) -> None: + _write_cache( + tmp_path, + { + "invoice1": { + "invoice_type": "general", + "invoice_number": "001", + "item_name": "*电子元件*电阻", + "total_amount": "1.00", + } + }, + ) + (tmp_path / ".invoice_cache" / "match_result.json").write_text("{}", encoding="utf-8") + (tmp_path / ".invoice_cache" / "normal_info.json").write_text("{}", encoding="utf-8") + items = load_requisition_items(tmp_path / ".invoice_cache") + assert len(items) == 1 + + +class TestLoadProjectDesc: + def test_reads_from_agent_state(self, tmp_path: Path) -> None: + (tmp_path / "agent_state.json").write_text( + json.dumps( + {"extracted_info": {"basic_info": {"reimbursement_description": "电子元件及集成电路采购"}}}, + ensure_ascii=False, + ), + encoding="utf-8", + ) + assert load_project_desc(tmp_path) == "电子元件及集成电路采购" + + def test_falls_back_when_missing(self, tmp_path: Path) -> None: + assert load_project_desc(tmp_path) == "电子元器件及工具耗材采购" + + def test_falls_back_on_corrupt_file(self, tmp_path: Path) -> None: + (tmp_path / "agent_state.json").write_text("not json", encoding="utf-8") + assert load_project_desc(tmp_path) == "电子元器件及工具耗材采购"