9bb011e429
- Replace st.chat_input with st-multimodal-chatinput (Ctrl+V paste, drag-drop, file button) - Extract _process_uploaded_file() shared handler (eliminates ~70 duplicated lines) - Add XLSX (openpyxl), XLS (xlrd), DOC (olefile) parsers to file_parser.py - Add backend/annotation_detector.py: circle detection (HoughCircles) + arrow detection (HoughLinesP clustering) + OCR correlation + LLM context formatting - Add annotation_result field to AgentState with session persistence - Wire annotation detection into process_input and _format_ocr_context - Add 11 new tests: 7 annotation detector + 4 multi-format parser - Update all docs: CLAUDE.md, README.md, CODE_GUIDE.md, ROADMAP.md
1186 lines
46 KiB
Markdown
1186 lines
46 KiB
Markdown
# 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 模板 → 自动验证 → 失败则自动修正(最多 3 次)→ 重试耗尽后失败上下文自动注入下一轮 → 返回可用的 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│
|
||
│logger.py │ │*.md (7个 │ │(FastAPI, │
|
||
│rag_ │ │Prompt模板) │ │独立进程) │
|
||
│adapter.py│ └──────────────┘ └───────────────┘
|
||
│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 包装。所有 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` — 只有 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 默认为 3**(`.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 标签: <?xml ... </jasperReport>
|
||
# 3. 直接以 <?xml 或 <jasperReport 开头
|
||
# 4. 文本中嵌入的 XML 片段(最后一个回退)
|
||
# 5. 兜底:直接返回原文本
|
||
```
|
||
|
||
---
|
||
|
||
## 7. LLM 调用层:llm.py
|
||
|
||
`backend/llm.py` 是整个系统唯一调用 LLM 的地方。核心设计是 `_BaseLLM` 抽象基类:
|
||
|
||
```python
|
||
class _BaseLLM:
|
||
def invoke(self, prompt: str) -> Any: # 同步调用,返回含 .content 的对象
|
||
raise NotImplementedError
|
||
def stream(self, prompt: str): # 流式调用,返回 Iterator[str]
|
||
raise NotImplementedError
|
||
```
|
||
|
||
### 7.1 日志包装器
|
||
|
||
所有 LLM 实例都通过 `_LLMLoggingWrapper` 包装,自动记录:
|
||
- 请求 prompt(完整内容,截断 10000 字符)
|
||
- 响应内容(完整内容,截断 10000 字符)
|
||
- 调用耗时(毫秒)
|
||
- 模型名称、后端、调用来源(caller 参数)
|
||
|
||
日志输出到 `logs/llm.log`(独立于业务日志)。
|
||
|
||
### 7.2 三个实现
|
||
|
||
**MiniMaxLLM**(`LLM_PROVIDER=anthropic`):
|
||
- 使用原始 `anthropic` SDK(`from anthropic import Anthropic`)
|
||
- API Key 优先读 `ANTHROPIC_API_KEY`,fallback `OPENAI_API_KEY`
|
||
- `invoke()` 遍历 `resp.content` 找 `type == "text"` 的 block
|
||
- `stream()` 使用 `client.messages.stream()` + `s.text_stream`
|
||
|
||
**OpenAIWrapper**(`LLM_PROVIDER=openai`):
|
||
- 使用 langchain-openai 的 `ChatOpenAI`
|
||
- 标准 OpenAI 兼容端点,配置 `base_url` 即可对接任何代理
|
||
|
||
**OllamaWrapper**(`LLM_BACKEND=local`):
|
||
- 使用 langchain-ollama 的 `ChatOllama`
|
||
- 本地运行,无需网络
|
||
|
||
### 7.3 调用约定
|
||
|
||
所有节点统一使用,传入 `caller` 参数标识调用来源:
|
||
```python
|
||
llm = get_llm(caller="classify_intent")
|
||
# 同步
|
||
resp = llm.invoke(prompt)
|
||
text = resp.content.strip()
|
||
# 流式
|
||
for chunk in llm.stream(prompt):
|
||
writer({"type": "stream", "node": "generate", "text": chunk})
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Prompt 系统:prompts/
|
||
|
||
### 8.1 加载机制
|
||
|
||
`prompts/loader.py` 实现了**热重载**——每次调用 `load_prompt()` 都从磁盘读取:
|
||
|
||
```python
|
||
def load_prompt(name: str) -> str:
|
||
filepath = _PROMPTS_DIR / _NAME_MAP[name] # e.g. prompts/intent_classify.md
|
||
text = filepath.read_text(encoding="utf-8").strip()
|
||
# 去除可能的 markdown frontmatter (--- ... ---)
|
||
return text
|
||
```
|
||
|
||
这意味着你可以直接编辑 `prompts/*.md`,下次请求立即生效,无需重启。
|
||
|
||
### 8.2 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{<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 核心函数
|
||
|
||
```python
|
||
def analyze_layout(file_path) -> dict:
|
||
# 1. 加载图片 (PIL / pdfplumber / PyMuPDF)
|
||
# 2. 判定 A4 比例: exact(±3%) / close(±8%) / not_a4
|
||
# 3. EasyOCR (优先) / PaddleOCR (回退) 提取文字元素 → [{x, y, w, h, font_size, text}]
|
||
# 4. 行分组: Y 轴容差聚类
|
||
# 5. 生成文本描述
|
||
# 返回: {template_type, rows, description, ...}
|
||
|
||
def match_rows_to_jrxml(layout_result, current_jrxml) -> dict:
|
||
# 1. 解析 JRXML 中的 band 结构
|
||
# 2. 对每行 OCR 文字,计算与每个 band 的文本相似度
|
||
# 3. 相似度 > 0.3 → 匹配成功
|
||
# 返回: {matched_rows, unmatched_rows, description}
|
||
|
||
def analyze_and_inject(file_path, base_prompt, current_jrxml) -> str:
|
||
# 根据 template_type 路由到不同的 Prompt 注入策略
|
||
```
|
||
|
||
### 11.3 JRXML Section 解析
|
||
|
||
```python
|
||
def _parse_jrxml_sections(jrxml):
|
||
# 先尝试 ElementTree 结构化解析
|
||
# 遍历所有 section tag (title, detail, pageHeader 等)
|
||
# 找到其下的 band 子元素
|
||
# 提取 band 的 text_content 作为匹配目标
|
||
# 失败则回退到正则: r'<(title|...|groupFooter)>\s*(<band[^>]*>.*?</band>)\s*</\1>'
|
||
```
|
||
|
||
### 11.4 依赖
|
||
|
||
- `EasyOCR`(推荐):`pip install easyocr`,Windows 兼容性好,支持中文+英文。
|
||
- `PaddleOCR`(回退):仅在 EasyOCR 不可用时尝试,Windows 下需额外安装 `paddlepaddle`。
|
||
|
||
---
|
||
|
||
## 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()
|
||
# .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 → 失败
|
||
|
||
---
|
||
|
||
## 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} 引用的字段是否在 <field> 中声明
|
||
# - <queryString> 是否包含 SELECT
|
||
# - pageWidth/pageHeight/name 属性是否存在
|
||
|
||
# 第二级:最小内容检查 (_check_minimum_content) ← v3 新增
|
||
# - 至少 1 个 <band> 元素
|
||
# - 至少 1 个 <textField> 或 <staticText> 元素(防止空壳 JRXML 通过验证)
|
||
|
||
# 第三级:XSD Schema 校验 (_validate_xsd)
|
||
# - 需要 validation_service/schemas/jasperreport_7_0_6.xsd
|
||
# - 文件缺失时跳过
|
||
```
|
||
|
||
### 13.2 通信方式
|
||
|
||
`backend/validation.py` 通过 HTTP POST 调用:
|
||
```python
|
||
def validate_jrxml(jrxml_text):
|
||
with httpx.Client(timeout=30.0) as client:
|
||
resp = client.post("http://localhost:8001/validate", json={"jrxml": jrxml_text})
|
||
return resp.json() # {valid: bool, error: str}
|
||
```
|
||
|
||
---
|
||
|
||
## 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. 日志系统: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) 中 17 个节点均使用 `@log_node("节点名")` 装饰器,自动记录:
|
||
- **入口日志** — 节点开始执行时的 state 摘要
|
||
- **出口日志** — 节点完成时的 state 摘要 + 耗时 (duration_ms)
|
||
- **异常日志** — 节点抛异常时的错误信息 + state 摘要
|
||
|
||
### 15.6 `@_log_route` 装饰器
|
||
|
||
[agent/graph.py](file:///d:/Idea%20Project/jaspersoft/agent/graph.py) 中 8 个路由函数均使用 `@_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
|
||
```
|
||
|
||
---
|
||
|
||
## 16. 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 自己的处理器
|
||
```
|
||
|
||
---
|
||
|
||
## 17. 配置参考
|
||
|
||
所有配置通过 `.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` | `3` | 最大自动修正次数 |
|
||
| `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) |
|
||
|
||
---
|
||
|
||
## 18. 如何添加新功能
|
||
|
||
### 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()` 的参数名一致。
|
||
|
||
---
|
||
|
||
## 19. 调试指南
|
||
|
||
### 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` | ~670 | Streamlit UI 入口(多模态聊天输入) |
|
||
| `agent/state.py` | ~48 | 状态类型定义(26 字段) |
|
||
| `agent/nodes.py` | ~740 | 15 个工作流节点 |
|
||
| `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` | ~320 | 多格式文件解析(7 种格式) |
|
||
| `backend/layout_analyzer.py` | ~495 | A4 模板布局分析 |
|
||
| `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` (7 个) | — | Prompt 模板 |
|
||
| `validation_service/main.py` | ~130 | FastAPI 验证服务 |
|
||
| `.env.example` | ~62 | 配置模板 |
|
||
| `requirements.txt` | ~42 | Python 依赖 |
|