From f25a93b5391545bc3d7083722c23ee4926296daa Mon Sep 17 00:00:00 2001 From: panda <1415243231@qq.com> Date: Sun, 24 May 2026 22:38:30 +0800 Subject: [PATCH] WIP: baseline on fix/retry-failure-root-causes --- agent/jrxml_windower.py | 28 +++- agent/nodes.py | 97 +++++++++++--- backend/ocr_extractor.py | 137 ++++++++++++++++--- docs/bug-report-5-retry-failure.md | 207 +++++++++++++++++++++++++++++ prompts/refine_layout.md | 4 +- 5 files changed, 438 insertions(+), 35 deletions(-) create mode 100644 docs/bug-report-5-retry-failure.md diff --git a/agent/jrxml_windower.py b/agent/jrxml_windower.py index 33c9b56..52b022c 100644 --- a/agent/jrxml_windower.py +++ b/agent/jrxml_windower.py @@ -181,6 +181,28 @@ def split_band_into_windows(band: BandInfo, max_chars: int = 4000) -> list[str]: # ── 重组 ────────────────────────────────────────────────────────── +def _recalc_band_height(band_xml: str, margin: int = 20) -> str: + """根据波段内所有子元素的 y + height 重新计算波段 height。""" + max_bottom = 0 + for m in re.finditer(r']*)/>', band_xml): + attrs = m.group(1) + ym = re.search(r'\sy\s*=\s*"(\d+)"', attrs) + hm = re.search(r'\sheight\s*=\s*"(\d+)"', attrs) + if ym and hm: + bottom = int(ym.group(1)) + int(hm.group(1)) + if bottom > max_bottom: + max_bottom = bottom + if max_bottom == 0: + return band_xml + new_height = max_bottom + margin + return re.sub( + r'(]*\sheight\s*=\s*)"(\d+)"', + rf'\g<1>"{new_height}"', + band_xml, + count=1, + ) + + def reassemble_band_windows(modified_windows: list[str]) -> str: """将多个窗口的修改结果重新合并为一个 band XML。 @@ -188,12 +210,12 @@ def reassemble_band_windows(modified_windows: list[str]) -> str: 中间拼接所有窗口内部的元素内容。 """ if len(modified_windows) == 1: - return modified_windows[0] + return _recalc_band_height(modified_windows[0]) first = modified_windows[0] band_open_end = first.find(">") if band_open_end == -1: - return "\n".join(modified_windows) + return _recalc_band_height("\n".join(modified_windows)) band_open = first[:band_open_end + 1] last = modified_windows[-1] @@ -205,7 +227,7 @@ def reassemble_band_windows(modified_windows: list[str]) -> str: if inner: inner_parts.append(inner) - return band_open + "\n" + "\n".join(inner_parts) + "\n" + band_close + return _recalc_band_height(band_open + "\n" + "\n".join(inner_parts) + "\n" + band_close) def reassemble_jrxml(parts: JRXMLParts, modified_bands: dict[str, str]) -> str: diff --git a/agent/nodes.py b/agent/nodes.py index 91b26a9..42fdff9 100644 --- a/agent/nodes.py +++ b/agent/nodes.py @@ -124,13 +124,8 @@ def process_input(state: AgentState) -> Dict: try: from backend.ocr_extractor import OcrExtractor extractor = OcrExtractor() - default_fields = [ - "发票代码", "发票号码", "开票日期", "合计金额", "校验码", - "价税合计", "总金额", "日期", "金额", "数量", "单价", "税率", - "购买方名称", "销售方名称", "货物名称", "规格型号", - "不含税金额", "税额", - ] - ocr_result = extractor.extract(uploaded_path, default_fields) + # 不传预设字段名,让 OCR 自动发现文档中的所有键值对 + ocr_result = extractor.extract(uploaded_path) if ocr_result.get("ocr_available"): state["ocr_extraction_result"] = ocr_result _node_log.info( @@ -483,12 +478,18 @@ def _format_row_coordinates(row: dict) -> dict: sorted_elems = sorted(elements, key=lambda e: e.get("x", 0)) cols = [] for ci, e in enumerate(sorted_elems): + x = e.get("x", 0) + y = e.get("y", 0) + w = e.get("w", 0) + h = e.get("h", 0) + if not (x > 0 and y > 0 and w > 0 and h > 0): + continue cols.append({ "col": ci, - "x": e.get("x", 0), - "y": e.get("y", 0), - "w": e.get("w", 0), - "h": e.get("h", 0), + "x": x, + "y": y, + "w": w, + "h": h, "font_size": e.get("font_size", 12), "text": e.get("text", ""), }) @@ -529,10 +530,33 @@ def _extract_xml_fragment(text: str) -> str: return text +def _count_zero_coordinate_elements(xml: str) -> tuple[int, int]: + """统计坐标无效(x=0 或 y=0 或 width=0 或 height=0)的 reportElement 数量。 + 返回 (zero_count, total_count)。 + """ + total = 0 + zero = 0 + for m in re.finditer(r']*)/>', xml): + total += 1 + attrs = m.group(1) + xm = re.search(r'\sx\s*=\s*"(\d+)"', attrs) + ym = re.search(r'\sy\s*=\s*"(\d+)"', attrs) + wm = re.search(r'\swidth\s*=\s*"(\d+)"', attrs) + hm = re.search(r'\sheight\s*=\s*"(\d+)"', attrs) + x = int(xm.group(1)) if xm else 0 + y = int(ym.group(1)) if ym else 0 + w = int(wm.group(1)) if wm else 0 + h = int(hm.group(1)) if hm else 0 + if x == 0 or y == 0 or w == 0 or h == 0: + zero += 1 + return zero, total + + def _programmatic_map_fields(jrxml: str, ocr_fields: list[dict]) -> str: """程序化字段映射:将 $F{{field_N}} 替换为 OCR 提取的真实字段名。 纯正则替换,不调 LLM。100% 确定性,零内容丢失。 + 未映射的 field_N 会被重命名为基于波段上下文的描述性名称。 """ result = jrxml for i, f in enumerate(ocr_fields): @@ -543,13 +567,45 @@ def _programmatic_map_fields(jrxml: str, ocr_fields: list[dict]) -> str: real_name = _sanitize_field_name(raw_name) if real_name == placeholder: continue - # 替换 field 声明: ]*\bname\s*=\s*"){re.escape(placeholder)}(")', rf'\g<1>{real_name}\g<2>', result, ) - # 替换所有引用: $F{{field_1}} → $F{{customer_name}} result = result.replace(f'$F{{{placeholder}}}', f'$F{{{real_name}}}') + + # 第二遍:为剩余未映射的 field_N 赋予基于波段位置的描述性名称 + remaining = set() + for m in re.finditer(r'\$F\{(field_\d+)\}', result): + remaining.add(m.group(1)) + if remaining: + _SECTION_TAGS = ( + "title", "pageHeader", "columnHeader", "detail", "columnFooter", + "pageFooter", "summary", "background", "noData", + ) + for placeholder in sorted(remaining, key=lambda x: int(re.search(r'\d+', x).group())): + n = int(re.search(r'\d+', placeholder).group()) + # 查找第一个引用此字段的位置,确定波段上下文 + pattern = rf'\$F\{{{re.escape(placeholder)}\}}' + m = re.search(pattern, result) + section = "data" + if m: + before = result[:m.start()] + # 从后往前找最近的 section 标签 + for tag in _SECTION_TAGS: + # 找最近的未闭合 section 标签 + opens = [o.start() for o in re.finditer(rf'<{tag}>', before)] + closes = [o.start() for o in re.finditer(rf'', before)] + last_open = opens[-1] if opens else -1 + last_close = closes[-1] if closes else -1 + if last_open > last_close: + section = tag + break + new_name = f"{section}_f{n}" + result = result.replace(f'$F{{{placeholder}}}', f'$F{{{new_name}}}') + result = re.sub( + rf'(<[\w:]*field\b[^>]*\bname\s*=\s*"){re.escape(placeholder)}(")', + rf'\g<1>{new_name}\g<2>', result, + ) return result @@ -943,9 +999,18 @@ def refine_layout(state: AgentState) -> Dict: content = response.content if hasattr(response, "content") else str(response) fragment = _extract_xml_fragment(content) if fragment: - band_results.append(fragment) - writer({"type": "stream", "node": "refine_layout", - "text": f"[{band.label} 窗口 {wi+1}/{len(windows)} 完成] "}) + zero_count, total = _count_zero_coordinate_elements(fragment) + if total > 0 and zero_count / total > 0.3: + _node_log.warning( + "refine_layout 窗口 %s/%d 零坐标元素 %d/%d (%.0f%%),使用原文", + band.label, wi + 1, zero_count, total, + zero_count / total * 100, + ) + band_results.append(win_xml) + else: + band_results.append(fragment) + writer({"type": "stream", "node": "refine_layout", + "text": f"[{band.label} 窗口 {wi+1}/{len(windows)} 完成] "}) else: _node_log.warning("refine_layout 窗口 %s/%d 返回空,使用原文", band.label, wi + 1) diff --git a/backend/ocr_extractor.py b/backend/ocr_extractor.py index 5efddf9..5105e9c 100644 --- a/backend/ocr_extractor.py +++ b/backend/ocr_extractor.py @@ -136,13 +136,13 @@ class OcrExtractor: def extract( self, file_path: str, - target_fields: list[str], + target_fields: Optional[list[str]] = None, ) -> dict: """执行两阶段 OCR 字段提取。 Args: file_path: 图片文件路径(支持 png/jpg/jpeg/bmp/webp) - target_fields: 需要提取的字段名称列表,如 ["发票代码", "发票号码", "合计金额"] + target_fields: 需要提取的字段名称列表。为空或 None 时自动发现文档中所有键值对。 Returns: 提取结果字典,格式见 ExtractionResult.to_dict() @@ -168,20 +168,40 @@ class OcrExtractor: return result.to_dict() result.ocr_available = True - for field_name in target_fields: - extracted = self._extract_field(field_name, elements) - if extracted: - result.fields.append(extracted) - else: - result.fields.append( - ExtractedField( - field_name=field_name, - field_value="", - bbox=[], - confidence=0.0, - extraction_method="none", + + if target_fields: + # 有预设字段名:按名单查找 + for field_name in target_fields: + extracted = self._extract_field(field_name, elements) + if extracted: + result.fields.append(extracted) + else: + result.fields.append( + ExtractedField( + field_name=field_name, + field_value="", + bbox=[], + confidence=0.0, + extraction_method="none", + ) + ) + else: + # 无预设字段名:自动发现文档中所有键值对 + discovered = self._discover_fields(elements) + for field in discovered: + extracted = self._extract_field(field, elements) + if extracted: + result.fields.append(extracted) + else: + result.fields.append( + ExtractedField( + field_name=field, + field_value="", + bbox=[], + confidence=0.0, + extraction_method="none", + ) ) - ) return result.to_dict() @@ -396,6 +416,83 @@ class OcrExtractor: # 阶段2: 字段精确提取 # ======================================================================== + def _discover_fields(self, elements: list[OcrTextElement]) -> list[str]: + """自动发现文档中的字段名(无需预设字段列表)。 + + 策略: + 1. 单元素内"标签: 值"模式 — 从中提取标签 + 2. 同行相邻键值对 — 短文本(标签) + 长文本(值) + 3. 表头行 — 首行/第二行的文本作为列字段名 + """ + separators = [":", ":", "=", "—"] + discovered: set[str] = set() + elements_sorted = sorted(elements, key=lambda e: (e.y_min, e.x_min)) + + # 策略 1: 单元素内嵌键值对 + for elem in elements: + text = elem.text + for sep in separators: + if sep in text: + parts = text.split(sep, 1) + label = parts[0].strip() + value = parts[1].strip() + if label and value and len(label) <= 20 and label != value: + discovered.add(label) + + # 策略 2: 同行相邻键值对(标签在左,值在右) + # 按行分组 + rows: dict[int, list[OcrTextElement]] = {} + for elem in elements_sorted: + row_key = int(elem.y_min) + for existing_key in list(rows.keys()): + if abs(int(elem.y_min) - existing_key) < 10: + row_key = existing_key + break + if row_key not in rows: + rows[row_key] = [] + rows[row_key].append(elem) + + for row_elems in rows.values(): + row_elems.sort(key=lambda e: e.x_min) + for i in range(len(row_elems) - 1): + left = row_elems[i] + right = row_elems[i + 1] + # 左边是短文本(可能标签),右边是相邻的正常文本(可能值) + if (len(left.text) <= 15 and len(right.text) > 0 + and abs(right.x_min - left.x_max) < left.width * 3): + # 左边不含仅数字/金额模式(这些更可能是值) + if not re.match(r'^[\d,.]+\s*%?$', left.text.strip()): + discovered.add(left.text.strip()) + + # 策略 3: 表头行 — 取前两行中较短的元素作为字段名候选 + sorted_row_keys = sorted(rows.keys()) + header_rows = sorted_row_keys[:min(3, len(sorted_row_keys))] + for row_key in header_rows: + for elem in rows.get(row_key, []): + text = elem.text.strip() + if text and len(text) <= 20 and not re.match(r'^[\d,.]+\s*%?$', text): + discovered.add(text) + + # 去重合并:移除值文本中误识别为标签的条目 + # 排除纯数字、日期、金额等明显是值的文本 + value_patterns = [ + r'^\d{1,2}[月/-]\d{1,2}[日/-]?\d{0,4}$', + r'^[\d,]+\.?\d*\s*%?$', + r'^[¥¥]\s*[\d,]+\.?\d*$', + r'^\d{3,}$', + ] + filtered = set() + for name in discovered: + is_value = False + for pat in value_patterns: + if re.match(pat, name): + is_value = True + break + if not is_value: + filtered.add(name) + + return sorted(filtered) + def _extract_field( self, field_name: str, @@ -558,6 +655,7 @@ class OcrExtractor: # ----------------------------------------------------------------------- PREDEFINED_PATTERNS: dict[str, str] = { + # 发票字段 "发票代码": r"[0-9A-Za-z]{10,12}", "发票号码": r"\d{8}", "合计金额": r"[\d,]+\.?\d*", @@ -571,6 +669,15 @@ class OcrExtractor: "数量": r"\d+\.?\d*", "单价": r"[\d,]+\.?\d*", "税率": r"\d+\.?\d*%?", + # 车历卡/维修结算单字段 + "维修单号": r"[A-Za-z0-9\-]{6,20}", + "车牌号": r"[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤川青藏琼宁][A-Z][·\-]?[A-Z0-9]{5,6}", + "联系电话": r"1[3-9]\d{9}", + "VIN码": r"[A-HJ-NPR-Z0-9]{17}", + "发动机号": r"[A-Z0-9]{6,12}", + # 采购单字段 + "采购日期": r"\d{4}[年/\-]\d{1,2}[月/\-]\d{1,2}日?", + "订单号": r"[A-Z0-9\-]{6,20}", } def _regex_match( diff --git a/docs/bug-report-5-retry-failure.md b/docs/bug-report-5-retry-failure.md new file mode 100644 index 0000000..43d86da --- /dev/null +++ b/docs/bug-report-5-retry-failure.md @@ -0,0 +1,207 @@ +# jaspersoft 五轮修正失败问题 — 现象文档 + +## 1. 问题概述 + +**触发场景**:用户上传一张车历卡(维修结算单)图片,系统通过 OCR 识别并生成 JRXML 报表模板。 + +**现象**:5 次自动修正循环全部失败,系统提示"[内容保真度不足] 得分 0.00/1.0",最终无法生成可用 JRXML。 + +**测试会话**:`sessions/6d39a91e11c54f02bb70a62d856ea2d4.json`(2026-05-24 15:14) + +--- + +## 2. 完整流程追踪 + +### 2.1 用户输入 + +- 上传文件:`e1113725c20fc4ec39bc9e4ab0caa6b2.jpg`(车历卡,1357×1920 RGB) +- 用户输入:空(仅上传文件) + +### 2.2 系统处理流程 + +``` +上传图片 + → process_input 节点(OCR 字段提取 + 布局分析) + → layout_analyzer(34 行 × 1 列,A4 纵向) + → ocr_extractor(4 策略提取) + → classify_intent(= initial_generation) + → retrieve + → route_after_retrieve(有 layout_schema,走 generate_skeleton) + → generate_skeleton(生成 ~34k 字符骨架 JRXML) + → refine_layout(Band 级窗口化精调) + → map_fields(程序化字段替换 $F{field_N} → 真实字段名) + → validate(validate 节点) + → XSD 验证:✅ 通过 + → OCR 保真度检查:❌ score=0.41 < 0.5 → 降级为 fail + → error_msg = "[内容保真度不足] 得分 0.41/1.0。元素覆盖不足:JRXML 仅有 142 个文本元素,OCR 源有 173 个文本元素(覆盖率 82%)。JRXML 中未声明任何字段,但 OCR 提取了结构化字段数据" + → 5 次 correct_jrxml 修正循环均失败 + → 状态:fail(MAX_RETRY=5 耗尽) +``` + +### 2.3 最终状态 + +``` +status: fail +retry_count: 5 +error_msg: "[内容保真度不足] 得分 0.00/1.0。JRXML 仅有 0 个文本元素,OCR 源有 173 个文本元素(覆盖率 0%)" +``` + +⚠️ **重要矛盾**:`current_jrxml`(24391 字符,142 个 textField)与 `error_msg`("0 个文本元素",score=0.00)存在矛盾。 + +验证服务审计(`validate-service-audit`)指出:"6d39a91e has 0 text elements causing score=0.00"——说明在**触发 fail 的那个时间点**,JRXML 确实只有 0 个文本元素。 + +但 session 文件最终保存的 `current_jrxml` 是 24391 字符版本。`jrxml_versions` 最后一条记录的 `jrxml` 才是触发失败的真实版本。 + +**结论**:`current_jrxml` 在 5 次修正过程中被逐步侵蚀,最终版本是某个接近空壳的状态。`jrxml_versions[-1]` 中的 `jrxml` 才是真正的失败版本。 + +--- + +## 3. 关键数据对比 + +### 3.1 JRXML 实际内容 + +session `6d39a91e` 的 `current_jrxml`: +- 长度:24391 字符 +- `` 元素:0 个 +- `` 声明:63 个 +- pageWidth/pageHeight:595×842(A4) +- namespace:无 ns0: 前缀(✅ 已消除) + +### 3.2 OCR 数据 + +layout_schema: +- total_rows: 34 +- total_columns: 1 +- 总文本元素:173 个 + +ocr_extraction_result: +- total_elements: 173 +- fields 数组:18 个字段,全部是**发票模板字段** + - 不含税金额、价税合计、单价、发票代码、发票号码、合计金额、开票日期、总金额、数量、日期、校验码、税率、税额、规格型号、货物名称、购买方名称、金额、销售方名称 + - 正确匹配率:3/18(17%) + +### 3.3 评分数据 + +`_check_ocr_fidelity` 实际运行结果: + +```python +# element_coverage 计算 +textField_count = 142 # 完整元素(非标签数) +staticText_count = 0 +total_jrxml_elements = 142 +ocr_text_count = 173 +element_coverage = min(142/173, 1.0) = 0.82 + +# field_coverage 计算 +jrxml_fields = {"print_date", "repair_number", ..., "vehicle_plate", ...} # 63 个英文字段 +ocr_field_names = {"发票代码", "发票号码", "合计金额", ...} # 18 个中文字段 +matched = jrxml_fields ∩ ocr_field_names = ∅ +field_coverage = 0/18 = 0.0 + +# score 计算 +score = 0.0 * 0.5 + 0.82 * 0.5 = 0.41 +``` + +**评分公式**(`nodes.py:1251`): +```python +score = round(field_coverage * 0.5 + element_coverage * 0.5, 3) +``` + +**降级条件**(`nodes.py:1294`): +```python +if fidelity["score"] < 0.5: + state["status"] = "fail" +``` + +--- + +## 4. 已确认的根因 + +### 根因 1 — 评分逻辑设计错误(P0) + +**问题**:`field_coverage` 将英文字段名和中文字段名做交集比对,在普通生成场景下永远为 0。 + +**原因**: +1. LLM 生成英文字段名(`print_date`、`vehicle_plate`)是正确的设计选择 +2. OCR 提取器硬套发票模板,提取中文字段名(`发票代码`、`合计金额`) +3. 两者来自完全不同的命名体系,不可能匹配 +4. `field_coverage=0` 是**预期行为**,而非错误 + +**修复方向**:评分公式改为只依赖 `element_coverage`,`field_coverage` 作为信息提示而非降级条件。 + +### 根因 2 — OCR 字段提取器无文档类型区分(P0) + +**问题**:`backend/ocr_extractor.py` 对所有单据使用同一套发票字段模板(14 个字段)。 + +**现象**: +- 车历卡被当作发票处理 +- 手机号 `13516727312` 被 6 个字段复用(发票代码/校验码/价税合计/单价/税率/不含税金额) +- 字段名错配:发票号码→"服务套餐"、总金额→"钣金"、数量→"零件等级" + +### 根因 3 — namespace 修复指令是条件触发(P1) + +**位置**:`prompts/correction.md` 第 11 行 + +**问题**:namespace 修复指令只在错误消息**包含 "namespace" 关键词**时才激活,是条件触发而非无条件指令。 + +**现象**: +- ecd592 session 所有 5 次修正的实际错误类型是"字段未声明"(`字段 'u53d1_u7968...' 在表达式中使用但未声明`),**不包含 "namespace" 关键词** +- 因此 namespace 修复指令从未被激活 +- ns0: 前缀在前两次修正中持续存在,直到第 3 次才被 LLM 自发消除 +- 最终 `current_jrxml`(ecd592)仍有 `` + +**修复方向**: +- `prompts/correction.md`:改为无条件指令(检查 JRXML 是否包含 `ns0:`,而非依赖错误类型) +- `prompts/initial_generation.md` + `skeleton_generation.md`:添加删除 ns0: 前缀的无条件指令 + +### 根因 4 — 正则 `\w+` 不支持中文(低优先级) + +**位置**:`nodes.py:1211` +```python +jrxml_fields = set(re.findall(r' 0)。禁止输出 x="0" y="0" 或 width="0" height="0"。** 坐标调整规则: - 表头行:直接使用 header_row 对应列的 x, y, width, height - 数据行:根据 first_data_row 的坐标模式,向下插值(每行 y 递增行高) - 标题行和表尾行:保持 y 位置大致不变,但调整 x 和 width 与列的采样坐标对齐 +- **调整完所有子元素坐标后,将 band height 更新为 max(所有子元素 y + height) + 20px。所有子元素的 y + height 不能超过 band height。** {template_context}