实现Agent对话,合格自动提交,不合格补充材料的能力
This commit is contained in:
+136
-32
@@ -5,6 +5,18 @@
|
||||
|
||||
对外接口:
|
||||
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
|
||||
@@ -12,35 +24,37 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from ..exceptions import ExtractionError
|
||||
from .invoice import CACHE_DIR_NAME, classify_invoice_batch
|
||||
from .llm_extractor import extract_document
|
||||
from .matcher import match_invoices_to_cards
|
||||
|
||||
log = get_logger("extractor")
|
||||
|
||||
# SSE 文件进度事件日志文件名
|
||||
FILE_EVENTS_LOG = "file_events.log"
|
||||
|
||||
def _classify_invoice_batch(
|
||||
invoices: list[dict[str, str]],
|
||||
) -> dict[str, list[dict[str, str]]]:
|
||||
"""按发票类型分组"""
|
||||
travel: list[dict[str, str]] = []
|
||||
general: list[dict[str, str]] = []
|
||||
application: list[dict[str, str]] = []
|
||||
for inv in invoices:
|
||||
inv_type = inv.get("invoice_type", "general")
|
||||
if inv_type == "application":
|
||||
application.append(inv)
|
||||
elif inv_type in ("train", "hotel"):
|
||||
travel.append(inv)
|
||||
else:
|
||||
general.append(inv)
|
||||
return {"travel": travel, "general": general, "application": application}
|
||||
# 支持的文件扩展名
|
||||
SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||
|
||||
|
||||
# JSON 缓存目录(相对于源文件目录)
|
||||
CACHE_DIR_NAME = ".invoice_cache"
|
||||
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
|
||||
|
||||
|
||||
# 支持的文件扩展名
|
||||
SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
||||
|
||||
|
||||
def _get_cache_dir(source_dir: Path) -> Path:
|
||||
@@ -87,7 +101,59 @@ def _load_from_cache(json_path: Path, expected_extension: str | None = None) ->
|
||||
return None
|
||||
|
||||
|
||||
def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None:
|
||||
def _build_summary(data: dict[str, Any]) -> dict[str, str]:
|
||||
"""从提取结果构建前端展示摘要(人类可读格式)。
|
||||
|
||||
返回所有非内部字段(排除 _source_file 等下划线前缀字段),
|
||||
并将 invoice_type 转换为中文标签。列表/字典类型的值会展开为可读文本。
|
||||
"""
|
||||
invoice_type_map = {
|
||||
"train": "高铁票",
|
||||
"hotel": "酒店住宿",
|
||||
"general": "普通发票",
|
||||
"payment": "支付记录",
|
||||
"application": "出差申请单",
|
||||
}
|
||||
|
||||
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」字段自动分类:
|
||||
@@ -99,28 +165,43 @@ def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None
|
||||
Args:
|
||||
file_path: 文件路径(PDF 或图片)。
|
||||
cache_dir: JSON 缓存目录。
|
||||
source_dir: 源目录(用于写入 SSE 进度事件)。
|
||||
|
||||
Returns:
|
||||
提取结果字典,失败时返回 None。
|
||||
(提取结果字典或 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_path.name
|
||||
log.info(f"使用缓存: {file_path.name}")
|
||||
return cached
|
||||
cached["_source_file"] = file_name
|
||||
log.info(f"使用缓存: {file_name}")
|
||||
_emit_file_event(source_dir, file_name, "cached")
|
||||
return cached, None
|
||||
|
||||
log.info(f"使用多模态提取: {file_path.name}")
|
||||
# 发送处理中事件
|
||||
_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_path.name
|
||||
result["_source_file"] = file_name
|
||||
_save_to_cache(file_path, result, cache_dir)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.warning(f"多模态提取失败: {file_path.name} ({e})")
|
||||
|
||||
return None
|
||||
# 发送完成事件(含摘要)
|
||||
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]:
|
||||
@@ -181,10 +262,19 @@ def extract_invoices(
|
||||
- applications: 出差事前申请单列表(单独存储,不参与支付匹配)
|
||||
- groups: 按文档类型分组的字典
|
||||
{'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单]}
|
||||
|
||||
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("未找到支持的文件")
|
||||
@@ -195,11 +285,15 @@ def extract_invoices(
|
||||
all_invoices = []
|
||||
all_cards = []
|
||||
applications = []
|
||||
# 记录失败文件及其错误信息
|
||||
failed_files: list[tuple[str, str]] = []
|
||||
|
||||
for file_path in all_files:
|
||||
result = _extract_document(file_path, cache_dir)
|
||||
result, err = _extract_document(file_path, cache_dir, source_dir)
|
||||
|
||||
if not result:
|
||||
if err:
|
||||
failed_files.append((file_path.name, err))
|
||||
log.warning(f"未能解析: {file_path.name}")
|
||||
continue
|
||||
|
||||
@@ -217,6 +311,16 @@ def extract_invoices(
|
||||
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)} 份")
|
||||
|
||||
if not all_invoices:
|
||||
@@ -231,7 +335,7 @@ def extract_invoices(
|
||||
all_documents.extend(record.get("_matched_invoices", []))
|
||||
all_documents.extend(applications)
|
||||
|
||||
groups = _classify_invoice_batch(all_documents)
|
||||
groups = classify_invoice_batch(all_documents)
|
||||
log.info(
|
||||
f"文档分类: 差旅发票 {len(groups['travel'])} 张, "
|
||||
f"普通发票 {len(groups['general'])} 张, "
|
||||
|
||||
Reference in New Issue
Block a user