From 6467fd4ae598cffce894b7dee03f834ace114c13 Mon Sep 17 00:00:00 2001 From: panda <1415243231@qq.com> Date: Tue, 19 May 2026 19:15:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v3=20robustness=20upgrade=20=E2=80=94?= =?UTF-8?q?=20EasyOCR,=20failure=20recovery,=20minimum=20content=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 15 +- CODE_GUIDE.md | 1074 ++++++++++++++++++++++++++++++++++++ agent/graph.py | 1 + agent/nodes.py | 88 ++- agent/state.py | 3 + app.py | 67 ++- backend/file_parser.py | 27 +- backend/layout_analyzer.py | 40 +- validation_service/main.py | 33 ++ 9 files changed, 1297 insertions(+), 51 deletions(-) create mode 100644 CODE_GUIDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 96f404a..22a840f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ 一个**本地桌面应用**,通过自然语言多轮对话帮助非技术用户创建 JasperReports 模板(JRXML 文件)。核心技术栈:Streamlit UI + LangGraph 状态机 + LLM 生成/修改 + 自动验证修正循环。 -**一句话**:用户用中文描述报表需求 → LLM 生成 JRXML → 自动验证 → 失败则自动修正(最多3次) → 返回可编译的 JRXML 文件。 +**一句话**:用户用中文描述报表需求 → LLM 生成 JRXML → 自动验证 → 失败则自动修正(最多5次) → 重试耗尽后失败上下文自动注入下一轮 → 返回可编译的 JRXML 文件。 ## 启动命令 @@ -20,6 +20,7 @@ STREAMLIT_SERVER_HEADLESS=true streamlit run app.py --server.port 8501 ## 当前配置(.env) +- **OCR**: EasyOCR(优先,ch_sim+en)→ PaddleOCR(回退),两者均未安装时仅返回图片元信息 - **LLM**: `cloud` / `anthropic` → MiniMax Anthropic 兼容 API (`MiniMax-M2.7`) - Base URL: `https://api.minimaxi.com/anthropic` - 认证: 通过 `OPENAI_API_KEY` 传入 Anthropic SDK(注意不是 `ANTHROPIC_API_KEY`) @@ -46,7 +47,7 @@ agent/graph.py (LangGraph 状态机) │ │ 验证修正循环: validate ─fail─► explain_error ─► correct_jrxml ─► validate │ ▲ │ - │ └──────── (retry < MAX_RETRY=3) ───────────────────┘ + │ └──────── (retry < MAX_RETRY=5) ───────────────────┘ │ ├──► prompts/loader.py Prompt 外部化:7 个 .md 文件热重载 ├──► backend/llm.py LLM 工厂: Anthropic SDK / OpenAI / Ollama (统一 stream/invoke) @@ -64,7 +65,7 @@ agent/graph.py (LangGraph 状态机) | 文件 | 职责 | 修改频率 | |------|------|---------| | `app.py` | Streamlit UI 入口,聊天界面 + 侧边栏 + 下载 + 文件上传 | **高** | -| `agent/state.py` | AgentState 类型定义(~23 字段,含 jrxml_versions/last_error_case) | 低 | +| `agent/state.py` | AgentState 类型定义(~24 字段,含 pending_failure_context) | 低 | | `agent/nodes.py` | 14 个工作流节点 + 流式生成 + 错误记录 | **高** | | `agent/graph.py` | 状态图编译 + 路由函数(预览跳过验证) | 中 | | `prompts/loader.py` | Prompt 加载器(从 .md 文件热重载) | 低 | @@ -72,8 +73,8 @@ agent/graph.py (LangGraph 状态机) | `backend/llm.py` | LLM 工厂,统一 `_BaseLLM` 接口(invoke + stream) | 中 | | `backend/rag_adapter.py` | RAGSearcher 单例,语义搜索接口 | 中 | | `backend/error_kb.py` | ErrorKB — 错误指纹去重 + ChromaDB 持久化 + 语义检索 | 中 | -| `backend/file_parser.py` | 文件解析: PDF(pdfplumber)/DOCX(python-docx)/图片(PIL+PaddleOCR可选)/文本 | 中 | -| `backend/layout_analyzer.py` | A4模板分析: 比例检测/PaddleOCR元素提取/行分组/JRXML行匹配 | 中 | +| `backend/file_parser.py` | 文件解析: PDF/DOCX/图片(EasyOCR→PaddleOCR回退)/文本 | 中 | +| `backend/layout_analyzer.py` | A4模板分析: 比例检测/EasyOCR→PaddleOCR元素提取/行分组/JRXML行匹配 | 中 | | `backend/embeddings.py` | 嵌入模型工厂 (HuggingFace/OpenAI) | 低 | | `backend/validation.py` | 验证服务 HTTP 客户端 | 低 | | `backend/session.py` | 会话 JSON 文件 CRUD | 低 | @@ -154,4 +155,6 @@ agent/graph.py (LangGraph 状态机) - **验证服务结构检查**: 字段引用一致性 (`$F{field}` vs `` 声明)、SQL SELECT 存在性、pageWidth/pageHeight/name 属性。 - **XSD 校验可选**: 需要 `validation_service/schemas/jasperreport_7_0_6.xsd` 存在。 - **rag 子模块**: 内部有独立的管线脚本(`batch_chunker.py` → `embed_chunks.py` → `import_to_chroma.py`),通常不需要在主项目中运行。 -- **PaddleOCR 可选**: A4 模板精确识别需要 `pip install paddleocr`,未安装时仅返回图片元信息。 +- **OCR 引擎**: 优先使用 EasyOCR(Windows 兼容性更好,`pip install easyocr`),回退 PaddleOCR。两者均未安装时仅返回图片元信息,建议至少安装 EasyOCR。 +- **MAX_RETRY**: 默认 5 次。重试耗尽后 `pending_failure_context` 记录失败信息,下次用户输入时自动注入。 +- **验证最小内容检查**: 验证服务额外检查至少 1 个 `` + 1 个 `` 或 ``,拦截空壳 JRXML。 diff --git a/CODE_GUIDE.md b/CODE_GUIDE.md new file mode 100644 index 0000000..407fe8c --- /dev/null +++ b/CODE_GUIDE.md @@ -0,0 +1,1074 @@ +# JRXML 生成代理 — 完整代码导读 + +> 读完本文档后,你将能够:理解项目架构、独立修改代码、添加新功能、调试常见问题。 + +--- + +## 目录 + +1. [项目是什么](#1-项目是什么) +2. [启动与运行](#2-启动与运行) +3. [架构全景图](#3-架构全景图) +4. [数据总线:AgentState](#4-数据总线agentstate) +5. [状态机:graphpy](#5-状态机graphpy) +6. [14 个节点详解:nodespy](#6-14-个节点详解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. [Streamlit UI:apppy](#15-streamlit-uiapppy) +16. [配置参考](#16-配置参考) +17. [如何添加新功能](#17-如何添加新功能) +18. [调试指南](#18-调试指南) + +--- + +## 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 +``` + +### 启动 + +需要**两个终端**同时运行: + +```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 → 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│ + └──────────┘ +``` + +### 关键设计决策 + +1. **LLM 调用不经过 LangChain**:`backend/llm.py` 直接使用 Anthropic SDK 和 OpenAI SDK,仅 Ollama 保留 langchain-ollama 包装。原因是 Anthropic SDK 需要透传 `api_key` 到 MiniMax 兼容端点。 + +2. **Prompt 热重载**:`prompts/loader.py` 每次都从磁盘读取 `.md` 文件,修改 Prompt 无需重启。 + +3. **流式输出**:生成节点使用 `get_stream_writer()` 发送 `custom` 事件,UI 通过 `stream_mode=["updates", "custom"]` 捕获逐字输出。 + +4. **验证服务独立进程**:FastAPI 运行在 8001 端口,主进程通过 HTTP 调用。这样可以把 Java 编译验证加进去而不影响 UI 进程。 + +--- + +## 4. 数据总线:AgentState + +`agent/state.py` — 只有 23 个字段的定义,不包含任何逻辑。 + +```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 # 重试耗尽后暂存失败信息,下次用户输入时自动注入 +``` + +**数据流向**:每个节点函数接收 `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_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 — 记录输入 + 自动注入失败上下文 + +```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}) + # 重置本轮字段 + 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 摘要压缩。 + +```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 三个实现 + +**MiniMaxLLM**(`LLM_PROVIDER=anthropic`): +- 使用原始 `anthropic` SDK(`from anthropic import Anthropic`) +- `api_key` 必须显式传入构造函数(SDK 不会自动读 `OPENAI_API_KEY`) +- `NO_PROXY=*` 绕过 Windows 代理 +- `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.2 调用约定 + +所有节点统一使用: +```python +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()` 都从磁盘读取: + +```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 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 类 + +```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. 错误自增长知识库 + +`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` + +--- + +## 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 核心函数 + +```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`。 + +--- + +## 12. 文件解析器 + +`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() + # 其他 → _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 三级验证 + +```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} +``` + +--- + +## 14. 会话持久化 + +`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 */ } +} +``` + +--- + +## 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() — 核心渲染函数 + +```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(仅含变更字段) +``` + +### 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 流式渲染细节 + +```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()` 每次都会被覆盖,显示累积到当前的所有文本,产生"逐字打字"的视觉效果。 + +### 15.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 自己的处理器 +``` + +--- + +## 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"功能: + +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` + +### 17.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() +``` + +### 17.3 添加新的文件格式支持 + +在 `backend/file_parser.py` 中: + +1. 在 `parsers` dict 中添加文件后缀映射 +2. 实现对应的 `_parse_xxx()` 函数 +3. 在 `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()`: + +```python +# 在 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()` 临时打印: + +```python +# 在 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 依赖 | diff --git a/agent/graph.py b/agent/graph.py index f7f8725..7b066cf 100644 --- a/agent/graph.py +++ b/agent/graph.py @@ -228,4 +228,5 @@ def create_initial_state() -> AgentState: history_states=[], jrxml_versions=[], last_error_case={}, + pending_failure_context={}, ) diff --git a/agent/nodes.py b/agent/nodes.py index e267742..209b901 100644 --- a/agent/nodes.py +++ b/agent/nodes.py @@ -27,14 +27,25 @@ HISTORY_MAX_SNAPSHOTS = int(os.getenv("HISTORY_MAX_SNAPSHOTS", "10")) # ============================================================ def process_input(state: AgentState) -> Dict: - """记录用户输入到对话历史,重置本轮请求状态。""" + """记录用户输入到对话历史,重置本轮请求状态。如有上次失败上下文则自动注入。""" user_input = state.get("user_input", "") - # 维护全量对话历史 + # 维护全量对话历史(始终记录原始用户消息) full_history = state.get("full_conversation_history", []) full_history.append({"role": "user", "content": user_input, "ts": _now_iso()}) state["full_conversation_history"] = full_history + # 自动注入上次失败上下文 + 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"] = {} + # 维护工作对话历史 conv_history = state.get("conversation_history", []) conv_history.append({"role": "user", "content": user_input}) @@ -402,6 +413,12 @@ def validate(state: AgentState) -> Dict: state["error_msg"] = "没有 JRXML 内容可供验证。" return state + # 过短的内容不可能是合法报表(最小骨架约 500+ 字符) + if len(jrxml.strip()) < 200: + state["status"] = "fail" + state["error_msg"] = f"JRXML 内容过短({len(jrxml.strip())} 字符),可能为不完整或空内容。" + return state + result = validate_jrxml(jrxml) state["status"] = "pass" if result.get("valid") else "fail" state["error_msg"] = result.get("error", "") @@ -481,26 +498,47 @@ def correct_jrxml(state: AgentState) -> Dict: def finalize(state: AgentState) -> Dict: """保存最终验证通过的 JRXML 并更新对话历史 + 版本记录。""" jrxml = state.get("current_jrxml", "") - state["final_jrxml"] = jrxml + status = state.get("status", "") - if jrxml.strip(): - versions = state.get("jrxml_versions", []) - if not isinstance(versions, list): - versions = [] - intent = state.get("intent", "") - label_map = { - "initial_generation": "初始生成", - "modify_report": "修改", - "correct_jrxml": f"自动修正 (第{state.get('retry_count', 1)}次)", - } - versions.append({ + if status == "pass": + state["final_jrxml"] = jrxml + if jrxml.strip(): + versions = state.get("jrxml_versions", []) + if not isinstance(versions, list): + versions = [] + intent = state.get("intent", "") + label_map = { + "initial_generation": "初始生成", + "modify_report": "修改", + "correct_jrxml": f"自动修正 (第{state.get('retry_count', 1)}次)", + } + versions.append({ + "ts": _now_iso(), + "jrxml": jrxml, + "intent": intent, + "label": label_map.get(intent, intent), + "status": status, + }) + state["jrxml_versions"] = versions + else: + # 验证未通过:不覆盖 final_jrxml,保留上一次成功的版本 + retries = state.get("retry_count", 0) + error_msg = state.get("error_msg", "未知错误") + # 记录失败上下文,下次用户输入时自动注入 + state["pending_failure_context"] = { + "error_msg": error_msg, + "bad_jrxml": state.get("current_jrxml", ""), + "retry_count": retries, "ts": _now_iso(), - "jrxml": jrxml, - "intent": intent, - "label": label_map.get(intent, intent), - "status": state.get("status", ""), + } + state["conversation_history"].append({ + "role": "assistant", + "content": ( + f"❌ 经过 {retries} 次重试后仍无法生成有效的 JRXML。\n" + f"错误: {error_msg}\n" + f"请描述您想要的修改,系统会自动加载失败上下文继续修复。" + ), }) - state["jrxml_versions"] = versions return state @@ -510,7 +548,10 @@ def _extract_jrxml(text: str) -> str: xml_pattern = re.compile(r"```(?:xml|jrxml)?\s*([\s\S]*?)```", re.IGNORECASE) m = xml_pattern.search(text) if m: - return m.group(1).strip() + content = m.group(1).strip() + if content: + return content + # markdown 代码块存在但内容为空 — 回退到直接匹配 jasper_tag = re.search(r"(<\?xml[\s\S]*?)", text, re.IGNORECASE) if jasper_tag: @@ -519,4 +560,11 @@ def _extract_jrxml(text: str) -> str: if text.startswith("") + if xml_start >= 0 and jr_end > xml_start: + return text[xml_start:jr_end + len("")].strip() + return text diff --git a/agent/state.py b/agent/state.py index bb2f8e3..849ea3a 100644 --- a/agent/state.py +++ b/agent/state.py @@ -37,3 +37,6 @@ class AgentState(TypedDict, total=False): # 需求5:错误自增长(记录修正前的状态,供 validate 节点判断是否入知识库) last_error_case: dict + + # 需求6:失败上下文传递 — 重试耗尽后暂存失败信息,下次用户输入时自动注入 + pending_failure_context: dict diff --git a/app.py b/app.py index 11a9951..9daec9b 100644 --- a/app.py +++ b/app.py @@ -138,18 +138,30 @@ def run_agent(user_input: str): agent_state["user_input"] = user_input agent_state["retry_count"] = 0 - # ---- UI 容器 ---- - streaming_placeholder = st.empty() # 流式文本 - nodes_container = st.container() # 节点进度区 + # ---- UI 占位 ---- + progress_placeholder = st.empty() # 实时节点进度 + streaming_placeholder = st.empty() # 流式文本 summary_placeholder = st.empty() # 总结卡片 - # 节点追踪 - executed_nodes: list[dict] = [] # {name, label, status, detail} + # 初始状态提示 + progress_placeholder.info("⏳ 正在分析您的需求...") + + executed_nodes: list[dict] = [] stream_text = "" stream_active = False - current_stream_node = "" final_state = None + def _render_progress(nodes: list[dict]): + """渲染实时节点进度到占位符。""" + if not nodes: + return + lines = [] + for i, node in enumerate(nodes): + icon = "●" if i == len(nodes) - 1 else "✓" + detail = f" — {node['detail']}" if node.get("detail") else "" + lines.append(f"{icon} {node['label']}{detail}") + progress_placeholder.markdown("\n\n".join(lines)) + try: for event in st.session_state.graph.stream( agent_state, stream_mode=["updates", "custom"] @@ -177,7 +189,6 @@ def run_agent(user_input: str): ) elif node_name in ("generate", "modify_jrxml", "correct_jrxml"): - # 流式文本已在上面的 custom 事件中展示 jrxml = node_state.get("current_jrxml", "") executed_nodes[-1]["detail"] = f"生成 {len(jrxml)} 字符 JRXML" @@ -199,31 +210,29 @@ def run_agent(user_input: str): final_state = node_state + # 每个节点完成后立即更新进度 + _render_progress(executed_nodes) + elif mode == "custom": cd = data if cd.get("type") == "stream": stream_text += cd.get("text", "") stream_active = True - current_stream_node = cd.get("node", "") streaming_placeholder.code(stream_text, language="xml") except Exception as e: + progress_placeholder.empty() st.error(f"工作流异常: {e}") return - # ---- 渲染节点进度区 ---- - with nodes_container: - with st.expander("处理过程", expanded=False): - for i, node in enumerate(executed_nodes): - icon = "✓" if i < len(executed_nodes) - 1 else "●" - detail_str = f" — {node['detail']}" if node.get("detail") else "" - st.caption(f"{icon} {node['label']}{detail_str}") - - # ---- 清除流式占位 ---- + # ---- 清理临时占位 ---- + progress_placeholder.empty() if stream_active: streaming_placeholder.empty() # ---- 总结卡片 ---- + # 注:node_state 只含变更字段,用 agent_state(被所有节点就地修改)获取完整状态 + final_state = agent_state if final_state: st.session_state.agent_state = final_state intent = final_state.get("intent", "") @@ -239,7 +248,6 @@ def run_agent(user_input: str): elif intent in ("undo_modification", "reset_session"): st.success("操作已完成") - # 消息已在节点中添加 elif intent in ("preview_report", "export_pdf", "export_jrxml"): jrxml = final_state.get("current_jrxml", "") @@ -279,10 +287,10 @@ def run_agent(user_input: str): if jrxml: with st.expander("查看当前 JRXML"): _render_jrxml(jrxml, max_lines=80) - st.caption("请简化报表结构后重试。") + st.caption("💡 下次输入修改需求时,系统会自动加载失败上下文继续修复。") st.session_state.messages.append({ "role": "assistant", - "content": f"❌ 经过 {retries} 次重试后仍无法生成有效的 JRXML。\n\n**错误:** {error_msg}", + "content": f"❌ 经过 {retries} 次重试后仍无法生成有效的 JRXML。\n\n**错误:** {error_msg}\n\n💡 请直接描述修改需求,系统会自动加载失败上下文。", "type": "error_explanation", }) else: @@ -431,6 +439,25 @@ with st.sidebar: else: # 新建模式:按 A4 模板处理 parsed_text = layout["description"] + else: + # tt == "unknown": OCR 不可用或未检测到文字元素 + has_ocr = result.get("method") not in ("metadata_only", None) + img_w, img_h = layout["image_size"] + ratio = layout["aspect_ratio"] + if has_ocr: + parsed_text = ( + f"[图片上传] 尺寸 {img_w}x{img_h}px, 比例 {ratio}。" + f"未检测到 A4 报表结构,图片将被视为参考样式。\n" + f"请根据用户的文字描述生成报表。" + ) + else: + parsed_text = ( + f"[图片上传] 尺寸 {img_w}x{img_h}px, 比例 {ratio}。\n" + f"⚠ OCR 引擎未安装,无法识别图片中的文字内容。\n" + f"请严格根据用户的文字描述来推断图片中的报表需求。\n" + f"(提示:如需图片文字识别,请运行 pip install paddleocr)" + ) + parsed_type = "image_reference" Path(tmp_path).unlink(missing_ok=True) diff --git a/backend/file_parser.py b/backend/file_parser.py index 4920df7..357d085 100644 --- a/backend/file_parser.py +++ b/backend/file_parser.py @@ -65,17 +65,36 @@ def parse_file(file_path: str, file_type: str = "") -> dict: # --------------------------------------------------------------------------- def _parse_image(path: Path) -> dict: - """OCR 提取图片中的文字。""" + """OCR 提取图片中的文字。优先 EasyOCR,回退 PaddleOCR。""" try: img = PIL.Image.open(path) info = f"[图片: {img.size[0]}x{img.size[1]}, {img.mode}]" except Exception: info = "[图片: 无法读取元数据]" - # 尝试 PaddleOCR + # 优先 EasyOCR(Windows 兼容性更好) + try: + import easyocr + import numpy as np + reader = easyocr.Reader(["ch_sim", "en"], gpu=False, verbose=False) + result = reader.readtext(np.array(img)) + lines = [text.strip() for (_, text, _) in result if text.strip()] + if lines: + return { + "text": f"{info}\n识别文本:\n" + "\n".join(lines), + "file_type": "image", + "method": "easyocr", + "error": None, + } + except ImportError: + pass + except Exception: + pass + + # 回退 PaddleOCR try: from paddleocr import PaddleOCR - ocr = PaddleOCR(lang="ch", use_angle_cls=False, show_log=False) + ocr = PaddleOCR(lang="ch") result = ocr.ocr(str(path)) lines = [] if result and result[0]: @@ -97,7 +116,7 @@ def _parse_image(path: Path) -> dict: # OCR 不可用 → 返回图片元信息 + 安装提示 return { - "text": f"{info}\n(如需 OCR 文字识别,请安装: pip install paddleocr)", + "text": f"{info}\n(如需 OCR 文字识别,请安装: pip install easyocr)", "file_type": "image", "method": "metadata_only", "error": "OCR 引擎未安装,已返回图片元信息", diff --git a/backend/layout_analyzer.py b/backend/layout_analyzer.py index 631aff5..376d970 100644 --- a/backend/layout_analyzer.py +++ b/backend/layout_analyzer.py @@ -371,11 +371,47 @@ def _load_image(path: Path) -> Optional[PIL.Image.Image]: def _ocr_elements(img: PIL.Image.Image, file_path: str) -> list[dict]: + """OCR 提取图片中的文字元素(位置+内容)。优先 EasyOCR,回退 PaddleOCR。""" + + # 优先 EasyOCR + try: + import easyocr + import numpy as np + + reader = easyocr.Reader(["ch_sim", "en"], gpu=False, verbose=False) + result = reader.readtext(np.array(img)) + + elements = [] + for (bbox, text, confidence) in result: + if not text.strip(): + continue + xs = [p[0] for p in bbox] + ys = [p[1] for p in bbox] + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + + elements.append({ + "x": round(x_min, 1), + "y": round(y_min, 1), + "w": round(x_max - x_min, 1), + "h": round(y_max - y_min, 1), + "font_size": round(y_max - y_min, 1), + "text": text.strip(), + }) + + elements.sort(key=lambda e: (e["y"], e["x"])) + return elements + except ImportError: + pass + except Exception: + pass + + # 回退 PaddleOCR try: from paddleocr import PaddleOCR import numpy as np - ocr = PaddleOCR(lang="ch", use_angle_cls=True, show_log=False) + ocr = PaddleOCR(lang="ch") result = ocr.ocr(np.array(img)) elements = [] @@ -405,6 +441,8 @@ def _ocr_elements(img: PIL.Image.Image, file_path: str) -> list[dict]: elements.sort(key=lambda e: (e["y"], e["x"])) return elements + except ImportError: + pass except Exception: pass diff --git a/validation_service/main.py b/validation_service/main.py index 9b4de3d..2945176 100644 --- a/validation_service/main.py +++ b/validation_service/main.py @@ -82,6 +82,35 @@ def _check_structural_issues(jrxml: str) -> list[str]: return issues +def _check_minimum_content(jrxml: str) -> list[str]: + """检查 JRXML 是否包含最基本的报表内容(至少要有 band 和文本元素)。""" + issues = [] + try: + root = ET.fromstring(jrxml) + except ET.ParseError: + return [] # 结构性检查已捕获 + + # 统计各类元素 + bands = 0 + text_fields = 0 + static_texts = 0 + for elem in root.iter(): + tag = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + if tag == "band": + bands += 1 + elif tag == "textField": + text_fields += 1 + elif tag == "staticText": + static_texts += 1 + + if bands == 0: + issues.append("报表没有任何 元素,无法渲染内容") + if text_fields == 0 and static_texts == 0: + issues.append("报表没有任何 元素,输出将是一片空白") + + return issues + + def _validate_xsd(jrxml: str) -> tuple[bool, str]: """根据 JasperReports XSD schema 验证 JRXML。""" if not SCHEMA_FILE.exists(): @@ -111,6 +140,10 @@ async def validate_jrxml(req: ValidationRequest): if structural_issues: return ValidationResponse(valid=False, error="; ".join(structural_issues)) + content_issues = _check_minimum_content(jrxml) + if content_issues: + return ValidationResponse(valid=False, error="; ".join(content_issues)) + valid, xsd_error = _validate_xsd(jrxml) if not valid: return ValidationResponse(valid=False, error=xsd_error)