Files
agent_jrxml/CODE_GUIDE.md
T

53 KiB
Raw Blame History

JRXML 生成代理 — 完整代码导读

读完本文档后,你将能够:理解项目架构、独立修改代码、添加新功能、调试常见问题。


目录

  1. 项目是什么
  2. 启动与运行
  3. 架构全景图
  4. 数据总线:AgentState
  5. 状态机:graphpy
  6. 18 个节点详解:nodespy
  7. LLM 调用层:llmpy
  8. Prompt 系统:prompts
  9. RAG 与向量搜索
  10. 分层精确生成
  11. 错误自增长知识库
  12. 布局分析器
  13. 文件解析器
  14. 验证服务
  15. 会话持久化
  16. 日志系统:loggerpy
  17. Streamlit UIapppy
  18. 配置参考
  19. 如何添加新功能
  20. 调试指南

1. 项目是什么

一句话:用户用中文描述报表需求 → LLM 生成 JRXML 模板 → 自动验证 → 失败则自动修正(最多 5 次)→ 重试耗尽后失败上下文自动注入下一轮 → 返回可用的 JRXML 文件。

技术栈StreamlitUI + LangGraph(状态机) + LLMMiniMax/OpenAI/Ollama + ChromaDB(向量库) + FastAPI(验证微服务)

核心价值:让非技术人员通过自然语言创建 JasperReports 报表模板,无需手写 XML。


2. 启动与运行

环境准备

# 1. 安装依赖
pip install -r requirements.txt

# 2. 复制配置文件,填入 API Key
cp .env.example .env
# 编辑 .env,至少填 OPENAI_API_KEY

启动

一键启动(推荐):双击 start.bat,自动打开两个窗口分别运行验证服务和 UI。停止用 stop.bat

手动启动(需要两个终端):

# 终端 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=anthropicOPENAI_BASE_URL=https://api.minimaxi.com/anthropic 当前默认,使用 MiniMax M2.7
OpenAI 兼容 LLM_PROVIDER=openaiOPENAI_BASE_URL=https://api.openai.com/v1 标准 OpenAI / 代理
Ollama 本地 LLM_BACKEND=localLOCAL_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 调用不经过 LangChainbackend/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 个字段的定义,不包含任何逻辑。

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 路由函数

路由函数是条件分支的判断逻辑,每个返回一个字符串决定下一个节点:

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 字段提取

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 摘要压缩。

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 新建报表 retrievegenerate
modify_report 修改现有报表 modify_jrxml
preview_report 预览报表 save_session(跳过验证)
export_pdf 导出 PDF save_session(跳过验证)
export_jrxml 下载 JRXML save_session(跳过验证)
undo_modification 撤销修改 handle_undo
consult_question 咨询问题 handle_consult
reset_session 重置会话 handle_reset

兜底策略:有现有报表 → modify_report,无 → initial_generation

6.5 retrieve — 语义检索

def retrieve(state):
    context = search_chunks(user_input, k=5)    # RAG 向量搜索
    if error_msg:
        error_context = search_error_cases(error_msg, k=2)  # 错误知识库
        context = f"{context}\n\n[历史错误修正案例]\n{error_context}"
    state["retrieved_context"] = context

搜索两个 ChromaDB 集合:

  • jrxml_chunks — 预构建的 JRXML 模板知识库(rag 子模块产出)
  • jrxml_error_cases — 自动积累的错误修正案例

6.6 generate — 流式生成 JRXML

def generate(state):
    writer = get_stream_writer()  # LangGraph 流式写入器
    llm = get_llm()
    prompt = load_prompt("initial_generation").format(
        context=state.get("retrieved_context", ""),
        user_request=state.get("user_input", ""),
    )
    full = []
    for chunk in llm.stream(prompt):        # 流式逐字生成
        full.append(chunk)
        writer({"type": "stream", "node": "generate", "text": chunk})  # 发送到 UI
    jrxml = _extract_jrxml("".join(full))
    state["current_jrxml"] = jrxml

流式原理writer() 发送的事件通过 LangGraph 的 custom 流到达 UI,在 app.py 中被捕获并逐字渲染。

6.7 modify_jrxml — 流式修改 JRXML

结构与 generate 相同,但 Prompt 不同:传入 current_jrxml + conversation_history + modification_request。同时在 full_conversation_history 中记录修改前后的对话对。

