From da79640259de4514ffb183acaec2a3129a70d59a Mon Sep 17 00:00:00 2001 From: panda <1415243231@qq.com> Date: Wed, 20 May 2026 10:17:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20OCR=E5=AD=97=E6=AE=B5=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E9=9B=86=E6=88=90=E4=BF=AE=E5=A4=8D=20+=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=97=A0=E9=99=90=E5=BE=AA=E7=8E=AF=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20=E4=B8=80=E9=94=AE=E5=90=AF=E5=8A=A8=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - process_input 传入17个默认中文字段(修复空列表导致零字段提取) - OCR提取结果自动注入 LLM 上下文 - save_session_node/load_session_node 持久化 session_id(修复切换会话无限 rerun) - app.py 会话切换后显式设置 session_id(纵深防御) - 新增 start.bat / stop.bat 一键启动/停止脚本 - 更新 CLAUDE.md + CODE_GUIDE.md 文档 Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 7 ++++ CODE_GUIDE.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++-- agent/nodes.py | 26 +++++++++++-- app.py | 1 + start.bat | 27 +++++++++++++ stop.bat | 8 ++++ 6 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 start.bat create mode 100644 stop.bat diff --git a/CLAUDE.md b/CLAUDE.md index 94bd19e..bdfc27e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,9 @@ ## 启动命令 +**方式 1 — 一键启动(Windows)**:双击 `start.bat`,自动打开两个窗口分别运行验证服务和 UI。停止用 `stop.bat`。 + +**方式 2 — 手动启动**: ```bash # 终端 1 — 验证服务(必须先启动) python -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0 @@ -57,6 +60,7 @@ agent/graph.py (LangGraph 状态机) ├──► backend/error_kb.py 错误知识库: 指纹去重 + ChromaDB 持久化 ├──► backend/file_parser.py 文件解析: PDF/DOCX/图片/文本 ├──► backend/layout_analyzer.py A4布局分析: OCR + 行分组 + JRXML行匹配 + ├──► backend/ocr_extractor.py OCR字段提取: 两阶段4策略精确提取单据字段值 ├──► backend/validation.py HTTP 客户端: POST /validate ├──► backend/session.py 会话持久化: JSON 文件 CRUD └──► validation_service/ 独立 FastAPI: 结构检查 + XSD 校验 @@ -78,6 +82,7 @@ agent/graph.py (LangGraph 状态机) | `backend/error_kb.py` | ErrorKB — 错误指纹去重 + ChromaDB 持久化 + 语义检索 | 中 | | `backend/file_parser.py` | 文件解析: PDF/DOCX/图片(EasyOCR→PaddleOCR回退)/文本 | 中 | | `backend/layout_analyzer.py` | A4模板分析: 比例检测/EasyOCR→PaddleOCR元素提取/行分组/JRXML行匹配 | 中 | +| `backend/ocr_extractor.py` | OCR单据字段提取: 两阶段流水线 + 4种策略(精确KV/模糊KV/正则/表格) + 17个默认中文字段 | 中 | | `backend/embeddings.py` | 嵌入模型工厂 (HuggingFace/OpenAI) | 低 | | `backend/validation.py` | 验证服务 HTTP 客户端 | 低 | | `backend/session.py` | 会话 JSON 文件 CRUD | 低 | @@ -166,6 +171,8 @@ agent/graph.py (LangGraph 状态机) - **XSD 校验可选**: 需要 `validation_service/schemas/jasperreport_7_0_6.xsd` 存在。 - **rag 子模块**: 内部有独立的管线脚本(`batch_chunker.py` → `embed_chunks.py` → `import_to_chroma.py`),通常不需要在主项目中运行。 - **OCR 引擎**: 优先使用 EasyOCR(Windows 兼容性更好,`pip install easyocr`),回退 PaddleOCR。两者均未安装时仅返回图片元信息,建议至少安装 EasyOCR。 +- **OCR 字段提取**: `process_input` 自动检测上传图片,调用 `OcrExtractor` 提取 17 个常见中文字段(发票代码/号码/金额/日期等),提取结果自动注入 LLM 上下文。 +- **会话持久化**: `session_id` 现已包含在 `save_session_node` 的持久化字段中,避免切换会话时因 `session_id` 丢失导致的无限 rerun bug。 - **MAX_RETRY**: 默认 3 次。重试耗尽后 `pending_failure_context` 记录失败信息,下次用户输入时自动注入。 - **验证最小内容检查**: 验证服务额外检查至少 1 个 `` + 1 个 `` 或 ``,拦截空壳 JRXML。 - **torchvision**: `transformers` 库的懒加载需要 `torchvision`,已作为依赖安装。 diff --git a/CODE_GUIDE.md b/CODE_GUIDE.md index 0dd38d0..b71f096 100644 --- a/CODE_GUIDE.md +++ b/CODE_GUIDE.md @@ -18,6 +18,7 @@ 10. [错误自增长知识库](#10-错误自增长知识库) 11. [布局分析器](#11-布局分析器) 12. [文件解析器](#12-文件解析器) +12b. [OCR 单据字段提取器](#12b-ocr-单据字段提取器) 13. [验证服务](#13-验证服务) 14. [会话持久化](#14-会话持久化) 15. [Streamlit UI:apppy](#15-streamlit-uiapppy) @@ -52,8 +53,9 @@ cp .env.example .env ### 启动 -需要**两个终端**同时运行: +**一键启动(推荐)**:双击 `start.bat`,自动打开两个窗口分别运行验证服务和 UI。停止用 `stop.bat`。 +**手动启动**(需要两个终端): ```bash # 终端 1 — 验证服务(必须先启动) python -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0 @@ -124,6 +126,9 @@ streamlit run app.py --server.port 8501 │layout_ │ │analyzer │ │.py │ + │ocr_ │ + │extractor │ + │.py │ │file_ │ │parser.py │ │validation│ @@ -188,6 +193,10 @@ class AgentState(TypedDict, total=False): # ── 失败上下文传递 ── pending_failure_context: dict # 重试耗尽后暂存失败信息,下次用户输入时自动注入 + + # ── OCR 单据字段提取 ── + ocr_extraction_result: dict # OCR字段提取结果(来自 OcrExtractor) + uploaded_file_path: str # 上传图片的临时路径 ``` **数据流向**:每个节点函数接收 `state`,修改后返回 `state`(实际上是 dict)。LangGraph 自动合并返回值到全局状态。 @@ -314,7 +323,7 @@ def build_graph(): `agent/nodes.py` 是系统的"血肉",每个节点实现一个处理步骤。 -### 6.1 process_input — 记录输入 + 自动注入失败上下文 +### 6.1 process_input — 记录输入 + 自动注入失败上下文 + OCR 字段提取 ```python def process_input(state: AgentState) -> Dict: @@ -335,12 +344,36 @@ def process_input(state: AgentState) -> Dict: # 追加到工作历史(含注入后的内容) state["conversation_history"].append({"role": "user", "content": user_input}) + + # OCR 单据字段精确提取(处理上传的图片文件) + uploaded_path = state.get("uploaded_file_path", "") + if uploaded_path and Path(uploaded_path).is_file(): + if suffix in (".png", ".jpg", ".jpeg", ".bmp", ".webp"): + extractor = OcrExtractor() + ocr_result = extractor.extract(uploaded_path, [ + "发票代码", "发票号码", "开票日期", "合计金额", "校验码", + "价税合计", "总金额", "日期", "金额", "数量", "单价", "税率", + "购买方名称", "销售方名称", "货物名称", "规格型号", + "不含税金额", "税额", + ]) + if ocr_result.get("ocr_available"): + state["ocr_extraction_result"] = ocr_result + # 将提取到的字段注入 LLM 上下文 + non_empty = [f for f in extracted_fields if f.get("field_value")] + if non_empty: + ocr_context = "[OCR 单据字段提取结果]\n" + ... + user_input = f"{ocr_context}\n\n{user_input}" + # 重置本轮字段 state["retry_count"] = 0 state["user_modification_request"] = user_input ``` -**注意**:维护了两个对话历史 — `conversation_history` 可能被压缩,`full_conversation_history` 永不丢失。失败上下文注入仅影响工作历史,全量历史保留原始消息。 +**注意**: +- 维护了两个对话历史 — `conversation_history` 可能被压缩,`full_conversation_history` 永不丢失 +- 失败上下文注入仅影响工作历史,全量历史保留原始消息 +- OCR 字段提取在 `process_input` 阶段自动执行,提取到的字段值同时存入 `ocr_extraction_result` 和注入到 `user_input` 前缀供 LLM 使用 +- `session_id` 已包含在持久化字段中,避免切换会话时的无限 rerun bug ### 6.2 manage_context — 上下文压缩 @@ -763,6 +796,66 @@ def parse_file(file_path, file_type="") -> dict: --- +## 12b. OCR 单据字段提取器 + +`backend/ocr_extractor.py` — 两阶段精确提取单据图像中的字段值。 + +### 12b.1 数据模型 + +```python +@dataclass +class OcrTextElement: # OCR 文本元素,含精确坐标 + text: str + x_min, y_min, x_max, y_max: float + confidence: float = 1.0 + # 属性: center_x, center_y, width, height, bbox + +@dataclass +class ExtractedField: # 提取的字段结果 + field_name: str + field_value: str + bbox: list[float] + confidence: float + extraction_method: str # exact_match / kv_pair / regex / table_match / none + +@dataclass +class ExtractionResult: # 完整提取结果 + file_path: str + image_size: tuple + fields: list[ExtractedField] + all_elements: list[OcrTextElement] + errors: list[str] + ocr_available: bool +``` + +### 12b.2 两阶段流水线 + +**阶段1 — 文档分析** (`_analyze_document`): +- 加载图片 → `_ocr_elements_enhanced()` → EasyOCR(ch_sim+en) → PaddleOCR 回退 +- 按 `OCR_CONFIDENCE_THRESHOLD` (默认 0.5) 过滤低置信度元素 +- 返回按 (y, x) 排序的 `OcrTextElement` 列表 + +**阶段2 — 字段提取** (`_extract_field`): +按优先级尝试 4 种策略: +1. **精确键值对** (`_exact_kv_match`, conf=0.95/0.85): 同一元素中 "字段名: 值" 模式 +2. **模糊键值对** (`_fuzzy_kv_match`, conf=0.75/0.60): 相邻元素匹配,同行/下一行搜索 +3. **正则模式** (`_regex_match`, conf=0.70/0.60): 12 种预定义模式 (发票代码/号码/金额/日期等) +4. **表格结构** (`_table_match`, conf=0.55): 行列分组 + 表头匹配 + +### 12b.3 集成点 + +- **`process_input`**: 检测到上传图片后自动调用,传入 17 个默认中文字段 +- **结果注入**: 提取到的字段值自动拼入 `user_input` 前缀(`[OCR 单据字段提取结果]`) +- **结果展示**: `app.py` 总结卡片中 "🔍 OCR 单据字段提取结果" 折叠区 + +### 12b.4 回退能力 + +- 任一 OCR 引擎不可用时静默回退,不影响主流程 +- 两种复用路径: `extract()` (全流程) 和 `extract_from_layout_result()` (复用已有布局分析) +- 便捷函数: `extract_ocr_fields()`, `extract_from_layout()` + +--- + ## 13. 验证服务 `validation_service/main.py` — 独立的 FastAPI 进程,提供 JRXML 验证。 @@ -1168,10 +1261,14 @@ st.json(state) # 打印完整状态(调试用,记得删除) | `backend/embeddings.py` | ~49 | 嵌入模型工厂 | | `backend/file_parser.py` | ~194 | 多格式文件解析 | | `backend/layout_analyzer.py` | ~495 | A4 模板布局分析 | +| `backend/ocr_extractor.py` | ~797 | OCR 单据字段精确提取 (两阶段+4策略) | | `backend/validation.py` | ~27 | 验证服务 HTTP 客户端 | | `backend/session.py` | ~113 | 会话 JSON CRUD | | `prompts/loader.py` | ~54 | Prompt 热重载 | | `prompts/*.md` (7 个) | — | Prompt 模板 | | `validation_service/main.py` | ~130 | FastAPI 验证服务 | +| `tests/test_ocr_extraction.py` | ~543 | OCR 提取器单元测试 (48 项) | +| `start.bat` | — | 一键启动脚本 (Windows) | +| `stop.bat` | — | 一键停止脚本 (Windows) | | `.env.example` | ~62 | 配置模板 | | `requirements.txt` | ~32 | Python 依赖 | diff --git a/agent/nodes.py b/agent/nodes.py index 0afc2a4..3160d69 100644 --- a/agent/nodes.py +++ b/agent/nodes.py @@ -123,7 +123,13 @@ def process_input(state: AgentState) -> Dict: try: from backend.ocr_extractor import OcrExtractor extractor = OcrExtractor() - ocr_result = extractor.extract(uploaded_path, []) + default_fields = [ + "发票代码", "发票号码", "开票日期", "合计金额", "校验码", + "价税合计", "总金额", "日期", "金额", "数量", "单价", "税率", + "购买方名称", "销售方名称", "货物名称", "规格型号", + "不含税金额", "税额", + ] + ocr_result = extractor.extract(uploaded_path, default_fields) if ocr_result.get("ocr_available"): state["ocr_extraction_result"] = ocr_result _node_log.info( @@ -134,6 +140,20 @@ def process_input(state: AgentState) -> Dict: "fields": len(ocr_result.get("fields", [])), }, ) + # 将提取到的字段注入到对话上下文,供 LLM 使用 + extracted_fields = ocr_result.get("fields", []) + non_empty = [f for f in extracted_fields if f.get("field_value")] + if non_empty: + lines = ["[OCR 单据字段提取结果]"] + for f in non_empty: + lines.append( + f"- {f['field_name']}: {f['field_value']}" + f"(置信度: {f['confidence']:.0%}, 方法: {f['extraction_method']})" + ) + ocr_context = "\n".join(lines) + user_input = f"{ocr_context}\n\n{user_input}" + # 同时更新工作对话历史中的最后一条 + conv_history[-1]["content"] = user_input except Exception as e: _node_log.warning(f"OCR 字段提取失败: {e}") state["ocr_extraction_result"] = {"error": str(e)} @@ -357,7 +377,7 @@ def load_session_node(state: AgentState) -> Dict: if data and data.get("agent_state"): saved = data["agent_state"] # 恢复核心字段(不覆盖当前请求的 user_input / stage) - for key in ("conversation_history", "full_conversation_history", + for key in ("session_id", "conversation_history", "full_conversation_history", "current_jrxml", "final_jrxml", "compressed_history", "session_name", "created_at", "history_states"): if key in saved and key not in ("user_input", "stage"): @@ -379,7 +399,7 @@ def save_session_node(state: AgentState) -> Dict: try: from backend.session import save_session persistable = {} - for key in ("conversation_history", "full_conversation_history", + for key in ("session_id", "conversation_history", "full_conversation_history", "current_jrxml", "final_jrxml", "compressed_history", "status", "error_msg", "history_states"): if key in state: diff --git a/app.py b/app.py index f02b576..64cb7ef 100644 --- a/app.py +++ b/app.py @@ -408,6 +408,7 @@ with st.sidebar: "切换会话", extra={"from_session": current_session_id, "to_session": new_sid}, ) + data["agent_state"]["session_id"] = new_sid st.session_state.agent_state = data["agent_state"] st.session_state.messages = [] st.rerun() diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..b4adf7b --- /dev/null +++ b/start.bat @@ -0,0 +1,27 @@ +@echo off +echo ============================================ +echo JRXML 代理 - 全自动启动 (验证服务 + UI) +echo ============================================ + +set STREAMLIT_SERVER_HEADLESS=true + +echo. +echo [1/2] 启动验证服务 (端口 8001)... +start "JRXML 验证服务" cmd /c "cd /d %~dp0 && .venv\Scripts\python -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0" + +timeout /t 3 /nobreak >nul + +echo [2/2] 启动 Streamlit UI (端口 8501)... +start "JRXML UI" cmd /c "cd /d %~dp0 && .venv\Scripts\streamlit run app.py --server.port 8501" + +timeout /t 3 /nobreak >nul + +echo. +echo ============================================ +echo 启动完成 +echo 验证服务: http://localhost:8001 +echo UI 界面: http://localhost:8501 +echo ============================================ +echo. +echo 关闭此窗口不会停止服务。关闭服务窗口或运行 stop.bat 停止。 +pause diff --git a/stop.bat b/stop.bat new file mode 100644 index 0000000..6eaab8f --- /dev/null +++ b/stop.bat @@ -0,0 +1,8 @@ +@echo off +echo 正在停止 JRXML 代理服务... + +taskkill /fi "WINDOWTITLE eq JRXML 验证服务*" /f 2>nul +taskkill /fi "WINDOWTITLE eq JRXML UI*" /f 2>nul + +echo 已停止。 +pause