fix: band-level windowed refine_layout + programmatic map_fields to prevent 91.5% content loss

Root cause: LLM receiving full 34k-char JRXML would regenerate from scratch
instead of modifying coordinates in-place, shrinking output to ~3k chars.

Solution (programmatic node control, not prompt engineering):

- New agent/jrxml_windower.py: decompose JRXML into header (never sent to
  LLM) + individual bands. Split bands >4000 chars at element boundaries.
  Reassemble with element count validation (>10% change = rollback).

- Rewrite refine_layout: per-band windowed LLM processing (~2-4k chars
  each). LLM cannot "reimagine" the entire report.

- Rewrite map_fields: 100% programmatic regex $F{field_N} -> real name
  replacement. Zero LLM calls, zero content loss.

- _sanitize_field_name: non-ASCII chars escaped to _uXXXX_ format for
  valid JRXML identifiers.

- Tests: 48 new unit tests (windower 28 + map_fields 20). All passing.
  Full suite 385 tests, zero regressions.
This commit is contained in:
2026-05-24 08:55:38 +08:00
parent bb6cc6e241
commit bd5bfbac2d
80 changed files with 39463 additions and 108 deletions
+126 -4
View File
@@ -44,12 +44,15 @@ cd frontend && npm run dev
│ ├── api/client.ts SSE 客户端 + fetch 封装
│ ├── stores/chat.ts Pinia: 消息/流式/节点进度
│ ├── stores/session.ts Pinia: 会话管理
│ ├── stores/kb.ts Pinia: KB 状态管理(多租户知识库)
│ ├── components/
│ │ ├── Sidebar.vue 会话列表 + 下载 + 历史版本
│ │ ├── ChatMessages.vue 消息列表渲染
│ │ ├── ProcessSection.vue 过程折叠区(替代 StreamingMessage + NodeProgress
│ │ ├── UnifiedInput.vue 统一输入框(文本+文件拖拽/粘贴/芯片)
│ │ ── SummaryCard.vue 结果摘要卡片(含耗时)
│ │ ├── UnifiedInput.vue 统一输入框(文本+文件拖拽/粘贴/芯片,含 .jrxml
│ │ ── SummaryCard.vue 结果摘要卡片(含耗时)
│ │ ├── KbSelector.vue KB 下拉选择器(对话中切换知识库)
│ │ └── KbManager.vue KB 管理面板(创建/上传/构建/删除)
│ └── utils/format.ts 工具函数
▼ HTTP + SSE (Server-Sent Events)
@@ -87,9 +90,16 @@ validation_service/ (FastAPI, 端口 8001) — 不变
| `backend/annotation_detector.py` | 批注检测: 圈选(cv2 HoughCircles) + 箭头(HoughLinesP聚类) + OCR关联 + LLM格式化 | 中 |
| `backend/embeddings.py` | 嵌入模型工厂 (HuggingFace/OpenAI) | 低 |
| `backend/validation.py` | 验证服务 HTTP 客户端 | 低 |
| `backend/session.py` | 会话 JSON 文件 CRUD | 低 |
| `backend/session.py` | 会话 JSON 文件 CRUD(含 kb_id | 低 |
| `backend/kb_manager.py` | 用户+知识库 CRUD(多租户,原子 JSON 持久化) | 中 |
| `backend/kb_searcher.py` | 知识库隔离搜索 + 模板检索(per-KB ChromaDB | 中 |
| `backend/kb_parser.py` | KB 解析管道:文件解析→字段提取→chunk切割→向量嵌入 | 中 |
| `backend/field_matcher.py` | OCR↔KB 字段匹配:Embedding 粗筛 + LLM 精确确认 | 中 |
| `agent/datasource.py` | 数据源模式解析:$P{{xxx}} 参数 vs JDBC 直连 | 低 |
| `agent/jrxml_windower.py` | JRXML Band 级窗口化引擎:拆解/切分/重组/元素计数校验 | 中 |
| `validation_service/main.py` | FastAPI 验证服务 | 低 |
| `scripts/init_kb.py` | 知识库初始化/模型下载 | 低 |
| `scripts/init_kb.py` | 旧 RAG 知识库初始化/模型下载 | 低 |
| `scripts/init_default_kb.py` | 多租户默认 KB 初始化(默认用户 + 预置 KB) | 低 |
| `app.py` | ~~旧 Streamlit UI~~(已由 api_server.py + frontend/ 替代) | 废弃 |
## 关键约定
@@ -427,3 +437,115 @@ cd frontend && npx playwright test
### consult_answer 前端显示修复
- `api_server.py``agent_complete` SSE 事件新增 `consult_answer` 字段
## 更新 (v12 — 2026-05-23)
### 多租户知识库系统
**核心架构**:用户自行维护多套知识库,每套 KB 拥有独立的文件存储、JSON 元数据和 ChromaDB 向量集合。会话可绑定不同 KB,LLM 基于 KB 中的字段定义和 JRXML 模板生成报表。
**存储架构**
```
kb_data/
├── users.json # 用户注册表
└── {user_id}/
├── profile.json
└── {kb_id}/
├── meta.json # KB 元数据 + 字段定义 + 模板索引
├── raw/ # 原始上传文件
├── chunks.json # RAG chunks(含 JRXML 模板完整文本)
└── chroma/ # KB 专属 ChromaDB
```
**新增后端模块**
| 文件 | 职责 |
|------|------|
| `backend/kb_manager.py` | 用户+KB CRUDcreate_user/list_users/create_kb/list_kbs/get_kb/delete_kb/update_kb_meta/get_kb_raw_dir/get_kb_chroma_path。原子 JSON 写入(tempfile + os.replace |
| `backend/kb_parser.py` | KB 解析管道:`parse_jrxml_fields()` XML 提取参数/字段/查询 → `process_file_for_kb()` 处理多种格式(jrxml/zip/tar/pdf/docx/xlsx/md`chunk_file_results()` 切割 → `build_kb_from_files()` 全管线(parse→chunk→embed→update meta |
| `backend/kb_searcher.py` | `KBChromaSearcher` 类:per-KB ChromaDB 懒连接。`search()` 语义搜索、`search_templates()` 仅搜索 JRXML 模板 chunk、`add_chunks()` 向量写入。全局 searcher 缓存 `_searchers: dict` |
| `backend/field_matcher.py` | OCR↔KB 字段匹配:1) Embedding 粗筛(余弦相似度 top-3)2) LLM 精确确认。返回 `{"工单号": "billNo", ...}` 映射 |
| `agent/datasource.py` | 数据源模式:`resolve_datasource_mode()` 检测用户意图 → `"parameter"`(默认 $P{xxx})或 `"jdbc"`(SQL 直连)。未配置 DB 时生成反问消息 |
| `scripts/init_default_kb.py` | 默认 KB 初始化:创建默认用户 → 解析 `rag/jrxml_source/` 下的 17 个 JRXML + 16 个 MD → chunk + embed → ChromaDB |
**新增 API 端点**api_server.py):
```
POST /api/users # 创建用户
GET /api/users # 用户列表
GET /api/users/{user_id} # 用户详情
DELETE /api/users/{user_id} # 删除用户
GET /api/users/{user_id}/kbs # KB 列表
POST /api/users/{user_id}/kbs # 创建 KB
GET /api/kbs/{kb_id} # KB 详情
DELETE /api/kbs/{kb_id} # 删除 KB
POST /api/kbs/{kb_id}/upload # 上传文件到 KB
POST /api/kbs/{kb_id}/build # 构建 KBchunk→embed
GET /api/kbs/{kb_id}/status # KB 状态
GET /api/kbs/{kb_id}/fields # KB 字段+模板列表
GET /api/kbs/{kb_id}/search?q=&type= # KB 语义搜索
PUT /api/sessions/{session_id}/kb # 绑定会话-KB
GET /api/sessions/{session_id}/kb # 获取会话绑定的 KB
```
**三条模板获取路径**
1. **管理页预上传**:用户上传文件到 KB → 解析管道 → chunks + ChromaDB → 对话选择 KB → retrieve 节点从 KB 检索
2. **对话框即时上传**:用户拖入 `.jrxml``_parse_jrxml_file()` → 注入 `agent_state["uploaded_template_jrxml"]` → 生成节点直接使用该模板
3. **口头引用模板**:用户说"根据标准结算单模板" → `_detect_template_intent()` 正则匹配 → `retrieve()` 在 KB 中搜索模板 → 注入 `kb_template_jrxml`
**模板上下文注入**:所有 6 个生成节点(generate/generate_skeleton/refine_layout/map_fields/modify_jrxml/correct_jrxml)通过 `_build_template_context(state)` 获取模板上下文,优先级:聊天上传 > KB 检索 > KB 字段定义。6 个 prompt 模板全部新增 `{template_context}` 占位符。
**前端新增**
| 文件 | 职责 |
|------|------|
| `stores/kb.ts` | Pinia store:用户列表、KB 列表、当前选择、字段/模板缓存、CRUD 操作、会话绑定 |
| `components/KbSelector.vue` | 对话顶部 KB 下拉选择器 + 管理按钮 |
| `components/KbManager.vue` | 模态面板:创建 KB、上传文件(支持 .jrxml/.md/.xlsx/.docx/.pdf/.csv/.zip 等)、构建、删除 |
**API Server 增强**
- `_process_files()` 检测 `.jrxml` 文件 → 提取参数/字段/查询/页面尺寸 → 注入 `uploaded_template_jrxml` + `uploaded_template_params`
- `agent/state.py` 新增 10 个字段:`kb_id`, `kb_fields`, `kb_field_mapping`, `uploaded_template_jrxml`, `uploaded_template_params`, `kb_template_jrxml`, `kb_template_name`, `datasource_mode`, `db_config`
**字段匹配管线**`_match_ocr_to_kb` → 尚未集成到节点):OCR 提取中文字段名 → `match_ocr_to_kb()` 两阶段匹配 → 结果为 `{"工单号": "billNo"}``format_field_mapping_context()` 注入 prompt → LLM 使用 `$P{billNo}` 而非 `$P{工单号}`
## 更新 (v13 — 2026-05-24)
### 3 阶段管道内容丢失修复 — Band 级窗口化 + 程序化字段映射
**问题**`generate_skeleton` 生成 34k 字符骨架 JRXML → `refine_layout` 将完整 34k 发给 LLM → LLM 重新生成简化版(~3k 字符,丢失 91.5%)。`map_fields` 同样存在字段映射时内容丢失问题。
**根因**:LLM 看到完整 JRXML 时倾向于"重新生成"而非"在原基础上修改坐标/字段名"。提示词调控无法可靠解决。
**修复方案**(按用户要求 — 程序化节点控制,不靠 LLM 提示词):
#### `refine_layout`Band 级窗口化 LLM 精调
新增 `agent/jrxml_windower.py` — JRXML 拆解/切分/重组引擎:
| 函数 | 用途 |
|------|------|
| `decompose_jrxml()` | ET 安全解析 → 分离 headerfield 声明/queryString 等,不发给 LLM+ 所有 band |
| `split_band_into_windows()` | 超过 4000 字符的 band 在元素闭合标签处切分为多个窗口 |
| `reassemble_band_windows()` | 合并同一 band 的多个窗口结果 |
| `reassemble_jrxml()` | header + 所有修改后 band + footer → 完整 JRXML |
| `count_elements()` | 正则计数 textField/staticText/field(兼容命名空间前缀) |
| `validate_element_count()` | 校验元素数变化,>10% 回退到前一版本 |
**LLM 每次只看到 ~2-4k 字符片段**,无法"重写整个报表"。header 部分完全不发给 LLM,原样保留。
#### `map_fields`:完全程序化替换(零 LLM 调用)
`_programmatic_map_fields()` — 纯正则替换 `$F{field_N}` → OCR 真实字段名,100% 确定性。
`_sanitize_field_name()` — 非 ASCII 字符(中文/日文)转义为 `_uXXXX_` Unicode 码点格式,确保 JRXML 合法。
#### 新增测试
| 文件 | 用例数 | 覆盖 |
|------|--------|------|
| `tests/test_jrxml_windower.py` | 28 | 拆解/往返重组/窗口切分/元素计数/命名空间/多 section 多 band |
| `tests/test_programmatic_map_fields.py` | 20 | 字段声明替换/引用替换/中文转义/坐标保留/部分映射/空字段跳过 |
完整测试套件(385 项)无回归。