6.8 validate — 验证 JRXML

def validate(state):
    jrxml = state.get("current_jrxml", "")
    if not jrxml:
        return fail("没有 JRXML 内容可供验证")
    if len(jrxml.strip()) < 200:  # 过短不可能是合法报表
        return fail(f"JRXML 内容过短({len(jrxml.strip())} 字符)")

    result = validate_jrxml(jrxml)  # HTTP POST 到 localhost:8001
    state["status"] = "pass" if result.get("valid") else "fail"
    state["error_msg"] = result.get("error", "")

    # 关键:如果是修正后通过的,将错误案例记录到知识库
    if result.get("valid") and state.get("retry_count", 0) > 0:
        record_error(case["error_msg"], case["bad_jrxml"], good_jrxml, ...)

200 字符阈值:最小合法 JRXML 骨架约 500+ 字符,200 字符以下不可能是完整报表。错误入库条件valid=True AND retry_count > 0 — 意味着这个错误之前不存在于知识库,经过修正才成功。

6.9 explain_error — 错误转人话

将技术性验证错误(如 "字段 'amount' 未声明")转为自然语言解释,帮助用户理解问题。

6.10 correct_jrxml — 自动修正

def correct_jrxml(state):
    # 保存修正前状态(供 validate 判断是否入库)
    state["last_error_case"] = {"error_msg": ..., "bad_jrxml": ..., "correction_prompt": prompt}

    # 流式生成修正后的 JRXML
    for chunk in llm.stream(prompt):
        writer({"type": "stream", "node": "correct_jrxml", "text": chunk})

    state["retry_count"] += 1  # 关键:递增重试计数

6.11 finalize — 完成

def finalize(state):
    if status == "pass":
        state["final_jrxml"] = jrxml
        versions.append({ts, jrxml, intent, label, status})  # 仅成功时入版本历史
    else:
        # 验证未通过:记录失败上下文,下次输入时自动注入
        state["pending_failure_context"] = {error_msg, bad_jrxml, retry_count, ts}
        # 不覆盖 final_jrxml,保留上一次成功的版本

关键:只有 status == "pass" 时才写入 jrxml_versionsfinal_jrxml。失败时记录 pending_failure_context 供下一轮 process_input 自动注入。

6.12 handle_consult / handle_undo / handle_reset

三个简单节点:

  • handle_consult:调用 LLM 回答问题,不走报表流程
  • handle_undo:从 history_states 弹出最近快照恢复
  • handle_reset:清空所有报表状态,保留会话

6.13 _extract_jrxml — XML 提取

从 LLM 响应中提取纯 JRXML。处理五种情况:

def _extract_jrxml(text):
    # 1. Markdown 代码块: ```xml ... ``` → 提取内部内容
    #    - 内容为空时回退(避免 LLM 输出空代码块)
    # 2. 完整 JasperReport 标签: <?xml ... </jasperReport>
    # 3. 直接以 <?xml 或 <jasperReport 开头
    # 4. 文本中嵌入的 XML 片段(最后一个回退)
    # 5. 兜底:直接返回原文本

7. LLM 调用层:llm.py

backend/llm.py 是整个系统唯一调用 LLM 的地方。核心设计是 _BaseLLM 抽象基类:

class _BaseLLM:
    def invoke(self, prompt: str) -> Any:       # 同步调用,返回含 .content 的对象
        raise NotImplementedError
    def stream(self, prompt: str):              # 流式调用,返回 Iterator[str]
        raise NotImplementedError

7.1 日志包装器

所有 LLM 实例都通过 _LLMLoggingWrapper 包装,自动记录:

  • 请求 prompt(完整内容,截断 10000 字符)
  • 响应内容(完整内容,截断 10000 字符)
  • 调用耗时(毫秒)
  • 模型名称、后端、调用来源(caller 参数)

日志输出到 logs/llm.log(独立于业务日志)。

7.2 三个实现

MiniMaxLLMLLM_PROVIDER=anthropic):

  • 使用原始 anthropic SDKfrom anthropic import Anthropic
  • API Key 优先读 ANTHROPIC_API_KEYfallback OPENAI_API_KEY
  • invoke() 遍历 resp.contenttype == "text" 的 block
  • stream() 使用 client.messages.stream() + s.text_stream

