# 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 UI:apppy](#17-streamlit-uiapppy) 18. [配置参考](#18-配置参考) 19. [如何添加新功能](#19-如何添加新功能) 20. [调试指南](#20-调试指南) --- ## 1. 项目是什么 **一句话**:用户用中文描述报表需求 → LLM 生成 JRXML 模板 → 自动验证 → 失败则自动修正(最多 5 次)→ 重试耗尽后失败上下文自动注入下一轮 → 返回可用的 JRXML 文件。 **技术栈**:Streamlit(UI) + LangGraph(状态机) + LLM(MiniMax/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 标签: # 3. 直接以 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{}', text) # 变量名 → text = re.sub(r"'[^']*'", "''", text) # 字符串 → text = re.sub(r'""', '""', text) text = re.sub(r'\b\d+\b', '', text) # 数字 → 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*(]*>.*?)\s*' ``` ### 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(精确识别首选)→ EasyOCR(ch_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} 引用的字段是否在 中声明 # - 是否包含 SELECT # - pageWidth/pageHeight/name 属性是否存在 # 第二级:最小内容检查 (_check_minimum_content) ← v3 新增 # - 至少 1 个 元素 # - 至少 1 个 元素(防止空壳 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) 获取 logger(name="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 UI:app.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) ``` → 推荐安装 EasyOCR(Windows 兼容性好):`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 依赖 |