- OCR: EasyOCR (primary, ch_sim+en) with PaddleOCR fallback for Windows compatibility - Validation: _check_minimum_content() rejects empty-shell JRXML (no band/textField) - Retry: MAX_RETRY 3→5, exhaustion records pending_failure_context for next-turn auto-injection - Finalize: only saves jrxml_versions on pass, preserves last good final_jrxml on fail - Extract JRXML: improved empty markdown block handling and XML fragment fallback - UI: real-time node progress via placeholder updates, initial "analyzing" feedback - UI: use agent_state (full) instead of node_state (partial) for summary card routing - UI: unknown template_type now gives LLM meaningful image context instead of metadata - Docs: updated CLAUDE.md and CODE_GUIDE.md to reflect all v3 changes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
41 KiB
JRXML 生成代理 — 完整代码导读
读完本文档后,你将能够:理解项目架构、独立修改代码、添加新功能、调试常见问题。
目录
- 项目是什么
- 启动与运行
- 架构全景图
- 数据总线:AgentState
- 状态机:graphpy
- 14 个节点详解:nodespy
- LLM 调用层:llmpy
- Prompt 系统:prompts
- RAG 与向量搜索
- 错误自增长知识库
- 布局分析器
- 文件解析器
- 验证服务
- 会话持久化
- Streamlit UI:apppy
- 配置参考
- 如何添加新功能
- 调试指南
1. 项目是什么
一句话:用户用中文描述报表需求 → LLM 生成 JRXML 模板 → 自动验证 → 失败则自动修正(最多 5 次)→ 重试耗尽后失败上下文自动注入下一轮 → 返回可用的 JRXML 文件。
技术栈:Streamlit(UI) + LangGraph(状态机) + LLM(MiniMax/OpenAI/Ollama) + ChromaDB(向量库) + FastAPI(验证微服务)
核心价值:让非技术人员通过自然语言创建 JasperReports 报表模板,无需手写 XML。
2. 启动与运行
环境准备
# 1. 安装依赖
pip install -r requirements.txt
# 2. 复制配置文件,填入 API Key
cp .env.example .env
# 编辑 .env,至少填 OPENAI_API_KEY
启动
需要两个终端同时运行:
# 终端 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 → generate │
│ ├─ 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 < 3) ────┘ │
│ ▼ │
│ finalize → END │
└──────────────────────────┬───────────────────────────────────┘
│
┌────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌───────────────┐
│backend/ │ │prompts/ │ │validation_ │
│llm.py │ │loader.py │ │service/main.py│
│rag_ │ │*.md (7个 │ │(FastAPI, │
│adapter.py│ │Prompt模板) │ │独立进程) │
│error_kb │ └──────────────┘ └───────────────┘
│.py │
│embeddings│
│.py │
│layout_ │
│analyzer │
│.py │
│file_ │
│parser.py │
│validation│
│.py │
│session.py│
└──────────┘
关键设计决策
-
LLM 调用不经过 LangChain:
backend/llm.py直接使用 Anthropic SDK 和 OpenAI SDK,仅 Ollama 保留 langchain-ollama 包装。原因是 Anthropic SDK 需要透传api_key到 MiniMax 兼容端点。 -
Prompt 热重载:
prompts/loader.py每次都从磁盘读取.md文件,修改 Prompt 无需重启。 -
流式输出:生成节点使用
get_stream_writer()发送custom事件,UI 通过stream_mode=["updates", "custom"]捕获逐字输出。 -
验证服务独立进程:FastAPI 运行在 8001 端口,主进程通过 HTTP 调用。这样可以把 Java 编译验证加进去而不影响 UI 进程。
4. 数据总线:AgentState
agent/state.py — 只有 23 个字段的定义,不包含任何逻辑。
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 # 重试耗尽后暂存失败信息,下次用户输入时自动注入
数据流向:每个节点函数接收 state,修改后返回 state(实际上是 dict)。LangGraph 自动合并返回值到全局状态。
注意 total=False 意味着所有字段都是可选的,实际使用中不需要初始化全部字段。
5. 状态机:graph.py
agent/graph.py 是系统的"骨架",定义了节点如何连接。核心是两个部分:
5.1 路由函数
路由函数是条件分支的判断逻辑,每个返回一个字符串决定下一个节点:
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_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_save`:预览/导出意图**跳过验证**直通 finalize(这是修复预览问题的关键)
- `route_after_correct`:重试次数 < 3 则继续验证循环,否则认输
### 5.2 图构建
```python
def build_graph():
workflow = StateGraph(AgentState)
# 注册节点
workflow.add_node("load_session", load_session_node)
workflow.add_node("process_input", process_input)
# ... 14 个节点
# 连线
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
│ │ │ │ │
▼ │ │ ▼ │
generate │ │ save_session │
│ │ │ │ │
└───┬────┘ │ ▼ │
│ │ finalize │
▼ │ │
save_session ◄───────────┘ │
│ │
├── preview/export? ──► finalize │
│ │
▼ │
validate ◄────────────────────────────────┘
│ │
pass fail
│ │
│ ▼
│ explain_error
│ │
│ ▼
│ correct_jrxml
│ │
│ ├── retry < 3? ──► validate (循环)
│ │
│ └── retry >= 3? ──► finalize (放弃)
│
▼
finalize ──► END
6. 14 个节点详解:nodes.py
agent/nodes.py 是系统的"血肉",每个节点实现一个处理步骤。
6.1 process_input — 记录输入 + 自动注入失败上下文
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})
# 重置本轮字段
state["retry_count"] = 0
state["user_modification_request"] = user_input
注意:维护了两个对话历史 — conversation_history 可能被压缩,full_conversation_history 永不丢失。失败上下文注入仅影响工作历史,全量历史保留原始消息。
6.2 manage_context — 上下文压缩
当 token 数超过 CONTEXT_MAX_TOKENS(默认 6000),将最早期的对话轮次送去 LLM 摘要压缩。
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 — 语义检索
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
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
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 — 自动修正
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 — 完成
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。处理五种情况:
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 抽象基类:
class _BaseLLM:
def invoke(self, prompt: str) -> Any: # 同步调用,返回含 .content 的对象
raise NotImplementedError
def stream(self, prompt: str): # 流式调用,返回 Iterator[str]
raise NotImplementedError
7.1 三个实现
MiniMaxLLM(LLM_PROVIDER=anthropic):
- 使用原始
anthropicSDK(from anthropic import Anthropic) api_key必须显式传入构造函数(SDK 不会自动读OPENAI_API_KEY)NO_PROXY=*绕过 Windows 代理invoke()遍历resp.content找type == "text"的 blockstream()使用client.messages.stream()+s.text_stream
OpenAIWrapper(LLM_PROVIDER=openai):
- 使用 langchain-openai 的
ChatOpenAI - 标准 OpenAI 兼容端点,配置
base_url即可对接任何代理
OllamaWrapper(LLM_BACKEND=local):
- 使用 langchain-ollama 的
ChatOllama - 本地运行,无需网络
7.2 调用约定
所有节点统一使用:
llm = get_llm()
# 同步
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() 都从磁盘读取:
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 7 个 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} |
修改现有 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} |
咨询问答 |
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 类
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. 错误自增长知识库
backend/error_kb.py — 自动积累修正成功的错误案例,下次遇到相似错误时提供参考。
10.1 错误指纹
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
11. 布局分析器
backend/layout_analyzer.py — 处理用户上传的图片/PDF,识别报表布局结构。
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 核心函数
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 解析
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。
12. 文件解析器
backend/file_parser.py — 统一的多格式文件解析入口。
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()
# 其他 → _parse_text() (UTF-8 / GBK)
各解析器的回退链
- 图片:EasyOCR(ch_sim+en)→ PaddleOCR → 仅返回元信息 + 安装提示
- PDF:pdfplumber → PyMuPDF → 失败
- DOCX:python-docx(含表格内容提取)→ 失败
- 文本:UTF-8 → GBK → 失败
13. 验证服务
validation_service/main.py — 独立的 FastAPI 进程,提供 JRXML 验证。
13.1 三级验证
@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 调用:
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}
14. 会话持久化
backend/session.py — 基于 JSON 文件的简单 CRUD,每个会话一个文件。
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
文件结构:
{
"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 */ }
}
15. Streamlit UI:app.py
app.py 是整个系统的入口,约 500 行。分为几个区域:
15.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)
15.2 run_agent() — 核心渲染函数
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(仅含变更字段)
15.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
15.4 流式渲染细节
# 在 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() 每次都会被覆盖,显示累积到当前的所有文本,产生"逐字打字"的视觉效果。
15.5 Ctrl+C 修复
Streamlit 默认会在非输入元素上按 c 键清除缓存。注入 JS 拦截裸 c 键(不含 Ctrl/Alt/Meta):
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 自己的处理器
16. 配置参考
所有配置通过 .env 文件管理。完整配置项:
| 变量 | 默认值 | 说明 |
|---|---|---|
LLM_BACKEND |
cloud |
cloud 或 local |
LLM_PROVIDER |
openai |
openai 或 anthropic |
LLM_MODEL |
gpt-4o |
云端模型名 |
LOCAL_LLM_MODEL |
qwen2.5-coder:7b |
Ollama 模型名 |
OPENAI_API_KEY |
— | API 密钥(Anthropic 模式也用这个) |
OPENAI_BASE_URL |
https://api.openai.com/v1 |
API 端点 |
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 |
撤销快照栈深度 |
17. 如何添加新功能
17.1 添加新的意图类型
假设要添加"导出 Excel"功能:
prompts/intent_classify.md— 在意图列表中加入export_excelagent/nodes.py— 在classify_intent的valid_intents列表中加入"export_excel"agent/graph.py— 在route_by_intent中添加elif intent == "export_excel": return "save_session"app.py— 侧边栏添加快捷按钮- 如果 Excel 导出的逻辑复杂,可以新增节点
handle_export_excel
17.2 添加新的 LLM 后端
在 backend/llm.py 的 get_llm() 中添加新的 provider:
elif provider == "my_provider":
class MyProviderLLM(_BaseLLM):
def invoke(self, prompt):
# 实现同步调用
def stream(self, prompt):
# 实现流式调用
return MyProviderLLM()
17.3 添加新的文件格式支持
在 backend/file_parser.py 中:
- 在
parsersdict 中添加文件后缀映射 - 实现对应的
_parse_xxx()函数 - 在
app.py的file_uploader的type参数中加入新后缀
17.4 添加新的验证规则
在 validation_service/main.py 的 _check_structural_issues() 中添加检查逻辑,返回描述问题的人类可读字符串即可。
17.5 修改 Prompt
直接编辑 prompts/*.md,保存后立即生效。Prompt 使用 Python str.format() 占位符,变量名必须与节点中 .format() 的参数名一致。
18. 调试指南
18.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)
→ 推荐安装 EasyOCR(Windows 兼容性好):pip install easyocr
→ PaddleOCR 可选回退:pip install paddlepaddle paddleocr(Windows 下可能需额外配置)
Q: 修改了 nodes.py 但不生效 → Streamlit 有热重载,保存文件后刷新浏览器即可。如果改的是 Prompt 文件,下次请求自动生效,无需做任何操作。
18.2 日志调试
当前项目没有集中的日志系统。最简单的方式是在节点中加 print():
# 在 nodes.py 中
def generate(state):
print(f"[DEBUG] Prompt length: {len(prompt)}")
print(f"[DEBUG] Generated JRXML length: {len(jrxml)}")
print() 输出会出现在 Streamlit 终端(终端 2)中。
18.3 状态检查
在 app.py 的 run_agent() 完成后,st.session_state.agent_state 包含最新状态。可以通过 Streamlit 的 st.write() 临时打印:
# 在 run_agent() 的最终处理中
st.json(state) # 打印完整状态(调试用,记得删除)
18.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 |
~530 | Streamlit UI 入口 |
agent/state.py |
~40 | 状态类型定义 |
agent/nodes.py |
~523 | 14 个工作流节点 |
agent/graph.py |
~232 | 状态图编译 + 路由 |
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 |
~194 | 多格式文件解析 |
backend/layout_analyzer.py |
~495 | A4 模板布局分析 |
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 验证服务 |
.env.example |
~62 | 配置模板 |
requirements.txt |
~32 | Python 依赖 |