OpenAIWrapperLLM_PROVIDER=openai):

  • 使用 langchain-openai 的 ChatOpenAI
  • 标准 OpenAI 兼容端点,配置 base_url 即可对接任何代理

OllamaWrapperLLM_BACKEND=local):

  • 使用 langchain-ollama 的 ChatOllama
  • 本地运行,无需网络

7.3 调用约定

所有节点统一使用,传入 caller 参数标识调用来源:

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() 都从磁盘读取:

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 类

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_GPURAG_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

  • 输入:压缩的布局 schemaschema_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() 之后调用:

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()

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 错误指纹

def _make_fingerprint(error_msg: str) -> str:
    text = error_msg.lower()
    text = re.sub(r'\$f\{[^}]+\}', '$f{<FIELD>}', text)   # 变量名 → <FIELD>
    text = re.sub(r"'[^']*'", "'<VALUE>'", text)          # 字符串 → <VALUE>
    text = re.sub(r'"<VALUE>"', '"<VALUE>"', text)
    text = re.sub(r'\b\d+\b', '<NUM>', text)              # 数字 → <NUM>
    return hashlib.md5(text.encode()).hexdigest()[:16]

目的:相同结构的错误(只是字段名不同)产生相同指纹,避免重复记录。例如 "字段 'amount' 未声明" 和 "字段 'total' 未声明" 的指纹相同。

10.2 数据流

correct_jrxml 节点
  │ 保存 last_error_case = {error_msg, bad_jrxml, correction_prompt}
  ▼
validate 节点 (pass 且 retry_count > 0)
  │ record_error(error_msg, bad_jrxml, good_jrxml, prompt)
  ▼
ErrorKB.record()
  │ 检查指纹 → 不存在则写入 ChromaDB collection "jrxml_error_cases"
  ▼
下次请求时
retrieve 节点
  │ search_error_cases(error_msg)
  ▼
注入 Prompt 作为参考案例

10.3 存储结构

ChromaDB 中每条记录:

  • id: 错误指纹(MD5 前 16 位)
  • document: JSON 字符串,含 error, bad_jrxml_snippet, good_jrxml_snippet, correction_prompt, model, tools
  • metadata: fingerprint, error_keywords, recorded_at, retry_success

12. 布局分析器

backend/layout_analyzer.py — 处理用户上传的图片/PDF,识别报表布局结构。另有 extract_layout_schema() 从 OCR 行数据提取列+区域的紧凑描述(用于分层精确生成)。

11.1 三种处理路径

上传图片
  │
  ├─ A4 比例 (0.686~0.728) + OCR 元素 ≥2
  │    └─ template_type = "full_a4"
  │       完整布局描述 → 生成整张报表
  │
  ├─ 非 A4 比例 + OCR 元素 ≥1
  │    └─ template_type = "partial_rows"
  │       ├─ 有现有 JRXML → match_rows_to_jrxml() → 定位修改
  │       └─ 无现有 JRXML → 按 A4 模板生成
  │
  └─ 无 OCR 元素 / OCR 不可用 / OCR 不可用
       └─ template_type = "unknown"
          ├─ 有 OCR 但非 A4 → 告知 LLM 图片尺寸 + 请根据文字描述生成
          └─ 无 OCR        → 告知 LLM OCR 不可用 + 请严格根据用户描述推断

11.2 核心函数

def analyze_layout(file_path) -> dict:
    # 1. 加载图片 (PIL / pdfplumber / PyMuPDF)
    # 2. 判定 A4 比例: exact(±3%) / close(±8%) / not_a4
    # 3. EasyOCR (优先) / PaddleOCR (回退) 提取文字元素 → [{x, y, w, h, font_size, text}]
    # 4. 行分组: Y 轴容差聚类
    # 5. 生成文本描述
    # 返回: {template_type, rows, description, ...}

def match_rows_to_jrxml(layout_result, current_jrxml) -> dict:
    # 1. 解析 JRXML 中的 band 结构
    # 2. 对每行 OCR 文字,计算与每个 band 的文本相似度
    # 3. 相似度 > 0.3 → 匹配成功
    # 返回: {matched_rows, unmatched_rows, description}

def analyze_and_inject(file_path, base_prompt, current_jrxml) -> str:
    # 根据 template_type 路由到不同的 Prompt 注入策略

11.3 JRXML Section 解析

