Files
agent_jrxml/CODE_GUIDE.md
T

1328 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# JRXML 生成代理 — 完整代码导读
> 读完本文档后,你将能够:理解项目架构、独立修改代码、添加新功能、调试常见问题。
---
## 目录
1. [项目是什么](#1-项目是什么)
2. [启动与运行](#2-启动与运行)
3. [架构全景图](#3-架构全景图)
4. [数据总线:AgentState](#4-数据总线agentstate)
5. [状态机:graphpy](#5-状态机graphpy)
6. [18 个节点详解:nodespy](#6-18-个节点详解nodespy)
7. [LLM 调用层:llmpy](#7-llm-调用层llmpy)
8. [Prompt 系统:prompts](#8-prompt-系统prompts)
9. [RAG 与向量搜索](#9-rag-与向量搜索)
10. [分层精确生成](#10-分层精确生成)
11. [错误自增长知识库](#11-错误自增长知识库)
12. [布局分析器](#12-布局分析器)
13. [文件解析器](#13-文件解析器)
14. [验证服务](#14-验证服务)
15. [会话持久化](#15-会话持久化)
16. [日志系统:loggerpy](#16-日志系统loggerpy)
17. [Streamlit UIapppy](#17-streamlit-uiapppy)
18. [配置参考](#18-配置参考)
19. [如何添加新功能](#19-如何添加新功能)
20. [调试指南](#20-调试指南)
---
## 1. 项目是什么
**一句话**:用户用中文描述报表需求 → LLM 生成 JRXML 模板 → 自动验证 → 失败则自动修正(最多 5 次)→ 重试耗尽后失败上下文自动注入下一轮 → 返回可用的 JRXML 文件。
**技术栈**StreamlitUI + LangGraph(状态机) + LLMMiniMax/OpenAI/Ollama + ChromaDB(向量库) + FastAPI(验证微服务)
**核心价值**:让非技术人员通过自然语言创建 JasperReports 报表模板,无需手写 XML。
---
## 2. 启动与运行
### 环境准备
```bash
# 1. 安装依赖
pip install -r requirements.txt
# 2. 复制配置文件,填入 API Key
cp .env.example .env
# 编辑 .env,至少填 OPENAI_API_KEY
```
### 启动
**一键启动(推荐)**:双击 `start.bat`,自动打开两个窗口分别运行验证服务和 UI。停止用 `stop.bat`
**手动启动**(需要两个终端):
```bash
# 终端 1 — 验证服务(必须先启动)
python -m uvicorn validation_service.main:app --port 8001 --host 0.0.0.0
# 终端 2 — Streamlit UI
streamlit run app.py --server.port 8501
```
浏览器打开 `http://localhost:8501`
### 三个 LLM 后端
| 后端 | 配置 | 适用场景 |
|------|------|---------|
| Anthropic 兼容 | `LLM_PROVIDER=anthropic``OPENAI_BASE_URL=https://api.minimaxi.com/anthropic` | 当前默认,使用 MiniMax M2.7 |
| OpenAI 兼容 | `LLM_PROVIDER=openai``OPENAI_BASE_URL=https://api.openai.com/v1` | 标准 OpenAI / 代理 |
| Ollama 本地 | `LLM_BACKEND=local``LOCAL_LLM_MODEL=qwen2.5-coder:7b` | 离线使用 |
---
## 3. 架构全景图
```
┌──────────────────────────────────────────────────────────────┐
│ app.py (Streamlit) │
│ 聊天界面 │ 侧边栏(会话管理/文件上传/历史下载) │ 流式渲染 │
│ run_agent() → graph.stream(agent_state) │
└──────────────────────────┬───────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ agent/graph.py (LangGraph) │
│ │
│ load_session → process_input → manage_context → save_snapshot│
│ → classify_intent │
│ ├─ initial_generation → retrieve │
│ │ ├─ [有布局schema] → generate_skeleton → refine │
│ │ │ → map_fields (3 阶段精确生成) │
│ │ └─ [无布局schema] → generate (原 1-shot) │
│ ├─ modify_report → modify_jrxml │
│ ├─ consult_question → handle_consult │
│ ├─ undo_modification → handle_undo │
│ ├─ reset_session → handle_reset │
│ └─ preview/export → save_session (跳过验证) │
│ │ │
│ ▼ │
│ save_session → validate │
│ pass ◄─── validate ─── fail │
│ │ │ │
│ │ explain_error │
│ │ │ │
│ │ correct_jrxml │
│ │ │ │
│ │ (retry < 5) ────┘ │
│ ▼ │
│ finalize → END │
└──────────────────────────┬───────────────────────────────────┘
┌────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌───────────────┐
│backend/ │ │prompts/ │ │validation_ │
│llm.py │ │loader.py │ │service/main.py│
│logger.py │ │*.md (10个 │ │(FastAPI, │
│rag_ │ │Prompt模板) │ │独立进程) │
│adapter.py│ └──────────────┘ └───────────────┘
│error_kb │
│.py │
│embeddings│
│.py │
│layout_ │
│analyzer │
│.py │
│ocr_ │
│extractor │
│.py │
│file_ │
│parser.py │
│ocr_ │
│extractor │
│.py │
│annotation│
│_detector │
│.py │
│validation│
│.py │
│session.py│
└──────────┘
```
### 关键设计决策
1. **LLM 调用不经过 LangChain**`backend/llm.py` 直接使用 Anthropic SDK 和 OpenAI SDK,仅 Ollama 保留 langchain-ollama 包装。所有 LLM 调用通过 `_LLMLoggingWrapper` 自动记录输入输出到 `logs/llm.log`
2. **Prompt 热重载**`prompts/loader.py` 每次都从磁盘读取 `.md` 文件,修改 Prompt 无需重启。
3. **流式输出**:生成节点使用 `get_stream_writer()` 发送 `custom` 事件,UI 通过 `stream_mode=["updates", "custom"]` 捕获逐字输出。
4. **验证服务独立进程**FastAPI 运行在 8001 端口,主进程通过 HTTP 调用。这样可以把 Java 编译验证加进去而不影响 UI 进程。
5. **结构化日志**`backend/logger.py` 提供 JSON 格式化日志,`trace_id` 通过 contextvars 贯穿全链路,LLM 调用与业务日志分离。
---
## 4. 数据总线:AgentState
`agent/state.py` — 只有 28 个字段的定义,不包含任何逻辑。
```python
class AgentState(TypedDict, total=False):
# ── 核心字段 ──
conversation_history: List[dict] # 当前工作对话(可能被压缩)
current_jrxml: str # 当前正在处理的 JRXML
user_input: str # 用户本轮输入
status: str # "pass" | "fail" | ""
error_msg: str # 验证错误信息
natural_explanation: str # 错误的人话解释
retry_count: int # 当前修正尝试次数
user_modification_request: str # 修改请求文本
final_jrxml: str # 最终通过的 JRXML
stage: str # 当前阶段
retrieved_context: str # 从 RAG 检索到的上下文
# ── 上下文压缩 ──
full_conversation_history: List[dict] # 完整对话(永不丢失)
compressed_history: str # 早期对话的摘要
current_token_count: int # 当前 token 估算值
# ── 会话持久化 ──
session_id: str # UUID 前 12 位
session_name: str # 用户第一条消息的前 50 字
created_at: str # ISO 时间戳
updated_at: str # ISO 时间戳
# ── 意图与撤销 ──
intent: str # 8 种意图之一
history_states: List[dict] # 快照栈,用于撤销
# ── 版本历史 ──
jrxml_versions: List[dict] # [{ts, jrxml, intent, label, status}]
# ── 错误知识库 ──
last_error_case: dict # {error_msg, bad_jrxml, correction_prompt}
# ── 失败上下文传递 ──
pending_failure_context: dict # 重试耗尽后暂存失败信息,下次用户输入时自动注入
# ── 分层精确生成 (v5) ──
layout_schema: dict # extract_layout_schema() 输出,列+区域结构
ocr_elements: list # OCR 原始行数据(用于阶段二坐标采样)
# ── OCR 与批注 (v3/v4) ──
ocr_extraction_result: dict # OCR 字段精确提取结果
uploaded_file_path: str # 上传图片的临时路径
annotation_result: dict # 批注检测结果(圈选+箭头)
```
**数据流向**:每个节点函数接收 `state`,修改后返回 `state`(实际上是 dict)。LangGraph 自动合并返回值到全局状态。
注意 `total=False` 意味着所有字段都是可选的,实际使用中不需要初始化全部字段。
---
## 5. 状态机:graph.py
`agent/graph.py` 是系统的"骨架",定义了节点如何连接。核心是两个部分:
### 5.1 路由函数
路由函数是条件分支的判断逻辑,每个返回一个字符串决定下一个节点:
```python
def route_by_intent(state) -> Literal["retrieve", "modify_jrxml", ...]:
intent = state.get("intent", "initial_generation")
if intent == "initial_generation": return "retrieve"
elif intent == "modify_report": return "modify_jrxml"
elif intent in ("preview_report", ...):return "save_session" # 跳过生成
elif intent == "consult_question": return "handle_consult"
...
def route_after_validate(state) -> Literal["finalize", "explain_error"]:
return "finalize" if state.get("status") == "pass" else "explain_error"
def route_after_retrieve(state) -> Literal["generate", "generate_skeleton"]:
"""layout_schema 有行时走 3 阶段精确生成,否则走原 1-shot"""
schema = state.get("layout_schema")
if schema and isinstance(schema, dict) and schema.get("total_rows", 0) > 0:
return "generate_skeleton"
return "generate"
def route_after_correct(state) -> Literal["validate", "finalize"]:
return "validate" if state.get("retry_count", 0) < MAX_RETRY else "finalize"
```
**MAX_RETRY 默认为 5**`.env` 中配置)。重试耗尽后进入 finalize,finalize 会将失败上下文写入 `pending_failure_context`,下次用户输入时 `process_input` 自动注入。
```
**关键路由逻辑**
- `route_by_intent`8 种意图分叉,是整个系统的"交通枢纽"
- `route_after_retrieve`:有 layout_schema → 3 阶段精确生成(generate_skeleton → refine_layout → map_fields),无 schema → 原 1-shot generate
- `route_after_save`:预览/导出意图**跳过验证**直通 finalize(这是修复预览问题的关键)
- `route_after_correct`:重试次数 < 5 则继续验证循环,否则认输
### 5.2 图构建
```python
def build_graph():
workflow = StateGraph(AgentState)
# 注册节点
workflow.add_node("load_session", load_session_node)
workflow.add_node("process_input", process_input)
# ... 18 个节点
# 连线
workflow.set_entry_point("load_session")
workflow.add_edge("load_session", "process_input") # 固定边
workflow.add_conditional_edges("classify_intent", route_by_intent, {...}) # 条件边
return workflow.compile()
```
**边的类型**
- `add_edge(from, to)` — 固定边,无条件跳转
- `add_conditional_edges(from, router, mapping)` — 条件边,router 返回值 → mapping 中的目标节点
### 5.3 完整流程图
```
┌──────────────┐
│ load_session │ ← 入口
└──────┬───────┘
┌──────▼───────┐
│process_input │
└──────┬───────┘
┌──────▼───────┐
│manage_context│ ← token 超阈值时压缩早期对话
└──────┬───────┘
┌──────▼───────┐
│save_snapshot │ ← 保存快照供撤销
└──────┬───────┘
┌──────▼───────┐
│classify_intent│ ← LLM 判断用户要做什么
└──────┬───────┘
┌────────┬────────┬──┴──┬────────┬────────┐
▼ ▼ ▼ ▼ ▼ ▼
retrieve modify save_ handle_ handle_ handle_
_jrxml session consult undo reset
│ │ │ │ │
┌────┤ │ │ ▼ │
│ │ │ │ save_session │
▼ │ │ │ │ │
generate│ │ │ ▼ │
(1-shot) │ │ │ finalize │
│ │ │ │ │
│ ▼ │ │ │
│ generate │ │ │
│ _skeleton │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ refine │ │ │
│ _layout │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ map_ │ │ │
│ fields │ │ │
│ │ │ │ │
└──┬──┘ │ │ │
│ │ │ │
▼ │ │ │
save_session ◄─┘ │ │
│ │ │
├── preview/export? ──► finalize │
│ ▲ │
▼ │ │
validate ◄─────────────────────┘ │
│ │ │
pass fail │
│ │ │
│ ▼ │
│ explain_error │
│ │ │
│ ▼ │
│ correct_jrxml │
│ │ │
│ ├── retry < 5? ──► validate (循环) │
│ │ │
│ └── retry >= 5? ──► finalize (放弃) │
│ │
▼ │
finalize ──► END │
```
---
## 6. 18 个节点详解:nodes.py
`agent/nodes.py` 是系统的"血肉",每个节点实现一个处理步骤。
### 6.1 process_input — 记录输入 + 自动注入失败上下文 + OCR 字段提取
```python
def process_input(state: AgentState) -> Dict:
user_input = state.get("user_input", "")
# 追加到全量历史(始终记录原始消息)
state["full_conversation_history"].append({"role": "user", "content": user_input, "ts": _now_iso()})
# 自动注入上次失败上下文
pending = state.get("pending_failure_context", {})
if pending and pending.get("error_msg"):
failure_note = (
f"[系统提示] 上次生成失败,以下是失败详情,请基于此修正:\n"
f"失败原因: {pending['error_msg']}\n"
f"上次失败的输出:\n{pending.get('bad_jrxml', '(无输出)')}"
)
user_input = f"{failure_note}\n\n---\n用户新输入:\n{user_input}"
state["pending_failure_context"] = {} # 用完即清
# 追加到工作历史(含注入后的内容)
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` 永不丢失
- 失败上下文注入仅影响工作历史,全量历史保留原始消息
- OCR 字段提取在 `process_input` 阶段自动执行,提取到的字段值同时存入 `ocr_extraction_result` 和注入到 `user_input` 前缀供 LLM 使用
- `session_id` 已包含在持久化字段中,避免切换会话时的无限 rerun bug
### 6.2 manage_context — 上下文压缩
当 token 数超过 `CONTEXT_MAX_TOKENS`(默认 6000),将最早期的对话轮次送去 LLM 摘要压缩。
```python
if token_count > CONTEXT_MAX_TOKENS:
recent = full_history[-CONTEXT_KEEP_RECENT:] # 最近 4 轮保留完整
older = full_history[:-CONTEXT_KEEP_RECENT:] # 更早的送去压缩
# LLM 生成摘要
state["compressed_history"] = summary
state["conversation_history"] = recent # 替换为压缩后的
```
**Token 计数**:使用 `tiktoken`(gpt-4o 编码器),不管实际用什么模型。回退方案是 `字符数 / 2.5`
### 6.3 save_state_snapshot — 保存快照
每次请求前保存当前报表状态到 `history_states` 栈,最多保留 10 个快照,供 `handle_undo` 恢复。
### 6.4 classify_intent — 意图分类
调用 LLM 将用户输入分为 8 种意图:
| 意图 | 含义 | 路由目标 |
|------|------|---------|
| `initial_generation` | 新建报表 | `retrieve``generate` |
| `modify_report` | 修改现有报表 | `modify_jrxml` |
| `preview_report` | 预览报表 | `save_session`(跳过验证) |
| `export_pdf` | 导出 PDF | `save_session`(跳过验证) |
| `export_jrxml` | 下载 JRXML | `save_session`(跳过验证) |
| `undo_modification` | 撤销修改 | `handle_undo` |
| `consult_question` | 咨询问题 | `handle_consult` |
| `reset_session` | 重置会话 | `handle_reset` |
兜底策略:有现有报表 → `modify_report`,无 → `initial_generation`
### 6.5 retrieve — 语义检索
```python
def retrieve(state):
context = search_chunks(user_input, k=5) # RAG 向量搜索
if error_msg:
error_context = search_error_cases(error_msg, k=2) # 错误知识库
context = f"{context}\n\n[历史错误修正案例]\n{error_context}"
state["retrieved_context"] = context
```
搜索两个 ChromaDB 集合:
- `jrxml_chunks` — 预构建的 JRXML 模板知识库(rag 子模块产出)
- `jrxml_error_cases` — 自动积累的错误修正案例
### 6.6 generate — 流式生成 JRXML
```python
def generate(state):
writer = get_stream_writer() # LangGraph 流式写入器
llm = get_llm()
prompt = load_prompt("initial_generation").format(
context=state.get("retrieved_context", ""),
user_request=state.get("user_input", ""),
)
full = []
for chunk in llm.stream(prompt): # 流式逐字生成
full.append(chunk)
writer({"type": "stream", "node": "generate", "text": chunk}) # 发送到 UI
jrxml = _extract_jrxml("".join(full))
state["current_jrxml"] = jrxml
```
**流式原理**`writer()` 发送的事件通过 LangGraph 的 `custom` 流到达 UI,在 `app.py` 中被捕获并逐字渲染。
### 6.7 modify_jrxml — 流式修改 JRXML
结构与 `generate` 相同,但 Prompt 不同:传入 `current_jrxml` + `conversation_history` + `modification_request`。同时在 `full_conversation_history` 中记录修改前后的对话对。
### 6.8 validate — 验证 JRXML
```python
def validate(state):
jrxml = state.get("current_jrxml", "")
if not jrxml:
return fail("没有 JRXML 内容可供验证")
if len(jrxml.strip()) < 200: # 过短不可能是合法报表
return fail(f"JRXML 内容过短({len(jrxml.strip())} 字符)")
result = validate_jrxml(jrxml) # HTTP POST 到 localhost:8001
state["status"] = "pass" if result.get("valid") else "fail"
state["error_msg"] = result.get("error", "")
# 关键:如果是修正后通过的,将错误案例记录到知识库
if result.get("valid") and state.get("retry_count", 0) > 0:
record_error(case["error_msg"], case["bad_jrxml"], good_jrxml, ...)
```
**200 字符阈值**:最小合法 JRXML 骨架约 500+ 字符,200 字符以下不可能是完整报表。**错误入库条件**:`valid=True AND retry_count > 0` — 意味着这个错误之前不存在于知识库,经过修正才成功。
### 6.9 explain_error — 错误转人话
将技术性验证错误(如 "字段 'amount' 未声明")转为自然语言解释,帮助用户理解问题。
### 6.10 correct_jrxml — 自动修正
```python
def correct_jrxml(state):
# 保存修正前状态(供 validate 判断是否入库)
state["last_error_case"] = {"error_msg": ..., "bad_jrxml": ..., "correction_prompt": prompt}
# 流式生成修正后的 JRXML
for chunk in llm.stream(prompt):
writer({"type": "stream", "node": "correct_jrxml", "text": chunk})
state["retry_count"] += 1 # 关键:递增重试计数
```
### 6.11 finalize — 完成
```python
def finalize(state):
if status == "pass":
state["final_jrxml"] = jrxml
versions.append({ts, jrxml, intent, label, status}) # 仅成功时入版本历史
else:
# 验证未通过:记录失败上下文,下次输入时自动注入
state["pending_failure_context"] = {error_msg, bad_jrxml, retry_count, ts}
# 不覆盖 final_jrxml,保留上一次成功的版本
```
**关键**:只有 `status == "pass"` 时才写入 `jrxml_versions``final_jrxml`。失败时记录 `pending_failure_context` 供下一轮 `process_input` 自动注入。
### 6.12 handle_consult / handle_undo / handle_reset
三个简单节点:
- `handle_consult`:调用 LLM 回答问题,不走报表流程
- `handle_undo`:从 `history_states` 弹出最近快照恢复
- `handle_reset`:清空所有报表状态,保留会话
### 6.13 _extract_jrxml — XML 提取
从 LLM 响应中提取纯 JRXML。处理五种情况:
```python
def _extract_jrxml(text):
# 1. Markdown 代码块: ```xml ... ``` → 提取内部内容
# - 内容为空时回退(避免 LLM 输出空代码块)
# 2. 完整 JasperReport 标签: <?xml ... </jasperReport>
# 3. 直接以 <?xml 或 <jasperReport 开头
# 4. 文本中嵌入的 XML 片段(最后一个回退)
# 5. 兜底:直接返回原文本
```
---
## 7. LLM 调用层:llm.py
`backend/llm.py` 是整个系统唯一调用 LLM 的地方。核心设计是 `_BaseLLM` 抽象基类:
```python
class _BaseLLM:
def invoke(self, prompt: str) -> Any: # 同步调用,返回含 .content 的对象
raise NotImplementedError
def stream(self, prompt: str): # 流式调用,返回 Iterator[str]
raise NotImplementedError
```
### 7.1 日志包装器
所有 LLM 实例都通过 `_LLMLoggingWrapper` 包装,自动记录:
- 请求 prompt(完整内容,截断 10000 字符)
- 响应内容(完整内容,截断 10000 字符)
- 调用耗时(毫秒)
- 模型名称、后端、调用来源(caller 参数)
日志输出到 `logs/llm.log`(独立于业务日志)。
### 7.2 三个实现
**MiniMaxLLM**`LLM_PROVIDER=anthropic`):
- 使用原始 `anthropic` SDK`from anthropic import Anthropic`
- API Key 优先读 `ANTHROPIC_API_KEY`fallback `OPENAI_API_KEY`
- `invoke()` 遍历 `resp.content``type == "text"` 的 block
- `stream()` 使用 `client.messages.stream()` + `s.text_stream`
**OpenAIWrapper**`LLM_PROVIDER=openai`):
- 使用 langchain-openai 的 `ChatOpenAI`
- 标准 OpenAI 兼容端点,配置 `base_url` 即可对接任何代理
**OllamaWrapper**`LLM_BACKEND=local`):
- 使用 langchain-ollama 的 `ChatOllama`
- 本地运行,无需网络
### 7.3 调用约定
所有节点统一使用,传入 `caller` 参数标识调用来源:
```python
llm = get_llm(caller="classify_intent")
# 同步
resp = llm.invoke(prompt)
text = resp.content.strip()
# 流式
for chunk in llm.stream(prompt):
writer({"type": "stream", "node": "generate", "text": chunk})
```
---
## 8. Prompt 系统:prompts/
### 8.1 加载机制
`prompts/loader.py` 实现了**热重载**——每次调用 `load_prompt()` 都从磁盘读取:
```python
def load_prompt(name: str) -> str:
filepath = _PROMPTS_DIR / _NAME_MAP[name] # e.g. prompts/intent_classify.md
text = filepath.read_text(encoding="utf-8").strip()
# 去除可能的 markdown frontmatter (--- ... ---)
return text
```
这意味着你可以直接编辑 `prompts/*.md`,下次请求立即生效,无需重启。
### 8.2 10 个 Prompt 文件
| 文件 | 调用节点 | 占位符 | 用途 |
|------|---------|--------|------|
| `intent_classify.md` | classify_intent | `{has_report}`, `{user_input}` | 8 分类意图识别 |
| `initial_generation.md` | generate | `{context}`, `{user_request}` | 首次生成 JRXML |
| `modification.md` | modify_jrxml | `{current_jrxml}`, `{conversation_history}`, `{modification_request}`, `{ocr_context}` | 修改现有 JRXML |
| `correction.md` | correct_jrxml | `{current_jrxml}`, `{error_msg}`, `{explanation}` | 修正验证错误 |
| `explain_error.md` | explain_error | `{error_msg}`, `{jrxml_snippet}` | 技术错误转人话 |
| `compression.md` | manage_context | `{conversation_text}` | 对话摘要压缩 |
| `consult.md` | handle_consult | `{question}` | 咨询问答 |
| `skeleton_generation.md` | generate_skeleton | `{layout_schema}`, `{context}`, `{user_request}` | 骨架 JRXML ($F{field_N}) |
| `refine_layout.md` | refine_layout | `{current_jrxml}`, `{sampled_coordinates}` | 像素级位置精调 |
| `field_mapping.md` | map_fields | `{current_jrxml}`, `{ocr_fields}` | 占位符 → 真实字段名 |
### 8.3 Prompt 模板写法
所有 Prompt 使用 Python `str.format()` 语法,占位符用 `{variable_name}`。文件中可以包含 markdown 格式、代码示例、Few-shot 示例等。
---
## 9. RAG 与向量搜索
### 9.1 架构
```
rag/ (git submodule — 独立的知识库构建管线)
├── jrxml_source/ # 107 个 JRXML 模板
├── batch_chunker.py # 模板分块
├── embed_chunks.py # 向量化
└── import_to_chroma.py # 导入 ChromaDB
▼ 产出
db/chroma/jrxml_chunks/ # ChromaDB 集合
▼ 消费
backend/rag_adapter.py # RAGSearcher 单例
▼ 调用
agent/nodes.py → retrieve() → search_chunks()
```
### 9.2 RAGSearcher 类
```python
class RAGSearcher:
def __init__(self):
# 懒加载:model 和 client 在首次使用时才初始化
self._model = None # SentenceTransformer
self._client = None # chromadb.PersistentClient
self._collection = None # ChromaDB collection
def search(self, query, k=5) -> list[dict]:
query_embedding = self.model.encode(query, normalize_embeddings=True)
results = self.collection.query(query_embeddings=[...], n_results=k)
return [{id, content, metadata, distance}, ...]
def search_as_context(self, query, k=5) -> str:
# 将搜索结果拼接成可直接注入 Prompt 的字符串
```
### 9.3 关键细节
- **模型懒加载**`SentenceTransformer` 加载需要几秒,只在首次查询时初始化
- **GPU 支持**:通过 `RAG_USE_GPU``RAG_USE_FP16` 环境变量控制
- **全局单例**`_get_searcher()` 保证只加载一次模型
- **容错**:如果 ChromaDB 集合不存在,返回空字符串,不影响主流程
---
## 10. 分层精确生成
专为 A4 报表图片上传场景设计,解决 OCR 元素过多(数百个)导致 LLM prompt 超长的问题。
### 10.1 触发条件
仅当满足以下条件时走 3 阶段管线:
- `intent == "initial_generation"`(新建报表)
- `layout_schema` 存在且 `total_rows > 0`(成功提取布局 schema
其他所有意图(modify_report、文本新建等)走原有 1-shot `generate` 节点,零行为变更。
### 10.2 3 阶段管线
```
上传 A4 图片
│ analyze_layout() → layout dict
│ extract_layout_schema() → schema
route_after_retrieve()
├─ 有 schema → generate_skeleton → refine_layout → map_fields
└─ 无 schema → generate (原 1-shot)
```
**Phase 1: generate_skeleton**
- 输入:压缩的布局 schema`schema_text`:列定义 + 区域 + 宽度分类)
- 输出:骨架 JRXML,所有字段用 `$F{field_N}` 占位
- 目标:正确的 band 结构和大致位置
**Phase 2: refine_layout**
- 输入:当前 JRXML + 采样坐标(表头行 + 首行数据 + 末行)
- 输出:像素级位置精调后的 JRXML
- 目标:精确的 x/y/w/h 数值,中间行通过插值处理
**Phase 3: map_fields**
- 输入:当前 JRXML + OCR 字段名列表(来自 `ocr_extraction_result.fields`
- 输出:`$F{field_N}` → 真实字段名(如 `$F{name}``$F{department}`
- 目标:可读且可编译的完整 JRXML
**关键设计**:中间阶段(骨架/精调)跳过验证,只有最终 mapped 结果进入 validate 循环。
### 10.3 extract_layout_schema()
位于 `backend/layout_analyzer.py`,在 `analyze_layout()` 之后调用:
```python
def extract_layout_schema(layout_result: dict) -> dict:
# 列检测:X 坐标聚类,同列条件 → X 中心距离 < avg_width * 0.5
# 区域分类:row[0] 元素少 → title; row[1] → header; 末尾1-2行 → footer
# 宽度分类:< A4宽度 10% → 窄; > 25% → 宽; 其余 → 中
# 返回: {columns, regions, total_rows, total_columns, a4_dimensions, schema_text}
```
`schema_text` 示例:`"报表布局: 5列 x 10行, A4纵向\n列定义: 序号(窄), 姓名(中), 部门(中), 职位(中), 入职日期(宽)\n区域: 标题(1行) → 表头(1行) → 数据(8行)"`
### 10.4 _format_row_coordinates()
```python
def _format_row_coordinates(row: dict) -> dict:
# 将 OCR 单行元素转为 {y_center, columns: [{col, x, y, w, h, font_size, text}]}
# 按 x 坐标从左到右排序
```
---
## 11. 错误自增长知识库
`backend/error_kb.py` — 自动积累修正成功的错误案例,下次遇到相似错误时提供参考。
### 10.1 错误指纹
```python
def _make_fingerprint(error_msg: str) -> str:
text = error_msg.lower()
text = re.sub(r'\$f\{[^}]+\}', '$f{<FIELD>}', text) # 变量名 → <FIELD>
text = re.sub(r"'[^']*'", "'<VALUE>'", text) # 字符串 → <VALUE>
text = re.sub(r'"<VALUE>"', '"<VALUE>"', text)
text = re.sub(r'\b\d+\b', '<NUM>', text) # 数字 → <NUM>
return hashlib.md5(text.encode()).hexdigest()[:16]
```
**目的**:相同结构的错误(只是字段名不同)产生相同指纹,避免重复记录。例如 "字段 'amount' 未声明" 和 "字段 'total' 未声明" 的指纹相同。
### 10.2 数据流
```
correct_jrxml 节点
│ 保存 last_error_case = {error_msg, bad_jrxml, correction_prompt}
validate 节点 (pass 且 retry_count > 0)
│ record_error(error_msg, bad_jrxml, good_jrxml, prompt)
ErrorKB.record()
│ 检查指纹 → 不存在则写入 ChromaDB collection "jrxml_error_cases"
下次请求时
retrieve 节点
│ search_error_cases(error_msg)
注入 Prompt 作为参考案例
```
### 10.3 存储结构
ChromaDB 中每条记录:
- **id**: 错误指纹(MD5 前 16 位)
- **document**: JSON 字符串,含 `error`, `bad_jrxml_snippet`, `good_jrxml_snippet`, `correction_prompt`, `model`, `tools`
- **metadata**: `fingerprint`, `error_keywords`, `recorded_at`, `retry_success`
---
## 12. 布局分析器
`backend/layout_analyzer.py` — 处理用户上传的图片/PDF,识别报表布局结构。另有 `extract_layout_schema()` 从 OCR 行数据提取列+区域的紧凑描述(用于分层精确生成)。
### 11.1 三种处理路径
```
上传图片
├─ A4 比例 (0.686~0.728) + OCR 元素 ≥2
│ └─ template_type = "full_a4"
│ 完整布局描述 → 生成整张报表
├─ 非 A4 比例 + OCR 元素 ≥1
│ └─ template_type = "partial_rows"
│ ├─ 有现有 JRXML → match_rows_to_jrxml() → 定位修改
│ └─ 无现有 JRXML → 按 A4 模板生成
└─ 无 OCR 元素 / OCR 不可用 / OCR 不可用
└─ template_type = "unknown"
├─ 有 OCR 但非 A4 → 告知 LLM 图片尺寸 + 请根据文字描述生成
└─ 无 OCR → 告知 LLM OCR 不可用 + 请严格根据用户描述推断
```
### 11.2 核心函数
```python
def analyze_layout(file_path) -> dict:
# 1. 加载图片 (PIL / pdfplumber / PyMuPDF)
# 2. 判定 A4 比例: exact(±3%) / close(±8%) / not_a4
# 3. EasyOCR (优先) / PaddleOCR (回退) 提取文字元素 → [{x, y, w, h, font_size, text}]
# 4. 行分组: Y 轴容差聚类
# 5. 生成文本描述
# 返回: {template_type, rows, description, ...}
def match_rows_to_jrxml(layout_result, current_jrxml) -> dict:
# 1. 解析 JRXML 中的 band 结构
# 2. 对每行 OCR 文字,计算与每个 band 的文本相似度
# 3. 相似度 > 0.3 → 匹配成功
# 返回: {matched_rows, unmatched_rows, description}
def analyze_and_inject(file_path, base_prompt, current_jrxml) -> str:
# 根据 template_type 路由到不同的 Prompt 注入策略
```
### 11.3 JRXML Section 解析
```python
def _parse_jrxml_sections(jrxml):
# 先尝试 ElementTree 结构化解析
# 遍历所有 section tag (title, detail, pageHeader 等)
# 找到其下的 band 子元素
# 提取 band 的 text_content 作为匹配目标
# 失败则回退到正则: r'<(title|...|groupFooter)>\s*(<band[^>]*>.*?</band>)\s*</\1>'
```
### 11.4 依赖
- `EasyOCR`(推荐):`pip install easyocr`,Windows 兼容性好,支持中文+英文。
- `PaddleOCR`(回退):仅在 EasyOCR 不可用时尝试,Windows 下需额外安装 `paddlepaddle`
---
## 13. 文件解析器
`backend/file_parser.py` — 统一的多格式文件解析入口。
```python
def parse_file(file_path, file_type="") -> dict:
# 返回: {text, file_type, method, error}
# 分发到:
# .png/.jpg/.jpeg/.bmp/.webp → _parse_image()
# .pdf → _parse_pdf()
# .docx → _parse_docx()
# .xlsx → _parse_xlsx()
# .xls → _parse_xls()
# .doc → _parse_doc()
# 其他 → _parse_text() (UTF-8 / GBK)
```
### 各解析器的回退链
- **图片**PaddleOCR(精确识别首选)→ EasyOCRch_sim+en)→ 仅返回元信息 + 安装提示
- **PDF**pdfplumber → PyMuPDF → 失败
- **DOCX**python-docx(含表格内容提取)→ 失败
- **XLSX**openpyxl(含多 sheet 支持)→ 失败
- **XLS**xlrd(旧版 Excel 格式)→ 失败
- **DOC**olefile(二进制格式,尽力而为提取)→ 失败
- **文本**UTF-8 → GBK → 失败
---
## 14. 验证服务
`validation_service/main.py` — 独立的 FastAPI 进程,提供 JRXML 验证。
### 13.1 三级验证
```python
@app.post("/validate")
async def validate_jrxml(req: ValidationRequest):
# 第一级:结构检查 (_check_structural_issues)
# - XML 是否可解析
# - $F{field} 引用的字段是否在 <field> 中声明
# - <queryString> 是否包含 SELECT
# - pageWidth/pageHeight/name 属性是否存在
# 第二级:最小内容检查 (_check_minimum_content) ← v3 新增
# - 至少 1 个 <band> 元素
# - 至少 1 个 <textField> 或 <staticText> 元素(防止空壳 JRXML 通过验证)
# 第三级:XSD Schema 校验 (_validate_xsd)
# - 需要 validation_service/schemas/jasperreport_7_0_6.xsd
# - 文件缺失时跳过
```
### 13.2 通信方式
`backend/validation.py` 通过 HTTP POST 调用:
```python
def validate_jrxml(jrxml_text):
with httpx.Client(timeout=30.0) as client:
resp = client.post("http://localhost:8001/validate", json={"jrxml": jrxml_text})
return resp.json() # {valid: bool, error: str}
```
---
## 15. 会话持久化
`backend/session.py` — 基于 JSON 文件的简单 CRUD,每个会话一个文件。
```python
create_session(name, agent_state) dict # 新建 {session_id}.json
load_session(session_id) dict | None # 读取
save_session(session_id, agent_state, name) # 更新
list_all_sessions() list[dict] # 列出(不含 agent_state
delete_session(session_id) bool # 删除文件
generate_session_id() str # UUID hex[:12]
```
**存储位置**`./sessions/{session_id}.json`
**文件结构**
```json
{
"session_id": "a1b2c3d4e5f6",
"session_name": "生成一个销售报表",
"created_at": "2026-05-19T10:30:00.000Z",
"updated_at": "2026-05-19T10:35:00.000Z",
"agent_state": { /* AgentState dict */ }
}
```
---
## 16. 日志系统:logger.py
`backend/logger.py` 提供结构化日志能力,是整个系统的"黑匣子"。
### 15.1 架构设计
```
backend/logger.py
├── JsonFormatter JSON 单行格式化,自动收集 extra 字段
├── get_logger(name) 获取 loggername="llm" → llm.log,其他 → app.log
├── generate_trace_id() 生成 16 位 hex trace_id
├── set_trace_id(tid) 通过 contextvars 设置当前请求的 trace_id
└── get_trace_id() 获取当前 trace_id(自动跨线程/协程传播)
```
### 15.2 日志文件
| 文件 | 对应 logger | 内容 |
|------|-----------|------|
| `logs/app.log` | `get_logger("agent")`, `get_logger("app")`, `get_logger("session")`, `get_logger("validation")` | 节点流转、路由决策、用户交互、会话操作、验证结果 |
| `logs/llm.log` | `get_logger("llm")` | LLM 请求 prompt、响应内容、耗时、异常 |
### 15.3 日志格式
每条日志是单行 JSON
```json
{
"timestamp": "2026-05-19T23:05:22.877+08:00",
"level": "INFO",
"logger": "jrxml.agent",
"trace_id": "b29010ab4a014249",
"message": "[节点入口] classify_intent",
"module": "nodes",
"function": "wrapper",
"line": 53,
"extra": {
"node": "classify_intent",
"phase": "entry",
"state": {
"session_id": "681e55231bab",
"intent": "",
"has_jrxml": false,
"retry_count": 0
}
}
}
```
### 15.4 trace_id 机制
每次在 [app.py](file:///d:/Idea%20Project/jaspersoft/app.py) 的 `run_agent()` 中调用 `set_trace_id(generate_trace_id())`,后续所有节点、路由、LLM 调用都自动带上同一个 trace_id。通过 `grep "b29010ab4a014249" logs/*.log` 可还原一次请求的完整链路。
### 15.5 `@log_node` 装饰器
[agent/nodes.py](file:///d:/Idea%20Project/jaspersoft/agent/nodes.py) 中 18 个节点均使用 `@log_node("节点名")` 装饰器,自动记录:
- **入口日志** — 节点开始执行时的 state 摘要
- **出口日志** — 节点完成时的 state 摘要 + 耗时 (duration_ms)
- **异常日志** — 节点抛异常时的错误信息 + state 摘要
### 15.6 `@_log_route` 装饰器
[agent/graph.py](file:///d:/Idea%20Project/jaspersoft/agent/graph.py) 中 9 个路由函数均使用 `@_log_route("路由名")`,自动记录每次路由决策(from → to)。
### 15.7 日志分析示例
```bash
# 按 trace_id 追踪一次完整请求
jq 'select(.trace_id=="b29010ab4a014249")' logs/app.log
# 统计各节点平均耗时
jq 'select(.extra.phase=="exit") | {node: .extra.node, ms: .extra.duration_ms}' logs/app.log | jq -s 'group_by(.node) | map({node: .[0].node, avg_ms: (map(.ms) | add / length)})'
# 查看所有 LLM 调用耗时
jq 'select(.extra.direction=="response") | {caller: .extra.caller, ms: .extra.duration_ms}' logs/llm.log
```
---
## 17. Streamlit UIapp.py
`app.py` 是整个系统的入口,约 560 行。分为几个区域:
### 16.1 组件树
```
st.set_page_config (wide layout)
├── st.components.v1.html (Ctrl+C 修复 — JS 拦截裸 'c' 键)
├── 侧边栏 (with st.sidebar)
│ ├── 会话管理 (selectbox + 新建/删除按钮)
│ ├── 快捷操作 (预览/撤销/重置按钮)
│ ├── 文件上传 (file_uploader + 解析 + 布局分析)
│ ├── 配置信息 (LLM backend/model/retry)
│ └── 下载区域 (最新 JRXML + 历史版本)
├── 标题 ("JRXML 报表生成器")
├── 聊天历史 (st.session_state.messages)
│ └── 按 msg["type"] 渲染: jrxml/error_explanation/success/consult/markdown
└── 聊天输入 (st.chat_input)
└── 触发 run_agent(full_prompt)
```
### 16.2 run_agent() — 核心渲染函数
```python
def run_agent(user_input):
# 1. 准备状态:设置 user_input,重置 retry_count
# 2. 创建 UI 占位符(实时更新):
# - progress_placeholder → 实时节点进度(每个节点完成后立即刷新)
# - streaming_placeholder → 流式文本(逐字追加)
# - summary_placeholder → 总结卡片
# 3. 初始提示:"⏳ 正在分析您的需求..."
# 4. 遍历 graph.stream(state, stream_mode=["updates", "custom"])
# - mode == "updates" → 记录 executed_nodes + 立即调用 _render_progress()
# - mode == "custom" → 逐字追加到 stream_text 并渲染到 streaming_placeholder
# 5. 清除临时占位(progress + streaming
# 6. 渲染总结卡片:用 agent_state(完整状态)而非 node_state(仅含变更字段)
```
### 16.3 文件上传流程
```
用户选择文件
app.py 侧边栏: file_uploader.on_change
创建临时文件 → parse_file(tmp_path) (EasyOCR → PaddleOCR 回退)
↓ (如果是图片/PDF)
analyze_layout(tmp_path)
template_type?
├─ full_a4 → parsed_text = layout["description"]
├─ partial_rows + 有 JRXML → match_rows_to_jrxml() → 修改定位描述
├─ partial_rows + 无 JRXML → layout["description"]
└─ unknown → 区分有/无 OCR,告诉 LLM 图片尺寸 + 文字描述引导 ← v3 新增
存入 st.session_state.uploaded_files
用户发送消息时 → 拼接 "[上传文件: xxx]\n{text}" + "用户需求:\n{prompt}"
清空 uploaded_files
```
### 16.4 流式渲染细节
```python
# 在 graph.stream 循环中:
elif mode == "custom":
cd = data
if cd.get("type") == "stream":
stream_text += cd.get("text", "") # 累积文本
streaming_placeholder.code(stream_text, language="xml") # 逐字刷新
```
这里 `streaming_placeholder.code()` 每次都会被覆盖,显示累积到当前的所有文本,产生"逐字打字"的视觉效果。
### 16.5 Ctrl+C 修复
Streamlit 默认会在非输入元素上按 `c` 键清除缓存。注入 JS 拦截裸 `c` 键(不含 Ctrl/Alt/Meta):
```javascript
parent.addEventListener('keydown', function(e) {
if (e.key === 'c' && !e.ctrlKey && !e.metaKey && !e.altKey) {
// 检查焦点不在 input/textarea/contentEditable
e.stopImmediatePropagation();
e.preventDefault();
}
}, true); // true = 捕获阶段,先于 Streamlit 自己的处理器
```
---
## 18. 配置参考
所有配置通过 `.env` 文件管理。完整配置项:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `LLM_BACKEND` | `cloud` | `cloud``local` |
| `LLM_PROVIDER` | `openai` | `openai``anthropic` |
| `LLM_MODEL` | `MiniMax-M2.7` | 云端模型名 |
| `LOCAL_LLM_MODEL` | `qwen2.5-coder:7b` | Ollama 模型名 |
| `OPENAI_API_KEY` | — | API 密钥(Anthropic 模式的 fallback |
| `ANTHROPIC_API_KEY` | — | Anthropic 兼容 API 密钥(优先) |
| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI API 端点 |
| `ANTHROPIC_BASE_URL` | `https://api.minimaxi.com/anthropic` | Anthropic 兼容端点 |
| `EMBED_BACKEND` | `local` | `local``cloud` |
| `RAG_EMBED_MODEL` | `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` | 嵌入模型 |
| `RAG_CHROMA_PATH` | `./db/chroma` | ChromaDB 存储路径 |
| `RAG_COLLECTION_NAME` | `jrxml_chunks` | ChromaDB 集合名 |
| `RAG_USE_GPU` | `true` | GPU 加速 |
| `RAG_USE_FP16` | `true` | 半精度推理 |
| `VALIDATION_SERVICE_URL` | `http://localhost:8001/validate` | 验证服务地址 |
| `MAX_RETRY` | `5` | 最大自动修正次数 |
| `CONTEXT_MAX_TOKENS` | `6000` | 触发压缩的 token 阈值 |
| `CONTEXT_KEEP_RECENT` | `4` | 压缩时保留最近 N 轮 |
| `SESSIONS_DIR` | `./sessions` | 会话文件目录 |
| `HISTORY_MAX_SNAPSHOTS` | `10` | 撤销快照栈深度 |
| `LOG_DIR` | `./logs` | 日志目录 |
| `LOG_LEVEL` | `DEBUG` | 日志级别 (DEBUG/INFO/WARNING/ERROR) |
---
## 19. 如何添加新功能
### 18.1 添加新的意图类型
假设要添加"导出 Excel"功能:
1. **`prompts/intent_classify.md`** — 在意图列表中加入 `export_excel`
2. **`agent/nodes.py`** — 在 `classify_intent``valid_intents` 列表中加入 `"export_excel"`
3. **`agent/graph.py`** — 在 `route_by_intent` 中添加 `elif intent == "export_excel": return "save_session"`
4. **`app.py`** — 侧边栏添加快捷按钮
5. 如果 Excel 导出的逻辑复杂,可以新增节点 `handle_export_excel`
### 18.2 添加新的 LLM 后端
`backend/llm.py``get_llm()` 中添加新的 provider
```python
elif provider == "my_provider":
class MyProviderLLM(_BaseLLM):
def invoke(self, prompt):
# 实现同步调用
def stream(self, prompt):
# 实现流式调用
return MyProviderLLM()
```
### 18.3 添加新的文件格式支持
`backend/file_parser.py` 中:
1.`parsers` dict 中添加文件后缀映射
2. 实现对应的 `_parse_xxx()` 函数
3.`app.py``file_uploader``type` 参数中加入新后缀
### 18.4 添加新的验证规则
`validation_service/main.py``_check_structural_issues()` 中添加检查逻辑,返回描述问题的人类可读字符串即可。
### 18.5 修改 Prompt
直接编辑 `prompts/*.md`,保存后立即生效。Prompt 使用 Python `str.format()` 占位符,变量名必须与节点中 `.format()` 的参数名一致。
---
## 20. 调试指南
### 19.1 常见问题
**Q: 验证服务连接失败**
```
无法连接到验证服务 (http://localhost:8001/validate)
```
→ 确认终端 1 已启动验证服务:`python -m uvicorn validation_service.main:app --port 8001`
**Q: Anthropic API 返回 401**
→ 检查 `.env``OPENAI_API_KEY` 是否已设置。注意:Anthropic 模式也使用 `OPENAI_API_KEY` 环境变量。
**Q: 流式输出不工作**
→ 确认 LLM 后端的 `stream()` 方法正确实现了 `yield`。检查 `get_stream_writer()` 是否在 LangGraph 节点的顶层调用(不能在嵌套函数中)。
**Q: ChromaDB 搜索返回空**
→ 检查 ChromaDB 集合是否存在:`chromadb.PersistentClient(path="./db/chroma").list_collections()`
→ 如果 `jrxml_chunks` 不存在,需要在 `rag/` 子模块中运行管线。
**Q: OCR 未安装 / 图片无法识别文字**
```
(如需 OCR 文字识别,请安装: pip install easyocr)
```
→ 推荐安装 EasyOCRWindows 兼容性好):`pip install easyocr`
→ PaddleOCR 可选回退:`pip install paddlepaddle paddleocr`Windows 下可能需额外配置)
**Q: 修改了 nodes.py 但不生效**
→ Streamlit 有热重载,保存文件后刷新浏览器即可。如果改的是 Prompt 文件,下次请求自动生效,无需做任何操作。
### 19.2 日志调试
项目已集成结构化日志系统(详见第 15 章)。调试时:
```bash
# 实时查看日志
tail -f logs/app.log | jq .
tail -f logs/llm.log | jq .
# 按 trace_id 追踪一次完整请求
jq 'select(.trace_id=="abc123")' logs/app.log
# 查看最近 5 次 LLM 调用
tail -5 logs/llm.log | jq '{caller: .extra.caller, model: .extra.model, ms: .extra.duration_ms}'
# 查看错误日志
jq 'select(.level=="ERROR")' logs/app.log
```
也可直接在 Streamlit 终端(终端 2)添加 `print()` 快速调试。
### 19.3 状态检查
`app.py``run_agent()` 完成后,`st.session_state.agent_state` 包含最新状态。可以通过 Streamlit 的 `st.write()` 临时打印:
```python
# 在 run_agent() 的最终处理中
st.json(state) # 打印完整状态(调试用,记得删除)
```
### 19.4 关键数据点
调试时最常需要检查的数据:
| 检查点 | 位置 | 含义 |
|--------|------|------|
| `state["intent"]` | classify_intent 后 | 意图分类是否正确 |
| `state["retrieved_context"]` | retrieve 后 | 检索到了什么模板 |
| `state["status"]` | validate 后 | 验证通过/失败 |
| `state["error_msg"]` | validate 后 | 具体错误是什么 |
| `state["retry_count"]` | correct_jrxml 后 | 修正了几次 |
| `state["conversation_history"][-1]` | 生成后 | LLM 最后输出了什么 |
| `state["compressed_history"]` | manage_context 后 | 压缩摘要内容 |
---
## 附录:文件清单
| 文件 | 行数 | 角色 |
|------|------|------|
| `app.py` | ~690 | Streamlit UI 入口(多模态聊天输入) |
| `agent/state.py` | ~52 | 状态类型定义(28 字段) |
| `agent/nodes.py` | ~900 | 18 个工作流节点 |
| `agent/graph.py` | ~270 | 状态图编译 + 路由(9 个路由函数) |
| `backend/llm.py` | ~105 | LLM 工厂 (3 个后端) |
| `backend/rag_adapter.py` | ~156 | ChromaDB 语义搜索 |
| `backend/error_kb.py` | ~226 | 错误知识库 |
| `backend/embeddings.py` | ~49 | 嵌入模型工厂 |
| `backend/file_parser.py` | ~320 | 多格式文件解析(7 种格式) |
| `backend/layout_analyzer.py` | ~600 | A4 模板布局分析 + 布局 schema 提取 |
| `backend/ocr_extractor.py` | ~380 | OCR 字段精确提取 |
| `backend/annotation_detector.py` | ~250 | 批注检测(圈选 + 箭头) |
| `backend/validation.py` | ~27 | 验证服务 HTTP 客户端 |
| `backend/session.py` | ~113 | 会话 JSON CRUD |
| `prompts/loader.py` | ~54 | Prompt 热重载 |
| `prompts/*.md` (10 个) | — | 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` | ~42 | Python 依赖 |