fix: OCR字段提取集成修复 + 会话切换无限循环修复 + 一键启动脚本

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 10:17:05 +08:00
parent c9f003e1b7
commit da79640259
6 changed files with 166 additions and 6 deletions
+7
View File
@@ -8,6 +8,9 @@
## 启动命令 ## 启动命令
**方式 1 — 一键启动(Windows)**:双击 `start.bat`,自动打开两个窗口分别运行验证服务和 UI。停止用 `stop.bat`
**方式 2 — 手动启动**
```bash ```bash
# 终端 1 — 验证服务(必须先启动) # 终端 1 — 验证服务(必须先启动)
python -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0 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/error_kb.py 错误知识库: 指纹去重 + ChromaDB 持久化
├──► backend/file_parser.py 文件解析: PDF/DOCX/图片/文本 ├──► backend/file_parser.py 文件解析: PDF/DOCX/图片/文本
├──► backend/layout_analyzer.py A4布局分析: OCR + 行分组 + JRXML行匹配 ├──► backend/layout_analyzer.py A4布局分析: OCR + 行分组 + JRXML行匹配
├──► backend/ocr_extractor.py OCR字段提取: 两阶段4策略精确提取单据字段值
├──► backend/validation.py HTTP 客户端: POST /validate ├──► backend/validation.py HTTP 客户端: POST /validate
├──► backend/session.py 会话持久化: JSON 文件 CRUD ├──► backend/session.py 会话持久化: JSON 文件 CRUD
└──► validation_service/ 独立 FastAPI: 结构检查 + XSD 校验 └──► validation_service/ 独立 FastAPI: 结构检查 + XSD 校验
@@ -78,6 +82,7 @@ agent/graph.py (LangGraph 状态机)
| `backend/error_kb.py` | ErrorKB — 错误指纹去重 + ChromaDB 持久化 + 语义检索 | 中 | | `backend/error_kb.py` | ErrorKB — 错误指纹去重 + ChromaDB 持久化 + 语义检索 | 中 |
| `backend/file_parser.py` | 文件解析: PDF/DOCX/图片(EasyOCR→PaddleOCR回退)/文本 | 中 | | `backend/file_parser.py` | 文件解析: PDF/DOCX/图片(EasyOCR→PaddleOCR回退)/文本 | 中 |
| `backend/layout_analyzer.py` | A4模板分析: 比例检测/EasyOCR→PaddleOCR元素提取/行分组/JRXML行匹配 | 中 | | `backend/layout_analyzer.py` | A4模板分析: 比例检测/EasyOCR→PaddleOCR元素提取/行分组/JRXML行匹配 | 中 |
| `backend/ocr_extractor.py` | OCR单据字段提取: 两阶段流水线 + 4种策略(精确KV/模糊KV/正则/表格) + 17个默认中文字段 | 中 |
| `backend/embeddings.py` | 嵌入模型工厂 (HuggingFace/OpenAI) | 低 | | `backend/embeddings.py` | 嵌入模型工厂 (HuggingFace/OpenAI) | 低 |
| `backend/validation.py` | 验证服务 HTTP 客户端 | 低 | | `backend/validation.py` | 验证服务 HTTP 客户端 | 低 |
| `backend/session.py` | 会话 JSON 文件 CRUD | 低 | | `backend/session.py` | 会话 JSON 文件 CRUD | 低 |
@@ -166,6 +171,8 @@ agent/graph.py (LangGraph 状态机)
- **XSD 校验可选**: 需要 `validation_service/schemas/jasperreport_7_0_6.xsd` 存在。 - **XSD 校验可选**: 需要 `validation_service/schemas/jasperreport_7_0_6.xsd` 存在。
- **rag 子模块**: 内部有独立的管线脚本(`batch_chunker.py``embed_chunks.py``import_to_chroma.py`),通常不需要在主项目中运行。 - **rag 子模块**: 内部有独立的管线脚本(`batch_chunker.py``embed_chunks.py``import_to_chroma.py`),通常不需要在主项目中运行。
- **OCR 引擎**: 优先使用 EasyOCRWindows 兼容性更好,`pip install easyocr`),回退 PaddleOCR。两者均未安装时仅返回图片元信息,建议至少安装 EasyOCR。 - **OCR 引擎**: 优先使用 EasyOCRWindows 兼容性更好,`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` 记录失败信息,下次用户输入时自动注入。 - **MAX_RETRY**: 默认 3 次。重试耗尽后 `pending_failure_context` 记录失败信息,下次用户输入时自动注入。
- **验证最小内容检查**: 验证服务额外检查至少 1 个 `<band>` + 1 个 `<textField>``<staticText>`,拦截空壳 JRXML。 - **验证最小内容检查**: 验证服务额外检查至少 1 个 `<band>` + 1 个 `<textField>``<staticText>`,拦截空壳 JRXML。
- **torchvision**: `transformers` 库的懒加载需要 `torchvision`,已作为依赖安装。 - **torchvision**: `transformers` 库的懒加载需要 `torchvision`,已作为依赖安装。
+100 -3
View File
@@ -18,6 +18,7 @@
10. [错误自增长知识库](#10-错误自增长知识库) 10. [错误自增长知识库](#10-错误自增长知识库)
11. [布局分析器](#11-布局分析器) 11. [布局分析器](#11-布局分析器)
12. [文件解析器](#12-文件解析器) 12. [文件解析器](#12-文件解析器)
12b. [OCR 单据字段提取器](#12b-ocr-单据字段提取器)
13. [验证服务](#13-验证服务) 13. [验证服务](#13-验证服务)
14. [会话持久化](#14-会话持久化) 14. [会话持久化](#14-会话持久化)
15. [Streamlit UIapppy](#15-streamlit-uiapppy) 15. [Streamlit UIapppy](#15-streamlit-uiapppy)
@@ -52,8 +53,9 @@ cp .env.example .env
### 启动 ### 启动
需要**两个终端**同时运行: **一键启动(推荐)**:双击 `start.bat`,自动打开两个窗口分别运行验证服务和 UI。停止用 `stop.bat`
**手动启动**(需要两个终端):
```bash ```bash
# 终端 1 — 验证服务(必须先启动) # 终端 1 — 验证服务(必须先启动)
python -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0 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_ │ │layout_ │
│analyzer │ │analyzer │
│.py │ │.py │
│ocr_ │
│extractor │
│.py │
│file_ │ │file_ │
│parser.py │ │parser.py │
│validation│ │validation│
@@ -188,6 +193,10 @@ class AgentState(TypedDict, total=False):
# ── 失败上下文传递 ── # ── 失败上下文传递 ──
pending_failure_context: dict # 重试耗尽后暂存失败信息,下次用户输入时自动注入 pending_failure_context: dict # 重试耗尽后暂存失败信息,下次用户输入时自动注入
# ── OCR 单据字段提取 ──
ocr_extraction_result: dict # OCR字段提取结果(来自 OcrExtractor
uploaded_file_path: str # 上传图片的临时路径
``` ```
**数据流向**:每个节点函数接收 `state`,修改后返回 `state`(实际上是 dict)。LangGraph 自动合并返回值到全局状态。 **数据流向**:每个节点函数接收 `state`,修改后返回 `state`(实际上是 dict)。LangGraph 自动合并返回值到全局状态。
@@ -314,7 +323,7 @@ def build_graph():
`agent/nodes.py` 是系统的"血肉",每个节点实现一个处理步骤。 `agent/nodes.py` 是系统的"血肉",每个节点实现一个处理步骤。
### 6.1 process_input — 记录输入 + 自动注入失败上下文 ### 6.1 process_input — 记录输入 + 自动注入失败上下文 + OCR 字段提取
```python ```python
def process_input(state: AgentState) -> Dict: 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}) 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["retry_count"] = 0
state["user_modification_request"] = user_input 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 — 上下文压缩 ### 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. 验证服务 ## 13. 验证服务
`validation_service/main.py` — 独立的 FastAPI 进程,提供 JRXML 验证。 `validation_service/main.py` — 独立的 FastAPI 进程,提供 JRXML 验证。
@@ -1168,10 +1261,14 @@ st.json(state) # 打印完整状态(调试用,记得删除)
| `backend/embeddings.py` | ~49 | 嵌入模型工厂 | | `backend/embeddings.py` | ~49 | 嵌入模型工厂 |
| `backend/file_parser.py` | ~194 | 多格式文件解析 | | `backend/file_parser.py` | ~194 | 多格式文件解析 |
| `backend/layout_analyzer.py` | ~495 | A4 模板布局分析 | | `backend/layout_analyzer.py` | ~495 | A4 模板布局分析 |
| `backend/ocr_extractor.py` | ~797 | OCR 单据字段精确提取 (两阶段+4策略) |
| `backend/validation.py` | ~27 | 验证服务 HTTP 客户端 | | `backend/validation.py` | ~27 | 验证服务 HTTP 客户端 |
| `backend/session.py` | ~113 | 会话 JSON CRUD | | `backend/session.py` | ~113 | 会话 JSON CRUD |
| `prompts/loader.py` | ~54 | Prompt 热重载 | | `prompts/loader.py` | ~54 | Prompt 热重载 |
| `prompts/*.md` (7 个) | — | Prompt 模板 | | `prompts/*.md` (7 个) | — | Prompt 模板 |
| `validation_service/main.py` | ~130 | FastAPI 验证服务 | | `validation_service/main.py` | ~130 | FastAPI 验证服务 |
| `tests/test_ocr_extraction.py` | ~543 | OCR 提取器单元测试 (48 项) |
| `start.bat` | — | 一键启动脚本 (Windows) |
| `stop.bat` | — | 一键停止脚本 (Windows) |
| `.env.example` | ~62 | 配置模板 | | `.env.example` | ~62 | 配置模板 |
| `requirements.txt` | ~32 | Python 依赖 | | `requirements.txt` | ~32 | Python 依赖 |
+23 -3
View File
@@ -123,7 +123,13 @@ def process_input(state: AgentState) -> Dict:
try: try:
from backend.ocr_extractor import OcrExtractor from backend.ocr_extractor import OcrExtractor
extractor = 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"): if ocr_result.get("ocr_available"):
state["ocr_extraction_result"] = ocr_result state["ocr_extraction_result"] = ocr_result
_node_log.info( _node_log.info(
@@ -134,6 +140,20 @@ def process_input(state: AgentState) -> Dict:
"fields": len(ocr_result.get("fields", [])), "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: except Exception as e:
_node_log.warning(f"OCR 字段提取失败: {e}") _node_log.warning(f"OCR 字段提取失败: {e}")
state["ocr_extraction_result"] = {"error": str(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"): if data and data.get("agent_state"):
saved = data["agent_state"] saved = data["agent_state"]
# 恢复核心字段(不覆盖当前请求的 user_input / stage # 恢复核心字段(不覆盖当前请求的 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", "current_jrxml", "final_jrxml", "compressed_history",
"session_name", "created_at", "history_states"): "session_name", "created_at", "history_states"):
if key in saved and key not in ("user_input", "stage"): if key in saved and key not in ("user_input", "stage"):
@@ -379,7 +399,7 @@ def save_session_node(state: AgentState) -> Dict:
try: try:
from backend.session import save_session from backend.session import save_session
persistable = {} 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", "current_jrxml", "final_jrxml", "compressed_history",
"status", "error_msg", "history_states"): "status", "error_msg", "history_states"):
if key in state: if key in state:
+1
View File
@@ -408,6 +408,7 @@ with st.sidebar:
"切换会话", "切换会话",
extra={"from_session": current_session_id, "to_session": new_sid}, 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.agent_state = data["agent_state"]
st.session_state.messages = [] st.session_state.messages = []
st.rerun() st.rerun()
+27
View File
@@ -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
+8
View File
@@ -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