def _parse_jrxml_sections(jrxml):
    # 先尝试 ElementTree 结构化解析
    # 遍历所有 section tag (title, detail, pageHeader 等)
    #   找到其下的 band 子元素
    #   提取 band 的 text_content 作为匹配目标
    # 失败则回退到正则: r'<(title|...|groupFooter)>\s*(<band[^>]*>.*?</band>)\s*</\1>'

11.4 依赖

  • EasyOCR(推荐):pip install easyocr,Windows 兼容性好,支持中文+英文。
  • PaddleOCR(回退):仅在 EasyOCR 不可用时尝试,Windows 下需额外安装 paddlepaddle

13. 文件解析器

backend/file_parser.py — 统一的多格式文件解析入口。

def parse_file(file_path, file_type="") -> dict:
    # 返回: {text, file_type, method, error}

    # 分发到:
    # .png/.jpg/.jpeg/.bmp/.webp → _parse_image()
    # .pdf                         → _parse_pdf()
    # .docx                        → _parse_docx()
    # .xlsx                        → _parse_xlsx()
    # .xls                         → _parse_xls()
    # .doc                         → _parse_doc()
    # 其他                         → _parse_text()  (UTF-8 / GBK)

各解析器的回退链

  • 图片:PaddleOCR(精确识别首选)→ EasyOCRch_sim+en)→ 仅返回元信息 + 安装提示
  • PDFpdfplumber → PyMuPDF → 失败
  • DOCXpython-docx(含表格内容提取)→ 失败
  • XLSXopenpyxl(含多 sheet 支持)→ 失败
  • XLSxlrd(旧版 Excel 格式)→ 失败
  • DOC:olefile(二进制格式,尽力而为提取)→ 失败
  • 文本UTF-8 → GBK → 失败

14. 验证服务

validation_service/main.py — 独立的 FastAPI 进程,提供 JRXML 验证。

13.1 三级验证

@app.post("/validate")
async def validate_jrxml(req: ValidationRequest):
    # 第一级:结构检查 (_check_structural_issues)
    #   - XML 是否可解析
    #   - $F{field} 引用的字段是否在 <field> 中声明
    #   - <queryString> 是否包含 SELECT
    #   - pageWidth/pageHeight/name 属性是否存在

    # 第二级:最小内容检查 (_check_minimum_content)  ← v3 新增
    #   - 至少 1 个 <band> 元素
    #   - 至少 1 个 <textField> 或 <staticText> 元素(防止空壳 JRXML 通过验证)

    # 第三级:XSD Schema 校验 (_validate_xsd)
    #   - 需要 validation_service/schemas/jasperreport_7_0_6.xsd
    #   - 文件缺失时跳过

13.2 通信方式

backend/validation.py 通过 HTTP POST 调用:

def validate_jrxml(jrxml_text):
    with httpx.Client(timeout=30.0) as client:
        resp = client.post("http://localhost:8001/validate", json={"jrxml": jrxml_text})
        return resp.json()  # {valid: bool, error: str}

15. 会话持久化

backend/session.py — 基于 JSON 文件的简单 CRUD,每个会话一个文件。

create_session(name, agent_state)  dict        # 新建 {session_id}.json
load_session(session_id)  dict | None          # 读取
save_session(session_id, agent_state, name)      # 更新
list_all_sessions()  list[dict]                 # 列出(不含 agent_state
delete_session(session_id)  bool               # 删除文件
generate_session_id()  str                     # UUID hex[:12]

存储位置./sessions/{session_id}.json

文件结构

{
  "session_id": "a1b2c3d4e5f6",
  "session_name": "生成一个销售报表",
  "created_at": "2026-05-19T10:30:00.000Z",
  "updated_at": "2026-05-19T10:35:00.000Z",
  "agent_state": { /* 完整的 AgentState dict */ }
}

16. 日志系统:logger.py

backend/logger.py 提供结构化日志能力,是整个系统的"黑匣子"。

15.1 架构设计

backend/logger.py
  ├── JsonFormatter        JSON 单行格式化,自动收集 extra 字段
  ├── get_logger(name)     获取 loggername="llm" → llm.log,其他 → app.log
  ├── generate_trace_id()  生成 16 位 hex trace_id
  ├── set_trace_id(tid)    通过 contextvars 设置当前请求的 trace_id
  └── get_trace_id()       获取当前 trace_id(自动跨线程/协程传播)

15.2 日志文件

