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:
@@ -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 个 `<band>` + 1 个 `<textField>` 或 `<staticText>`,拦截空壳 JRXML。
|
||||
- **torchvision**: `transformers` 库的懒加载需要 `torchvision`,已作为依赖安装。
|
||||
|
||||
+100
-3
@@ -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 依赖 |
|
||||
|
||||
+23
-3
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user