文件 对应 logger 内容
logs/app.log get_logger("agent"), get_logger("app"), get_logger("session"), get_logger("validation") 节点流转、路由决策、用户交互、会话操作、验证结果
logs/llm.log get_logger("llm") LLM 请求 prompt、响应内容、耗时、异常

15.3 日志格式

每条日志是单行 JSON

{
  "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.pyrun_agent() 中调用 set_trace_id(generate_trace_id()),后续所有节点、路由、LLM 调用都自动带上同一个 trace_id。通过 grep "b29010ab4a014249" logs/*.log 可还原一次请求的完整链路。

15.5 @log_node 装饰器

agent/nodes.py 中 18 个节点均使用 @log_node("节点名") 装饰器,自动记录:

  • 入口日志 — 节点开始执行时的 state 摘要
  • 出口日志 — 节点完成时的 state 摘要 + 耗时 (duration_ms)
  • 异常日志 — 节点抛异常时的错误信息 + state 摘要

15.6 @_log_route 装饰器

agent/graph.py 中 9 个路由函数均使用 @_log_route("路由名"),自动记录每次路由决策(from → to)。

15.7 日志分析示例

# 按 trace_id 追踪一次完整请求
jq 'select(.trace_id=="b29010ab4a014249")' logs/app.log

# 统计各节点平均耗时
jq 'select(.extra.phase=="exit") | {node: .extra.node, ms: .extra.duration_ms}' logs/app.log | jq -s 'group_by(.node) | map({node: .[0].node, avg_ms: (map(.ms) | add / length)})'

# 查看所有 LLM 调用耗时
jq 'select(.extra.direction=="response") | {caller: .extra.caller, ms: .extra.duration_ms}' logs/llm.log

17. Streamlit UIapp.py

app.py 是整个系统的入口,约 560 行。分为几个区域:

16.1 组件树

st.set_page_config (wide layout)

├── st.components.v1.html  (Ctrl+C 修复 — JS 拦截裸 'c' 键)

├── 侧边栏 (with st.sidebar)
│   ├── 会话管理 (selectbox + 新建/删除按钮)
│   ├── 快捷操作 (预览/撤销/重置按钮)
│   ├── 文件上传 (file_uploader + 解析 + 布局分析)
│   ├── 配置信息 (LLM backend/model/retry)
│   └── 下载区域 (最新 JRXML + 历史版本)
│
├── 标题 ("JRXML 报表生成器")
│
├── 聊天历史 (st.session_state.messages)
│   └── 按 msg["type"] 渲染: jrxml/error_explanation/success/consult/markdown
│
└── 聊天输入 (st.chat_input)
    └── 触发 run_agent(full_prompt)

16.2 run_agent() — 核心渲染函数

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 流式渲染细节

# 在 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):

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 cloudlocal
LLM_PROVIDER openai openaianthropic
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 localcloud
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_intentvalid_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.pyget_llm() 中添加新的 provider

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.pyfile_uploadertype 参数中加入新后缀

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 → 检查 .envOPENAI_API_KEY 是否已设置。注意:Anthropic 模式也使用 OPENAI_API_KEY 环境变量。

Q: 流式输出不工作 → 确认 LLM 后端的 stream() 方法正确实现了 yield。检查 get_stream_writer() 是否在 LangGraph 节点的顶层调用(不能在嵌套函数中)。

Q: ChromaDB 搜索返回空 → 检查 ChromaDB 集合是否存在:chromadb.PersistentClient(path="./db/chroma").list_collections() → 如果 jrxml_chunks 不存在,需要在 rag/ 子模块中运行管线。

Q: OCR 未安装 / 图片无法识别文字

(如需 OCR 文字识别,请安装: pip install easyocr)

→ 推荐安装 EasyOCRWindows 兼容性好):pip install easyocr → PaddleOCR 可选回退:pip install paddlepaddle paddleocrWindows 下可能需额外配置)

Q: 修改了 nodes.py 但不生效 → Streamlit 有热重载,保存文件后刷新浏览器即可。如果改的是 Prompt 文件,下次请求自动生效,无需做任何操作。

19.2 日志调试

项目已集成结构化日志系统(详见第 15 章)。调试时:

# 实时查看日志
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.pyrun_agent() 完成后,st.session_state.agent_state 包含最新状态。可以通过 Streamlit 的 st.write() 临时打印:

# 在 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 依赖