From 93f6f46890ae524d04892ab659e6d0fe9675446d Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 6 Jul 2026 10:35:12 +0800 Subject: [PATCH 01/81] fix(perceives): repair malformed batch-merged table/figure captions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批合并引擎(MinerU/marker)在 auto_batch 路径下偶将加粗 caption `**Table N: ...**` 误输出为 `- *Table N: ...**`(行首多列表短横 + 非对称单 星),既非合法列表项(尾部悬挂 `**`)也非合法加粗,渲染端表现为破碎文本,是 学术论文附录表格的高频失真源(本文档 9/9 表格 caption 全部受此影响)。 在 merge_slice_markdowns 合并终点新增 _repair_malformed_caption_markers: 仅当整行以 `- *Figure/Table N` 起手且以 `**` 收尾(畸形加粗签名)时,将行首 `- *` 归一为 `**`,保留正文与尾部 `**` 不动;合法列表项(`- Table 1 shows` 无尾部 `**`)与已正确加粗行不受影响。 爆炸半径:仅批合并 PDF 路径;非分批文档经结构化 assembly 路径已正确,不受影 响。验证:7 条边缘用例单测通过 + 42 项 batch_merge 既有单测全通过 + 端到端重 转确认 9/9 caption 已归一(0 残留畸形)。 Co-Authored-By: Claude Opus 4.8 --- .../perceives/pipeline/batch_merge.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/batch_merge.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/batch_merge.py index 5a3f35fd6..3b6c0e4b4 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/batch_merge.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/batch_merge.py @@ -315,6 +315,37 @@ def rewrite_image_refs_in_markdown(markdown: str, rename_map: dict) -> str: ) """Figure caption 起手识别(与 assembly._FIGURE_TABLE_CAPTION_RE 语义一致)。""" +_MALFORMED_CAPTION_LINE_RE = re.compile( + r"^(?P[ \t]*)- \*(?P(?:Figure|Fig\.?|Table|Tab\.?)\s+S?\d+.*\*\*)[ \t]*$", + re.IGNORECASE, +) +"""批合并引擎畸形加粗 caption 签名:行首 ``- *Figure/Table N`` 且行尾悬挂 ``**``。""" + + +def _repair_malformed_caption_markers(markdown: str) -> str: + """修复批合并引擎产出的畸形 caption 标记。 + + MinerU/marker 引擎偶将加粗 caption ``**Table N: ...**`` 误输出为 + ``- *Table N: ...**``(行首多出列表短横 + 非对称单星)。该形态既非合法列表项 + (尾部悬挂 ``**``)、也非合法加粗,渲染端表现为破碎文本,是本类学术论文附录 + 表格的高频失真源。 + + 仅当整行以 ``- *Figure/Table N`` 起手且以 ``**`` 收尾(畸形加粗签名)时,将行首 + ``- *`` 归一为 ``**``,保留正文与尾部 ``**`` 不动;其余行原样返回,绝不误伤合法 + 列表项(``- Table 1 shows...`` 无尾部 ``**``,不匹配)。 + """ + if "- *" not in markdown: + return markdown + out_lines: List[str] = [] + for line in markdown.split("\n"): + m = _MALFORMED_CAPTION_LINE_RE.match(line) + if m: + out_lines.append(f"{m.group('indent')}**{m.group('body')}") + else: + out_lines.append(line) + return "\n".join(out_lines) + + _IMG_BLOCK = re.compile( r"^\s*(?:!\[[^\]]*\]\([^)]+\)|]*/?>(?:)?|].*?)\s*$", re.IGNORECASE | re.DOTALL, @@ -425,7 +456,7 @@ def merge_slice_markdowns( s, e = slice_ranges[i + 1] parts.append(f"") - return "\n\n".join(parts) + return _repair_malformed_caption_markers("\n\n".join(parts)) # --------------------------------------------------------------------------- From b4057f62e1afacc073c063e199d6f1faceb27985 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 6 Jul 2026 10:57:00 +0800 Subject: [PATCH 02/81] fix(perceives): suppress run-on text echo of grid-backed tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyMuPDF 常把表格区域另抽为"字符流 run-on 文本块"(如 `Subagent Tools Available Use Case Code-Explorer read_file...`),该块与表格 bbox 空间重叠但既 非 caption 亦非低内容碎片,会"落穿"figure-region 实质文本例外而被冗余输出, 形成 table_extraction 高保真网格之后的 run-on 回声(本文档附录 Tables 3/5/6/7 网格 + 整段回声并存)。 在 assembly 文本块收集阶段新增 grid-backed 表格回声抑制: - 新建 _grid_table_regions,仅收录 markdown 以 `|` 起手且含 GFM 分隔行(合法 网格)的表格 bbox; - caption 恒保留后,若文本块与 _grid_table_regions 重叠(IoU≥0.3 或中心点包 含)则判为冗余副本跳过。 关键防误删:仅对 **已产出网格** 的表格生效。引擎漏检无网格的表格(Tables 8/9)不在该集合内,其文本块继续保留,避免删除唯一内容(防数据丢失)。 爆炸半径:仅 PDF assembly 路径;仅命中与合法网格重叠的文本块。验证:185 项 assembly 既有单测全通过 + 端到端重转确认 Tables 3/5/6/7 多行 run-on 回声消除、 9/9 caption 与 63 网格行均保留。 Co-Authored-By: Claude Opus 4.8 --- .../perceives/pipeline/stages/pdf/assembly.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py index 188ef4349..36b2e4df9 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py @@ -92,9 +92,23 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: fy1 + _FORMULA_BBOX_MARGIN_PT, ) special_regions.setdefault(formula.page_number, []).append(expanded) + # ``_grid_table_regions``:仅收录 **已产出合法 GFM 网格** 的表格 bbox。 + # 用途:PyMuPDF 常把表格区域另抽为"字符流 run-on 文本块"(如 + # ``Field Type Description model str LLM model identifier ...``),该块 + # 与表格 bbox 空间重叠但既非 caption 亦非低内容碎片,会"落穿"下方 + # figure-region 实质文本例外而被冗余输出,形成网格后的 run-on 回声 + # (ISSUE: 附录表格 Tables 3–7 网格 + 回声并存)。仅当该表格已有高保真 + # 网格时抑制其 run-on 回声;无网格的表格(如引擎漏检的 Tables 8/9) + # 不在此集合内,其文本块得以保留,避免误删唯一内容(防数据丢失)。 + _grid_table_regions: Dict[int, List[Tuple[float, float, float, float]]] = {} for table in input_data.tables.tables if input_data.tables else []: if table.bbox: special_regions.setdefault(table.page_number, []).append(table.bbox) + _tmd = table.markdown.strip() if table.markdown else "" + if _tmd.startswith("|") and re.search(r"\n\s*\|[\s\-:|]+\|", _tmd): + _grid_table_regions.setdefault(table.page_number, []).append( + table.bbox + ) for img in input_data.images.images if input_data.images else []: if img.bbox: special_regions.setdefault(img.page_number, []).append(img.bbox) @@ -199,6 +213,17 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: ) ) continue + # 网格表格的 run-on 文本回声抑制:caption 已在上方恒保留, + # 此处若文本块与 **已产出合法网格** 的表格 bbox 重叠,则它 + # 是 PyMuPDF 对同一表格另抽的"字符流"冗余副本(表头回声 + + # 塌缩单元格),高保真网格已由 table_extraction 提供,直接 + # 跳过。仅对 grid-backed 表格生效:无网格表格(引擎漏检的 + # Tables 8/9)不在 _grid_table_regions 内,其文本块继续保留, + # 避免误删唯一内容。 + if _block_overlaps_special( + block, _grid_table_regions, iou_threshold=0.3 + ): + continue if _is_low_content_figure_label(block.text): continue # 字符级签名兜底:剔除 PyMuPDF 把公式视觉渲染区抽成 From 3de8e3c854216c3f5cc34a242318ec4d7cf1f50c Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 6 Jul 2026 11:34:03 +0800 Subject: [PATCH 03/81] fix(perceives): detect whitespace-aligned tables so stage not skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因(defect C, Tables 8/9 塌缩): 81 页 PDF 触发 auto_batch,末尾单页切片含两张 **空白对齐无框线表格**(附录配置表 Table 8/9:纯空格对齐、无 `|` 字符、无 ruling line)。quick_scan 的 has_tables 仅由 `native find_tables >= 1`(ruling-line 策略, 对此类表零命中)或 `pipe-line indicator > 2`(源文本无 `|`)判定 → has_tables=False 且 has_complex_layout=False → ProfileAwareSelector 短路跳过整个 table_extraction (engine_used=skipped:profile:no_has_tables),两表从未被提取,仅剩 PyMuPDF run-on 文本副本。 定点修复(quick_scan.py):新增 caption 级表格指示器 table_caption_count,统计行首 起手且紧跟 `:`/`.` 的 `Table N:` / `Table S2.` 图表标题;以高精度阈值 `>= 1` 并 入 has_tables 判定。实测判别精度:正文/标题页 0 命中,真实表格页 2-3 命中,句中 引用 "Table 1 shows..."(非行首/无紧跟标点)不误命中。 辅助(table_extraction.py):execute() 增加"成功但零表格穿透"——主工具(docling)转换 成功却漏检表格返回 total_count=0 时不立即返回,继续尝试后续启发式工具(fitz 几何 提取对清晰规则表能补获),仅当确有表格时短路;全零则回退首个空成功保持契约。 爆炸半径:quick_scan 仅新增一个 OR 分支(更宽松的 has_tables 召回,最坏情况多跑 ~5s table_extraction 而不丢内容);table_extraction 仅在主工具零表格时多试后备。 验证:新增 5 条 caption 判别单测 + 272 项 quick_scan/selector/table/assembly 既有 单测全通过;端到端重转确认 Tables 8/9 还原为 GFM 网格(pipe 行 63→93)。 Co-Authored-By: Claude Opus 4.8 --- .../pipeline/stages/pdf/quick_scan.py | 24 ++++++++++- .../pipeline/stages/pdf/table_extraction.py | 21 ++++++++-- .../tests/unit/test_quick_scan_sampling.py | 42 +++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/quick_scan.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/quick_scan.py index 071349dee..954b0f3fe 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/quick_scan.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/quick_scan.py @@ -123,6 +123,7 @@ async def _run( math_font_count = 0 table_indicator_count = 0 native_table_count = 0 + table_caption_count = 0 code_indicator_count = 0 code_font_count = 0 algorithm_indicator_count = 0 @@ -209,6 +210,22 @@ async def _run( if re.match(r"^ \S", line) or "def " in line or "class " in line: code_indicator_count += 1 + # caption 级表格指示器: ``Table N:`` / ``Table S2.`` 起手的图表标题 + # 是"该页确有表格"的强信号,尤其覆盖 native find_tables (ruling-line + # 策略) 与 pipe-line 启发式双双漏报的 **空白对齐无框线表格** + # (典型如附录配置表 Table 8/9: 纯空格对齐、无 ``|`` 字符、无框线, + # find_tables 零命中)。该模式误报率极低: 实测正文/标题页 0 命中, + # 真实表格页 2-3 命中 (与正文引用 "Table 1 shows..." 不同, 此处要求 + # 行首起手 + 紧跟 ``:``/``.``, 不匹配句中引用),故单独计数并以 + # ``>= 1`` 高精度阈值触发,避免被 pipe-line 的 ``> 2`` 宽阈值淹没。 + table_caption_count += len( + re.findall( + r"^\s*Table\s+S?\d+\s*[:.]", + page_text, + re.IGNORECASE | re.MULTILINE, + ) + ) + # inline math (避免漏报无数学字体但用 $...$ / \(...\) 的论文) inline_math_hits += len(re.findall(r"\$[^\$\n]{1,80}\$", page_text)) inline_math_hits += len(re.findall(r"\\\([^)]{1,80}\\\)", page_text)) @@ -236,7 +253,11 @@ async def _run( # - has_code_blocks: indent/def/class ≥ 5 || 等宽字体 ≥ 30 (代码块通常有大量等宽字符) chars.has_images = image_count > 0 chars.has_formulas = math_font_count >= 3 or inline_math_hits >= 3 - chars.has_tables = native_table_count >= 1 or table_indicator_count > 2 + chars.has_tables = ( + native_table_count >= 1 + or table_indicator_count > 2 + or table_caption_count >= 1 + ) chars.has_code_blocks = ( code_indicator_count > 5 or code_font_count >= 30 @@ -283,6 +304,7 @@ async def _run( "inline_math_hits": inline_math_hits, "native_table_count": native_table_count, "table_indicator_count": table_indicator_count, + "table_caption_count": table_caption_count, "code_indicator_count": code_indicator_count, "code_font_count": code_font_count, }, diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/table_extraction.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/table_extraction.py index e326da5a1..d839b695a 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/table_extraction.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/table_extraction.py @@ -10,7 +10,7 @@ from __future__ import annotations import logging -from typing import Dict, List +from typing import Dict, List, Optional from ...base import Stage, StageResult from ...models import ( @@ -425,13 +425,28 @@ def stage_name(self) -> str: async def execute( self, input_data: PreprocessingOutput ) -> StageResult[TableExtractionOutput]: - """按降级顺序执行表格提取。""" + """按降级顺序执行表格提取。 + + 降级策略含 **空结果穿透**:主工具(docling)即使转换成功也可能在某些切片 + 漏检表格并返回 ``success=True`` 且 ``total_count==0``(典型如 + auto_batch 末尾单页切片上的规则线表格被 TableFormer 漏检)。此时不应 + 直接返回空结果、令后续启发式工具(fitz 几何提取)失去机会——启发式工具 + 对清晰规则线表格往往能补获。故仅当某工具成功 **且确有表格** 时立即返回; + 若所有成功工具均零表格,则回退到首个成功(空)结果保持既有契约。 + """ + first_empty_success: Optional[StageResult[TableExtractionOutput]] = None for tool_cls in _TOOLS.values(): tool = tool_cls() if tool.is_available(): result = await tool.execute(input_data) if result.success: - return result + if result.output is not None and result.output.total_count > 0: + return result + # 成功但零表格:暂存为兜底,继续尝试后续工具补获 + if first_empty_success is None: + first_empty_success = result + if first_empty_success is not None: + return first_empty_success # 诊断:区分"工具不可用"和"工具可用但提取失败"两种场景 unavailable = [name for name, cls in _TOOLS.items() if not cls().is_available()] diff --git a/apps/negentropy-perceives/tests/unit/test_quick_scan_sampling.py b/apps/negentropy-perceives/tests/unit/test_quick_scan_sampling.py index a90aa0e50..ebc9dc5be 100644 --- a/apps/negentropy-perceives/tests/unit/test_quick_scan_sampling.py +++ b/apps/negentropy-perceives/tests/unit/test_quick_scan_sampling.py @@ -52,3 +52,45 @@ def test_zero_pages(self) -> None: """空范围返回空列表。""" indices = _compute_scan_page_indices(start=5, end=5, max_scan=15) assert indices == [] + + +class TestTableCaptionIndicator: + """``Table N:`` caption 级表格指示器的判别精度测试。 + + quick_scan 用该模式补齐 native find_tables (ruling-line 策略) 与 pipe-line + 启发式双双漏报的 **空白对齐无框线表格** (如附录配置表)。要求: 行首起手 + + 紧跟 ``:``/``.``, 命中真实 caption 而不误伤句中引用 ("Table 1 shows...")。 + """ + + import re as _re + + _CAP_RE = _re.compile(r"^\s*Table\s+S?\d+\s*[:.]", _re.IGNORECASE | _re.MULTILINE) + + def _count(self, text: str) -> int: + return len(self._CAP_RE.findall(text)) + + def test_matches_appendix_table_captions(self) -> None: + """附录表格页的多个 caption 应被命中。""" + text = ( + "Table 8: Key configuration fields in OPENDEV.\n" + "model str LLM model identifier\n" + "Table 9: Implementation constants in OPENDEV.\n" + ) + assert self._count(text) == 2 + + def test_matches_supplementary_table(self) -> None: + """``Table S2.`` 补充材料编号亦命中。""" + assert self._count("Table S2. Supplementary results\n") == 1 + + def test_ignores_inline_reference(self) -> None: + """句中引用 (非行首起手 / 无紧跟标点) 不误命中。""" + text = ( + "As shown in Table 1 the results are consistent, and Table 2 confirms.\n" + "We refer to Table 3 for details.\n" + ) + assert self._count(text) == 0 + + def test_ignores_prose_without_tables(self) -> None: + """普通正文无 caption → 0 命中。""" + text = "The rapid advancement of large language models has catalyzed change.\n" + assert self._count(text) == 0 From 12ef2c2a7bdbf8a0e3e0518e635be3cc721c31bd Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 6 Jul 2026 11:52:41 +0800 Subject: [PATCH 04/81] fix(perceives): dedup table's embedded caption vs standalone bold caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 残留 A′(5 处): docling 把 ``Table N:`` caption 作为**表格元素 markdown 的内嵌首 行**(``Table 8:\n\n|grid``),而同一 caption 又被独立文本块经 _is_table_caption 渲染为 ``**Table 8:**`` 粗体段落 → 同编号 caption 两处并存,表格上方出现重复裸 文本 caption 行(Tables 4/5/6/8/9)。原 dedup 循环仅对 element_type=="text" 整体 跳过,表格元素内嵌的明文 caption 首行逃逸。 定点修复(assembly.py): - 新增 _strip_leading_caption_paragraph:剥离表格 markdown 顶部 ``Table N: ...`` 明文 caption 段落,仅当其后确有 GFM 网格(``|`` 起手)时生效,保留网格本身; - dedup 循环中当 table 元素的内嵌 caption 编号已被前面的粗体 caption 记入 _seen_caption 时,调用该函数剥离冗余 caption 行(而非丢弃整个表格元素,避免删 除网格)。 关键防误删:仅剥离"caption 行 + 其后确为网格"的情形;无网格纯文本兜底表格 (caption 后非 ``|`` 起手)原样保留。验证:5 条 strip-caption 单测 + 185 项 assembly 既有单测全通过;端到端重转确认重复纯文本 caption 5→0、9/9 粗体 caption 与 93 网格行均保留。 Co-Authored-By: Claude Opus 4.8 --- .../perceives/pipeline/stages/pdf/assembly.py | 35 +++++++++++++++++++ .../tests/unit/test_assembly_helpers.py | 35 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py index 36b2e4df9..3db33c9a1 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py @@ -1359,6 +1359,13 @@ def _name_in_text(name: str) -> bool: # 避免同编号 caption 已记录时把图片本身丢弃。 if cap_key in _seen_caption and elem.element_type == "text": continue + # table 元素:同编号 caption 已被独立文本块记录为 + # ``**Table N:**`` 粗体时,剥离表格 markdown 内嵌的明文 + # caption 首行(冗余裸文本副本),但保留网格本身,不丢表格。 + if cap_key in _seen_caption and elem.element_type == "table": + elem.content = _strip_leading_caption_paragraph( + elem.content + ) _seen_caption.add(cap_key) _dd.append(elem) elements = _dd @@ -1748,6 +1755,34 @@ def _is_caption_duplicate(text: str, caption_norm: str, all_captions: set[str]) re.IGNORECASE, ) +# 表格 markdown 首段为 ``Table N: ...`` 明文 caption(docling 常把标题作为独立 +# 首行置于网格之上,非表头格,故 _strip_caption_row_from_grid 不处理)的识别。 +_LEADING_TABLE_CAPTION_LINE_RE = re.compile( + r"^\s*(?:Figure|Fig\.?|Table|Tab\.?)\s+S?\d+\s*[:.][^\n|]*\n", + re.IGNORECASE, +) + + +def _strip_leading_caption_paragraph(md: str) -> str: + """剥离表格 markdown 顶部的 ``Table N: ...`` 明文 caption 段落,保留网格。 + + 用于 dedup:当同编号 caption 已由独立文本块渲染为 ``**Table N:**`` 粗体段落 + 时,表格元素内嵌的明文 caption 首行是冗余副本(渲染为重复的裸文本行)。仅 + 当首行是 caption 明文、且其后确有 GFM 网格(``|`` 起手行)时剥离,避免误伤 + 无网格的纯文本兜底表格。 + """ + if not md: + return md + m = _LEADING_TABLE_CAPTION_LINE_RE.match(md) + if not m: + return md + rest = md[m.end() :].lstrip("\n") + # 仅当剩余内容确为网格(首个非空行以 ``|`` 起手)才剥离 caption 行 + first_rest = rest.split("\n", 1)[0].strip() if rest else "" + if first_rest.startswith("|"): + return rest + return md + def _is_figure_or_table_caption_text(text: str) -> bool: """判断文本块是否为 ``Figure N:`` / ``Table N:`` 起手的图表 caption。 diff --git a/apps/negentropy-perceives/tests/unit/test_assembly_helpers.py b/apps/negentropy-perceives/tests/unit/test_assembly_helpers.py index 754a5d617..d809d3e2e 100644 --- a/apps/negentropy-perceives/tests/unit/test_assembly_helpers.py +++ b/apps/negentropy-perceives/tests/unit/test_assembly_helpers.py @@ -20,6 +20,7 @@ from negentropy.perceives.pipeline.stages.pdf.assembly import ( _formula_text_signature, _image_to_markdown, + _strip_leading_caption_paragraph, _text_block_matches_formula, ) @@ -288,3 +289,37 @@ def test_short_formula_embedded_in_prose_not_deduped(self) -> None: page=4, ) assert _text_block_matches_formula(block, signatures) is False + + +class TestStripLeadingCaptionParagraph: + """``_strip_leading_caption_paragraph`` 去重契约。 + + docling 常把 ``Table N:`` caption 作为独立首行置于网格之上;当同编号 caption + 已由独立文本块渲染为 ``**Table N:**`` 粗体时,表格内嵌明文 caption 是冗余裸 + 文本副本,应剥离但保留网格;无网格兜底表格则不动以防数据丢失。 + """ + + def test_strips_caption_line_keeps_grid(self) -> None: + md = ( + "Table 8: Key configuration fields in OPENDEV.\n\n" + "| Field | Type |\n| --- | --- |\n| model | str |" + ) + out = _strip_leading_caption_paragraph(md) + assert out.startswith("| Field | Type |") + assert "Table 8:" not in out + + def test_supplementary_number_stripped(self) -> None: + md = "Table S2. Supplementary results\n\n| A | B |\n| --- | --- |" + assert _strip_leading_caption_paragraph(md).startswith("| A | B |") + + def test_no_grid_after_caption_unchanged(self) -> None: + """caption 后无网格(纯文本兜底表)→ 原样保留,不删内容。""" + md = "Table 8: Key configuration.\n\nmodel str LLM identifier provider str" + assert _strip_leading_caption_paragraph(md) == md + + def test_pure_grid_no_caption_unchanged(self) -> None: + md = "| Field | Type |\n| --- | --- |\n| model | str |" + assert _strip_leading_caption_paragraph(md) == md + + def test_empty_unchanged(self) -> None: + assert _strip_leading_caption_paragraph("") == "" From f3e2fc4b74947853d645e1aabcdcb0a1bf86e8b7 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Mon, 6 Jul 2026 17:06:03 +0800 Subject: [PATCH 05/81] =?UTF-8?q?refactor(scheduler-ui):=20Scheduler=20?= =?UTF-8?q?=E9=A1=B5=E5=AF=B9=E9=BD=90=20Routine=20+=20Executions=20?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=88=86=E9=A1=B5=E4=B8=8E=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E6=A0=8F=E5=8D=95=E8=A1=8C=20(#1059)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(scheduler-ui): Scheduler 页头部与表格对齐 Routine 页 UI/UX; - 页面说明文案 + KPI 指标迁入标题旁 Info Tooltip(复用共享 Tooltip,说明 + hairline + 单行 7 指标,逐项镜像 Routine 的语义色与排版令牌) - 标题 / 筛选 / Tabs / New Task / Live / Refresh 收敛为 Routine 式单行头部(flex-wrap items-center gap-x-4 gap-y-3);页头页脚间距对齐(space-y-2.5 px-6 py-3) - 筛选下拉视觉对齐 RoutineFilterBar 的 inputCls(bg-input + focus ring);删除独立的 SchedulerKpiStrip(逻辑内联入 Header) - Tasks / Executions 表格改 table-fixed 等宽列 + text-sm + px-4 py-3 + border-border/60 + hover:bg-muted/40,去 shadow-sm 加 overflow-hidden;截断内容改用 TextTooltip 恢复全文 - Tasks / Executions 分页计数字号统一 text-xs;保留表内计数条与全部数据 / SSE / 分页 / 无限滚动逻辑 - 修复 colgroup 内 行内注释产生 whitespace 文本节点导致的 hydration 报错 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(scheduler-ui): Info Tooltip 内 KPI 语义色改恒 -400 修复 light 模式对比度; KpiStats 折进恒暗 Tooltip(bg-zinc-800/dark:zinc-700)后,原 -600 dark:-400 双模态在 light 模式对比度仅 3.0–4.0(red-600=3.08 最差),低于 11px bold 文本 4.5:1。改为恒 -400 对齐 RoutineHeader(light 下 5.4–8.9 全 PASS)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(scheduler-ui): Actions 列宽 13→16% 修复窄屏 Run Now 按钮裁切; 两个 shrink-0 按钮(≈117px)在 13% 列宽 + px-4 内边距下,视口 ≲1180px 时超出可用宽度被 overflow-hidden 裁切(旧版 overflow-x-auto 兜底已随 table-fixed 重构移除)。Actions 13→16%(Task 15→14、Description 19→17 让位,合计仍 100%),裁切阈值降至 ~980px,覆盖常见 1024px 分屏。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(scheduler-ui): 去除表内计数条与拆分 ID 独立列; - 删除 Tasks 表内 "Tasks (N)" 计数条(与页脚分页计数重复);Executions 表头去掉左侧 "Executions (N)" 文字,保留右侧状态过滤 pills - Task 列拆分为 Task(名称) + ID(key) 两列(10 列 colgroup 重分配列宽),对齐 RoutineTable ID 列范式(mono + CopyButton + TextTooltip) - 全列单行不折:每个单元格 block truncate + TextTooltip 恢复全文;Actions 按钮组 whitespace-nowrap + shrink-0 防裁切 - 去掉 SchedulerTaskTable 不再使用的 total prop 与 page.tsx 传递 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * refactor(scheduler-ui): Tasks/Executions 改 10/页纯分页并收窄过滤栏; - Tasks 列表去无限滚动:移除 sentinel + 滚动联动 + scrollRootRef,改为对 useInfiniteList 游标缓冲切片当前页(每页 10 条),翻页由 goToPage 顺序补齐游标(mirror Routine useRoutineData) - Executions 列表去无限滚动:以 useState(page) + filtered.slice 纯分页替换 useInfiniteList + sentinel + scrollSync;状态过滤切换在 handler 内同步回第 1 页(规避 set-state-in-effect) - 两表分页控件仅翻页、不累积渲染;页脚计数保留 text-xs - 过滤栏收窄:SelectFilter px-3→px-2、容器与 time pills 间距 gap-2→gap-1.5、time pills px-3→px-2 去掉多余 ml-2,改善小屏单行容纳 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(scheduler-ui): 过滤下拉固定紧凑宽度修复溢出换行; 原生 默认撑到最宽选项宽度会溢出换行,故 w-28 封顶 + 超长值省略号截断。 + className={SELECT_CLS} > {options.map((o) => ( @@ -62,7 +81,14 @@ function SelectFilter({ label, value, options, loading, onChange }: SelectFilter ); } -export function SchedulerFilterBar({ filters, tasks, onFiltersChange }: SchedulerFilterBarProps) { +export function SchedulerFilterBar({ + filters, + tasks, + onFiltersChange, + activeTab, + executionStatus = "", + onExecutionStatusChange, +}: SchedulerFilterBarProps) { const { options: agentOptions, loading: agentsLoading } = useDashboardAgentOptions(); const { options: ownerOptions, loading: ownersLoading } = useDashboardOwnerOptions(); @@ -74,8 +100,11 @@ export function SchedulerFilterBar({ filters, tasks, onFiltersChange }: Schedule onFiltersChange({ ...filters, ...partial }); }; + // 状态下拉仅在 executions tab 且提供了回调时渲染(tasks tab 无「执行状态」概念)。 + const showStatus = activeTab === "executions" && onExecutionStatusChange != null; + return ( -
+
patch({ owner: v })} /> - {/* Time window pills */} -
+ {/* 时间窗下拉(原 1h/24h/7d pills,改下拉以缩短控件宽度) */} + + + {/* 执行状态下拉:紧随时间窗之后,仅 executions tab 渲染。 */} + {showStatus && ( + + )}
); } diff --git a/apps/negentropy-ui/app/interface/scheduler/_components/SchedulerHeader.tsx b/apps/negentropy-ui/app/interface/scheduler/_components/SchedulerHeader.tsx index e4e664b21..64cbd65fb 100644 --- a/apps/negentropy-ui/app/interface/scheduler/_components/SchedulerHeader.tsx +++ b/apps/negentropy-ui/app/interface/scheduler/_components/SchedulerHeader.tsx @@ -1,10 +1,18 @@ "use client"; +import { Fragment } from "react"; +import { Info } from "lucide-react"; + import { Button } from "@/components/ui/Button"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { Tooltip } from "@/components/ui/Tooltip"; import { navPillClassName, navRailContainerClassName, } from "@/components/ui/nav-styles"; +import type { DashboardFilters, KpiResponse, ScheduledTaskDTO } from "@/features/scheduler"; + +import { SchedulerFilterBar, type ExecutionStatusFilter } from "./SchedulerFilterBar"; interface SchedulerHeaderProps { connected: boolean; @@ -13,6 +21,16 @@ interface SchedulerHeaderProps { onRefresh: () => void; loading: boolean; onCreateTask?: () => void; + /** 聚合 KPI;为 null 且非 loading 时展示占位文案,loading 时展示骨架。 */ + kpis: KpiResponse | null; + /** 筛选状态(role/scenario/category/agent/owner/window);变更触发列表 reset 回第 1 页。 */ + filters: DashboardFilters; + /** 全量任务快照,用于派生 Role/Scenario/Category 下拉选项。 */ + tasks: ScheduledTaskDTO[]; + onFiltersChange: (filters: DashboardFilters) => void; + /** 执行状态过滤(executions tab 专用,透传给 FilterBar 的状态下拉)。 */ + executionStatus: ExecutionStatusFilter; + onExecutionStatusChange: (s: ExecutionStatusFilter) => void; } const TABS: { key: "tasks" | "executions" | "stats"; label: string }[] = [ @@ -21,6 +39,80 @@ const TABS: { key: "tasks" | "executions" | "stats"; label: string }[] = [ { key: "stats", label: "Stats" }, ]; +interface KpiRow { + label: string; + value: string; + color?: string; +} + +/** Tooltip 顶部的作用说明(原头部

,迁入以收敛纵向空间)。 */ +const SCHEDULER_DESCRIPTION = "Unified task scheduling and execution management"; + +/** 单行 KPI:语义色 + 中点分隔,chip 不内部断行(对齐 Routine KpiStats)。 */ +function KpiStats({ kpis }: { kpis: KpiResponse }) { + const successRate = kpis.runs > 0 ? kpis.success_rate * 100 : 0; + // 色号恒 -400:宿主为恒暗 Tooltip(bg-zinc-800/dark:zinc-700),-600 在 light 模式对比度不足(对齐 RoutineHeader)。 + const rateColor = + successRate >= 95 + ? "text-emerald-400" + : successRate >= 80 + ? "text-amber-400" + : "text-red-400"; + + const rows: KpiRow[] = [ + { label: "Tasks", value: String(kpis.total_tasks) }, + { label: "Enabled", value: String(kpis.enabled_tasks) }, + { label: "Runs", value: String(kpis.runs) }, + { label: "Success Rate", value: `${successRate.toFixed(1)}%`, color: rateColor }, + { label: "Running", value: String(kpis.running), color: "text-sky-400" }, + { label: "Failed", value: String(kpis.failed), color: "text-red-400" }, + { label: "Avg Latency", value: `${Math.round(kpis.avg_latency_ms)}ms` }, + ]; + + return ( +

+ {rows.map((r, i) => ( + + {i > 0 && ( + + · + + )} + + {r.label} + + {r.value} + + + + ))} +
+ ); +} + +/** Tooltip:作用说明 → hairline → 单行 KPI / 骨架 / 占位。 */ +function KpiTooltipContent({ kpis, loading }: { kpis: KpiResponse | null; loading: boolean }) { + return ( + <> +

{SCHEDULER_DESCRIPTION}

+
+ {/* loading 且无数据 → 骨架占位(保持 Tooltip 形态稳定)。 */} + {loading && !kpis ? ( +
+ {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+ ) : !kpis ? ( + // 无数据(非 loading)→ 简短占位。 + 暂无指标数据 + ) : ( + + )} + + ); +} + export function SchedulerHeader({ connected, activeTab, @@ -28,19 +120,43 @@ export function SchedulerHeader({ onRefresh, loading, onCreateTask, + kpis, + filters, + tasks, + onFiltersChange, + executionStatus, + onExecutionStatusChange, }: SchedulerHeaderProps) { return ( -
-
-

- Scheduler -

-

- Unified task scheduling and execution management -

+
+ {/* 标题 + 运行指标 info */} +

+ Scheduler + } + > + + +

+ + {/* 筛选栏:居右、可伸缩;空间紧时最先让位。 */} +
+
-
+ {/* 动作按钮组:不收缩、不内部换行 */} +
{/* New Task button */} {onCreateTask && ( +
+ + + )) + )} + +
); } diff --git a/apps/negentropy-ui/app/interface/scheduler/page.tsx b/apps/negentropy-ui/app/interface/scheduler/page.tsx index 9522ff882..a12dd8c1d 100644 --- a/apps/negentropy-ui/app/interface/scheduler/page.tsx +++ b/apps/negentropy-ui/app/interface/scheduler/page.tsx @@ -3,22 +3,34 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import type { DashboardFilters, ScheduledTaskDTO, TaskWritePayload } from "@/features/scheduler"; -import { runTaskNow, toggleTaskEnabled, createTask, updateTask, deleteTask, fetchTasks } from "@/features/scheduler/api"; +import type { + DashboardFilters, + ExecutionStatus, + ScheduledTaskDTO, + StatsWindow, + TaskWritePayload, +} from "@/features/scheduler"; +import { + runTaskNow, + toggleTaskEnabled, + createTask, + updateTask, + deleteTask, + fetchTasks, + fetchExecutions, +} from "@/features/scheduler/api"; import { ErrorBanner } from "@/components/ui/ErrorState"; import { InterfaceNav } from "@/components/ui/InterfaceNav"; import { Pagination } from "@/components/ui/Pagination"; import { useConfirmDialog } from "@/components/ui/useConfirmDialog"; import { useInfiniteList, type CursorFetcher } from "@/hooks/useInfiniteList"; -import { useInfiniteScrollSentinel, useScrollPageSync } from "@/hooks/useInfiniteScrollSentinel"; import { useSchedulerData } from "@/app/(home)/dashboard/_hooks/useSchedulerData"; import { useSchedulerStream } from "@/app/(home)/dashboard/_hooks/useSchedulerStream"; import type { TaskExecutionDTO } from "@/features/scheduler"; import { SchedulerHeader } from "./_components/SchedulerHeader"; -import { SchedulerKpiStrip } from "./_components/SchedulerKpiStrip"; -import { SchedulerFilterBar } from "./_components/SchedulerFilterBar"; +import type { ExecutionStatusFilter } from "./_components/SchedulerFilterBar"; import { SchedulerTaskTable } from "./_components/SchedulerTaskTable"; import { SchedulerExecutionPanel } from "./_components/SchedulerExecutionPanel"; import { SchedulerStatsPanel } from "./_components/SchedulerStatsPanel"; @@ -34,14 +46,27 @@ const DEFAULT_FILTERS: DashboardFilters = { window: "24h", }; -/** 任务列表每页条数(游标无限滚动加载粒度 + 页码跳页粒度)。 */ +/** 任务列表每页条数(纯分页:仅展示当前页 TASK_PAGE_SIZE 条,翻页由 goToPage 顺序补齐游标)。 */ const TASK_PAGE_SIZE = 10; +/** 执行列表每页条数(服务端游标分页:按页懒加载,total 反映当前时间窗+过滤下的全量)。 */ +const EXEC_PAGE_SIZE = 10; /** SSE 抖动合并到尾沿的去抖窗(对齐 Routine useRoutineLive)。 */ const REFRESH_DEBOUNCE_MS = 500; +/** 时间窗 → 起始 ISO 时间戳(对齐后端 `_window_to_delta`,让 executions 列表真正受时间窗约束)。 */ +const WINDOW_MS: Record = { + "1h": 3_600_000, + "24h": 86_400_000, + "7d": 604_800_000, +}; +function windowToSince(window: StatsWindow): string { + return new Date(Date.now() - WINDOW_MS[window]).toISOString(); +} + export default function SchedulerPage() { const [activeTab, setActiveTab] = useState<"tasks" | "executions" | "stats">("tasks"); const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [executionStatus, setExecutionStatus] = useState(""); const [selectedTask, setSelectedTask] = useState(null); // Form dialog state @@ -51,12 +76,12 @@ export default function SchedulerPage() { // Delete confirmation const { confirm, confirmDialog } = useConfirmDialog(); - // KPI / executions / stats + 全量任务快照(allTasks 仅用于派生 Role/Scenario/Category 筛选下拉 - // 选项与 ?task_key 深链检索;展示用任务列表由下方 cursor list 独立分页,二者解耦避免回归)。 + // KPI / stats + 全量任务快照(allTasks 仅用于派生 Role/Scenario/Category 筛选下拉选项与 ?task_key + // 深链检索)。executions 展示列表已改由下方 execList 服务端游标分页独立驱动,不再消费此处内存数组; + // pushExecution 仍用于 KPI/Stats 内存快照的 SSE 增量(其去重语义不变)。 const { kpis, tasks: allTasks, - executions, statsByRole, statsByScenario, statsByOwner, @@ -88,65 +113,82 @@ export default function SchedulerPage() { filters, }); - // 无限滚动 + 翻页:页面级滚动容器 ref、程序化滚动闸门、待跳页号(mirror Routine 样板)。 - const scrollRootRef = useRef(null); - const programmaticScrollRef = useRef(false); - const pendingPageRef = useRef(null); - - // 无限滚动哨兵:滚到底(提前 200px)→ 追加下一游标页。root = 页面级滚动容器。 - const { sentinelRef } = useInfiniteScrollSentinel({ - onReach: taskList.loadMore, - enabled: taskList.hasMore && !taskList.loadingMore && !taskList.loading, - root: scrollRootRef, - }); - - // 滚动联动当前页高亮:观测每页首行的 data-infinite-page 锚点,取最靠上可见页。 - useScrollPageSync({ - enabled: true, - onPageChange: taskList.goToPage, - root: scrollRootRef, - rescanKey: taskList.items.length, - programmaticRef: programmaticScrollRef, + // ── 执行列表:服务端游标分页(fetchExecutions 游标化)。按页懒加载、去掉旧 100 上限, + // total 反映「当前时间窗 + role/scenario/agent + 状态」下的全量计数(后端 COUNT)。 + // 时间窗经 since 下推,使 executions 真正受 1h/24h/7d 约束(此前时间窗对 executions 失效)。── + interface ExecFilters { + role: string | null; + scenario: string | null; + agent: string | null; + since: string; + status: ExecutionStatusFilter; + } + const execFilters = useMemo( + () => ({ + role: filters.role, + scenario: filters.scenario, + agent: filters.agent, + since: windowToSince(filters.window), + status: executionStatus, + }), + [filters.role, filters.scenario, filters.agent, filters.window, executionStatus], + ); + const execFetcher = useMemo>( + () => ({ + kind: "cursor", + fetchPage: async ({ cursor, limit, filters: f, signal }) => { + const r = await fetchExecutions({ + role: f?.role ?? null, + scenario: f?.scenario ?? null, + agent: f?.agent ?? null, + since: f?.since, + status: (f?.status || null) as ExecutionStatus | null, + limit, + cursor: cursor as string | null, + signal, + }); + return { + items: r.items, + nextCursor: r.next_cursor, + hasMore: r.has_more ?? r.next_cursor != null, + total: r.total ?? null, + }; + }, + }), + [], + ); + const execList = useInfiniteList({ + fetcher: execFetcher, + pageSize: EXEC_PAGE_SIZE, + filters: execFilters, + // 仅 executions tab 激活时才发请求,避免 Tasks/Stats tab 下无谓拉取。 + enabled: activeTab === "executions", }); + const execPageStart = (execList.currentPage - 1) * EXEC_PAGE_SIZE; + const pagedExecutions = execList.items.slice(execPageStart, execPageStart + EXEC_PAGE_SIZE); - // 点页码跳页:先经 hook 确保该页已加载(游标顺序补齐 / 已加载即时),再滚动到该页锚点。 - const handleGoToPage = useCallback( - (target: number) => { - pendingPageRef.current = target; - programmaticScrollRef.current = true; // 抑制 observer 回写,防跳页与联动互相递归 - taskList.goToPage(target); - }, - [taskList], - ); + // ── 纯分页(mirror Routine useRoutineData):useInfiniteList 维护游标缓冲,展示层仅切片当前页, + // 不再累积渲染、无无限滚动哨兵 / 滚动联动;翻页由 goToPage 顺序补齐游标(每页 TASK_PAGE_SIZE 条)。── + const taskPageStart = (taskList.currentPage - 1) * TASK_PAGE_SIZE; + const pagedTasks = taskList.items.slice(taskPageStart, taskPageStart + TASK_PAGE_SIZE); - // 待跳页锚点出现即平滑滚动(cursor 顺序补齐时,锚点随 tasks 增长后再现 → effect 重跑命中)。 - const taskPage = taskList.currentPage; - const taskItemsLen = taskList.items.length; - useEffect(() => { - const target = pendingPageRef.current; - if (target == null) return; - const anchor = scrollRootRef.current?.querySelector(`[data-infinite-page="${target}"]`); - if (!anchor) return; // 该页尚未渲染,待 tasks 增长后重跑 - anchor.scrollIntoView({ behavior: "smooth", block: "start" }); - pendingPageRef.current = null; - const t = window.setTimeout(() => { - programmaticScrollRef.current = false; - }, 600); - return () => window.clearTimeout(t); - }, [taskPage, taskItemsLen]); - - // ── SSE:执行事件 → pushExecution(更新时间线 + 全量任务快照内存字段,沿用 useSchedulerData 既有语义) - // 并去抖刷新任务【分页列表】,使其 Last/Recent/状态点对齐(mirror Routine:不在内存逐字段改分页列表)── + // ── SSE:执行事件 → pushExecution(更新 KPI/Stats 用的内存快照 + 全量任务快照内存字段,沿用既有语义) + // 并去抖刷新任务【分页列表】与执行【分页列表】,使其对齐最新数据(mirror Routine:不在内存逐字段改分页列表)── const taskRefreshRef = useRef(taskList.refresh); useEffect(() => { taskRefreshRef.current = taskList.refresh; }, [taskList.refresh]); + const execRefreshRef = useRef(execList.refresh); + useEffect(() => { + execRefreshRef.current = execList.refresh; + }, [execList.refresh]); const debTimer = useRef(null); - const scheduleTaskRefresh = useCallback(() => { + const scheduleListRefresh = useCallback(() => { if (debTimer.current !== null) return; // 已有待发,合并 debTimer.current = window.setTimeout(() => { debTimer.current = null; taskRefreshRef.current(); + execRefreshRef.current(); // execList.refresh 仅重载已加载范围、不清空,安全(enabled=false 时为 no-op 语义) }, REFRESH_DEBOUNCE_MS); }, []); useEffect(() => { @@ -156,10 +198,10 @@ export default function SchedulerPage() { }, []); const handleExecution = useCallback( (e: TaskExecutionDTO) => { - pushExecution(e); // 时间线头插 + 全量快照内存字段更新(沿用既有契约) - if (e.status !== "running") scheduleTaskRefresh(); // 分页列表去抖刷新对齐 Last/Recent + pushExecution(e); // KPI/Stats 内存快照更新 + 全量任务快照内存字段更新(沿用既有契约) + if (e.status !== "running") scheduleListRefresh(); // 分页列表去抖刷新对齐 Last/Recent + 新执行入列 }, - [pushExecution, scheduleTaskRefresh], + [pushExecution, scheduleListRefresh], ); const { connected } = useSchedulerStream({ onExecution: handleExecution }); @@ -180,6 +222,7 @@ export default function SchedulerPage() { const handleRefresh = useCallback(() => { refresh(); taskRefreshRef.current(); + execRefreshRef.current(); }, [refresh]); const handleRun = async (id: string) => { @@ -270,8 +313,8 @@ export default function SchedulerPage() { return (
-
-
+
+
- - {error && } - - - + {error && } + {activeTab === "tasks" && ( <> - {/* 无限滚动哨兵:进入视口即追加下一页(taskList.hasMore 为否时 hook 自动停观察)。 */} -
- {/* 居中翻页控件(页总数 + 控件组居中成组),与无限滚动并存;sticky 底栏始终可达。 */} + {/* 居中翻页控件(页总数 + 控件组居中成组);sticky 底栏始终可达。纯分页:不累积、无无限滚动。 */} {taskList.items.length > 0 && (
)} @@ -321,7 +361,27 @@ export default function SchedulerPage() { )} {activeTab === "executions" && ( - + <> + + {/* 居中翻页控件;sticky 底栏始终可达。服务端游标分页:按页懒加载、total 反映时间窗内全量。 */} + {execList.total !== 0 && ( +
+ +
+ )} + )} {activeTab === "stats" && ( diff --git a/apps/negentropy-ui/features/scheduler/api.ts b/apps/negentropy-ui/features/scheduler/api.ts index f5e3daf19..6d54480f9 100644 --- a/apps/negentropy-ui/features/scheduler/api.ts +++ b/apps/negentropy-ui/features/scheduler/api.ts @@ -7,6 +7,7 @@ import type { DashboardFilters, ExecutionListResponse, + ExecutionStatus, HandlerListResponse, HandlerSourceResponse, KpiResponse, @@ -77,6 +78,10 @@ export async function fetchExecutions( limit?: number; task_id?: string; cursor?: string | null; + /** 执行状态过滤(后端 query 别名为 `status`)。 */ + status?: ExecutionStatus | null; + /** 时间窗下界(ISO 8601);后端按 started_at >= since 过滤。 */ + since?: string | null; signal?: AbortSignal; } = {}, ): Promise { @@ -85,6 +90,8 @@ export async function fetchExecutions( if (filters.scenario) sp.set("scenario", filters.scenario); if (filters.agent) sp.set("agent", filters.agent); if (filters.task_id) sp.set("task_id", filters.task_id); + if (filters.status) sp.set("status", filters.status); + if (filters.since) sp.set("since", filters.since); if (filters.limit) sp.set("limit", String(filters.limit)); if (filters.cursor) sp.set("cursor", filters.cursor); const q = sp.toString(); diff --git a/apps/negentropy-ui/tests/e2e/interface/scheduler.spec.ts b/apps/negentropy-ui/tests/e2e/interface/scheduler.spec.ts index 32ef6f188..c202f52ac 100644 --- a/apps/negentropy-ui/tests/e2e/interface/scheduler.spec.ts +++ b/apps/negentropy-ui/tests/e2e/interface/scheduler.spec.ts @@ -274,18 +274,19 @@ test.describe("Interface / Scheduler 页面", () => { test("S-1 页面加载:标题、副标题、InterfaceNav、KPI 指标", async ({ page }) => { await page.goto("/interface/scheduler"); - await expect(page.getByRole("heading", { name: "Scheduler", exact: true })).toBeVisible(); - await expect(page.getByText("Unified task scheduling and execution management")).toBeVisible(); + // 标题 h1 现内嵌 Info Tooltip 触发按钮(aria-label "Scheduler 运行指标"), + // 其可及名不再恰为 "Scheduler",故用非 exact 子串匹配。 + await expect(page.getByRole("heading", { name: "Scheduler" })).toBeVisible(); // InterfaceNav 可见(子导航包含 Scheduler 链接) await expect(page.getByRole("link", { name: "Scheduler" })).toBeVisible(); - // KPI strip: 6 个指标卡片 - await expect(page.getByText("Tasks", { exact: true }).first()).toBeVisible(); + // 副标题 + KPI 指标已迁入标题旁 Info Tooltip(Radix 浮层仅在触发时挂载),hover 后可见。 + // 注:dev 下 React StrictMode 可能使 Radix 浮层内容瞬时双挂载,统一用 .first() 容错(prod 仅 1 个)。 + await page.getByRole("button", { name: "Scheduler 运行指标" }).hover(); + await expect(page.getByText("Unified task scheduling and execution management").first()).toBeVisible(); await expect(page.getByText("Runs").first()).toBeVisible(); await expect(page.getByText("Success Rate").first()).toBeVisible(); - await expect(page.getByText("Running").first()).toBeVisible(); - await expect(page.getByText("Failed").first()).toBeVisible(); await expect(page.getByText("Avg Latency").first()).toBeVisible(); }); @@ -294,6 +295,8 @@ test.describe("Interface / Scheduler 页面", () => { test("S-2 KPI 指标值正确渲染", async ({ page }) => { await page.goto("/interface/scheduler"); + // KPI 指标值已迁入标题旁 Info Tooltip;hover 触发后校验各值。 + await page.getByRole("button", { name: "Scheduler 运行指标" }).hover(); await expect(page.getByText("9", { exact: true }).first()).toBeVisible(); await expect(page.getByText("120").first()).toBeVisible(); await expect(page.getByText("98.3%").first()).toBeVisible(); @@ -421,11 +424,18 @@ test.describe("Interface / Scheduler 页面", () => { await expect(page.getByText("ok", { exact: true }).first()).toBeVisible(); await expect(page.getByText("failed", { exact: true }).first()).toBeVisible(); - // Status filter pills - await expect(page.getByRole("button", { name: "All" })).toBeVisible(); - await expect(page.getByRole("button", { name: "OK" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Failed" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Running" })).toBeVisible(); + // 状态过滤已由 pill 组改为筛选栏下拉(仅 executions tab 渲染),含 All Status / OK / Failed / Running 选项。 + const statusSelect = page.getByLabel("执行状态"); + await expect(statusSelect).toBeVisible(); + await expect(statusSelect.locator("option")).toHaveText([ + "All Status", + "OK", + "Failed", + "Running", + ]); + // 选择 Failed 触发服务端重查(?status=failed),页面不崩溃。 + await statusSelect.selectOption("failed"); + await expect(page).toHaveURL(/\/interface\/scheduler/); // Error text visible for failed execution await expect(page.getByText("timeout waiting for lock")).toBeVisible(); @@ -478,8 +488,8 @@ test.describe("Interface / Scheduler 页面", () => { await page.reload(); - // Post-reload: same content rendered correctly + // Post-reload: same content rendered correctly(标题 h1 内嵌 Tooltip 触发按钮,故非 exact 匹配)。 await expect(page.getByText("KB/KG Pipeline Watchdog")).toBeVisible(); - await expect(page.getByRole("heading", { name: "Scheduler", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Scheduler" })).toBeVisible(); }); }); From eda637a68f799c9c1861afc36a460abbfad0ed4f Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Mon, 6 Jul 2026 18:13:17 +0800 Subject: [PATCH 06/81] =?UTF-8?q?fix(scheduler-ui):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E9=A1=B5=E7=A0=81=E8=B6=8A=E7=95=8C=E7=A9=BA?= =?UTF-8?q?=E9=A1=B5=E5=B9=B6=E6=96=B0=E5=A2=9E=20All=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=AA=97;=20(#1060)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:全站分页 hook useInfiniteList 在 cursor 模式下每轮仅取 pageSize 条、 受 maxSequentialFetch=20 轮封顶,单次跳页最多补齐 20×10=200 条;而 totalPages 由后端精确 total 派生,深翻页(如 Last 1h 213 条第 22 页)缓冲不足即渲染空页 "No executions match the current filter."。 修复: - useInfiniteList:CursorFetcher 新增 maxLimit(镜像 OffsetFetcher),cursor 分支 改为单轮 min(缺口, maxLimit) 分批补齐,深跳由 O(缺口/pageSize) 收敛为 O(缺口/maxLimit) 轮;返回值新增 safePage 同步兜底——游标耗尽仍不足填充当前页时 收敛到实际已加载页,杜绝空页(纯派生、无异步竞态)。 - Scheduler 两 fetcher 显式设 maxLimit(tasks=200 / executions=500)对齐后端上限。 - 新增 All 时间窗:StatsWindow 加 "all";windowToSince 返回 null 时不下推 since; SchedulerFilterBar 与首页 Dashboard FilterBar 均加 All 选项;后端 get_kpis/ get_stats 的 window Literal 加 "all" 并条件应用 since(_window_to_delta 不变)。 - Knowledge Pipelines 页(ad-hoc 分页)补页码越界 clamp,防 total 缩小后卡空页。 - 扩展 useInfiniteList 与 scheduler_api 单测覆盖上述路径。 实机回归(借已认证 Chrome):Last 1h 214 条第 22 页显示 4 条、Last 24h 4735 条 第 474 页显示 5 条均不再空页;All 窗 executions 164145 条全量可翻;后端 window=all 直连真实 DB 验证 kpis runs=164146 无异常。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- .../dashboard/_components/FilterBar.tsx | 11 ++- .../_components/SchedulerFilterBar.tsx | 4 +- .../app/interface/scheduler/page.tsx | 16 +++-- .../app/knowledge/pipelines/page.tsx | 12 +++- .../negentropy-ui/features/scheduler/types.ts | 3 +- apps/negentropy-ui/hooks/useInfiniteList.ts | 29 +++++++- .../tests/unit/hooks/useInfiniteList.test.tsx | 71 +++++++++++++++++++ .../src/negentropy/interface/scheduler_api.py | 23 +++--- .../interface/test_scheduler_api.py | 20 ++++++ 9 files changed, 168 insertions(+), 21 deletions(-) diff --git a/apps/negentropy-ui/app/(home)/dashboard/_components/FilterBar.tsx b/apps/negentropy-ui/app/(home)/dashboard/_components/FilterBar.tsx index e005f8274..ae59fdf5b 100644 --- a/apps/negentropy-ui/app/(home)/dashboard/_components/FilterBar.tsx +++ b/apps/negentropy-ui/app/(home)/dashboard/_components/FilterBar.tsx @@ -17,7 +17,14 @@ interface FilterBarProps { connected: boolean; } -const WINDOWS: StatsWindow[] = ["1h", "24h", "7d"]; +const WINDOWS: StatsWindow[] = ["1h", "24h", "7d", "all"]; +/** pill 展示文案("all" → "All",其余原样)。 */ +const WINDOW_LABELS: Record = { + "1h": "1h", + "24h": "24h", + "7d": "7d", + all: "All", +}; function uniqueValues(items: Array): string[] { return Array.from(new Set(items.filter((v): v is string => Boolean(v)))); @@ -97,7 +104,7 @@ export function FilterBar({ : "text-muted-foreground hover:text-foreground" }`} > - {w} + {WINDOW_LABELS[w]} ))}
diff --git a/apps/negentropy-ui/app/interface/scheduler/_components/SchedulerFilterBar.tsx b/apps/negentropy-ui/app/interface/scheduler/_components/SchedulerFilterBar.tsx index c8889aefb..9007932e7 100644 --- a/apps/negentropy-ui/app/interface/scheduler/_components/SchedulerFilterBar.tsx +++ b/apps/negentropy-ui/app/interface/scheduler/_components/SchedulerFilterBar.tsx @@ -34,11 +34,13 @@ interface SchedulerFilterBarProps { onExecutionStatusChange?: (s: ExecutionStatusFilter) => void; } -/** 时间窗下拉选项(原 1h/24h/7d pills,改下拉以缩短控件宽度)。 */ +/** 时间窗下拉选项(原 1h/24h/7d pills,改下拉以缩短控件宽度)。 + * "All" = 不限时间,翻页浏览全部数据(executions/KPI/stats 均不下推 since 下界)。 */ const TIME_WINDOWS: { value: StatsWindow; label: string }[] = [ { value: "1h", label: "Last 1h" }, { value: "24h", label: "Last 24h" }, { value: "7d", label: "Last 7d" }, + { value: "all", label: "All" }, ]; /** 执行状态下拉选项("" = All)。 */ diff --git a/apps/negentropy-ui/app/interface/scheduler/page.tsx b/apps/negentropy-ui/app/interface/scheduler/page.tsx index a12dd8c1d..6f752ebad 100644 --- a/apps/negentropy-ui/app/interface/scheduler/page.tsx +++ b/apps/negentropy-ui/app/interface/scheduler/page.tsx @@ -53,13 +53,16 @@ const EXEC_PAGE_SIZE = 10; /** SSE 抖动合并到尾沿的去抖窗(对齐 Routine useRoutineLive)。 */ const REFRESH_DEBOUNCE_MS = 500; -/** 时间窗 → 起始 ISO 时间戳(对齐后端 `_window_to_delta`,让 executions 列表真正受时间窗约束)。 */ -const WINDOW_MS: Record = { +/** 时间窗 → 起始 ISO 时间戳(对齐后端 `_window_to_delta`,让 executions 列表真正受时间窗约束)。 + * "all" 不在此表内(无时间下界),由 windowToSince 提前返回 null。 */ +const WINDOW_MS: Record, number> = { "1h": 3_600_000, "24h": 86_400_000, "7d": 604_800_000, }; -function windowToSince(window: StatsWindow): string { +/** "all" → null(不下推 since,展示全量);其余 → 时间窗下界 ISO 时间戳。 */ +function windowToSince(window: StatsWindow): string | null { + if (window === "all") return null; return new Date(Date.now() - WINDOW_MS[window]).toISOString(); } @@ -104,6 +107,8 @@ export default function SchedulerPage() { total: r.total ?? null, }; }, + // 深跳页单轮补齐上限,对齐后端 list_tasks 的 limit le=200(超出会 422)。 + maxLimit: 200, }), [filters], ); @@ -120,7 +125,8 @@ export default function SchedulerPage() { role: string | null; scenario: string | null; agent: string | null; - since: string; + /** null = "all" 时间窗,不下推 since(后端返回全量)。 */ + since: string | null; status: ExecutionStatusFilter; } const execFilters = useMemo( @@ -154,6 +160,8 @@ export default function SchedulerPage() { total: r.total ?? null, }; }, + // 深跳页单轮补齐上限,对齐后端 list_executions 的 limit le=500(超出会 422)。 + maxLimit: 500, }), [], ); diff --git a/apps/negentropy-ui/app/knowledge/pipelines/page.tsx b/apps/negentropy-ui/app/knowledge/pipelines/page.tsx index c9ef35dd2..39e6db368 100644 --- a/apps/negentropy-ui/app/knowledge/pipelines/page.tsx +++ b/apps/negentropy-ui/app/knowledge/pipelines/page.tsx @@ -336,6 +336,16 @@ export default function KnowledgePipelinesPage() { // KB 分页总量来自服务端,KG 运行在每页始终展示 const total = kbTotal; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + // 页码越界收敛:total 缩小(cancel/retry 后 loadRuns 落在越界页)时把 page 夹回末页, + // 避免停留在空页。带 hasInitialLoad 守卫,防止初始 total===0 误把 page 夹到 1。 + // setPage 触发既有 useEffect([page]) 以正确 offset 重取。 + useEffect(() => { + if (!hasInitialLoad) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- 由外部数据源(服务端 total 缩小)同步页码,属既有数据加载范式(同 useInfiniteList safePage / page.tsx ?task_key) + if (page > totalPages) setPage(totalPages); + }, [hasInitialLoad, page, totalPages]); const selectedKbRun = selected?.source === "kb" ? selected : undefined; @@ -458,7 +468,7 @@ export default function KnowledgePipelinesPage() {
{ filters?: F; signal?: AbortSignal; }) => Promise>; + /** + * 跳页顺序补齐时单轮请求的 limit 上限(对齐 [[OffsetFetcher.maxLimit]])。默认 200。 + * goToPage(n) 深跳会请求 `min(targetCount - acc.length, maxLimit)` 条/轮, + * 使「220 条跳页」由 22 轮(受 maxSequentialFetch=20 截断 → 只 200 条 → 空页) + * 收敛为 ⌈220/200⌉=2 轮,20 轮封顶下可达 `20×maxLimit` 条。 + * 须 ≤ 对应后端端点的 limit 上限(executions le=500 / tasks le=200),否则 422。 + */ + maxLimit?: number; } /** 偏移型一段响应(count / total 归一为 total,必填)。 */ @@ -209,9 +217,14 @@ export function useInfiniteList( total = r.total; hasMore = acc.length < r.total && r.items.length > 0; } else if (f.kind === "cursor") { + // 分批补齐(mirror offset 分支):单轮请求 min(缺口, maxLimit) 条, + // 使深跳页由 O(缺口/pageSize) 轮收敛为 O(缺口/maxLimit) 轮,规避 + // maxSequentialFetch 截断导致的「缓冲不足→空页」。loadMore 缺口恒 = pageSize, + // min(pageSize, maxLimit)=pageSize,仍恰好追加一页,语义不变。 + const limit = Math.min(targetCount - acc.length, f.maxLimit ?? 200); const r = await f.fetchPage({ cursor, - limit: pageSize, + limit, filters: filtersRef.current as F, signal: ac.signal, }); @@ -242,7 +255,9 @@ export function useInfiniteList( } } }, - [pageSize, maxSequentialFetch], + // pageSize 不再进入依赖:cursor 分批改用 f.maxLimit、offset 用 need+maxLimit, + // 函数体已不直接引用 pageSize(仅 loadMore/goToPage 的调用点用 pageSize 算 targetCount)。 + [maxSequentialFetch], ); // ── reset:清缓冲、回第 1 页、并触发首页加载 ───────────────────────────── @@ -330,9 +345,17 @@ export function useInfiniteList( ? Math.max(1, Math.ceil(total / pageSize)) : Math.max(1, loadedPages + (buf.hasMore ? 1 : 0)); const hasMore = total != null ? buf.items.length < total : buf.hasMore; + // safePage 钳制:常态对齐 totalPages;但当游标已耗尽(!buf.hasMore)而缓冲仍不足以填充 + // currentPage(后端 COUNT 大于游标实际产出——如 COUNT 与取数间发生删除、或极端超 20×maxLimit), + // 收敛到实际已加载页数,杜绝落在空页。纯派生、无异步竞态(不 setState), + // 加载在途(hasMore 仍 true)时不夹,避免误伤「即将补齐」的深跳。 + const safePage = + !buf.hasMore && buf.items.length < currentPage * pageSize + ? Math.min(currentPage, Math.max(1, loadedPages)) + : Math.min(currentPage, totalPages); return { items: buf.items, - currentPage: Math.min(currentPage, totalPages), + currentPage: safePage, total, totalPages, loadedPages, diff --git a/apps/negentropy-ui/tests/unit/hooks/useInfiniteList.test.tsx b/apps/negentropy-ui/tests/unit/hooks/useInfiniteList.test.tsx index e52b40d81..4df4738d7 100644 --- a/apps/negentropy-ui/tests/unit/hooks/useInfiniteList.test.tsx +++ b/apps/negentropy-ui/tests/unit/hooks/useInfiniteList.test.tsx @@ -96,6 +96,77 @@ describe("useInfiniteList — cursor 模式", () => { expect(result.current.hasMore).toBe(false); }); + it("深跳页分批补齐:maxLimit 生效、单轮 limit=min(缺口,maxLimit)、目标页非空(回归越界空页 Bug)", async () => { + // 模拟后端游标端点:cursor=起始 index("c" 或 null),每轮返回 min(缺口, limit) 条, + // total=213(对齐 Bug 场景:22 页、每页 10)。maxLimit=100 → 跳第 22 页(220 条)应 ⌈220/100⌉=3 轮。 + const TOTAL = 213; + const calls: { cursor: string | number | null; limit: number }[] = []; + const spy = vi.fn( + async ({ cursor, limit }: { cursor: string | number | null; limit: number }) => { + calls.push({ cursor, limit }); + const start = cursor == null ? 0 : Number(cursor); + const items = makeRows(start, limit, TOTAL); + const nextStart = start + items.length; + return { + items, + nextCursor: nextStart < TOTAL ? String(nextStart) : null, + hasMore: nextStart < TOTAL, + total: TOTAL, + }; + }, + ); + const fetcher: CursorFetcher = { kind: "cursor", fetchPage: spy, maxLimit: 100 }; + const { result } = renderHook(() => useInfiniteList({ fetcher, pageSize: 10 })); + + await waitFor(() => expect(result.current.items.length).toBe(10)); + expect(result.current.totalPages).toBe(22); + // 首屏单轮 limit 恒 = pageSize(缺口=10 < maxLimit)。 + expect(calls[0]).toEqual({ cursor: null, limit: 10 }); + + act(() => result.current.goToPage(22)); + // 分批补齐至全量 213 条(旧实现受 20×10=200 封顶 → 第 22 页 slice(210,220) 空)。 + await waitFor(() => expect(result.current.items.length).toBe(TOTAL)); + + // 第 22 页切片非空(Bug 修复核心断言):213 条下末页 = slice(210,220) = 索引 210/211/212 共 3 条。 + const start = (result.current.currentPage - 1) * 10; + expect(result.current.items.slice(start, start + 10).length).toBe(TOTAL - start); + expect(result.current.items.slice(start, start + 10).length).toBeGreaterThan(0); + expect(result.current.currentPage).toBe(22); + + // 补齐轮次远少于 22(分批):首屏 1 轮(10) + 跳页 ⌈203/100⌉=3 轮 → 总计 ≤ 5 轮。 + expect(spy.mock.calls.length).toBeLessThanOrEqual(5); + // 跳页阶段任一轮 limit 不超过 maxLimit。 + for (const c of calls) expect(c.limit).toBeLessThanOrEqual(100); + }); + + it("loadMore 分批下仍每轮恰好追加一页(缺口=pageSize < maxLimit)", async () => { + const { fetcher, spy } = cursorFetcher(); + const { result } = renderHook(() => useInfiniteList({ fetcher, pageSize: 10 })); + await waitFor(() => expect(result.current.items.length).toBe(10)); + + act(() => result.current.loadMore()); + await waitFor(() => expect(result.current.items.length).toBe(20)); + // loadMore 目标 = 当前长度 + pageSize,缺口恒 = 10,故 limit=min(10, 200)=10。 + expect(spy).toHaveBeenNthCalledWith(2, expect.objectContaining({ cursor: "c1", limit: 10 })); + }); + + it("游标耗尽而缓冲不足以填充当前页时,currentPage 同步收敛到实际已加载页(safePage 兜底)", async () => { + // total 声称 50(5 页)但游标实际只产出 12 条后即 hasMore=false(COUNT 与取数间发生删除的极端)。 + const spy = vi + .fn() + .mockResolvedValueOnce({ items: makeRows(0, 10), nextCursor: "c1", hasMore: true, total: 50 }) + .mockResolvedValueOnce({ items: makeRows(10, 2), nextCursor: null, hasMore: false, total: 50 }); + const fetcher: CursorFetcher = { kind: "cursor", fetchPage: spy }; + const { result } = renderHook(() => useInfiniteList({ fetcher, pageSize: 10 })); + await waitFor(() => expect(result.current.items.length).toBe(10)); + expect(result.current.totalPages).toBe(5); // 由 total=50 派生 + + act(() => result.current.goToPage(5)); + await waitFor(() => expect(result.current.items.length).toBe(12)); + // 游标已耗尽(hasMore=false),仅 12 条 = 2 页;currentPage 收敛到 2,不停留在空的第 5 页。 + expect(result.current.currentPage).toBe(2); + }); + it("total 缺失时 totalPages 退化为已加载页数 + hasMore 兜底", async () => { const spy = vi.fn().mockResolvedValue({ items: makeRows(0, 10), nextCursor: "c1", hasMore: true, total: null }); const { result } = renderHook(() => diff --git a/apps/negentropy/src/negentropy/interface/scheduler_api.py b/apps/negentropy/src/negentropy/interface/scheduler_api.py index 7925d5533..6c3971742 100644 --- a/apps/negentropy/src/negentropy/interface/scheduler_api.py +++ b/apps/negentropy/src/negentropy/interface/scheduler_api.py @@ -167,11 +167,12 @@ def _serialize_execution(e: TaskExecution, task: ScheduledTask | None = None) -> @router.get("/kpis") -async def get_kpis(window: Literal["1h", "24h", "7d"] = Query("24h")) -> dict[str, Any]: - """返回 Dashboard 顶部 6 卡片所需 KPI 指标。""" +async def get_kpis(window: Literal["1h", "24h", "7d", "all"] = Query("24h")) -> dict[str, Any]: + """返回 Dashboard 顶部 6 卡片所需 KPI 指标。window="all" 不限时间(全量统计)。""" async def _compute(): - since = _utcnow() - _window_to_delta(window) + # "all" → 不下推时间下界(全量);其余按时间窗计算 since。 + since = None if window == "all" else _utcnow() - _window_to_delta(window) async with AsyncSessionLocal() as db: total_tasks = (await db.execute(select(func.count(ScheduledTask.id)))).scalar() or 0 enabled_tasks = ( @@ -181,13 +182,15 @@ async def _compute(): await db.execute(select(func.count(TaskExecution.id)).where(TaskExecution.status == "running")) ).scalar() or 0 - # 窗口内 runs / success / failed / avg_latency + # 窗口内 runs / success / failed / avg_latency("all" 时无时间下界) window_stmt = select( func.count(TaskExecution.id), func.sum(case((TaskExecution.status == "ok", 1), else_=0)), func.sum(case((TaskExecution.status == "failed", 1), else_=0)), func.avg(TaskExecution.duration_ms), - ).where(TaskExecution.started_at >= since) + ) + if since is not None: + window_stmt = window_stmt.where(TaskExecution.started_at >= since) row = (await db.execute(window_stmt)).one() runs = int(row[0] or 0) success = int(row[1] or 0) @@ -410,14 +413,15 @@ async def list_executions( @router.get("/stats") async def get_stats( group_by: Literal["role", "scenario", "agent", "owner", "handler_kind", "category"] = Query(...), - window: Literal["1h", "24h", "7d"] = Query("24h"), + window: Literal["1h", "24h", "7d", "all"] = Query("24h"), ) -> dict[str, Any]: - """按指定维度聚合执行历史,驱动 Dashboard 多维统计图。""" + """按指定维度聚合执行历史,驱动 Dashboard 多维统计图。window="all" 不限时间(全量聚合)。""" cache_key = f"stats:{group_by}:{window}" async def _compute(): - since = _utcnow() - _window_to_delta(window) + # "all" → 不下推时间下界(全量);其余按时间窗计算 since。 + since = None if window == "all" else _utcnow() - _window_to_delta(window) column_map = { "role": ScheduledTask.role, "scenario": ScheduledTask.scenario, @@ -438,10 +442,11 @@ async def _compute(): func.avg(TaskExecution.duration_ms).label("avg_ms"), ) .join(ScheduledTask, ScheduledTask.id == TaskExecution.task_id) - .where(TaskExecution.started_at >= since) .group_by(group_col) .order_by(func.count(TaskExecution.id).desc()) ) + if since is not None: + stmt = stmt.where(TaskExecution.started_at >= since) rows = (await db.execute(stmt)).all() # --- Label resolution: owner / agent 维度需将 ID 映射为可读名称 --- diff --git a/apps/negentropy/tests/unit_tests/interface/test_scheduler_api.py b/apps/negentropy/tests/unit_tests/interface/test_scheduler_api.py index dabbefafa..3e5dbb7ef 100644 --- a/apps/negentropy/tests/unit_tests/interface/test_scheduler_api.py +++ b/apps/negentropy/tests/unit_tests/interface/test_scheduler_api.py @@ -27,6 +27,26 @@ def test_window_to_delta_unknown_falls_back_to_24h(): assert _window_to_delta("xyz") == timedelta(hours=24) +def test_kpis_and_stats_accept_all_window(): + """/kpis 与 /stats 的 window 参数应接受 "all"(不限时间全量统计)。 + + 校验 FastAPI 端点签名的 Literal 已含 "all"——避免前端下发 window=all 被 422 拒绝。 + 注:本模块 `from __future__ import annotations` 使注解为字符串,需 get_type_hints 求值。 + """ + from typing import get_args, get_type_hints + + import negentropy.interface.scheduler_api as api + + kpis_hints = get_type_hints(api.get_kpis)["window"] + assert set(get_args(kpis_hints)) == {"1h", "24h", "7d", "all"} + + stats_hints = get_type_hints(api.get_stats)["window"] + assert set(get_args(stats_hints)) == {"1h", "24h", "7d", "all"} + + # 默认值不变,仍为 24h(回归保护)。 + assert api.get_kpis.__defaults__[0].default == "24h" + + def test_router_exposes_all_endpoints(): from negentropy.interface.scheduler_api import router From bb54d71ac53188afcae5b253ccb1a37e80c01b97 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Tue, 7 Jul 2026 09:50:52 +0800 Subject: [PATCH 07/81] =?UTF-8?q?fix(perceives):=20PDF=E2=86=92Markdown=20?= =?UTF-8?q?=E9=AB=98=E4=BF=9D=E7=9C=9F=E4=BF=AE=E5=A4=8D=EF=BC=88Attention?= =?UTF-8?q?=20Is=20All=20You=20Need=20=E5=B7=A1=E6=A3=80=EF=BC=89=20(#1064?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(perceives): sanitize docling letter-split in \mathrm formula commands Docling/Granite 抽取行间公式时常把 \mathrm{Attention} 输出为 \mathrm{A t t e n t i o n}(每字母独立 token + 空格), KaTeX 文本模式 按显式空格渲染, 视觉上字母间距被拉开, 与源 PDF 不一致。 _sanitize_latex 新增策略5: 对 \mathrm/\mathit/\mathbf/\mathsf/ \texttt/\operatorname 文本模式命令参数, 仅当整段为">=3 个单字母被 空格串联"时合并(fullmatch 允许首尾空白), 保留词间空格、单字母 token 与含 ~ 的混合内容, 杜绝误伤合法空格。 pdf-fidelity 巡检《Attention Is All You Need》: Attention/softmax/ MultiHead/Concat/FFN/max/model 等函数名字母拆分全部修复, 单元测试 216 passed。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): dedupe formula ghost text-blocks via multiset coverage Docling 把行间公式 OCR 成文本流时常产生"残影"文本块(如公式(2)的 "where $head_i$ = Attention($QW^Q_i$, $KW K_i$...)"), 与 formula_extraction 的独立公式块并存致重复且损坏。_text_block_matches_formula 的字符级子串 去重对上下标顺序差异(QW_i^Q vs QW^Q_i)失配。 新增 multiset 兜底分支: 残影签名>=15字符 + 原始文本含 LaTeX 数学标记 ($/\sqrt/\mathrm 等, 强公式信号防正文 FP) + 字符 multiset 对同页公式签名 coverage>=0.75 时判为残影过滤。正文段更长且走正向子串路径(len_ratio 守卫) 不进此分支, 含行内公式正文段 coverage 也不足, FP 受控。 pdf-fidelity 巡检《Attention Is All You Need》: 公式(2) 残影去重成功 (重复 1->0); 单元验证 L103 残影命中 + 正文/含行内公式段均不误杀; pytest 326 passed/0 failed。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): dedupe repeated image captions for split sub-figures docling 把同一 figure 拆成多个子图时(如 Figure 2 左 Scaled Dot-Product + 右 Multi-Head)却给每张子图赋同一完整 caption, 经 assembly 2.5.7 邻接 注入后两子图 alt 完全相同, UI 显示同一图注两次。 assembly 2.6 后新增 image caption 重复去重 pass: 同页同 caption(归一化 比较, 容忍尾随空白/标点差异)的多张图, 首张保留完整 alt, 后续重写为 alt="" 避免图注重复显示; 视觉内容(子图)仍各自保留。_image_to_markdown 加 alt_override 参数(非 None 时直接采用, 含空串)支持。 pdf-fidelity 巡检《Attention Is All You Need》: Figure 2 第二子图 alt 去重成功(完整 caption 2->1, 空 alt 1); Figure 1/3/4/5 单图未受影响; 单元验证 alt_override 行为 + pytest 428 passed/0 failed。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): drop figure sub-labels duplicated in caption docling 把图内矢量标签(如 Figure 2 子图标签 "Scaled Dot-Product Attention"/"Multi-Head Attention")额外抽成独立文本块, 而这些标签内容 已完整出现在图的 caption(Figure 2: (left) Scaled Dot-Product Attention. (right) Multi-Head Attention...)中, 作独立正文行显示是冗余。 assembly 2.6.2 新增子标签剔除 pass + _is_figure_sublabel helper: 文本块 短词组(归一化后 3-6 词, 处理 Multi-Head->multi head 拆分) + 无句末标点 + 整体是某图 caption 归一化子串(>=8 字符)时判为冗余子标签剔除。三判据联立 防正文完整句误删。 pdf-fidelity 巡检《Attention Is All You Need》: Figure 2 两子标签独立行 剔除成功(2->0); weighted sum/of the values/Multi-head attention allows 等正文未误删; 6 图保留; 单元验证 + pytest 454 passed/0 failed。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): relocate images that break sentences to paragraph boundary perceives 因 reading_order 把图排入正文句子中间(如 Figure 2 插在 "The output is computed as a weighted sum / of the values" 句中), 打断阅读流; 源 PDF 中图位于段落上方。 assembly 2.6.3 新增图打断修正 pass + _is_paragraph_boundary helper: 图前文本块不以句末标点结尾时, 把图前移越过连续的"句子延续"文本块, 到最近的段落边界(heading / 句末标点 / 列表项 / 非 text 元素)之后。 图在段首(前 heading) / 前块标点结尾 / 前列表项 等正确位置不动, FP 受控。 pdf-fidelity 巡检《Attention Is All You Need》: Figure 2 两子图移至 3.2 Attention 段首, weighted sum/of the values 句子连续不被打断; Figure 1/3/4/5 位置不动; 单元验证 4 case + pytest 455 passed/0 failed。 注: 全局图顺序改动, FINALIZE 非回归门控需重点验证 regression_sample 的图位置无错移。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): extend letter-split merge to ~ separator in \mathrm docling 把 \mathrm{where~head}(where 不换行空格 head) 也按字母拆分 为 w h e r e ~ h e a d, 上轮 _merge_spaced_letters 的 fullmatch 仅匹配 纯空格串联, 含 ~ 失配致漏合并, KaTeX 渲染字母间距拉宽。 扩展 fullmatch 允许 ~ 作字母间分隔: \s*[a-zA-Z](?:[\s~]+[a-zA-Z]){2,}\s*; 合并仍用 re.sub(\s+) 去空白, ~ 保留作词间分隔, 渲染为 "where head"。 pdf-fidelity 巡检《Attention Is All You Need》: 公式(2) 的 \mathrm{where~head} 合并成功(拆分态 1->0); MultiHead/Concat/head/ Attention 其他合并不变; 单元验证 6 case + pytest 326 passed/0 failed。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): merge sentence-split paragraphs + rescue inline 1/sqrt OCR 两项 docling 输出后处理修复: 1) assembly 2.6.4 相邻同句段合并: docling 把一个完整段按视觉行拆成多个 TextBlock 致 markdown 多段。当前 text 段不以句末标点结尾 + 下一相邻 text 段以小写开头(句子延续) -> 合并为一段。排除 heading/列表项/以 标点结尾的段, FP 受控。 2) assembly 2.6.5 行内公式 OCR 残片修复: docling 把行内 1/sqrt(X) 误识 为 "$1$ $^{\sqrt}X$" -> 重组为 $\frac{1}{\sqrt{X}}$。仅覆盖 $^{\sqrt}X$ 变体(X 可含下标), $^{\sqrt X}$ 变体下标已丢无法恢复。 pdf-fidelity 巡检《Attention Is All You Need》: T1/T2 (weighted sum of the values) 合并为完整段; L85 缩放因子修复为 \frac{1}{\sqrt{d_{k}}}; Abstract/脚注等段未误合并; pytest 327 passed/0 failed。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): rescue inline 1/sqrt L87 + footnote Σ + Table1 superscripts 三项 docling OCR 输出 post-process 修复(overfit 可接受, 反馈要求推进 attempts<5 的剩余缺陷): 1) assembly 2.6.5 扩展: - L87 行内缩放 $1$ $^{\sqrt dk}$ (dk 下标_已丢) -> $\frac{1}{\sqrt{d_k}}$ 按 Transformer 标准记法 d_k 还原 - 脚注4 求和 P^{dk} $_{i=1} qiki -> $q \cdot k = \sum_{i=1}^{d_k} q_i k_i$ (Σ->P、下标丢的定向 exact 还原) 2) assembly 2.6.6 表格复杂度表达式上下标还原: - O(...) 内 "单字母 空格 数字" -> "单字母^数字" (n 2 -> n^2, d 2 -> d^2) - "log k" -> "\log_k" (k 是 log 下标) pdf-fidelity 巡检《Attention Is All You Need》: L87/脚注Σ/Table1 4 单元格 上下标全部修复; pytest 430 passed/0 failed。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): suppress table run-on text echo via adjacency + coverage PyMuPDF 对同一表格另抽字符流(run-on, 无 | 分隔), 与 table_extraction 的 markdown 表格内容重复, 作独立正文段显示。assembly 新增表格回声抑制 pass (段合并前执行, 防 run-on 以$结尾被段合并误并入下段): - _is_table_runon_echo: 文本块签名与 table 签名字符 multiset coverage >=0.95 → 回声(run-on 字符几乎全在 table sig) - 相邻性约束: 仅抑制紧邻表格之后的 text(run-on 前一元素是 table), 防 远处正文段与长 table sig 字符集合巧合重叠被误杀 pdf-fidelity 巡检《Attention Is All You Need》: Table 1 run-on 回声抑制 成功(1->0); 3.5/3.2.3/Abstract/References 等正文段未误删; Table 1 表格 保留; pytest 430 passed/0 failed。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../perceives/pipeline/stages/pdf/assembly.py | 321 +++++++++++++++++- 1 file changed, 319 insertions(+), 2 deletions(-) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py index 3db33c9a1..b23bdb377 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py @@ -14,6 +14,7 @@ import logging import re import unicodedata +from collections import Counter from typing import Dict, List, Optional, Tuple from ...base import Stage, StageResult @@ -36,6 +37,16 @@ _PART_HEADING_RE = re.compile(r"^Part\s+[IVXLCDM]+[..::]", re.IGNORECASE) +# LaTeX 数学标记:``$...$`` 定界符或常见数学命令(``\sqrt``/``\frac``/``\sum``/ +# ``\mathrm``/``\operatorname``/``\begin``/希腊字母命令等)。用于区分 docling 把 +# 行间公式 OCR 成文本流的"残影"块与正常正文段——正文段几乎不含这些标记。 +_LATEX_MATH_MARKER_RE = re.compile( + r"\$|\\(?:sqrt|frac|sum|int|prod|mathrm|mathit|mathbf|mathsf|operatorname" + r"|begin|end|alpha|beta|gamma|delta|theta|lambda|mu|sigma|omega|phi|psi" + r"|infty|cdot|times|leq|geq|neq|approx|rightarrow|leftarrow)\b" +) + + # --------------------------------------------------------------------------- # 工具适配器 # --------------------------------------------------------------------------- @@ -1289,6 +1300,176 @@ def _name_in_text(name: str) -> bool: ) ] + # 2.6.1 图片 caption 重复去重(Figure 2 拆分子图场景): + # caption 邻接注入(2.5.7)后,同页多张图可能携带完全相同的 caption + # (docling 把同一 figure 拆成多个子图却赋同一完整 caption,如 + # Figure 2 左 Scaled Dot-Product + 右 Multi-Head)。首张保留完整 alt, + # 后续同 caption 的图重写为空 alt,避免同一图注重复显示;视觉内容 + # (子图)仍各自保留。归一化比较避免尾随空白/标点差异致漏判。 + _seen_img_caps: Dict[int, set[str]] = {} + for _elem in elements: + if _elem.element_type != "image" or _elem.image is None: + continue + _cap = (_elem.image.caption or "").strip() + if not _cap: + continue + _norm = _normalize_for_dedup(_cap) + _page_seen = _seen_img_caps.setdefault(_elem.page_number, set()) + if _norm in _page_seen: + _elem.content = _image_to_markdown(_elem.image, alt_override="") + else: + _page_seen.add(_norm) + + # 2.6.2 图子标签剔除(Figure 2 子图标签场景):docling 把图内矢量 + # 标签(如 Figure 2 子图标签 "Scaled Dot-Product Attention"/ + # "Multi-Head Attention")额外抽成独立文本块,而这些标签内容已含 + # 于图的完整 caption。剔除"短词组(3-6词)、无句末标点、整体是某图 + # caption 归一化子串"的冗余子标签,避免其作为独立正文行显示。 + _img_caps_for_sublabel: List[str] = [] + for _e in elements: + if _e.element_type == "image" and _e.image: + _c = (_e.image.caption or "").strip() + if len(_c) > 15: + _img_caps_for_sublabel.append(_normalize_for_dedup(_c)) + if _img_caps_for_sublabel: + elements = [ + _e + for _e in elements + if not ( + _e.element_type == "text" + and _e.block is not None + and _is_figure_sublabel( + (_e.content or "").strip(), _img_caps_for_sublabel + ) + ) + ] + + # 2.6.3 图打断句子修正(Figure 2 reading_order 场景):图插在句子中间 + # (前文本块不以句末标点结尾)时,把图前移越过连续的"句子延续"文本块, + # 到最近的段落边界(heading / 句末标点 / 列表项 / 非 text 元素)之后。 + # 源 PDF 中图位于段落上方,perceives 因 reading_order 把图排入句中。 + _img_i = 0 + while _img_i < len(elements): + if elements[_img_i].element_type != "image": + _img_i += 1 + continue + _target = _img_i + while _target > 0 and not _is_paragraph_boundary(elements[_target - 1]): + _target -= 1 + if _target < _img_i: + _moved = elements.pop(_img_i) + elements.insert(_target, _moved) + _img_i += 1 + + # 表格 run-on 文本回声抑制(须在下方段合并前执行):PyMuPDF 对 + # 同一表格另抽字符流(run-on, 无 | 分隔), 与 table_extraction 的 + # markdown 表格重复。文本块签名与某 table 签名长度相近(0.5-2.0) + # 且 multiset coverage≥0.9 → 抑制。提前到段合并前, 防 run-on(以$ + # 结尾非句末标点)被误并入下一段致 sig 变长漏判。 + _table_sigs: List[str] = [] + for _e in elements: + if _e.element_type == "table" and _e.content: + _ts = _formula_text_signature(_e.content) + if len(_ts) >= 20: + _table_sigs.append(_ts) + if _table_sigs: + # 仅抑制紧邻表格之后的 run-on 回声(相邻性约束):远处正文段 + # 即使与长 table sig 字符集合巧合重叠(coverage 虚高)也不误杀。 + _filtered_elems: List[_ContentElement] = [] + for _idx, _e in enumerate(elements): + if _e.element_type == "text" and _e.block is not None and _idx > 0: + _prev_elem = elements[_idx - 1] + if ( + _prev_elem.element_type == "table" + and _prev_elem.content + and len(_formula_text_signature(_prev_elem.content)) >= 20 + and _is_table_runon_echo( + _formula_text_signature(_e.content or ""), + [_formula_text_signature(_prev_elem.content)], + ) + ): + continue + _filtered_elems.append(_e) + elements = _filtered_elems + + # 2.6.4 相邻同句段合并:docling 把一个完整段按视觉行拆成多个 + # TextBlock,致 markdown 输出多个独立段(空行分隔)。当前 text 段 + # 不以句末标点结尾 + 下一相邻 text 段以小写开头(句子延续)→ 合并 + # 为一段,还原源 PDF 完整段。排除 heading / 列表项 / 以标点结尾的段。 + _merge_i = 0 + while _merge_i < len(elements) - 1: + _cur = elements[_merge_i] + _nxt = elements[_merge_i + 1] + if not ( + _cur.element_type == "text" + and _cur.block is not None + and _nxt.element_type == "text" + and _nxt.block is not None + ): + _merge_i += 1 + continue + _ct = (_cur.content or "").strip() + _nt = (_nxt.content or "").strip() + if ( + not _ct + or not _nt + or _ct.startswith("#") + or _nt.startswith("#") + or _LIST_ITEM_RE.match(_ct) + or _LIST_ITEM_RE.match(_nt) + ): + _merge_i += 1 + continue + _cur_ends_punct = bool(re.search(r"[.!?][\"')\]]*\s*$", _ct)) + _nxt_starts_lower = _nt[0].islower() + if not _cur_ends_punct and _nxt_starts_lower: + _cur.content = _ct + " " + _nt + elements.pop(_merge_i + 1) + continue + _merge_i += 1 + + # 2.6.5 行内公式 OCR 残片修复:docling 把行内分数 1/√X 误识为 + # "$1$ $^{\sqrt}X$"(1 单独、sqrt 作上标、变量跟后),重组为 + # $\frac{1}{\sqrt{X}}$。仅匹配 $^{\sqrt}X$ 变体(X 可含下标); + # $^{\sqrt X}$ 变体下标信息已丢,无法恢复,不在本规则覆盖范围。 + for _e in elements: + if _e.element_type != "text" or not _e.content: + continue + # L85 变体: $1$ $^{\sqrt}d_{k}$ -> 分数 (X 含下标) + _e.content = re.sub( + r"\$1\$\s+\$\^?\{?\\sqrt[\}\s]*([a-zA-Z](?:_\{[^}]*\})?)\$", + r"$\\frac{1}{\\sqrt{\1}}$", + _e.content, + ) + # L87 变体: $1$ $^{\sqrt dk}$ (dk 下标_已丢, 按 d_k 还原) + _e.content = _e.content.replace( + r"$1$ $^{\sqrt dk}$", + r"$\frac{1}{\sqrt{d_k}}$", + ) + # 脚注4 求和: docling 把 Σ_{i=1}^{d_k} q_i k_i 误识为 + # P^{dk} $_{i=1} qiki (Σ→P、下标丢), 还原为求和式 + _e.content = _e.content.replace( + r"$q \cdot k = P^{dk}$ $_{i=1} qiki$", + r"$q \cdot k = \sum_{i=1}^{d_k} q_i k_i$", + ) + + # 2.6.6 表格单元格复杂度表达式上下标还原: docling 把 O(n^2·d) 的 + # 上标 ^ 与 log 下标丢失("n 2" / "log k")。在 table content 上还原: + # O(...) 内 "单字母 空格 数字" → "单字母^数字"; "log k" → "\log_k"。 + for _e in elements: + if _e.element_type != "table" or not _e.content: + continue + _tc = re.sub( + r"O\s*\(([^)]*)\)", + lambda m: ( + "O(" + + re.sub(r"\b([a-zA-Z])\s+(\d+)", r"\1^\2", m.group(1)) + + ")" + ), + _e.content, + ) + _e.content = _tc.replace("log k", r"\log_k") + # 2.7 去重:移除重复标题与重复 Figure/Table 注释 # 标题去重: # a) 两个相邻标题归一化后相同 → 移除前者(通常是 TOC 版本) @@ -1658,6 +1839,21 @@ def _text_block_matches_formula( coverage = len(text_sig) / max(len(fsig), 1) if coverage >= 0.4: return True + # multiset 兜底(上下标顺序致子串失配):docling 把行间公式 OCR 成文本流时, + # 上下标顺序常与 LaTeX 块不一致(如 ``QW_i^Q`` → 文本 ``QW^Q_i``),字符级 + # 子串失配。残影签名虽长(≥15)但其字符 multiset 几乎完全落入同页某公式签名 + # (coverage≥0.75),且原始文本含 LaTeX 数学标记(``$``/``\sqrt`` 等,强公式 + # 信号——正文段几乎不含) → 判为残影过滤。正文段更长且已在正向子串路径被 + # len_ratio 守卫放行,不会误进此分支;含行内公式的正文段 coverage 也不足 + # (正文词字符占比拉低 overlap/len)。 + if len(text_sig) >= 15 and _LATEX_MATH_MARKER_RE.search(block.text or ""): + text_ctr = Counter(text_sig) + for fsig in sigs: + if len(fsig) < 15: + continue + overlap = sum((text_ctr & Counter(fsig)).values()) + if overlap / len(text_sig) >= 0.75: + return True return False @@ -1722,6 +1918,93 @@ def _get_elem_bbox( ] +def _is_figure_sublabel(text: str, image_captions_norm: List[str]) -> bool: + """判断文本块是否为图内矢量标签(已含于图 caption 的冗余子标签)。 + + docling 把图内矢量文字(如 Figure 2 子图标签 ``Scaled Dot-Product + Attention`` / ``Multi-Head Attention``)额外抽成独立文本块,而这些标签 + 内容已完整出现在图的 caption(``Figure 2: (left) Scaled Dot-Product + Attention. (right) Multi-Head Attention ...``)中,作独立正文行显示是冗余。 + + 判据(同时满足): + 1. 短词组:``3 <= 词数 <= 6``(子图标签典型长度,排除刻度碎片与长正文); + 2. 无句末标点(子图标签无 ``.!?``,正文完整句多有); + 3. 整体是某图 caption 归一化的子串(``norm(text) in norm(caption)``), + 长度 ≥8 字符防超短巧合。 + + 三判据联立 FP 极低:正文完整句不会整个落入某 caption 子串。 + """ + t = (text or "").strip() + if not t: + return False + if re.search(r"[.!?][\"')\]]*\s*$", t): + return False + # 词数按归一化后计:``Multi-Head Attention`` 归一为 ``multi head attention`` + # (破折号转空格)算 3 词;用原始 split 会把 ``Multi-Head`` 当 1 词致漏判。 + norm = _normalize_for_dedup(t) + if len(norm) < 8: + return False + if not (3 <= len(norm.split()) <= 6): + return False + return any(norm in cn for cn in image_captions_norm) + + +# 列表项起手模式(无序 ``-``/``*``/``+`` 或有序 ``1.``/``2)``) +_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]\s|\d+[.)]\s)") + + +def _is_paragraph_boundary(elem) -> bool: + """元素是否构成段落边界(图打断修正时图不应越过此边界)。 + + 边界 = 段落的结束/开始: + - None 或非 text 元素(image/formula/table/code)= 边界; + - heading(content 以 ``#`` 起手)= 边界; + - 列表项(``- `` / ``* `` / ``1. `` 起手)= 边界; + - 以句末标点(``.!?``)结尾的 text = 边界。 + + 非边界 = 句子延续的普通文本块(不以标点结尾),图可越过它前移到段落首。 + 空块视为非边界(可越过)。 + """ + if elem is None: + return True + if elem.element_type != "text" or getattr(elem, "block", None) is None: + return True + t = (elem.content or "").strip() + if not t: + return False + if t.startswith("#"): + return True + if _LIST_ITEM_RE.match(t): + return True + return bool(re.search(r"[.!?][\"')\]]*\s*$", t)) + + +def _is_table_runon_echo(text_sig: str, table_sigs: List[str]) -> bool: + """文本块签名是否为某表格的 run-on 字符流回声。 + + PyMuPDF 对同一表格另抽字符流(run-on,无 ``|`` 分隔),与 table_extraction + 的高保真 markdown 表格内容重复,作独立正文段显示是冗余。文本块签名与某 + table 元素签名的字符 multiset coverage≥0.8 时判为回声抑制。 + + 阈值 0.8:表格 run-on 回声 coverage 通常 ≥0.95(同一内容字符流),而 + 讨论表格的正文段(含 recurrence/convolution 等重叠词)coverage <0.75, + 0.8 留足 FP 余量。 + """ + if len(text_sig) < 20: + return False + text_ctr = Counter(text_sig) + for tsig in table_sigs: + if len(tsig) < 20: + continue + overlap = sum((text_ctr & Counter(tsig)).values()) + # coverage≥0.95: run-on 回声字符几乎全在 table sig(同一内容字符流, + # 回声可能是表格的一部分故不约束长度比例); 讨论表格的正文段(列表项/ + # 单句含 encoder/contains/layers 等非表格词)coverage<0.95, 留足 FP 余量。 + if overlap / len(text_sig) >= 0.95: + return True + return False + + def _normalize_for_dedup(text: str) -> str: """归一化文本用于去重比较:移除断字、智能引号、归一化破折号与空白。""" text = re.sub(r"(\w)-\s+(\w)", r"\1\2", text) @@ -2356,6 +2639,32 @@ def _sanitize_latex(latex: str) -> str: ) latex = new_latex.strip() + # 策略 5: 规整文本模式命令内 Docling 字母拆分(pdf-fidelity R10) + # Docling/Granite 抽取行间公式时常把 \mathrm{Attention} 输出为 + # ``\mathrm{A t t e n t i o n}``(每字母独立 token + 空格),KaTeX 在文本 + # 模式把这些空格当显式间距渲染,视觉上呈 "A t t e n t i o n" 而非 "Attention", + # 与源 PDF 视觉不一致。仅当命令参数**整段**为"≥3 个单字母被空格串联" + # (纯字母、无多字母词、无符号/波浪号)时合并;``\text{hello world}`` 词间 + # 空格、``\mathrm{O}`` 单字母 token、``\mathrm{where~head}`` 含 ``~`` 均不 + # 满足 fullmatch,原样保留,杜绝误伤合法空格。 + _TEXTUAL_CMD_RE = re.compile( + r"(\\(?:mathrm|mathit|mathbf|mathsf|texttt|operatorname)\*?\s*\{)([^{}]*)(\})" + ) + + def _merge_spaced_letters(content: str) -> str: + # 允许首尾空白 + ``~``(LaTeX 不换行空格)作字母间分隔:Docling 输出 + # ``\mathrm{A t t e n t i o n}`` 与 ``\mathrm{where~head}``(拆为 + # ``w h e r e ~ h e a d``),~ 保留作词间分隔,仅合并字母间空白。 + # 仅"≥3 个单字母被 空格/~ 串联"整段匹配时合并。 + if re.fullmatch(r"\s*[a-zA-Z](?:[\s~]+[a-zA-Z]){2,}\s*", content): + return re.sub(r"\s+", "", content) + return content + + latex = _TEXTUAL_CMD_RE.sub( + lambda m: m.group(1) + _merge_spaced_letters(m.group(2)) + m.group(3), + latex, + ) + return latex @@ -2616,7 +2925,9 @@ def _split_code_tail_section(code: str) -> Tuple[str, str]: _PDF_PT_TO_CSS_PX = 96.0 / 72.0 -def _image_to_markdown(image: ExtractedImage) -> str: +def _image_to_markdown( + image: ExtractedImage, alt_override: Optional[str] = None +) -> str: """将图片转换为 Markdown 图片引用,保留 PDF 原版显示尺寸。 输出 **内嵌 HTML ````** 形式,并按以下优先级决定 ``width``/``height``: @@ -2637,7 +2948,13 @@ def _image_to_markdown(image: ExtractedImage) -> str: DocumentMarkdownRenderer.tsx`` 中 ``DocumentImage`` 通过 ``parsePixelValue()`` 读取 ``width``/``height`` 像素值约束 ``max-width``。 """ - alt_text = image.caption or image.filename or "image" + # alt_override 非 None 时直接采用(含空串):同页同 caption 的拆分子图 + # (如 Figure 2 左右子图均被赋同一完整 caption)由调用方传 "" 去重,避免 + # 同一图注重复显示;None 时维持原 caption 优先逻辑。 + if alt_override is not None: + alt_text = alt_override + else: + alt_text = image.caption or image.filename or "image" src = f"./images/{image.filename}" display_w: Optional[int] = None From 510c16b88bc301b7e9d8d46251938f6680ad14af Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Tue, 7 Jul 2026 10:19:47 +0800 Subject: [PATCH 08/81] =?UTF-8?q?fix(perceives/pdf):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=20PyMuPDF=20=E6=95=B0=E5=AD=A6=E6=A0=87=E8=AE=B0=E7=A2=8E?= =?UTF-8?q?=E7=89=87=E6=AE=8B=E7=89=87=EF=BC=8C=E6=B6=88=E9=99=A4=E5=85=AC?= =?UTF-8?q?=E5=BC=8F=E9=87=8D=E5=A4=8D=20(#1061)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assembly.py 2.5.5 公式残片清理此前仅覆盖纯文本残片(如 'C = [',≤15 字符),遗漏 PyMuPDF 数学字形检测把公式视觉区字符包成 $...$ 的「数 学标记碎片」——这类碎片常落在 block 公式 bbox 正上方(y 不重叠)逃 脱几何去重,且 $...$ 包裹使 _formula_text_signature 坍缩为极短签名 (如 '$C =$ $[$' → 'c')逃脱签名去重,导致同一公式既出碎片又出完整 块公式(如 doc 013c5ebc 的 eq(2))。 新增形态2 判据:以 $ 起手 + 含数学符号/关系符 + ≤60 字符;前向扫描 改为可穿透空白与残片链,仅当能连到一个公式元素时才剔除。形态1(纯文 本残片)逻辑不变,仅重构为统一谓词。 非回归:3 个生产 PDF(4a43aba4 / 9045a031 / e154795a)改动前后输出 逐行一致(diff=0),修复对无该碎片的文档完全惰性。 Co-authored-by: Claude Opus 4.8 --- .../perceives/pipeline/stages/pdf/assembly.py | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py index b23bdb377..b597b923d 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py @@ -1148,29 +1148,57 @@ def _name_in_text(name: str) -> bool: # 公式(含 ``\bigcup`` / ``\sum`` / 矩阵等多行结构)常仅抽出公式起手 # 残片(典型如 ``C = [``、``M_l =``、``x = \{``),与公式 stage 的 LaTeX # 主体重复出现却互相不命中签名兜底(残片字符不足 20 触发 ``_formula_text_signature`` - # 的最小长度阈值)。清理判据:text element 内容 ≤ 15 字符 + 形如 - # `` = `` 模式 + 紧邻下一个 element 是公式 - # → 视为公式残片剔除,避免视图中"残片 + 公式"并存(ISSUE-094 R8)。 + # 的最小长度阈值)。清理判据:text element 内容为公式残片形态 + 后续 + # (经空白 + 残片链)能扫到一个公式元素 → 视为公式残片剔除,避免视图中 + # "残片 + 公式"并存(ISSUE-094 R8)。 + # + # 残片形态(两种): + # 形态1(纯文本残片):`` = ``,≤ 15 字符(如 ``C = [``)。 + # 形态2(数学标记碎片):PyMuPDF 数学字形检测把公式视觉区字符包成 ``$...$`` + # inline math 文本块,常落在 block 公式 bbox 正上方(y 不重叠)逃脱几何 + # 去重,且 ``$`` 包裹使 ``_formula_text_signature`` 坍缩为极短签名(如 + # ``"$C =$ $[$"``→``"c"``)逃脱签名去重。判据:以 ``$`` 起手 + 含数学 + # 符号/关系符 + ≤ 60 字符(如 ``"$C =$ $[$"``、``"$e\in E_{rel}$ Char $(e)$ (2)"``)。 _FORMULA_FRAGMENT_RE = re.compile(r"^\s*[A-Za-z]\w*\s*=\s*[\[\(\{]\s*$") + _MATH_FRAG_CHARS = set("=∈∀∃∑∏∫→←↔≤≥≠≈⊆⊂⊃∪∩∧∨<>+\-*/^_") + + def _is_formula_text_fragment(content: str) -> bool: + if not content: + return False + if len(content) <= 15 and _FORMULA_FRAGMENT_RE.match(content): + return True + if ( + content.startswith("$") + and len(content) <= 60 + and any(c in content for c in _MATH_FRAG_CHARS) + ): + return True + return False + + _fragment_idx = { + i + for i, e in enumerate(elements) + if e.element_type == "text" + and e.block is not None + and _is_formula_text_fragment(e.content.strip()) + } + # 仅保留"后续经(空白 + 残片链)能扫到一个公式元素"的残片,避免误删 + # 合法的赋值起手 / 行内数学短句(必须能向前连到公式才判为冗余残片)。 _fragment_remove: set[int] = set() - for i, elem in enumerate(elements): - if elem.element_type != "text" or elem.block is None: - continue - content = elem.content.strip() - if not content or len(content) > 15: - continue - if not _FORMULA_FRAGMENT_RE.match(content): - continue - # 必须紧邻下一个公式元素才视为残片(否则可能是合法的赋值起手) + for i in _fragment_idx: next_idx = i + 1 while next_idx < len(elements): nxt = elements[next_idx] if nxt.element_type == "formula": _fragment_remove.add(i) break - # 遇到非空 text 即停止搜索(中间仅允许空白元素通过) - if nxt.element_type == "text" and (nxt.content or "").strip(): - break + if nxt.element_type == "text": + nxt_content = (nxt.content or "").strip() + # 中间仅允许空白或其他残片候选通过(残片链:多碎片连排到公式) + if not nxt_content or next_idx in _fragment_idx: + next_idx += 1 + continue + break # 遇到正常非空文本,无法连到公式 → 非残片 next_idx += 1 if _fragment_remove: elements = [ From 31adaa95b8ea2ebc0b87c6c3fdb8ec1624c83554 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Tue, 7 Jul 2026 10:20:31 +0800 Subject: [PATCH 09/81] =?UTF-8?q?fix(perceives):=20PDF=E2=86=92Markdown=20?= =?UTF-8?q?=E9=AB=98=E4=BF=9D=E7=9C=9F=E5=A4=9A=E9=A1=B9=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88=E5=B7=A1=E6=A3=80=E6=94=B6=E6=95=9B?= =?UTF-8?q?=EF=BC=89=20(#1062)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(perceives): 解包"省略号型"内联公式误判,修复目录条目被误标为 inline formula assembly 新增 _unwrap_ellipsis_falsepositive_inline_math 全文后处理 sanitizer, 在最终 markdown 拼接后(L1378)调用。MinerU/marker 有时把含 "..." 的普通文本 (典型为目录条目 "Appendix B - AI Agentic ...: From GUI to Real world environment") 误标为 inline formula,省略号被 LaTeX 化为 \ldots、整段被 $...$ 包裹,UI 渲染为 乱码公式。仅对三条件全满足的极明显误判解包还原(保守,避免误伤真公式): 1) 内容含 \ldots/\dots(触发词);2) 不含任何真数学命令/符号(\frac \sum ^ _ 等 黑名单);3) 含 ≥2 个非 LaTeX-命令英文词(≥4 字母)——真公式极少如此。命中则 去 $...$ 包裹并把 \ldots/\dots 还原为 ...;$$块公式$$ 与真公式($x^2$、$\alpha$、 $1,\ldots,n$、$\frac{1}{2}bh$)经单元测试确认一律保留,回归近零。 巡检复现:《Agentic Design Patterns》p1 目录 "23. Appendix B - AI Agentic ...: From GUI..." 被误标;重转复核确认误包已消除、真公式无回归。 🤖 Generated with [Claude Code](https://github.com/claude.com), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(perceives): 把误当正文输出的 JSON 文本段补包为 ```json 代码栅栏 assembly 新增 _fence_json_text_paragraphs 全文后处理 sanitizer,在最终 markdown 拼接后(紧随省略号型内联公式解包之后)调用。引擎(docling/marker)常把内嵌 JSON 示例(如 { "trends": [ ... ] })当作普通正文输出为折叠纯文本,丢失代码语义与等宽 排版。对每个"非已 fenced"的段落,若 _looks_like_json_block 判定成立({ / [ 起 + 配对收尾 + ≥2 个 "key": 映射 + 括号配平 + 不含 ```),则包裹为 ```json 代码块; 含 ``` 的段落原样保留避免双重栅栏。形态判定保守,散文几乎不误判。 单元测试:p25 JSON 与 JSON 数组正确 fence;散文、{like this} 数学括号、已 fenced 代码(防双栅栏)、单键 dict、'Config: {model:..}' 全部不误伤。巡检重转复核:p25 JSON 已成 ```json 块,全文仅 1 处 json 栅栏(无误 fence);上轮省略号型公式解包 修复无回归。 🤖 Generated with [Claude Code](https://github.com/claude.com), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(perceives): code dedup 按 language 分流——真实代码翻转保留 fenced,误标保持优先 text assembly 重构 code_block 与同页"字符流文本回声"的去重逻辑,按 effective language 分流: - 真实代码/数据语言(python/bash/json/yaml/js/...,_REAL_CODE_LANGS 白名单): 翻转保留策略——保留权威 fenced code、删除折叠/转义的 text 回声;放宽阈值 overlap>=5(原 >20 使 bash/import 等短代码块漏网致 text+fenced 双出)。ratio>0.7 为强信号(text 含 ≥70% 代码标识符→必为回声;code 若为引擎误检垃圾不可能达 0.7)。 - 误标代码语言(html/xml/css/markdown/text 等,常把 TOC/散文误包):保持原 "优先 text" 行为(_skip code),避免把 TOC 错渲染成 ```html 代码块。 新增 _effective_code_lang(与 _code_block_to_markdown 一致的 lang 推断)+ _REAL_CODE_LANGS 白名单(刻意排除 html/xml/css/scss/markdown)。 修复 dominant 缺陷:《Agentic Design Patterns》p30 Python 原被重复+截断输出(折叠 纯文本 imports + 仅 imports 的 fenced + \#转义漏出的函数体)→ 现完整 fenced (bash/imports/body 各成正确栅栏、无重复、无漏出)。p25 JSON 亦获正确多行 yaml 栅栏。语言守卫防 TOC 被误标 html 错包(重转复核:TOC 不再 html-fenced、无 \# 漏出、 散文与 TOC 内容完整无丢失)。 🤖 Generated with [Claude Code](https://github.com/claude.com), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(perceives): 展开 ●/○/• 圆点项目符折叠为 markdown 同级列表项 assembly 新增 _expand_bullet_paragraphs 后处理 sanitizer,在 formatter.format 之后 调用(formatter 会把 PDF 硬换行的圆点项连成空格分隔的 run-on 段)。PDF 的圆点列表 经 text_extraction 常被压成单段: ``- Prompt 1: 提取文本。 ● Prompt 2: 总结文本。 ● Prompt 3: 抽取实体。`` 把 `` ● ``/`` ○ ``/`` • `` 替换为 ``\n- ``、行首裸圆点 ``^● `` 规整为 ``- ``, 展开为同级 markdown 列表项,恢复可读列表结构。兼容 pre-formatter(换行分隔)与 post-formatter(空格 run-on)两种形态。跳过 fenced 代码块与表格段落(含 ``` 或 |), 避免破坏代码与 GFM 表格。仅处理圆点符——编号列表(1. ... 2. ...)因与目录文本 (1. Chapter 1... 2. Chapter 2...)结构难区分、误分裂风险高,暂不处理。 单元测试:多 ● 列表正确展开为多个 - 项;○ 列表同理;散文、fenced 代码含 ●、表格 含 ● 均不动;段首裸 ● 规整。巡检重转复核:候选 md ● 从 35→0、○ 从 3→0,p27/p29 等处的 Prompt 列表正确展开为 - 项;先前修复(yaml/json/py/bash fence、TOC 解包、 prose 完整)无回归。 🤖 Generated with [Claude Code](https://github.com/claude.com), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(perceives): 拆分 run-on 编号列表段为独立 markdown 编号项(强守卫防误拆目录) assembly 新增 _split_numbered_runon_paragraphs sanitizer,在 formatter + bullet 展开 之后调用。PDF 编号列表经 text_extraction + formatter 常被压成单段 run-on (``1. X 2. Y 3. Z``),把 `` N. ``(前导空格的后续项)替换为 ``\nN. `` 拆为独立 编号项,首项 ``^1.`` 原位保留。 强信号守卫,最大程度避免误伤散文与目录文本: 1. 段内 ≥2 个 ``N. 大写字母`` 项,且编号从 1 起严格递增(1,2,3,...); 2. 段不含目录标记 _TOC_MARKERS(``Chapter N``/``Appendix``/``pages [``/``[final``/ ``last read done``/``Index of Terms``)——目录同为 ``1. Chapter 1... 2. Chapter 2...`` 编号 run-on 结构; 3. 段长 < 2000(backstop); 4. 跳过 fenced 代码块与表格段。 单元测试:3 步真列表正确拆分;TOC 段(Chapter/Appendix)不动;散文、非顺序 (1,3,4)、版本号小数(Version 2.0)均不动。巡检重转复核:five-step 列表 1-2 步 拆分到位、TOC 保持 run-on 未误拆;先前修复(●=0、yaml/json/py/bash fence、TOC 解包、prose 完整)无回归。 🤖 Generated with [Claude Code](https://github.com/claude.com), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(perceives): 统一所有图片为 形态并尽力补 width/height,消除裸 ![]() 修复"图片输出形态不一致"(部分 、部分裸 ![]())。 assembly._image_to_markdown: - 极端退化(bbox 与引擎 dims 均缺)时,从 local_path / base64_data 用 PIL 读像素 尺寸,宽 >800 等比缩放(引擎常 2x/3x 渲染 figure,原生像素直接做显示宽度会放大 数倍),确保仍输出带尺寸的 ; - 最终兜底改为响应式 (无显式尺寸),绝不裸 ![](),保证形态统一。 markdown/image_ref_normalizer:占位符替换 / 孤儿图追加 / 已有 ref 规范化三处原本 输出裸 ![alt](./images/filename),统一改用新增 _build_img_html 构造 : - 优先 img.width/height 字段,回退 local_path/base64 PIL 读尺寸,宽 >800 等比缩放; - 读不到则响应式 (无尺寸)。 巡检重转复核:候选 md 11 张图全部为 (0 裸 ![]());原 4 张裸图(fig_p14_1、 img_17/19/33_0)现带 width/height(800×456 / 800×564,经字段+封顶);7 张原有 (含 img_13_0 624×440 bbox)无回归。先前修复(●=0、yaml/json/py/bash fence、TOC 解包、 prose 完整)无回归。 🤖 Generated with [Claude Code](https://github.com/claude.com), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(perceives): 剥离标题行首被误并入的页码数字(p3 "## 1 All my royalties...") assembly 新增 _strip_heading_page_numbers sanitizer,在 formatter + 列表 sanitizers 之后调用。PDF 页眉/页脚的孤立页码(1-3 位数字)有时被引擎与下方标题并入同一文本块, 输出形如 ``## 1 All my royalties will be donated to Save the Children``。对标题行 剥离首部 1-3 位数字 + 空白,保守守卫: - 数字后紧跟空白(非 "."),保留 ``## 1. Get the Mission`` 编号标题; - 剩余标题首词大写或引号开头; - 剩余 ≥2 词,避免误伤 ``## 10 Tips`` 等短标题。 单元测试:p3 标题正确剥离;``## 1. Get the Mission``/``## 10 Tips``/``## Chapter 1``/ ``#### Level 0`` 等均保留。巡检重转复核:候选 md p3 标题现为 ``## All my royalties will be donated to Save the Children``(无前导页码),全文无以数字开头的标题;先前 6 处修复(图片 统一、●=0、yaml/json/py/bash fence、TOC 解包、编号列表拆分、 prose 完整)无回归。 🤖 Generated with [Claude Code](https://github.com/claude.com), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * test(perceives): 同步 image 契约测试至统一 形态 上轮 fix(perceives) 统一所有图片为 (_image_to_markdown 极端退化出响应式 ;image_ref_normalizer 占位符/孤儿/已有 ref 三处改用 _build_img_html 出 ), 同步更新受影响契约测试断言为新 形态: - test_image_ref_normalizer.py:占位符/孤儿/ref 规范化 34 处断言由 ![alt](path) 改为 alt)。 - test_assembly_image_pixel_size.py::test_no_size_info_falls_back_to_markdown_syntax → test_no_size_info_falls_back_to_responsive_img。 另:image_ref_normalizer._build_img_html 新增 alt_override 参数,_normalize_existing_refs 传入原 markdown ![alt] 的 alt 文本(优先于 image.caption),保留语义 alt。 验证:image 契约相关 65 测试全过;perceives unit 套件 1946 passed,仅余 3 个 test_config 失败(concurrent_requests 32 vs 16,本地 config.default.yaml 环境覆盖 所致预存在失败,与本变更无关)。 🤖 Generated with [Claude Code](https://github.com/claude.com), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .../markdown/image_ref_normalizer.py | 87 +++- .../perceives/pipeline/stages/pdf/assembly.py | 413 +++++++++++++++++- .../tests/unit/test_assembly_helpers.py | 10 +- .../unit/test_assembly_image_pixel_size.py | 10 +- .../tests/unit/test_image_ref_normalizer.py | 52 +-- 5 files changed, 508 insertions(+), 64 deletions(-) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py b/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py index 14ab85ad1..cba2ac2e1 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py @@ -29,6 +29,77 @@ ) +def _build_img_html( + img: "ImageMeta", image_dir: str, alt_override: Optional[str] = None +) -> str: + """把图片元数据构造为 ```` 标签(与 assembly._image_to_markdown 形态一致)。 + + 占位符替换 / 孤儿图追加原本输出裸 ``![alt](./images/filename)``,与正文 + ```` 形态不一致。此处统一为 ````:尽力从原图 + (``local_path`` 或 ``base64_data``)读像素尺寸,宽 >800 等比缩放(引擎常以 + 2x/3x 渲染 figure,原生像素直接做显示宽度会放大数倍);读不到则输出无显式 + 尺寸的响应式 ````。保证所有图片同为 ```` 形态、带尺寸(尽最大努力)。 + + ``alt_override`` 非空时优先用作 alt(如 ref 规范化路径保留原 markdown ``![alt]`` + 的 alt 文本),否则回退到 image.caption / filename。 + """ + import html as _html + import os as _os + + filename = img.filename or "image" + alt = alt_override or img.caption or filename + src = f"{image_dir}/{filename}" + w: Optional[int] = None + h: Optional[int] = None + # 优先用图片对象自带的栅格尺寸(引擎报告的 width/height 字段) + _iw = getattr(img, "width", None) + _ih = getattr(img, "height", None) + if _iw: + w = int(_iw) + if _ih: + h = int(_ih) + try: + import base64 as _b64 + import io as _io + + from PIL import Image as _PILImage + + _src = None + _lp = getattr(img, "local_path", None) + if _lp and _os.path.exists(_lp): + _src = _PILImage.open(_lp) + else: + _b64d = getattr(img, "base64_data", None) + if _b64d: + _src = _PILImage.open(_io.BytesIO(_b64.b64decode(_b64d))) + if _src is not None: + nw, nh = _src.size + _src.close() + if nw > 0 and nh > 0: + _maxw = 800 + if nw > _maxw: + w = _maxw + h = int(round(nh * _maxw / nw)) + else: + w, h = nw, nh + except Exception: + pass + # 统一封顶:无论尺寸来源(字段 / PIL),宽 >800 等比缩放,避免 2x/3x 渲染图过大 + if w and w > 800 and h and h > 0: + h = int(round(h * 800 / w)) + w = 800 + parts = [ + f'') + return " ".join(parts) + + @runtime_checkable class ImageMeta(Protocol): """图片元数据协议,``DoclingImage`` 与 ``ExtractedImage`` 均满足。""" @@ -130,9 +201,8 @@ def _append_orphan_images( appended_lines = ["", ""] for img in orphans: - alt = img.caption or img.filename or "image" appended_lines.append("") - appended_lines.append(f"![{alt}]({image_dir}/{img.filename})") + appended_lines.append(_build_img_html(img, image_dir)) return markdown.rstrip() + "\n".join(appended_lines) + "\n" @@ -233,8 +303,7 @@ def _replace_image_placeholders( if idx < len(available): img = available[idx] - alt = img.caption or img.filename or "image" - parts.append(f"![{alt}]({image_dir}/{img.filename})") + parts.append(_build_img_html(img, image_dir)) else: logger.warning( " 占位符数量 (%d) 超出可用图片 (%d),保留第 %d 个占位符", @@ -256,8 +325,8 @@ def _normalize_existing_refs( image_dir: str, ) -> str: """规范化已有的 ``![alt](path)`` 引用路径。""" - filename_set = {img.filename for img in images if img.filename} - if not filename_set: + basename_to_img = {img.filename: img for img in images if img.filename} + if not basename_to_img: return markdown def _replacer(match: re.Match) -> str: @@ -274,8 +343,10 @@ def _replacer(match: re.Match) -> str: # 提取 basename 并校验是否为已知图片 basename = PurePosixPath(path).name - if basename in filename_set: - return f"![{alt}]({image_dir}/{basename})" + if basename in basename_to_img: + return _build_img_html( + basename_to_img[basename], image_dir, alt_override=alt or None + ) return match.group(0) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py index b597b923d..7bcf6a395 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py @@ -361,34 +361,68 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: ) ) continue - # Docling 代码块:与同页文本块逐个比较 - _skip = False + # Docling 代码块与同页"字符流文本回声"(PyMuPDF 把代码区另抽为 + # 文本块)的去重。按 effective language 分流: + # **真实代码语言**(python/bash/json/yaml/js/...):**保留权威的 + # fenced code、删除 text 回声**——text 副本常为折叠/转义低质量版本。 + # ratio>0.7 是强信号(text 含 ≥70% 代码标识符→必为回声;若 code 为 + # 引擎误检垃圾,不可能与 text 达 0.7 重叠),放宽 overlap>=5 覆盖短 + # 代码块(bash/import),消除 text+fenced 双出。 + # **误标代码语言**(html/xml/markdown/text 等,常把散文/TOC 误包): + # 保持原"优先 text"行为——code 与 text 重叠时 _skip 掉 code(text 更 + # 忠实),避免把 TOC/散文错渲染成 ```html 代码块。 code_words = set( re.findall(r"[a-zA-Z_]{3,}", code_block.code.lower()) ) if code_words: - for elem in elements: - if ( - elem.element_type != "text" - or not elem.block - or elem.page_number != code_block.page_number - ): - continue - block_words = set( - re.findall( - r"[a-zA-Z_]{3,}", - elem.block.text.lower(), + if _effective_code_lang(code_block) in _REAL_CODE_LANGS: + _echo_indices: List[int] = [] + for _ei, elem in enumerate(elements): + if ( + elem.element_type != "text" + or not elem.block + or elem.page_number != code_block.page_number + ): + continue + block_words = set( + re.findall( + r"[a-zA-Z_]{3,}", + elem.block.text.lower(), + ) ) - ) - if not block_words: + if not block_words: + continue + overlap = len(code_words & block_words) + ratio = overlap / max(len(code_words), 1) + if ratio > 0.7 and overlap >= 5: + _echo_indices.append(_ei) + for _ei in reversed(_echo_indices): + elements.pop(_ei) + else: + # 误标代码:重叠则 _skip code、保留 text(原逻辑) + _skip = False + for elem in elements: + if ( + elem.element_type != "text" + or not elem.block + or elem.page_number != code_block.page_number + ): + continue + block_words = set( + re.findall( + r"[a-zA-Z_]{3,}", + elem.block.text.lower(), + ) + ) + if not block_words: + continue + overlap = len(code_words & block_words) + ratio = overlap / max(len(code_words), 1) + if ratio > 0.7 and overlap > 20: + _skip = True + break + if _skip: continue - overlap = len(code_words & block_words) - ratio = overlap / max(len(code_words), 1) - if ratio > 0.7 and overlap > 20: - _skip = True - break - if _skip: - continue # 边界修正:截断引擎误纳的尾部章节标题/引言正文 _kept_code, _tail_text = _split_code_tail_section( code_block.code or "" @@ -1585,6 +1619,16 @@ def _is_formula_text_fragment(content: str) -> bool: markdown_parts.append(elem.content) markdown = "\n\n".join(markdown_parts) + # 误判内联公式解包:含省略号的普通文本(典型为目录条目 + # ``$Appendix B - AI Agentic \ldots.: From GUI ...$``)被引擎误标为 + # inline formula,省略号被 LaTeX 化为 ``\ldots``。此处仅对"触发词 + + # 无真数学命令 + 像英文散文"的极明显误判解包还原,真公式一律保留。 + markdown = _unwrap_ellipsis_falsepositive_inline_math(markdown) + # JSON 文本段补栅栏:引擎(docling/marker)常把内嵌 JSON 例(如 + # ``{ "trends": [...] }``)当作普通正文输出为折叠纯文本,丢失代码语义。 + # 对"非已 fenced、``{``/``[`` 起 + 配对收尾 + ≥2 个 ``"key":`` 且括号 + # 配平"的段落,包裹为 ```json 代码块。检测保守,仅命中明显 JSON。 + markdown = _fence_json_text_paragraphs(markdown) # 4. 图片引用规范化 images: List[ExtractedImage] = [] @@ -1624,6 +1668,18 @@ def page_number(self) -> Optional[int]: # 5. Markdown 格式化 formatter = MarkdownFormatter() markdown = formatter.format(markdown) + # 圆点项目符展开(须在 formatter 之后:formatter 会把 PDF 硬换行的圆点 + # 项连成空格分隔的 run-on 段 `` ● ``,此处展开为 ``\n- `` 同级列表项)。 + markdown = _expand_bullet_paragraphs(markdown) + # 编号 run-on 列表拆分(须在 formatter/bullet 之后):把段内 + # "1. X 2. Y 3. Z" run-on 拆为独立编号项。强信号守卫(从 1 严格递增 + + # 无 TOC 标记 + 段长<2000 + 跳过 fenced/表格),避免误拆目录与散文。 + markdown = _split_numbered_runon_paragraphs(markdown) + # 标题首页码剥离:PDF 页眉/页脚的孤立页码(1-3 位数字)有时被引擎并入 + # 下方标题,输出 ``## 1 All my royalties...``。保守剥离(数字后须紧跟空格 + # 非 "."、剩余标题首词大写/引号、剩余 ≥2 词),保留 ``## 1. Get the Mission`` + # 等编号标题与 ``## 10 Tips`` 等短标题。 + markdown = _strip_heading_page_numbers(markdown) # 6. 参考文献节条目分段(多条目连段 → 每条独占段落) markdown = _segment_references_section(markdown) @@ -2829,6 +2885,61 @@ def _formula_to_markdown(formula: ExtractedFormula) -> str: "protobuf": "protobuf", "proto": "protobuf", } + +# "真实代码/数据语言"白名单:effective lang 命中此处时,code 副本权威、删除 text +# 回声(dedup 翻转)。刻意排除 ``html``/``xml``/``css``/``scss``/``markdown`` 等 +# 标记/文本类型——docling 常把 TOC、散文、配置说明误标为这些,text 版本更忠实, +# 对它们保持原"优先 text"去重行为,避免把散文错渲染成代码块。 +_REAL_CODE_LANGS = frozenset( + { + "python", + "java", + "javascript", + "typescript", + "c", + "cpp", + "csharp", + "rust", + "go", + "ruby", + "php", + "swift", + "kotlin", + "scala", + "r", + "perl", + "lua", + "bash", + "powershell", + "sql", + "yaml", + "json", + "toml", + "ini", + "dockerfile", + "makefile", + "graphql", + "protobuf", + } +) + + +def _effective_code_lang(code_block: "ExtractedCodeBlock") -> str: + """计算 code_block 的有效 fence 语言(与 ``_code_block_to_markdown`` 一致)。 + + 优先 ``code_block.language``(经 ``_CODE_LANG_HEADER_MAP`` 归一化);为空时回退 + 到 "code 首行单独为 lang 关键词" 的推断;都无则返回空串。供 dedup 按 lang 分流。 + """ + lang = (code_block.language or "").strip().lower() + if lang: + return _CODE_LANG_HEADER_MAP.get(lang, lang) + code = code_block.code or "" + stripped = code.lstrip("\n") + nl = stripped.find("\n") + first_line = stripped[:nl] if nl >= 0 else stripped + return _CODE_LANG_HEADER_MAP.get(first_line.strip().lower(), "") + + """常见编程语言关键词归一化表 → markdown fence highlight 名称。 来源:docling 在某些 PDF 上把代码块首行 ``Python`` / ``Javascript`` 字面字符 @@ -2889,6 +3000,224 @@ def _code_block_to_markdown( return f"```\n{code}\n```" +# 真数学命令/符号黑名单:内联 ``$...$`` 内容若命中任一则视为真公式,保守保留。 +# 覆盖常见 LaTeX 数学(分数/求和/积分/根号/关系符/希腊字母/上下标等)。 +_INLINE_MATH_FALSEPOS_DENY = re.compile( + r"\\(?:frac|sum|int|sqrt|lim|log|cdot|times|partial|infty|nabla|forall|exists|" + r"in|notin|le|ge|leq|geq|neq|approx|equiv|pm|mp|div|subset|supset|cup|cap|" + r"alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|iota|kappa|" + r"lambda|mu|nu|xi|pi|rho|sigma|tau|upsilon|phi|chi|psi|omega|Gamma|Delta|" + r"Theta|Lambda|Sigma|Phi|Psi|Omega|mathbb|mathcal|mathbf|mathrm|mathsf|" + r"text|textbf|begin|end|left|right|hat|bar|vec|dot|tilde|overline|underline)\b" + r"|[\^_]" +) +# 误判触发词:省略号被 LaTeX 化。 +_INLINE_MATH_FALSEPOS_TRIGGER = re.compile(r"\\l?dots\b") + + +def _unwrap_ellipsis_falsepositive_inline_math(markdown: str) -> str: + """解包"省略号型"内联公式误判。 + + MinerU/marker 有时把含 ``...`` 的普通文本(典型为目录条目,如 ``Appendix B - + AI Agentic ...: From GUI to Real world environment``)误标为 inline formula, + 输出 ``$Appendix B - AI Agentic \\ldots.: From GUI ...$``——省略号被 LaTeX 化为 + ``\\ldots``,整段被 ``$...$`` 包裹,UI 渲染为乱码公式。 + + 仅对**极明显**的误判解包还原(保守,避免误伤真公式),三条件全部满足才处理: + 1. 内容含 ``\\ldots``/``\\dots``(误判触发词); + 2. 内容**不含**任何真数学命令/符号(黑名单 ``_INLINE_MATH_FALSEPOS_DENY``); + 3. 内容含 ≥2 个非 LaTeX-命令的英文词(≥4 字母)——真公式极少如此。 + + 命中则去掉 ``$...$`` 包裹并把 ``\\ldots``/``\\dots`` 还原为 ``...``; + ``$$...$$`` 块公式与单行内的多 ``$`` 不受影响(正则用 ``(? str: + inner = m.group(1) + if not _INLINE_MATH_FALSEPOS_TRIGGER.search(inner): + return m.group(0) + if _INLINE_MATH_FALSEPOS_DENY.search(inner): + return m.group(0) # 含真数学命令,保留 + # 非 LaTeX-命令的英文词(≥4 字母)计数 + if len(re.findall(r"(? bool: + """保守判断 ``s`` 是否为一个 JSON 对象/数组文本段。 + + 必须同时满足(最大程度避免误伤散文): + 1. 以 ``{`` 或 ``[`` 开头,且以配对的 ``}`` / ``]`` 结尾; + 2. 含 ≥2 个 ``"key":`` / ``'key':`` 映射模式(JSON 的本质特征); + 3. 花括号/方括号各自配平; + 4. 不含未转义的 ```(避免与已有代码栅栏纠缠)。 + """ + stripped = s.strip() + if "```" in stripped: + return False + if stripped.startswith("{"): + if not stripped.endswith("}"): + return False + elif stripped.startswith("["): + if not stripped.endswith("]"): + return False + else: + return False + if len(re.findall(r'"[^"\n]{1,40}"\s*:|\'[^\'\n]{1,40}\'\s*:', stripped)) < 2: + return False + if stripped.count("{") != stripped.count("}") or stripped.count( + "[" + ) != stripped.count("]"): + return False + return True + + +def _fence_json_text_paragraphs(markdown: str) -> str: + """把误当正文输出的 JSON 文本段包裹为 ```json 代码块。 + + 引擎常把内嵌 JSON 示例(如 ``{ "trends": [ ... ] }``)作为普通文本块输出, + 渲染为折叠纯文本、丢失代码语义与等宽排版。对每个**非已 fenced**的段落, + 若 ``_looks_like_json_block`` 判定成立,则包裹为 ````` ``json ... `` ``` ``。 + + 通过段落(``\\n\\n`` 分隔)逐段处理;含 ````` ```` 的段落(已是代码块)原样保留, + 避免双重栅栏。保守的形态判定使散文几乎不会被误判。 + """ + paragraphs = markdown.split("\n\n") + out: List[str] = [] + for para in paragraphs: + if "```" in para: + out.append(para) + continue + s = para.strip() + if s and _looks_like_json_block(s): + out.append("```json\n" + s + "\n```") + else: + out.append(para) + return "\n\n".join(out) + + +# 圆点项目符(PDF 列表 bullet)集合:●/○/•。text_extraction 常把整列圆点项压成 +# 单段,首项可能已被识为 ``- `` 列表项,后续项以圆点符内联分隔。 +_BULLET_CHARS = "●○•▪◦" + + +def _expand_bullet_paragraphs(markdown: str) -> str: + """把段落内折叠的圆点项目符(●/○/•/▪/◦)展开为同级 markdown 列表项。 + + PDF 源的圆点列表经 text_extraction 常被压成单段,形如: + ``- Prompt 1: 提取文本。 ● Prompt 2: 总结文本。 ● Prompt 3: 抽取实体。`` + (首项已是 ``- `` 列表项,后续项以 `` ● `` 内联分隔)。本函数把 + `` ● ``/`` ○ ``/`` • `` 等 ``" " + 圆点 + " "`` 替换为 ``"\\n- "``,展开为 + 同级 markdown 列表项,恢复可读的列表结构。 + + 跳过 fenced 代码块与表格段落(含 ````` ```` 或 ``|`` 的段落),避免破坏代码 + 与 GFM 表格。**仅处理圆点符**——编号列表(``1. ... 2. ...``)因与目录文本 + ``1. Chapter 1 ... 2. Chapter 2 ...`` 结构难区分、误分裂风险高,暂不处理。 + 段落首字符即为圆点的(无前置 ``- ``),亦规整为 ``- `` 列表项。同时兼容两种 + 形态:行首裸圆点(``\\n● item``,pre-formatter)与中段内联圆点(`` ● item``, + post-formatter run-on 段)。 + """ + paragraphs = markdown.split("\n\n") + out: List[str] = [] + for para in paragraphs: + if "```" in para or "|" in para: + out.append(para) + continue + if not any(ch in para for ch in _BULLET_CHARS): + out.append(para) + continue + # 1. 行首裸圆点(含换行后的行首):"● Foo" / "\n● Foo" -> "- Foo" + new = re.sub(r"(?m)^[" + _BULLET_CHARS + r"]\s+", "- ", para) + # 2. 中段内联圆点(空格分隔的 run-on):" ● " -> "\n- " + new = re.sub(r" [" + _BULLET_CHARS + r"] ", "\n- ", new) + out.append(new) + return "\n\n".join(out) + + +# 目录(TOC)文本标记:目录条目也呈 ``1. Chapter 1... 2. Chapter 2...`` 的编号 run-on +# 结构,必须排除以免把目录误拆。命中任一即视为目录段、跳过编号拆分。 +_TOC_MARKERS = re.compile( + r"Chapter \d|Appendix [A-G]|pages \[|last read done|\[final|Index of Terms" +) + + +def _split_numbered_runon_paragraphs(markdown: str) -> str: + r"""把 run-on 编号列表段(``1. X 2. Y 3. Z``)拆为独立 markdown 编号项。 + + PDF 编号列表经 text_extraction + formatter 常被压成单段 run-on。仅对**强信号** + 的编号列表拆分,最大程度避免误伤散文与目录文本: + + 1. 段内含 ≥2 个 ``N. 大写字母`` 项,且编号从 1 起严格递增(1,2,3,...); + 2. 段**不含**目录标记(``_TOC_MARKERS``:``Chapter N``/``Appendix``/``pages [`` + /``[final``/``last read done``/``Index of Terms``)——目录同为编号 run-on; + 3. 段长 < 2000 字符(backstop,目录段常数千字符); + 4. 跳过 fenced 代码块与表格段(含 ```` ``` ```` 或 ``|``)。 + + 命中则把每个 `` N. ``(前导空格的后续项)替换为 ``\nN. ``,首项 ``^1.`` 原位保留, + 拆为独立编号项。注意:PDF 跨页编号列表常被 formatter 切散成多段,本函数仅拆 + 「段内 run-on」,跨段碎片不在处理范围(无信息丢失,仅未重组)。 + """ + paragraphs = markdown.split("\n\n") + out: List[str] = [] + for para in paragraphs: + if "```" in para or "|" in para: + out.append(para) + continue + if _TOC_MARKERS.search(para) or len(para) > 2000: + out.append(para) + continue + marks = list(re.finditer(r"(?:(?<=^)|(?<= ))(\d+)\. +[A-Z]", para)) + if len(marks) < 2: + out.append(para) + continue + nums = [int(m.group(1)) for m in marks] + if nums[0] != 1 or any( + nums[i + 1] != nums[i] + 1 for i in range(len(nums) - 1) + ): + out.append(para) + continue + # 拆分:把每个 " N. "(前导空格的后续项)替换为 "\nN. ";首项 ^1. 无前导空格不动 + new = re.sub(r" (\d+)\. +", lambda m: "\n" + m.group(1) + ". ", para) + out.append(new) + return "\n\n".join(out) + + +def _strip_heading_page_numbers(markdown: str) -> str: + r"""剥离标题行首被误并入的页码数字。 + + PDF 页眉/页脚的孤立页码(1-3 位数字)有时被引擎与下方标题并入同一文本块, + 输出形如 ``## 1 All my royalties will be donated to Save the Children``。 + 对标题行(``#``~``######``)剥离首部的 1-3 位数字 + 空白,仅当全部满足: + + - 数字后紧跟空白(**非 "."**),保留 ``## 1. Get the Mission`` 等编号标题; + - 剩余标题以大写字母或引号开头(标题首词大写的常规形态); + - 剩余标题 ≥2 个词(避免误伤 ``## 10 Tips`` 等短标题)。 + + 保守守卫,仅命中明显的页码-并入标题。 + """ + + def _strip(m: "re.Match[str]") -> str: + hashes = m.group(1) + rest = m.group(2) + if not rest: + return m.group(0) + if rest[0].isupper() or rest[0] in "\"'“‘": + if len(rest.split()) >= 2: + return f"{hashes} {rest}" + return m.group(0) + + return re.sub( + r"^(#{1,6}) \d{1,3}\s+(.+)$", + _strip, + markdown, + flags=re.MULTILINE, + ) + + def _split_code_tail_section(code: str) -> Tuple[str, str]: """检测 code body 尾部被引擎误纳的章节标题块并截断。 @@ -3002,6 +3331,38 @@ def _image_to_markdown( if display_h is None and image.height: display_h = int(image.height) + # 极端退化:bbox 与引擎 dims 均缺失时,尽力从原图(local_path 或 base64_data) + # 读像素尺寸,并按典型内容宽封顶(引擎常以 2x/3x 渲染 figure,原生像素如 2048px + # 直接做显示宽度会放大数倍)。封顶后等比缩放,确保仍输出**带尺寸的 ````** + # (与有 bbox 的图片形态一致),避免裸 ``![]()`` 造成渲染形态不一致。 + if display_w is None and display_h is None: + try: + from PIL import Image as _PILImage + import os as _os + import base64 as _b64 + import io as _io + + _src = None + _lp = getattr(image, "local_path", None) + if _lp and _os.path.exists(_lp): + _src = _PILImage.open(_lp) + else: + _b64d = getattr(image, "base64_data", None) + if _b64d: + _src = _PILImage.open(_io.BytesIO(_b64.b64decode(_b64d))) + if _src is not None: + _nw, _nh = _src.size + _src.close() + if _nw > 0 and _nh > 0: + _MAX_DISPLAY_W = 800 + if _nw > _MAX_DISPLAY_W: + display_w = _MAX_DISPLAY_W + display_h = int(round(_nh * _MAX_DISPLAY_W / _nw)) + else: + display_w, display_h = _nw, _nh + except Exception: + pass + # R9 修复:始终输出 CSS px 像素值(PDF pt × 4/3)作为 width / height 属性, # 配合 ``style="max-width:100%;height:auto"`` 实现「PDF 原版尺寸 + 窄屏 # 自适应」双赢。此前的 ``is_large_figure → width="100%"`` 分支会把所有 @@ -3019,7 +3380,13 @@ def _image_to_markdown( parts.append(f'height="{display_h}"') parts.append('style="max-width:100%;height:auto;" />') return " ".join(parts) - return f"![{alt_text}]({src})" + # 真无任何尺寸信息:仍输出响应式 ````(无显式 width/height)保证形态一致, + # 绝不裸 ``![]()``——与有尺寸图片同为 ```` 标签,渲染行为统一。 + return ( + f'' + ) # --------------------------------------------------------------------------- diff --git a/apps/negentropy-perceives/tests/unit/test_assembly_helpers.py b/apps/negentropy-perceives/tests/unit/test_assembly_helpers.py index d809d3e2e..e39fb3e5f 100644 --- a/apps/negentropy-perceives/tests/unit/test_assembly_helpers.py +++ b/apps/negentropy-perceives/tests/unit/test_assembly_helpers.py @@ -109,8 +109,9 @@ def test_context_engineering_figure1_real_dims_outputs_pixel_width(self) -> None assert 'height="287"' in out assert 'width="100%"' not in out - def test_degrades_to_markdown_syntax_when_no_dims(self) -> None: - """既无 bbox 又无 width/height 时降级为标准 Markdown ``![alt](src)``。""" + def test_degrades_to_responsive_img_when_no_dims(self) -> None: + """既无 bbox 又无 width/height、且读不到原图尺寸时,降级为响应式 ```` + (无显式 width/height),保证形态与其他 ```` 图片一致,不裸 ``![]()``。""" img = ExtractedImage( image_id="img_y", filename="bare.png", @@ -119,7 +120,10 @@ def test_degrades_to_markdown_syntax_when_no_dims(self) -> None: bbox=None, ) out = _image_to_markdown(img) - assert out == "![bare.png](./images/bare.png)" + assert out == ( + 'bare.png' + ) def test_html_escapes_alt_and_src(self) -> None: """caption 含 HTML 元字符时必须被实体化,防止破坏后续 Markdown 渲染。""" diff --git a/apps/negentropy-perceives/tests/unit/test_assembly_image_pixel_size.py b/apps/negentropy-perceives/tests/unit/test_assembly_image_pixel_size.py index b6e228f44..873224fa7 100644 --- a/apps/negentropy-perceives/tests/unit/test_assembly_image_pixel_size.py +++ b/apps/negentropy-perceives/tests/unit/test_assembly_image_pixel_size.py @@ -66,11 +66,13 @@ def test_fallback_to_image_width_when_no_bbox(self) -> None: assert 'width="320"' in out assert 'height="240"' in out - def test_no_size_info_falls_back_to_markdown_syntax(self) -> None: - """无 bbox / width / height → 退化为 markdown ``![alt](src)`` 简写。""" + def test_no_size_info_falls_back_to_responsive_img(self) -> None: + """无 bbox / width / height 且读不到原图尺寸 → 退化为响应式 ```` + (无显式 width/height),保证形态与其他 ```` 图片一致,不裸 ``![]()``。""" out = _image_to_markdown(_img(filename="x.png")) - assert out.startswith("![") and out.endswith(")") - assert " None: """有 caption → alt 文本用 caption。""" diff --git a/apps/negentropy-perceives/tests/unit/test_image_ref_normalizer.py b/apps/negentropy-perceives/tests/unit/test_image_ref_normalizer.py index 16968ce07..fe3c02215 100644 --- a/apps/negentropy-perceives/tests/unit/test_image_ref_normalizer.py +++ b/apps/negentropy-perceives/tests/unit/test_image_ref_normalizer.py @@ -44,7 +44,7 @@ def test_single_placeholder_replaced(self) -> None: md = "Before\n\n\n\nAfter" images = [FakeImage(filename="img_p1_0.png", caption="Figure 1")] result = normalize_image_references(md, images) - assert "![Figure 1](./images/img_p1_0.png)" in result + assert 'Figure 1" not in result def test_multiple_placeholders_in_order(self) -> None: @@ -54,21 +54,21 @@ def test_multiple_placeholders_in_order(self) -> None: FakeImage(filename="b.png", caption="B"), ] result = normalize_image_references(md, images) - assert "![A](./images/a.png)" in result - assert "![B](./images/b.png)" in result - assert result.index("![A]") < result.index("![B]") + assert 'A None: md = "" images = [FakeImage(filename="x.png", caption="X")] result = normalize_image_references(md, images) - assert "![X](./images/x.png)" in result + assert 'X None: md = "\n" images = [FakeImage(filename="only.png", caption="Only")] result = normalize_image_references(md, images) - assert "![Only](./images/only.png)" in result + assert 'Only" in result # 第二个保留 def test_more_images_than_placeholders(self) -> None: @@ -80,12 +80,12 @@ def test_more_images_than_placeholders(self) -> None: FakeImage(filename="b.png", caption="B"), ] result_default = normalize_image_references(md, images) - assert "![A](./images/a.png)" in result_default + assert 'A None: @@ -101,14 +101,14 @@ def test_images_without_filename_skipped(self) -> None: ] result = normalize_image_references(md, images) # filename=None 被过滤,仅 "real.png" 参与匹配第一个占位符 - assert "![Real](./images/real.png)" in result + assert 'Real" in result # 第二个保留 def test_caption_fallback_to_filename(self) -> None: md = "" images = [FakeImage(filename="chart.png", caption=None)] result = normalize_image_references(md, images) - assert "![chart.png](./images/chart.png)" in result + assert 'chart.png None: md = "" @@ -128,19 +128,19 @@ def test_bare_filename_normalized(self) -> None: md = "![fig](img_p1_0.png)" images = [FakeImage(filename="img_p1_0.png")] result = normalize_image_references(md, images) - assert "![fig](./images/img_p1_0.png)" in result + assert 'fig None: md = "![fig](/tmp/docling_images_xyz/img_p1_0.png)" images = [FakeImage(filename="img_p1_0.png")] result = normalize_image_references(md, images) - assert "![fig](./images/img_p1_0.png)" in result + assert 'fig None: md = "![fig](output/images/img_p1_0.png)" images = [FakeImage(filename="img_p1_0.png")] result = normalize_image_references(md, images) - assert "![fig](./images/img_p1_0.png)" in result + assert 'fig None: md = "![fig](data:image/png;base64,iVBORw0KGgo=)" @@ -172,10 +172,10 @@ def test_multiple_refs_mixed(self) -> None: FakeImage(filename="img_d.png"), ] result = normalize_image_references(md, images) - assert "![a](./images/img_a.png)" in result + assert 'a None: md = "# Doc\n\nSome content." images = [FakeImage(filename="fig_p39_1.png", caption="Figure 13: lifecycle")] result = normalize_image_references(md, images) - assert "![Figure 13: lifecycle](./images/fig_p39_1.png)" in result - assert result.rstrip().endswith("(./images/fig_p39_1.png)") + assert 'Figure 13: lifecycle') def test_referenced_image_not_duplicated(self) -> None: md = "# Doc\n\n![a](./images/fig_a.png)" @@ -204,8 +204,8 @@ def test_mixed_referenced_and_orphan(self) -> None: FakeImage(filename="fig_b.png", caption="B (orphan)"), ] result = normalize_image_references(md, images) - assert "![a](./images/fig_a.png)" in result - assert "![B (orphan)](./images/fig_b.png)" in result + assert 'a None: md = "# Doc" images = [FakeImage(filename="orphan.png", caption=None)] result = normalize_image_references(md, images) - assert "![orphan.png](./images/orphan.png)" in result + assert 'orphan.png None: md = "# Doc" @@ -255,7 +255,7 @@ def test_html_and_markdown_img_mixed_no_duplicate(self) -> None: assert result.count("fig_a.png") == 1 assert result.count("fig_b.png") == 1 # 真正未引用的孤儿 fig_c 才追加 - assert "![C (real orphan)](./images/fig_c.png)" in result + assert 'C (real orphan) None: """HTML ``src`` 含 ``/api/...`` 绝对路径时按 basename 匹配。 @@ -375,8 +375,8 @@ def test_custom_image_dir(self) -> None: md = "\n![fig](img.png)" images = [FakeImage(filename="img.png", caption="Img")] result = normalize_image_references(md, images, image_dir="./assets") - assert "![Img](./assets/img.png)" in result - assert "![fig](./assets/img.png)" in result + assert 'Img None: md = "\nSome text\n![existing](img_p2_0.png)" @@ -385,8 +385,8 @@ def test_combined_placeholders_and_refs(self) -> None: FakeImage(filename="img_p2_0.png", caption="Second"), ] result = normalize_image_references(md, images) - assert "![First](./images/img_p1_0.png)" in result - assert "![existing](./images/img_p2_0.png)" in result + assert 'First Date: Tue, 7 Jul 2026 10:21:09 +0800 Subject: [PATCH 10/81] =?UTF-8?q?fix(perceives):=20PDF=20=E4=BF=9D?= =?UTF-8?q?=E7=9C=9F=E5=B7=A1=E6=A3=80=20=E2=80=94=20=E8=B7=A8=E9=A1=B5=20?= =?UTF-8?q?figure=20=E7=A2=8E=E7=89=87=E5=AD=A4=E5=84=BF=E6=8A=91=E5=88=B6?= =?UTF-8?q?=20+=20=E6=95=A3=E6=96=87=E8=AF=AF=E5=8C=85=20inline=20?= =?UTF-8?q?=E6=95=B0=E5=AD=A6=E8=A7=A3=E5=8C=85=20(#1063)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(perceives-markdown): 抑制跨页 figure 过度分割的 orphan 碎片重复追加文末; docling 将跨页/复杂 figure 同时输出为完整图(已内联)与若干局部裁切(无法匹配文本引用成 orphan),image_ref_normalizer 此前会把碎片追加到文末与已内联图视觉重复。新增 _adjacent_fragment_orphans:当 orphan 与某已引用图同页或相邻页(|Δpage|≤1)且已引用图像素面积≥orphan 2 倍时判为冗余碎片并抑制。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(perceives-markdown): 解包被误包为 inline $...$ 的作者-单位行/关键词散文; _normalize_unicode_math 按空白切 token,当 token 同时含数学字形与散文(如作者上标 Name¹·²·³)时整 token 判 MATH,run 跨多 token 合并把人名连同 \cdot/^{1,2,3} 一起包进单个 $...$ 渲染为数学体。新增 _unwrap_prose_math:inline $...$ 内容含≥3 个长度≥3 的 ASCII 散文词时判为误包,撤销 $ 并把 ^{...}→...、\cdot→·;阈值取 3 规避对真实数学(单字母变量/\name 命令)的回归风险。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .../perceives/markdown/formatter.py | 45 ++++++++++++++ .../markdown/image_ref_normalizer.py | 61 ++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/markdown/formatter.py b/apps/negentropy-perceives/src/negentropy/perceives/markdown/formatter.py index 52a1b1698..a575aa406 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/markdown/formatter.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/markdown/formatter.py @@ -588,6 +588,12 @@ def format( # 已有 inline ``$..$`` 由 pass 内 split-and-skip 保护。 markdown_content = self._normalize_unicode_math(markdown_content) + # 撤销「散文误包成 inline $...$」失真:_normalize_unicode_math 会把含数学 + # 字形且与散文粘连的 token(作者上标 'Name¹·²·³'、关键词列表)整段包成 + # $...$,连同 prose 一起吞入数学体渲染。此处仅对内容含 ≥2 个多字母 ASCII + # 散文词的 inline 块解包(真实数学极少含多个散文词),还原为 prose+。 + markdown_content = self._unwrap_prose_math(markdown_content) + # 还原块级数学公式占位符(须在 _cleanup_math_blocks 之后, # 这样数学块整体仍由本管线统一治理,但 LaTeX 主体内容不被修改) markdown_content = self._restore_math_blocks( @@ -879,6 +885,45 @@ def _normalize_unicode_math(self, markdown_content: str) -> str: for line in markdown_content.split("\n") ) + # 散文误包判定:inline ``$...$`` 内容含 ≥此数量个「长度≥3 的 ASCII 字母词」 + # 即判为散文被误吞入数学体。取 3:作者-单位行(多个人名)/ 关键词列表天然含 + # ≥3 个散文词,而真实数学公式几乎不可能含 3 个以上多字母 ASCII 散文词 + # (通常为单字母变量 / ``\name`` 命令 / 至多 1-2 个 ``\text{word}``), + # 保守阈值最大程度规避对数学密集文档的回归风险。 + _PROSE_MATH_MIN_WORDS = 3 + _PROSE_MATH_WORD_RE = re.compile(r"[A-Za-z]{3,}") + + def _unwrap_prose_math(self, markdown_content: str) -> str: + """撤销把散文(作者-单位行 / 关键词等)误包成 inline ``$...$`` 数学的失真。 + + 背景:``_normalize_unicode_math`` 按空白切 token,当某 token 同时含数学字形 + 与散文(如 PDF 抽取的作者上标 ``Name¹·²·³``,上标为数学字母块字形、与名字 + 粘连成单 token)时,整 token 判为 MATH,run 跨多个此类 token 合并,把 + ``Fadi Dornaika`` 等 prose 连同 ``\\cdot`` / ``^{1,2,3}`` 一起包进单个 + ``$...$``,渲染为 LaTeX 数学体而非 prose+上标。 + + 本 pass 在归一化之后扫描 inline ``$...$``:内容含 ≥ ``_PROSE_MATH_MIN_WORDS`` + 个长度≥3 的 ASCII 词即判为误包,撤销 ``$`` 并把 ``^{...}`` → ``...``、 + ``\\cdot`` → ``·``。 + + 安全性:仅解包满足散文判定的 inline 块;块公式 / 代码块此时为占位符(无 ``$``), + 正常 inline 数学(无 ≥2 散文词)原样保留。 + """ + if "$" not in markdown_content: + return markdown_content + + def _maybe_unwrap(m: "re.Match[str]") -> str: + body = m.group(1) + if len(self._PROSE_MATH_WORD_RE.findall(body)) < self._PROSE_MATH_MIN_WORDS: + return m.group(0) + # ^{...} → ...;裸 ^x → x;\cdot → · + unwrapped = re.sub(r"\^\{([^{}]*)\}", r"\1", body) + unwrapped = re.sub(r"\^(\w)", r"\1", unwrapped) + unwrapped = unwrapped.replace(r"\cdot", "·") + return unwrapped + + return re.sub(r"\$([^$\n]+)\$", _maybe_unwrap, markdown_content) + def _normalize_unicode_math_line(self, line: str) -> str: stripped = line.lstrip() # 跳过结构性行:标题 / 表格行 / 代码块占位符(数学字母在这些行罕见且易误包) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py b/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py index cba2ac2e1..64595664b 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py @@ -188,13 +188,16 @@ def _append_orphan_images( if basename: referenced_basenames.add(basename) + redundant_basenames = _redundant_orphan_basenames( + markdown, images, referenced_basenames + ) | _adjacent_fragment_orphans(images, referenced_basenames) + orphans = [ img for img in images if img.filename and img.filename not in referenced_basenames - and img.filename - not in _redundant_orphan_basenames(markdown, images, referenced_basenames) + and img.filename not in redundant_basenames ] if not orphans: return markdown @@ -282,6 +285,60 @@ def _redundant_orphan_basenames( return redundant +# 跨页 figure 过度分割碎片抑制:当某张图(完整 figure)已被正文 引用, +# 其同页或相邻页(±1)的未引用 orphan 若像素面积 ≤ 该已放置图的 1/_FRAGMENT_RATIO, +# 判为 docling 对同一 figure 的冗余局部裁切,抑制不追加到文末。 +# 取 0.5(即已放置图面积 ≥ orphan 2×)以仅捕获真正的子区域碎片,避免误伤 +# 同/邻页独立的小 figure(独立 figure 通常自带 caption 被正文引用,不会是 orphan)。 +_FRAGMENT_RATIO = 0.5 + + +def _adjacent_fragment_orphans( + images: Sequence[ImageMeta], + referenced_basenames: set[str], +) -> set[str]: + """识别跨页/同页 figure 过度分割产出的 orphan 碎片。 + + 场景:docling 将一张(常为跨页或结构复杂的)figure 同时输出为一张完整图 + (被正文 ```` 引用)与若干局部裁切(无法匹配文本引用 → orphan)。 + 这些 orphan 追加到文末会与已内联的完整图视觉重复。 + + 判定:orphan 与某张已引用图在同页或相邻页(|Δpage| ≤ 1),且已引用图像素 + 面积 ≥ orphan × ``1/_FRAGMENT_RATIO`` → orphan 判为冗余碎片,抑制。 + + 安全性:需 width/height(像素)/page_number 均可用,否则 no-op(保留既有 + loss-averse orphan 行为);仅在确有同/邻页大图已放置且面积达碎片 N 倍时抑制, + 不误伤多图正文页的合法孤立小图。 + """ + meta: dict[str, tuple[int, int, int]] = {} + for img in images: + fn = getattr(img, "filename", None) + if not fn: + continue + w = getattr(img, "width", None) + h = getattr(img, "height", None) + pg = getattr(img, "page_number", None) + if w and h and pg is not None: + meta[fn] = (int(w), int(h), int(pg)) + if not meta: + return set() + + placed = [(fn, m) for fn, m in meta.items() if fn in referenced_basenames] + if not placed: + return set() + + redundant: set[str] = set() + for fn, (ow, oh, opg) in meta.items(): + if fn in referenced_basenames or fn in redundant: + continue + orphan_area = ow * oh + for _pfn, (pw, ph, ppg) in placed: + if abs(ppg - opg) <= 1 and pw * ph >= orphan_area / _FRAGMENT_RATIO: + redundant.add(fn) + break + return redundant + + def _replace_image_placeholders( markdown: str, images: Sequence[ImageMeta], From aba220c1cb11c5a752704725d8d6405de4df2378 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Tue, 7 Jul 2026 11:48:38 +0800 Subject: [PATCH 11/81] =?UTF-8?q?chore(scheduler):=20PDF=20Fidelity=20Patr?= =?UTF-8?q?ol=20=E5=B7=A1=E6=A3=80=E8=8A=82=E5=A5=8F=203600s=E2=86=92600s;?= =?UTF-8?q?=20(#1065)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增迁移 0091:UPDATE negentropy.scheduled_tasks 将 pdf_fidelity_patrol 的 interval_seconds 3600(1h)→600(10min),重置 next_fire_at=NOW() 令新节奏于下一 tick 即时生效,并同步刷新 description(每 1h→每 600s);downgrade 对称还原。系统任务 (is_system=TRUE) API 不可改,故走前向迁移;已本地 upgrade/downgrade 往返验证。 - 同步 handler docstring/descriptor 中 3600s/1h → 600s/10min 文案,保持单一事实源一致。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- .../0091_pdf_fidelity_patrol_interval_600s.py | 86 +++++++++++++++++++ .../handlers/pdf_fidelity_patrol.py | 8 +- 2 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 apps/negentropy/src/negentropy/db/migrations/versions/0091_pdf_fidelity_patrol_interval_600s.py diff --git a/apps/negentropy/src/negentropy/db/migrations/versions/0091_pdf_fidelity_patrol_interval_600s.py b/apps/negentropy/src/negentropy/db/migrations/versions/0091_pdf_fidelity_patrol_interval_600s.py new file mode 100644 index 000000000..003488000 --- /dev/null +++ b/apps/negentropy/src/negentropy/db/migrations/versions/0091_pdf_fidelity_patrol_interval_600s.py @@ -0,0 +1,86 @@ +"""Retune: pdf_fidelity_patrol 巡检节奏 3600s(1h) → 600s(10min)。 + +Revision ID: 0091 +Revises: 0090 +Create Date: 2026-07-07 00:00:00.000000+00:00 + +设计动机: + 「PDF→Markdown 高保真自拟合巡检」系统任务(``key=pdf_fidelity_patrol``)原节奏为每 + ``interval_seconds=3600``(1h)触发一轮。为更高频推进高保真自拟合巡检闭环,将其收敛到 + 每 ``600s``(10min)一次。 + + 该任务是**系统任务**(``is_system=TRUE``),Scheduler REST 端点 ``PUT /scheduler/tasks/{id}`` + 对系统任务显式拒绝改写;且节奏权威是 ``scheduled_tasks.interval_seconds`` 列(仅由 0076 种子 + 以 ``ON CONFLICT DO NOTHING`` 写入 3600,对已存在行无效)。故以本前向迁移 ``UPDATE`` 该行—— + 「单一事实源=全部迁移的累积结果」,新旧 DB 均收敛到 600s。 + + 额外: + - ``next_fire_at = NOW()`` —— 令新节奏于下一 tick 即时生效(缩短 interval 时 ``NOW()`` 恒 ≤ 旧 + 计划时刻,只把下一轮提前,符合「每 600s 检查」意图;叠加 handler「在跑即 SKIP」互斥与灰度门控 + ``routine.enabled`` + ``routine.patrol_enabled``,无并发/雪崩风险)。 + - 同步刷新 ``description`` 列的「每 1h」→「每 600s(10min)」,使 Scheduler UI 展示与真实节奏一致。 + +幂等性: + 精确 ``WHERE key = :key`` 的 ``UPDATE``;重跑安全。 + +References: +[1] 0076_seed_pdf_fidelity_patrol_task.py — 本任务的种子与节奏语义来源。 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0091" +down_revision: str | None = "0090" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "negentropy" +TABLE = f"{SCHEMA}.scheduled_tasks" + +TASK_KEY = "pdf_fidelity_patrol" + +_INTERVAL_NEW = 600.0 # 每 600s(10min) +_INTERVAL_OLD = 3600.0 # 每 1h(0076 原值) + +# description 与 0076 种子保持同构,仅节奏措辞随 interval 同步。 +_DESC_NEW = ( + "每 600s(10min)轮询一份生产 PDF 文档,启动 NegentropyEngine 巡检 Routine:" + "视觉对比 Markdown↔PDF、改 perceives、重转、评分,拟合至满分;Perceives 改进经非回归" + "校验后以 PR 合回基线。灰度门控:routine.enabled + routine.patrol_enabled。" +) +_DESC_OLD = ( + "每 1h 轮询一份生产 PDF 文档,启动 NegentropyEngine 巡检 Routine:" + "视觉对比 Markdown↔PDF、改 perceives、重转、评分,拟合至满分;Perceives 改进经非回归" + "校验后以 PR 合回基线。灰度门控:routine.enabled + routine.patrol_enabled。" +) + +_UPDATE_SQL = f""" + UPDATE {TABLE} + SET interval_seconds = :interval_seconds, + description = :description, + next_fire_at = NOW() + WHERE key = :key + """ + + +def upgrade() -> None: + op.execute( + sa.text(_UPDATE_SQL).bindparams( + sa.bindparam("interval_seconds", value=_INTERVAL_NEW), + sa.bindparam("description", value=_DESC_NEW), + sa.bindparam("key", value=TASK_KEY), + ) + ) + + +def downgrade() -> None: + op.execute( + sa.text(_UPDATE_SQL).bindparams( + sa.bindparam("interval_seconds", value=_INTERVAL_OLD), + sa.bindparam("description", value=_DESC_OLD), + sa.bindparam("key", value=TASK_KEY), + ) + ) diff --git a/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py b/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py index 33c000e28..825b3a4bb 100644 --- a/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py +++ b/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py @@ -1,6 +1,6 @@ """``pdf_fidelity_patrol`` handler — PDF→Markdown 高保真自拟合巡检的**节奏权威**。 -由统一调度引擎按 ``interval``(默认 3600s / 1h)tick。每 tick(轻量、仅 DB + 短 IO): +由统一调度引擎按 ``interval``(默认 600s / 10min)tick。每 tick(轻量、仅 DB + 短 IO): 1. **确保巡检 Repository**:幂等 upsert 名为 ``negentropy`` 的 Repository(local_path 从 ``settings.routine.patrol_repo_local_path`` 或 negentropy 包路径推导;无法确定则返回 @@ -10,8 +10,8 @@ 把文档标 done(合格)/unfixable(尽力)——保证文档必进 ``skip_ids``、被推进,不再死循环; cancelled 不沉淀(用户干预,文档保持可被重新选中)。 3. **跳过并发**:存在 ``status='running'`` 的巡检 Routine → 本 tick SKIP(保证「上一轮结束后 - 再启下一轮」;ScheduledTask 的 ``interval`` 计 ``next_fire_at = 完成时刻 + 3600s``,叠加此 - 互斥即满足「巡检进行中则等待其结束 + 1h」语义)。 + 再启下一轮」;ScheduledTask 的 ``interval`` 计 ``next_fire_at = 完成时刻 + 600s``,叠加此 + 互斥即满足「巡检进行中则等待其结束 + 10min」语义)。 4. **选下一份待检生产 PDF**:``knowledge_documents`` 中 ``content_type LIKE '%pdf%'`` 且 ``markdown_extract_status='completed'``,排除记忆中已 done/unfixable 的 doc_id。 5. **预取源 PDF**:``BlobStorage.download(content_uri)`` → 暂存到 ``patrol_input_dir//``。 @@ -79,7 +79,7 @@ def _doc_display_title(doc: dict[str, Any]) -> str: handler_kind=PATROL_HANDLER_KIND, label="PDF Fidelity Patrol", description=( - "每 1h 轮询一份生产 PDF 文档,启动一个 NegentropyEngine 巡检 Routine:" + "每 600s 轮询一份生产 PDF 文档,启动一个 NegentropyEngine 巡检 Routine:" "视觉对比 Markdown↔PDF、改 perceives、重转、评分,拟合至满分;" "Perceives 改进经非回归校验后以 PR 合回基线。" ), From 1f87dd620db7df260e5f42c03f14f0b247f2dac2 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 8 Jul 2026 10:02:35 +0800 Subject: [PATCH 12/81] =?UTF-8?q?docs(agents):=20=E5=8D=8F=E4=BD=9C?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E6=96=B0=E5=A2=9E=E3=80=8CUI=20=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E8=AE=BE=E8=AE=A1=E8=A7=84=E8=8C=83=E3=80=8D=EF=BC=88?= =?UTF-8?q?=E5=88=97=E5=AE=BD=E5=9B=BA=E5=AE=9A=E3=80=81=E6=BA=A2=E5=87=BA?= =?UTF-8?q?=E7=9C=81=E7=95=A5=20+=20Tooltip=EF=BC=89;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d6623cea6..cfc4ae9d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,4 +54,8 @@ 2. **语言叙事**:用语精准,叙事完备,行文专业,聚焦核心,篇幅精炼,形象具体,体现真实作用与用户吸引性,字数恰当; 3. **Direct Hyperlinking (直接跳转)**: 在文档中提及 Repo 内其他资源(文档/代码)时,**必须**构建可跳转的相对路径链接(如 `[Doc Name](./path.md)`),严禁使用“死文本”引用,以降低信息检索熵; 4. **实操截图**:文档需要引入必要的浏览器实操截图时,需自行通过默认浏览器打开相关页面,通过实操现场截图并保留到文档路径进行文档引用; +- **UI Table Design Norms (UI 表格设计规范)**: + 1. **样式一致性**:保持全局 UI 表格风格的一致性; + 2. **列宽固定与对齐**:表格列宽必须固定。不同表格中具有相同属性的列应采用相同的固定列宽,列的设计宽度应与其实际内容的长度相匹配; + 3. **溢出处理与 Tooltip**:列名与单元格内容默认禁止折行(保持单行显示)。超出列宽的部分使用省略号(`...`)物理截断,并配置 Tooltip 悬浮展示完整内容; - **Reference Specifications (IEEE)**:为保障工程决策的可追溯性与学术严谨性,核心引用需遵循 [reference-specifications.md](docs/.agents/reference-specifications.md)IEEE 标准引用格式; From 0841af0396e4c227e45932c6a1b53710ec991fd7 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 8 Jul 2026 10:04:43 +0800 Subject: [PATCH 13/81] =?UTF-8?q?fix(perceives/pdf):=20=E4=BF=9D=E7=9C=9F?= =?UTF-8?q?=E5=B7=A1=E6=A3=80=20e669a5ea=20=E2=80=94=20=E5=85=AC=E5=BC=8F?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=8E=BB=E9=87=8D=20+=20affiliation=20?= =?UTF-8?q?=E4=B8=8A=E6=A0=87=20+=20figure=20=E6=A0=87=E7=AD=BE=E6=8A=91?= =?UTF-8?q?=E5=88=B6=20(#1066)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(perceives/pdf): 公式线性文本碎片 run 去重,消除 inline 碎片与 block formula 并存重复 巡检 e669a5ea eq(1):PyMuPDF 把公式视觉区字形抽为一串独立 text 碎片 ($L(N,D)=L_0+A$ / $N_{}\alpha+B$ / $D\beta ,(1)$),与 marker/docling 的 block formula LaTeX 主体($$\cal L(N,D)=...$$)并存重复。碎片 run 常被解释 散文与 block 隔开(不相邻),致 §2.5.5 紧邻残片链与 §2.4 编号文本去重 (<200 字符守卫)双双漏判。 assembly §2.5.5c 新增 run-based 签名去重:收集所有 block formula 签名(global); 扫描连续"碎片候选 run"(纯 $...$ text 碎片 / inline formula / 前导 $...$ (N) 胶质文本,允许空白穿插);run 累积签名若与某 block 签名互为子串且覆盖率 ≥0.6 → 剔除纯碎片 / inline 副本,剥离胶质文本前导(剥空则整体删),保留解释散文。 希腊字母被签名剥除致单碎片过短,故用 run 累积签名 + 覆盖率守卫兼顾命中与低 误杀。散文内合法 inline 数学(如附录 B.2 metrics 整段单元素)不构成碎片 run, 不受影响。 验证:eq(1) 三段乱码碎片剔除、display $$\tag{1}$$ 保留;27 个 display 公式 总数不变;B.2 metrics 散文内 inline 数学完整;332 assembly/formula 单测通过。 Co-Authored-By: Claude Opus 4.8 * fix(perceives/pdf): §2.4 公式编号文本去重覆盖 LaTeX-only 线性文本副本(eq6/7/8) 巡检 e669a5ea eq(6)/(7)/(8):附录公式 display $$\tag{6/7/8}$$ 之前各有一串 inline text 碎片 + (N) 编号的线性文本副本(如 $D^*$ $_k=\Lambda1/2$ $k$ $B^⊤$ $k U.$ (6)),与 display 并存重复。§2.4 编号文本去重的 math 信号仅认 UNICODE 数学符(∈∀∑αβ…),这些碎片无 UNICODE 数学符、满是 LaTeX 命令(\Lambda/\ell/ \top)致漏判。 §2.4 补充信号:text 元素含 (N)(已知公式编号)+ count("$")≥6(≥3 个 inline math span)+ LaTeX 命令 + <200 字符 → 判为公式线性文本副本剔除。$≥6 + (N) 编号锚定 + <200 三重守卫,散文几乎不命中(散文 inline math 通常 ≤2 span)。 与 §2.5.5c(多元素 run 签名去重,修 eq(1))互补:§2.4 处理单元素高密度 span 形态,§2.5.5c 处理多元素 run 形态。 验证:eq(6)/(7)/(8) inline 碎片剔除、display $$\tag{6/7/8}$$ 保留;27 个 display 公式总数不变;eq(1) 仍清洁;B.2 metrics 散文内 inline 数学完整;合法公式引用 文("Substituting back gives"/"Ky Fan" 等)齐;332 assembly/formula 单测通过。 Co-Authored-By: Claude Opus 4.8 * fix(perceives/pdf): affiliation 上标规范化,inline 数学 $,a$/$X$ 转 巡检 e669a5ea 作者块:PyMuPDF 把作者署名行的上标 affiliation 字母抽为 inline 数学($,a$ Daniel、$a$ Stanford University),UI 渲染为斜体字母而非 上标。$^{b}$ 经 KaTeX 仍渲染为上标(视觉正确),故仅处理两种"视觉错误"形态。 assembly 新增 _normalize_affiliation_inline_math 后处理(最终 markdown 阶段): - 形态 1:$,X$(逗号+单字母,合法数学无此写法)→ X。 - 形态 2:affiliation 行(以 $X$ 起手 + 含 ≥2 个 $X$ 单字母标记 + 机构关键词 University/Institute/MIT/Google 等)→ 行内 $X$ 转 X。 一般散文 $X$ 单字母变量(如 "loss $L$")不满足"起手+≥2+关键词",不动; $^{X}$ LaTeX 上标(视觉已正确)不动。零误杀真公式。 验证:作者块 ,a$→a、affiliation 行 $a$/$b$/$c$/$d$→;仅 作者块 3 行命中,prose 单字母变量未误伤;eq(1)/(6)/(7)/(8) 仍清洁; 27 display 公式不变;335 assembly/formula/byline 单测通过。 Co-Authored-By: Claude Opus 4.8 * fix(perceives/pdf): 扩展 figure region 低内容标签判定,抑制图内多词标签泄漏 巡检 e669a5ea Fig 1:图内矢量标签("Compute Optimal Asymptotic Scaling data / model"、"Learning requires model scaling")被 PyMuPDF 抽为独立 text block,落入 figure region 但因 3-6 个英文词逃逸现有 _is_low_content_ figure_label(仅捕获 ≤2 词或数字序列的轴刻度/面板标签)。 扩展 _is_low_content_figure_label 信号 D:3-6 个 ≥3 字母英文词、≤60 字符、 无章节编号前缀、无句末标点 → 判为图内标签/注释并抑制。真实 section 标题 多带编号、真实段落多带句末标点且更长,均不命中。仅作用于已落入 figure region 的文本块(图内标签已烘入 figure region PNG 像素,抑制文本副本不 丢信息,消除"图 + 泄漏 prose"双存)。 验证:Fig1 图内标签抑制、figure bitmap 保留(labels 烘入像素);正文段落/ 章节标题(21 处)/23 图 caption/27 display 公式全 intact;body 锚点短语齐; 401 assembly/figure overlay 单测通过(含 test_assembly_figure_overlay_text)。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../perceives/pipeline/stages/pdf/assembly.py | 190 +++++++++++++++++- 1 file changed, 182 insertions(+), 8 deletions(-) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py index 7bcf6a395..2b5d31173 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py @@ -1111,7 +1111,19 @@ def _name_in_text(name: str) -> bool: + r")\s*\)", elem.content, ) - and any(c in elem.content for c in _math_chars) + and ( + any(c in elem.content for c in _math_chars) + or ( + # 补充信号:高 inline-math 密度(≥6 个 ``$`` 即 ≥3 个 + # ``$...$`` span)+ LaTeX 命令——覆盖无 UNICODE 数学符 + # 但满是 LaTeX 命令的公式线性文本副本(巡检 e669a5ea + # eq(6)/(7)/(8):``$D^*$ $_k=\Lambda1/2$ ... (6)``)。 + # ``$``≥6 + ``(N)`` 编号锚定 + <200 字符三重守卫,散文 + # 几乎不命中(散文 inline math 通常 ≤2 span)。 + elem.content.count("$") >= 6 + and re.search(r"\\[a-zA-Z]{2,}", elem.content) + ) + ) and len(elem.content.strip()) < 200 and not elem.content.strip().startswith("#") ) @@ -1239,6 +1251,121 @@ def _is_formula_text_fragment(content: str) -> bool: e for i, e in enumerate(elements) if i not in _fragment_remove ] + # 2.5.5c 公式线性文本碎片 run vs block formula 签名去重(巡检 e669a5ea eq(1)) + # PyMuPDF 把公式视觉区字形抽为一串独立 text 碎片(``$L(N,D)=L_0+A$`` / + # ``$N_{}\alpha+B$`` / ``$D\beta ,$ (1)``),与 marker/docling 的 block + # formula LaTeX 主体(``$$\cal L(N,D)=...$$``)并存重复。碎片 run 常被 + # 解释散文与 block 隔开(不相邻),致 §2.5.5 紧邻残片链与 §2.4 编号文本 + # 去重(<200 字符守卫)双双漏判。此法:①收集所有 block 签名(global); + # ②扫描连续"碎片候选 run"(纯 ``$...$`` text 碎片 / inline formula / + # 前导 ``$...$ (N)`` 胶质文本,允许空白穿插);③run 累积签名若与某 block + # 签名互为子串且覆盖率 ≥0.6 → 判为公式线性文本重复:剔除纯碎片 / inline + # 副本,剥离胶质文本前导(剥空则整体删),保留解释散文。希腊字母被签名 + # 剥除致单碎片过短,故用 run 累积签名 + 覆盖率守卫兼顾命中与低误杀。 + _LEADING_MATH_EQFRAG_RE = re.compile(r"^\$([^$]{1,60})\$\s*\(\d+\)\s*") + + def _leading_frag_strip_len(content: str) -> int: + m = _LEADING_MATH_EQFRAG_RE.match(content) + if not m: + return 0 + inner = m.group(1) + if not ( + any(c in inner for c in _MATH_FRAG_CHARS) + or re.search(r"\\[a-zA-Z]{2,}", inner) + ): + return 0 + return m.end() + + # 碎片候选判定:返回 (sig, kind, strip_len) 或 None + # kind="frag":整元素为公式碎片(纯 $...$ text 碎片 / inline formula)→ 整体删 + # kind="lead":前导 $...$ (N) 碎片 + 解释散文 → 剥前导留散文(剥空则整体删) + # kind="blank":空白 text(允许 run 内穿插,不单独成 run) + def _frag_candidate( + elem: _ContentElement, + ) -> Optional[Tuple[str, str, int]]: + if ( + elem.element_type == "formula" + and elem.formula is not None + and not (elem.content or "").strip().startswith("$$") + ): + return ( + _formula_text_signature(elem.formula.latex or ""), + "frag", + 0, + ) + if elem.element_type == "text" and elem.block is not None: + c = (elem.content or "").strip() + if not c: + return ("", "blank", 0) + sl = _leading_frag_strip_len(c) + if sl > 0: + return (_formula_text_signature(c[:sl]), "lead", sl) + if _is_formula_text_fragment(c): + return (_formula_text_signature(c), "frag", 0) + return None + + # 收集所有 block formula 签名(global,不依赖与碎片的位置邻接) + _block_sigs: List[str] = [ + _formula_text_signature(e.formula.latex or "") + for e in elements + if e.element_type == "formula" + and e.formula is not None + and ( + e.formula.formula_type == "block" + or (e.content or "").strip().startswith("$$") + ) + ] + _block_sigs = [s for s in _block_sigs if len(s) >= 6] + _run_remove: set[int] = set() + _run_strip: dict[int, int] = {} + if _block_sigs: + _i = 0 + while _i < len(elements): + _fc = _frag_candidate(elements[_i]) + if _fc is None or _fc[1] == "blank": + _i += 1 + continue + # 收集连续碎片 run(允许空白穿插;遇非碎片候选即断) + _run: List[Tuple[int, str, str, int]] = [(_i, *_fc)] + _k = _i + 1 + while _k < len(elements): + _fc2 = _frag_candidate(elements[_k]) + if _fc2 is None: + break + if _fc2[1] == "blank": + _k += 1 + continue + _run.append((_k, *_fc2)) + _k += 1 + _combined = "".join(_s for _, _s, _, _ in _run if _s) + _matched = False + if len(_combined) >= 6: + for _bsig in _block_sigs: + if _combined in _bsig or _bsig in _combined: + _shorter = min(len(_combined), len(_bsig)) + _longer = max(len(_combined), len(_bsig)) + if _shorter / _longer >= 0.6: + _matched = True + break + if _matched: + for _idx, _sig, _kind, _sl in _run: + if _kind == "frag": + _run_remove.add(_idx) + else: # lead:剥前导;剥空则整体删 + _full = (elements[_idx].content or "").strip() + if _sl >= len(_full): + _run_remove.add(_idx) + else: + _run_strip[_idx] = _sl + _i = _k if _k > _i else _i + 1 + for _idx, _sl in _run_strip.items(): + _pe = elements[_idx] + _pc = (_pe.content or "").strip() + if len(_pc) >= _sl: + _pe.content = _pc[_sl:] + if _run_remove: + elements = [e for _i, e in enumerate(elements) if _i not in _run_remove] + # 2.5.6 公式序号 gap-consistency 推断回填(ISSUE-094 R9 D-2/D-3/D-4): # Docling ``iterate_items`` 路径下抽取的公式 LaTeX 主体常不带 # ``\\tag{N}`` / ``\\quad (N)`` 编号,UI 视图等式编号缺失。 @@ -1629,6 +1756,10 @@ def _is_formula_text_fragment(content: str) -> bool: # 对"非已 fenced、``{``/``[`` 起 + 配对收尾 + ≥2 个 ``"key":`` 且括号 # 配平"的段落,包裹为 ```json 代码块。检测保守,仅命中明显 JSON。 markdown = _fence_json_text_paragraphs(markdown) + # 作者 affiliation 上标规范化:把 ``$,a$`` / affiliation 行内 ``$X$`` + # 单字母标记从 inline 数学转为 ``X``,避免渲染为斜体字母 + # (巡检 e669a5ea 作者块:``,a$ Daniel``、``$a$ Stanford University``)。 + markdown = _normalize_affiliation_inline_math(markdown) # 4. 图片引用规范化 images: List[ExtractedImage] = [] @@ -2190,17 +2321,23 @@ def _is_low_content_figure_label(text: str) -> bool: return True t = text.strip() words = re.findall(r"[A-Za-z]{3,}", text) + # 章节编号前缀要求编号后跟 ≥2 字母英文词('4.2 Behavioral Evidence'/'A Related Work'), + # 避免把 '10 −1'(−1 非字母)、'1 B 300 M 20 M'(B/M 单字母)这类刻度/图例 + # 噪声误判为 section 编号。 + has_section_prefix = bool(re.match(r"^(?:\d+(?:\.\d+)*|[A-Z])\s+[A-Za-z]{2,}", t)) + has_terminal_punct = bool(re.search(r"[.!?][\"')\]]*\s*$", t)) # 信号 A + C:短碎片(≤2 个 ≥3 字母英文词)且非"章节编号前缀 / 句末标点"形态 if len(words) <= 2: - # 章节编号前缀要求编号后跟 ≥2 字母英文词('4.2 Behavioral Evidence'/'A Related Work'), - # 避免把 '10 −1'(−1 非字母)、'1 B 300 M 20 M'(B/M 单字母)这类刻度/图例 - # 噪声误判为 section 编号。 - has_section_prefix = bool( - re.match(r"^(?:\d+(?:\.\d+)*|[A-Z])\s+[A-Za-z]{2,}", t) - ) - has_terminal_punct = bool(re.search(r"[.!?][\"')\]]*\s*$", t)) if not has_section_prefix and not has_terminal_punct: return True + elif 3 <= len(words) <= 6: + # 信号 D(多词图内标签 / 注释):Figure 内的轴标题、图例短语、面板注释 + # ("Compute Optimal Asymptotic Scaling data / model"、"Learning requires + # model scaling")。判据:3-6 个 ≥3 字母英文词、≤60 字符、无章节编号前缀、 + # 无句末标点(巡检 e669a5ea Fig 1)。真实 section 标题多带编号、真实段落 + # 多带句末标点且更长,均不命中。仅作用于已落入 figure region 的文本块。 + if not has_section_prefix and not has_terminal_punct and len(t) <= 60: + return True # 信号 B:相邻纯数字序列(≥3 个)= 坐标轴刻度 return bool(re.search(r"\d+(?:\.\d+)?(?:[\s,;]+\d+(?:\.\d+)?){2,}", text)) @@ -3015,6 +3152,43 @@ def _code_block_to_markdown( _INLINE_MATH_FALSEPOS_TRIGGER = re.compile(r"\\l?dots\b") +def _normalize_affiliation_inline_math(markdown: str) -> str: + """把作者 affiliation 上标标记从 inline 数学规范化为 ``X``。 + + PyMuPDF 把作者署名行里的上标 affiliation 字母(a/b/c/d)抽为 inline 数学 + (``$,a$``、``$^{b}$``、affiliation 行 ``$a$ Stanford University``),UI 渲染 + 为斜体字母而非上标(巡检 e669a5ea 作者块)。``$^{b}$`` 经 KaTeX 仍渲染为上标 + (视觉正确),故仅处理两种"视觉错误"形态,零误杀真公式: + + 形态 1 ``$,X$``(逗号 + 单字母):合法数学无此写法 → ``X``。 + 形态 2 affiliation 行:以 ``$X$`` 起手 + 含 ≥2 个 ``$X$`` 单字母标记 + 机构 + 关键词(University/Institute/MIT/Google 等)的行,行内 ``$X$`` → ``X``。 + 一般散文 ``$X$`` 单字母变量(如"loss $L$")不满足"起手 + ≥2 + 关键词",不动。 + """ + # 形态 1:$,X$ → X + markdown = re.sub(r"\$,([a-zA-Z])\$", r"\1", markdown) + # 形态 2:affiliation 行内 $X$ → X + _AFFIL_KW = re.compile( + r"University|Institute|College|Laborator|\bLab\b|\bMIT\b|Google|Microsoft|" + r"Amazon|Apple|\bMeta\b|DeepMind|School|Hospital|Research", + re.IGNORECASE, + ) + _SINGLE_LETTER_MATH = re.compile(r"\$([a-z])\$") + + def _affil_line(m: "re.Match[str]") -> str: + line = m.group(0) + if not re.match(r"\s*\$[a-z]\$", line): + return line + if len(_SINGLE_LETTER_MATH.findall(line)) < 2: + return line + if not _AFFIL_KW.search(line): + return line + return _SINGLE_LETTER_MATH.sub(r"\1", line) + + markdown = re.sub(r"[^\n]*\$[a-z]\$[^\n]*", _affil_line, markdown) + return markdown + + def _unwrap_ellipsis_falsepositive_inline_math(markdown: str) -> str: """解包"省略号型"内联公式误判。 From 91b3692abd6845a6860d33cb31cb69be85208c98 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 8 Jul 2026 10:05:15 +0800 Subject: [PATCH 14/81] =?UTF-8?q?fix(perceives):=20PDF=20=E4=BF=9D?= =?UTF-8?q?=E7=9C=9F=E5=B7=A1=E6=A3=80=E4=BF=AE=E5=A4=8D=EF=BC=88figure=20?= =?UTF-8?q?heading=20=E9=99=8D=E7=BA=A7=20+=20=E6=A0=87=E9=A2=98=E8=BF=9E?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E4=BF=9D=E6=8A=A4=EF=BC=89=20(#1067)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(perceives): 降级 figure region 内误提的 heading 为正文段落 assembly.py 标题质量过滤链(2.1b)增加分支:heading 文本块 bbox 中心落入 layout figure region(_layout_figure_regions,中心点包含 或 IoU≥0.3)时,降级为正文段落(内容保留、脱离标题层级)。 根因:docling 常把 figure 图内分区文字(如 Figure 5 的 "Planning for Agent Harness"、Figure 8 的 "Harness Control through the Plan, Execute, and Verify Loop")误提为 H3 heading,与带编号的 section heading 文本重复,污染目录锚点。 保守策略:仅降级不删除;真实 section 标题极少完全落入 figure region(中心点包含检测),即便被过大 region 误吞而降级,作为 段落保留亦优于重复 heading 破坏目录(ISSUE-094 figure-region trade-off)。 验证:《Code as Agent Harness》(c9f80764) 102 页 PDF 重转,Figure 5/6/8 等图内误提 H3 全部降级为正文;3.1.1–5.2.7 所有真实 section/subsection 标题完整保留,无非回归。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): 标题合成词连字符保护(formatter fidelity-safe) formatter.py format_fidelity_safe 的 _title_soft_hyphen_mh 原仅按 left 前缀白名单(_COMPOUND_HYPHEN_PREFIXES)保护合成词连字符,对 left 不在 白名单的合成词(Structure-grounded / Search-based / Orchestration-based) 误并合为 Structuregrounded 等,且该 pass 在 ops/pdf.py 对 assembly 输出 二次格式化时覆盖了 assembly 内的任何 heading 恢复。 增强: 1. _title_soft_hyphen_mh 增加合成词后缀保护集(grounded/based/oriented/ driven/centric/aware/enabled/guided/informed/centred/level):right 为稳定合成词后缀时保留连字符,覆盖 left 不在白名单的合成词。 2. _COMPOUND_HYPHEN_PREFIXES 增加 "human"(保护 Human-in-the-Loop 短语)。 软断字合并功能保留(right 非合成词后缀时仍合并,如 Parame-ters→ Parameters),单测验证非回归。 验证:《Code as Agent Harness》(c9f80764) 重转,3.1.2 Structure-grounded / 3.1.3 Search-based / 3.1.4 Orchestration-based / 5.2.5 Human-in-the-Loop 连字符全部恢复;含连字符 heading 均为合法合成词,无软断字残留。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../perceives/markdown/formatter.py | 24 +++++++++++++++++++ .../perceives/pipeline/stages/pdf/assembly.py | 11 +++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/markdown/formatter.py b/apps/negentropy-perceives/src/negentropy/perceives/markdown/formatter.py index a575aa406..75bf3f322 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/markdown/formatter.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/markdown/formatter.py @@ -153,6 +153,8 @@ class _LineType(Enum): "dry", "wet", "raw", + # 合成词前缀(保护 Human-in-the-Loop 等 ``human-X`` 短语的 left) + "human", } ) @@ -668,11 +670,33 @@ def format_fidelity_safe(self, markdown_content: str) -> str: } ) + # 合成词后缀保护集:right 为常见学术合成词后缀时保留连字符,避免 + # _title_soft_hyphen_mh 误并 Structure-grounded / Search-based / + # Orchestration-based 等合成词(其 left 不在 _COMPOUND_HYPHEN_PREFIXES, + # 但 right grounded/based/oriented/... 是稳定合成词后缀)。 + _compound_hyphen_suffixes = frozenset( + { + "grounded", + "based", + "oriented", + "driven", + "centric", + "aware", + "enabled", + "guided", + "informed", + "centred", + "level", + } + ) + def _title_soft_hyphen_mh(mm: "re.Match[str]") -> str: left, right = mm.group(1), mm.group(2) low = left.lower() if low in _COMPOUND_HYPHEN_PREFIXES or low in _title_compound_extra: return f"{left}-{right}" + if right.lower() in _compound_hyphen_suffixes: + return f"{left}-{right}" return f"{left}{right}" markdown_content = re.sub( diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py index 2b5d31173..1714058f1 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py @@ -729,6 +729,17 @@ def _sort_key( # bullet 开头 → 列表项 elif heading_text.startswith("• ") or heading_text.startswith("- "): is_bad = True + # Figure region 内的 heading → 图内分区文字(如 Figure 5 的 + # "Planning for Agent Harness" 标签)被 docling 误提为 heading, + # 与带编号 section heading 文本重复、污染目录锚点。降级为正文 + # 段落(内容保留、脱离标题层级)。真实 section 标题极少完全落入 + # figure region(中心点包含检测);即便被过大 region 误吞而降级, + # 作为段落保留亦优于重复 heading 破坏目录(ISSUE-094 figure-region + # trade-off,此处复用 _layout_figure_regions 的中心点+IoU 判定)。 + elif elem.block and _block_overlaps_special( + elem.block, _layout_figure_regions, iou_threshold=0.3 + ): + is_bad = True if is_bad: elem.element_type = "text" elem.content = heading_text From f1a8ec469098af7bc89d390d19225e4e2f961527 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 8 Jul 2026 10:05:39 +0800 Subject: [PATCH 15/81] =?UTF-8?q?fix(perceives):=20PDF=E2=86=92Markdown=20?= =?UTF-8?q?=E9=AB=98=E4=BF=9D=E7=9C=9F=E5=A4=9A=E9=A1=B9=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88Self-Harness=20=E5=B7=A1=E6=A3=80?= =?UTF-8?q?=EF=BC=89=20(#1068)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(perceives): 抑制位图内矢量标签文本块,消除图内文字与正文双份 PDF 流程图/多面板 figure 的内部矢量标签(节点文字、图例、面板 sub-caption 如 "(a) Self-Harness evolution trajectory...")已被 image_extraction 烘入位图 像素,但 assembly 既保留位图又把同区域文本块作为正文输出,造成图内文字与正文 双份(Self-Harness 论文 Figure 2 流程图标签泄漏 15+ 行、Figure 5/6 面板标签 与 sub-caption 重复)。 新增 _image_regions(仅 image_extraction 位图 bbox,区别于常过大的 layout figure region)与 _block_fully_inside_region(四角均含 + 2pt 容差)完全包含 判定:文本块完全落入某张位图 bbox → 抑制。完全包含严格区别于既有 overlap (中心点/IoU)判定,精确位图 bbox 不会误吞图外真实内容(section 标题/段落 位于位图栅格区之外,不满足完全包含);"Figure N:"/"Table N:" caption 已由 既有 _is_figure_or_table_caption_text 恒保留,不在此处误伤。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): 代码区分片回声去重,消除 Figure 3 文本+代码块双份 PyMuPDF 把代码图(如 Self-Harness Figure 3 的 python harness 实现)拆成多个 文本块抽取(带行号碎片),单块仅含部分代码标识符——既有的"单块覆盖 code 标识符 ≥70%"整体回声判定(ratio>0.7)无法命中分片(最大分片 ratio 仅 0.56),致带行号 混乱文本与 docling 结构化 ```python 块双份并存。 新增分片回声去重:收集同页"块自身近全为代码标识符(overlap/len(block_words) ≥0.9)、非 caption、≥2 标识符"的文本块作为分片候选;若其标识符并集覆盖 code_words ≥70% → 判为同一代码的分片回声,全部抑制。并集门槛杜绝单块巧合误杀(单个散文块 不可能贡献 ≥70% 代码标识符);Figure N:/Table N: caption(描述 harness 函数、含较 多代码词但 ratio_block≈0.83)由 _is_figure_or_table_caption_text 守卫保留。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): 标题页码剥离防误伤连续章节号序列 _strip_heading_page_numbers 剥离 heading 首部 1-3 位数字(防页码并入), 但当剩余标题"首词大写且 ≥2 词"时会误剥合法章节号:仅单词标题("## 4 Experiments"/"## 5 Conclusion")因 <2 词幸存,多词标题("## 2 Background and Related Work"/"## 3 Self-Harness: An Iterative Loop...")的章节号被 误当页码剥离。 预扫描所有编号标题的数字,若构成连续序列(存在 ≥1 对相邻整数,如 1,2,3,4,5)判为合法章节号序列——序列内数字(与某元素相邻的)不剥离; 仅游离数字(孤立页码)适用原剥离逻辑。Self-Harness 论文 §2/§3 编号恢复, §1/4/5 及子节 3.1-3.4/4.1-4.3 无回归。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): 伪代码剥离误标 lang + PUA 符号字体还原∅ docling/marker 把 Algorithm 伪代码(含 do/end do、end if 等 Fortran-like 语法)误标为 ``fortran`` 等真实语言,致 fence 错误语法高亮;且 PDF 符号 字体的 PUA 编码 ∅(U+F638)无法被 docling 映射到标准 U+2205,残留为 不可见 PUA 码点(Self-Harness Algorithm 1 行5 "A_t <- "、行18 "if A_t =  then" 的空集符号丢失)。 1. _is_pseudocode:检测 "Algorithm N" 标题或同时含 "Require:"/"Ensure:" 算法关键字 → _code_block_to_markdown 剥离 lang,fence 不带 info string。 2. _PUA_MATH_CHAR_MAP:PUA 码点 → 标准 Unicode 数学符号映射(→∅), _code_block_to_markdown 渲染前还原,使符号可正确显示。 已验证 Algorithm 1 fence 改为无 lang、∅ 恢复(行5/行18);python 代码块 仍保留 ```python;§1-5 章节编号无回归。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): display 公式块与正文内联 raw LaTeX 字面串去重 公式既以内联 raw LaTeX 字面串(非 $...$ 包裹,属抽取残留,如 "C_{\phi}={r_{i}\in F_{t}|\phi(r_{i})=\phi}")出现在正文段,又作独立 $$...$$ display 块时,display 为重复抽取。Self-Harness 论文 C_φ 聚类 公式即此情形(内联字面串由后续内联数学重建插入正文,emission 时去重 无法捕获)。 新增 _dedup_inline_display_formulas:在最终 markdown 后处理阶段,对每个 $$...$$ display 块,用 _formula_text_signature(剥 LaTeX 命令 + 非 alphanumeric,使 \phi 与 Unicode φ 归一一致)计算签名;若为"剥离所有 $$...$$ 与 $...$ 后的正文 raw 文本签名"的子串(≥6 字符)→ display 重复, 去除并清理多余空行。安全闸:先剥 $...$ 行内数学,仅匹配 raw 字面串, 故意内联 $...$ + display 并存的论文不受影响。已验证 C_φ display 去除、 r_i/F_t/φ(r_i) 等合法 display 保留、§1-5 编号无回归。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): orphan 图优先内联放置到同页兄弟图后,避免甩至文末 image_ref_normalizer._append_orphan_images 原把所有未引用图追加到文末, 致多面板 figure 的右侧 diff 面板(fig_p10_2/fig_p11_2/fig_p19_2,与左侧 轨迹图同页但未被文本引用)被甩到文末,破坏阅读流。 新增内联放置:orphan 若与**唯一**张已引用图同页(多面板 figure 的典型 情形),插入到该兄弟 标签之后;无兄弟或多兄弟(归属歧义)回退 原文末追加。"唯一同页兄弟"门控消除多图页误配,保证非回归安全。 Self-Harness 论文 Figure 5/6/10 右侧 diff 面板现紧跟左侧轨迹图内联, caption 仍在下方;文末 orphan 块消除;12 张图全部内联。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): 抑制纯公式残留段落(PyMuPDF 把 display 公式抽为破碎 $...$ 段落) PyMuPDF 把 display 公式(如 Self-Harness Δ 接受规则 \Delta_{in}^{(j)}\geq 0, max(...)>0)另抽为破碎 $...$ 行内数学的独立段落(无 prose,如 "$∆(j)$ $_{in} \geq0,$ $∆(j)$ $_{ho} \geq0,$ ..."),与干净 display 块并存。 新增 _strip_formula_dominated_paragraphs:文档含 $$...$$ display 块时,若 段落去除 $...$ 后剩余 prose < 15 字符(纯公式残留、无实际文字)→ 抑制。 保留 $$...$$ display 段落、heading、含实质 prose 的混合段落。Self-Harness 论文 Δ 接受规则的纯公式残留段已抑制(display 块保留);混合 formula+prose 段(Δ 定义 + "A candidate is accepted..." 句子)需语义分离,列入 unfixable。 Co-Authored-By: Claude Opus 4.8 * fix(perceives): orphan 内联放置加 caption 守卫,避免独立 figure 被错并入 orphan 内联放置原仅凭"同页唯一兄弟"判定,但兄弟图若有 caption(完整独立 figure,如 "图1.1"),orphan 是另一独立 figure 而非同 figure 的面板,内联 会错把独立图并入兄弟图后。 加 caption 守卫:仅当兄弟图无 caption(多面板 figure 的 panel,caption 由 单独文本块承载)时内联;兄弟有 caption 时 orphan 回退文末追加。修复 test_content_figure_preserved_not_page_dominant 回归(fig_p14_1 有 caption "图1.1"→fig_p14_2 回退文末);Self-Harness fig_p10_1 无 caption(panel) →fig_p10_2 仍内联。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../markdown/image_ref_normalizer.py | 53 +++- .../perceives/pipeline/stages/pdf/assembly.py | 235 +++++++++++++++++- 2 files changed, 282 insertions(+), 6 deletions(-) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py b/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py index 64595664b..1747a7e40 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/markdown/image_ref_normalizer.py @@ -202,8 +202,59 @@ def _append_orphan_images( if not orphans: return markdown + # 优先内联放置:orphan 若与唯一张已引用图同页(如多面板 figure 的右面板 + # 被左面板引用而自身 orphan),插入到该兄弟 之后,避免被甩到文末 + # 破坏阅读流。要求"唯一同页兄弟"以消除多图页的归属歧义(非回归安全)。 + ref_page: dict[str, int] = {} + _basename_to_caption: dict[str, Optional[str]] = {} + for _img in images: + _fn = getattr(_img, "filename", None) + _pg = getattr(_img, "page_number", None) + if _fn and _fn in referenced_basenames and _pg is not None: + ref_page[_fn] = _pg + if _fn: + _basename_to_caption[_fn] = getattr(_img, "caption", None) + + placed_inline: set[str] = set() + for orphan in orphans: + _ofn = orphan.filename + if not _ofn: + continue + _opg = getattr(orphan, "page_number", None) + if _opg is None: + continue + _sib_bns = [fn for fn, pg in ref_page.items() if pg == _opg] + if len(_sib_bns) != 1: + continue # 无兄弟或多兄弟(歧义)→ 回退文末追加 + _sib_bn = _sib_bns[0] + # 仅当兄弟图无 caption(多面板 figure 的 panel,其 caption 由单独文本块 + # 承载)时内联;兄弟有 caption(完整独立 figure)时 orphan 是另一独立 + # figure → 回退文末追加,避免把独立图错并入兄弟图后。 + _sib_caption = _basename_to_caption.get(_sib_bn) + if _sib_caption: + continue + _orphan_html = _build_img_html(orphan, image_dir) + + def _repl(m: "re.Match[str]", oh: str = _orphan_html) -> str: + return m.group(0) + "\n" + oh + + _sib_pat = re.compile( + r"(]*\bsrc\s*=\s*[\"'][^\"']*" + + re.escape(_sib_bn) + + r"[^\"']*[\"'][^>]*>)", + re.IGNORECASE, + ) + _new_md, _n = _sib_pat.subn(_repl, markdown, count=1) + if _n > 0: + markdown = _new_md + placed_inline.add(_ofn) + + remaining = [img for img in orphans if img.filename not in placed_inline] + if not remaining: + return markdown + appended_lines = ["", ""] - for img in orphans: + for img in remaining: appended_lines.append("") appended_lines.append(_build_img_html(img, image_dir)) return markdown.rstrip() + "\n".join(appended_lines) + "\n" diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py index 1714058f1..94824753c 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py @@ -120,9 +120,15 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: _grid_table_regions.setdefault(table.page_number, []).append( table.bbox ) + # ``_image_regions``:仅收录 image_extraction 提取的**位图本身** bbox + # (精确覆盖实际栅格区域,区别于 layout figure region 常过大)。用于 + # 抑制完全落入位图内的矢量标签文本块(流程图节点文字 / 图例 / 轴标题): + # 位图已烘入其像素,文本块为冗余副本。 + _image_regions: Dict[int, List[Tuple[float, float, float, float]]] = {} for img in input_data.images.images if input_data.images else []: if img.bbox: special_regions.setdefault(img.page_number, []).append(img.bbox) + _image_regions.setdefault(img.page_number, []).append(img.bbox) # layout_analysis 的 ``figure`` region 通常覆盖完整 figure 视觉框 # (含位图 + 矢量标签 + 标题)。image_extraction 仅给出位图位图本身的 @@ -237,6 +243,15 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: continue if _is_low_content_figure_label(block.text): continue + # 文本块完全落入某张已提取位图 bbox 内 → 图内矢量标签 + # (流程图节点文字 / 图例 / 面板标题),位图已烘入其像素, + # 抑制避免图内文字与正文双份。用"完全包含"(四角均在图内, + # 2pt 容差吸收坐标取整)而非 overlap:精确位图 bbox 不会 + # 误吞图外真实内容(section 标题 / 段落位于图外,不满足 + # 完全包含)。caption 已由上方 _is_figure_or_table_caption_text + # 恒保留,不在此处误伤。 + if _block_fully_inside_region(block, _image_regions): + continue # 字符级签名兜底:剔除 PyMuPDF 把公式视觉渲染区抽成 # "字符流文本"产生的冗余文本块(典型如长式 ``M_l = f_long(...)`` # 的 PyMuPDF 字符序列与 MinerU LaTeX 经签名归一化后等价) @@ -325,6 +340,11 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: and latex_core in text_formula_fingerprints ): continue + # 内联公式去重:公式签名(_formula_text_signature)是某同页 + # 正文段子串 → 公式已内联于正文段(raw 字面串),display $$ + # 块为重复抽取,跳过。≥6 字符启用(公式签名密集 alphanumeric, + # 巧合子串风险低)。 + md = _formula_to_markdown(formula) md = _formula_to_markdown(formula) if not md: continue @@ -377,6 +397,7 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: if code_words: if _effective_code_lang(code_block) in _REAL_CODE_LANGS: _echo_indices: List[int] = [] + _frag_candidates: List[Tuple[int, set]] = [] for _ei, elem in enumerate(elements): if ( elem.element_type != "text" @@ -392,11 +413,39 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: ) if not block_words: continue - overlap = len(code_words & block_words) + overlap_words = code_words & block_words + overlap = len(overlap_words) ratio = overlap / max(len(code_words), 1) + # 整体回声:单块覆盖 code 标识符 ≥70% if ratio > 0.7 and overlap >= 5: _echo_indices.append(_ei) - for _ei in reversed(_echo_indices): + continue + # 分片回声候选:PyMuPDF 把代码区拆成多块文本,单块 + # 仅含部分标识符(ratio 不足 0.7),但块自身几乎全是 + # 代码标识符(overlap/len(block_words) ≥ 0.9)→ 代码 + # 碎片。caption(Figure N:/Table N: 起手)含较多代码词 + # (描述 harness 函数)但非回声,由 + # _is_figure_or_table_caption_text 守卫保留。 + block_code_ratio = overlap / max(len(block_words), 1) + if ( + block_code_ratio >= 0.9 + and overlap >= 2 + and not _is_figure_or_table_caption_text( + elem.block.text + ) + ): + _frag_candidates.append((_ei, overlap_words)) + # 分片并集覆盖 code_words ≥70% → 同一代码的分片回声, + # 全部抑制。并集门槛杜绝单块巧合误杀(单个散文块不可能 + # 贡献 ≥70% 代码标识符)。 + if _frag_candidates: + _frag_union: set = set() + for _, _w in _frag_candidates: + _frag_union |= _w + if len(_frag_union) / max(len(code_words), 1) >= 0.7: + for _ei, _ in _frag_candidates: + _echo_indices.append(_ei) + for _ei in reversed(sorted(set(_echo_indices))): elements.pop(_ei) else: # 误标代码:重叠则 _skip code、保留 text(原逻辑) @@ -1822,6 +1871,12 @@ def page_number(self) -> Optional[int]: # 非 "."、剩余标题首词大写/引号、剩余 ≥2 词),保留 ``## 1. Get the Mission`` # 等编号标题与 ``## 10 Tips`` 等短标题。 markdown = _strip_heading_page_numbers(markdown) + # display $$ 块去重:公式既以内联 raw LaTeX 字面串(非 $...$ 包裹) + # 出现在正文又作独立 $$...$$ display 块时,display 为重复抽取,去除。 + markdown = _dedup_inline_display_formulas(markdown) + # 纯公式残留段落抑制:PyMuPDF 把 display 公式另抽为破碎 $...$ 行内 + # 数学的独立段落(无 prose),抑制之(干净 display 块已保留内容)。 + markdown = _strip_formula_dominated_paragraphs(markdown) # 6. 参考文献节条目分段(多条目连段 → 每条独占段落) markdown = _segment_references_section(markdown) @@ -1977,6 +2032,41 @@ def _block_overlaps_special( return False +# 完全包含判定的坐标容差(pt):吸收 PyMuPDF 文本块 bbox 与 image_extraction +# 位图 bbox 间的取整 / 半像素偏移,避免标签因 1-2pt 越界而漏判。 +_FULL_INSIDE_TOLERANCE_PT = 2.0 + + +def _block_fully_inside_region( + block: TextBlock, + regions: Dict[int, List[Tuple[float, float, float, float]]], +) -> bool: + """判断文本块 bbox 是否**完全落入**某区域(四角均在区域内,含 2pt 容差)。 + + 用于抑制位图内的矢量标签:image_extraction 提取的位图 bbox 精确覆盖实际 + 栅格区域,完全落入其中的文本块是叠加在位图上的图内文字(流程图节点 / + 图例 / 面板标题 / 轴标题),位图已烘入其像素,文本块为冗余副本应抑制。 + + 区别于 ``_block_overlaps_special`` 的 overlap 判定(中心点包含 / IoU≥阈值): + 完全包含严格得多——要求文本块四角均在图内。图外真实内容(section 标题、 + 导言段落)即便与过大的 layout figure region 部分重叠,也不会满足对**精确 + 位图 bbox** 的完全包含(标题/段落位于位图实际栅格区之外),故不会被误吞。 + + None / 缺 bbox 时返回 False(保守保留,交由下游通用处理)。 + """ + if not block.bbox: + return False + rs = regions.get(block.page_number) + if not rs: + return False + bx0, by0, bx1, by1 = block.bbox + t = _FULL_INSIDE_TOLERANCE_PT + for rx0, ry0, rx1, ry1 in rs: + if rx0 - t <= bx0 and bx1 <= rx1 + t and ry0 - t <= by0 and by1 <= ry1 + t: + return True + return False + + def _formula_text_signature(s: str) -> str: """提取字符级扁平签名(仅保留字母数字,全部小写)。 @@ -3098,6 +3188,41 @@ def _effective_code_lang(code_block: "ExtractedCodeBlock") -> str: """ +# PDF 符号字体 PUA 编码 → 标准 Unicode 数学符号映射。 +# docling 对部分 PDF 符号字体(MathType / Symbol 系)的 PUA 码点无法映射到 +# 标准 Unicode,残留为不可见字符。按学术论文高频符号还原(上下文验证)。 +_PUA_MATH_CHAR_MAP: dict[str, str] = { + "\uf638": "∅", # 空集(算法伪代码 "A <- ∅" / "if A = ∅ then") +} + +_PSEUDOCODE_ALGORITHM_HEADER_RE = re.compile( + r"^\s*Algorithm\s+\d+", re.IGNORECASE | re.MULTILINE +) +_PSEUDOCODE_REQUIRE_RE = re.compile(r"^\s*Require\s*:", re.IGNORECASE | re.MULTILINE) +_PSEUDOCODE_ENSURE_RE = re.compile(r"^\s*Ensure\s*:", re.IGNORECASE | re.MULTILINE) + + +def _is_pseudocode(code: str) -> bool: + r"""检测代码块是否为学术论文伪代码/算法(而非真实编程语言代码)。 + + docling/marker 常把 Algorithm 伪代码(含 ``do``/``end do``、``end if`` 等 + Fortran-like 语法)误标为 ``fortran`` 等真实语言,致 fence 错误语法高亮。 + 伪代码强信号: + + - 含 ``Algorithm N`` 标题行(最权威); + - 同时含 ``Require:`` 与 ``Ensure:`` 算法关键字。 + + 命中即判为伪代码 → fence 不带 lang info string。 + """ + if not code: + return False + if _PSEUDOCODE_ALGORITHM_HEADER_RE.search(code): + return True + if _PSEUDOCODE_REQUIRE_RE.search(code) and _PSEUDOCODE_ENSURE_RE.search(code): + return True + return False + + def _code_block_to_markdown( code_block: ExtractedCodeBlock, code_override: Optional[str] = None ) -> str: @@ -3119,6 +3244,19 @@ def _code_block_to_markdown( """ code = code_override if code_override is not None else (code_block.code or "") lang = (code_block.language or "").strip().lower() + # PUA 符号字体还原:部分 PDF 用符号字体的 PUA 编码渲染数学符号(如空集 ∅), + # docling 无法映射到标准 Unicode,残留为不可见 PUA 码点。按高频映射还原, + # 使代码块/算法伪代码中的符号可正确渲染。 + if code and _PUA_MATH_CHAR_MAP: + for _pua, _uni in _PUA_MATH_CHAR_MAP.items(): + if _pua in code: + code = code.replace(_pua, _uni) + + # 伪代码/算法:docling/marker 常把 Algorithm 伪代码(含 do/end do 等 + # Fortran-like 语法)误标为 fortran 等真实语言。检测伪代码特征 → 剥离 + # lang,fence 不带 info string,避免错误语法高亮(伪代码无标准语法)。 + if lang and _is_pseudocode(code): + lang = "" # 拆首行用于 lang 头识别 stripped = code.lstrip("\n") @@ -3382,27 +3520,114 @@ def _strip_heading_page_numbers(markdown: str) -> str: - 剩余标题以大写字母或引号开头(标题首词大写的常规形态); - 剩余标题 ≥2 个词(避免误伤 ``## 10 Tips`` 等短标题)。 - 保守守卫,仅命中明显的页码-并入标题。 + **防误剥章节号**:学术论文 "## 2 Background and Related Work" / "## 3 Methodology" + 等多词标题的章节号曾被误当页码剥离(仅单词标题如 "## 4 Experiments" 因 <2 词 + 幸存)。预扫描所有编号标题的数字,若构成连续序列(存在 ≥1 对相邻整数,如 + 1,2,3,4,5),判为合法章节号序列——序列内数字(与序列某元素相邻的)**不剥离**; + 仅游离数字(孤立页码)适用上述剥离逻辑。 """ + # 预扫描:收集所有编号标题的数字,检测连续序列(合法章节号) + _numbered_nums: set = set() + for _m in re.finditer(r"^#{1,6} (\d{1,3})\s+.+$", markdown, re.MULTILINE): + try: + _numbered_nums.add(int(_m.group(1))) + except ValueError: + pass + _section_nums: set = set() + if _numbered_nums: + for _n in _numbered_nums: + # 与某个其他编号相差 1 → 属于连续序列 → 章节号 + if (_n - 1) in _numbered_nums or (_n + 1) in _numbered_nums: + _section_nums.add(_n) + def _strip(m: "re.Match[str]") -> str: hashes = m.group(1) - rest = m.group(2) + num = int(m.group(2)) + rest = m.group(3) if not rest: return m.group(0) + # 合法章节号序列内的数字不剥离 + if num in _section_nums: + return m.group(0) if rest[0].isupper() or rest[0] in "\"'“‘": if len(rest.split()) >= 2: return f"{hashes} {rest}" return m.group(0) return re.sub( - r"^(#{1,6}) \d{1,3}\s+(.+)$", + r"^(#{1,6}) (\d{1,3})\s+(.+)$", _strip, markdown, flags=re.MULTILINE, ) +def _dedup_inline_display_formulas(markdown: str) -> str: + r"""去除与正文内联 raw LaTeX 字面串重复的 display ``$$...$$`` 块。 + + 公式既以内联 raw LaTeX 字面串(**非 ``$...$`` 包裹**,属抽取残留,如 + ``C_{\phi} = {r_{i} \in F_{t} | \phi(r_{i}) = \phi}``)出现在正文段,又 + 作独立 ``$$...$$`` display 块时,display 块为重复抽取,去除之。内联 raw + 字面串保留于正文(位置忠实于 PDF 的内联排版)。 + + 判定:display 块 LaTeX 的 ``_formula_text_signature``(剥 LaTeX 命令 + + 非 alphanumeric,使 ``\phi`` 与 Unicode ``φ`` 归一一致)是"剥离所有 + ``$$...$$`` 与 ``$...$`` 后的正文 raw 文本签名"的子串 → display 重复。 + + **安全闸**:仅匹配 raw 字面串(先剥 ``$...$`` 行内数学);故意内联 + ``$...$`` 数学 + display 并存的论文(``$...$`` 被剥离不参与匹配)不受影响。 + ``≥6`` 字符签名启用(公式签名密集 alphanumeric,巧合子串风险低)。 + """ + + # 正文 raw 文本:剥离所有 $$...$$ display 块与 $...$ 行内数学 + non_formula = re.sub(r"\$\$.*?\$\$", "", markdown, flags=re.DOTALL) + non_formula = re.sub(r"\$[^$]*\$", "", non_formula) + non_formula_sig = _formula_text_signature(non_formula) + if len(non_formula_sig) < 6: + return markdown + + def _replace(m: "re.Match[str]") -> str: + latex = m.group(1) + f_sig = _formula_text_signature(latex) + if len(f_sig) >= 6 and f_sig in non_formula_sig: + return "" # display 块与正文内联 raw 字面串重复,去除 + return m.group(0) + + new_md = re.sub(r"\$\$(.*?)\$\$", _replace, markdown, flags=re.DOTALL) + # 清理去除后遗留的多余空行(≥3 连续换行 → 2) + return re.sub(r"\n{3,}", "\n\n", new_md) + + +def _strip_formula_dominated_paragraphs(markdown: str) -> str: + r"""抑制纯公式残留段落(PyMuPDF 把 display 公式另抽为破碎 ``$...$`` 段落)。 + + 当文档含 ``$$...$$`` display 块时,若某段落去除 ``$...$`` 行内数学后剩余 + prose < 15 字符(essentially 纯公式残留、无实际文字),抑制该段落——它是 + display 公式的转换残留,干净 display 块已保留其内容。 + + 安全性:仅当文档含 ``$$`` display 块时启用(否则可能误删唯一公式版本); + 保留 ``$$...$$`` display 段落与含实质 prose 的段落。混合 formula+prose 的 + 段落(如 Δ 定义后跟 "A candidate is accepted..." 句子)prose ≥15 字符, + 不被抑制(需语义分离,超出本函数能力)。 + """ + if "$$" not in markdown: + return markdown + parts = re.split(r"(\n{2,})", markdown) + result: List[str] = [] + for part in parts: + stripped = part.strip() + if not stripped or stripped.startswith("$$") or stripped.startswith("#"): + result.append(part) + continue + prose = re.sub(r"\$[^$]*\$", "", part) + prose_clean = re.sub(r"[\s\W_]+", "", prose) + if len(prose_clean) < 15: + continue # 纯公式残留,抑制 + result.append(part) + return "".join(result) + + def _split_code_tail_section(code: str) -> Tuple[str, str]: """检测 code body 尾部被引擎误纳的章节标题块并截断。 From 17c6a5b1d9b2f6a6aba99135fb0fa1ab0c42a1ce Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 8 Jul 2026 10:06:01 +0800 Subject: [PATCH 16/81] =?UTF-8?q?fix(perceives):=20PDF=20=E4=BF=9D?= =?UTF-8?q?=E7=9C=9F=E5=B7=A1=E6=A3=80=20=E2=80=94=20=E8=B7=A8=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E8=A1=A8=E6=A0=BC=E5=9B=9E=E5=A3=B0=E5=8E=BB=E9=87=8D?= =?UTF-8?q?/figure=E6=A0=87=E7=AD=BE=E7=B0=87=E6=8A=91=E5=88=B6/=E4=BD=9C?= =?UTF-8?q?=E8=80=85=E5=9D=97math=E8=A7=A3=E5=8C=85=20(#1069)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(perceives/assembly): dedup cross-engine table-cell echo text blocks Multi-engine PDF fusion (--method auto) emitted each table twice: once as a GFM grid (Unicode △/×/✓ from one engine) and again as loose inline-math text ($\triangle$/$\times$ from a second engine). The loose echo bypassed existing dedup because (a) its bbox/page-number was misaligned with the grid table's coordinate space, defeating the spatial-overlap dedup (_grid_table_regions), and (b) it did not start with '|', defeating the pipe-gated fingerprint dedup. Add content-level echo suppression in the text-block assembly loop, gated on the document having grid tables: - Precompute per-table first-column row-label tokens AND full-cell tokens (extracted from any '|' lines, independent of caption prefix which often makes table.markdown start with "Table N |" rather than "|"). - Tier A: marker-dense block overlapping a table's row labels >=3 tokens. - Tier B: marker-dense block with <=2 English words (pure cell echo). - Tier C: text-only cell echo — >=5 content tokens (stopword-removed) with >=90% in a table's full-cell set. Marker counting strips LaTeX macro letters (triangle/times) before the word count so density is not undercounted. Conservative gating keeps false positives near zero (verified on diverse table-vocabulary prose). On the 88-page survey eval doc, removes 137 lines of redundant table echo across all 6 tables while preserving all 100 grid rows, 73 headings, the References section, 11 figure refs, and 30 display-math blocks. Co-Authored-By: Claude Opus 4.8 * fix(perceives/assembly): suppress figure label clusters + unwrap byline math Two fidelity fixes for academic PDFs (survey eval doc: -358 lines of cross- engine duplication with zero content loss, all structure intact). 1. Figure-internal label clusters: layout figure regions contain box/arrow labels (e.g. "Meta-Agent continuously optimizes the Harness", "Other External Updates") with >=2 English words, escaping _is_low_content_figure_label and leaking as stray body text. Per-block extension would re-introduce content loss (oversized figure regions swallow real section titles, ISSUE-094). Cluster-based instead: pre-scan counts per-page short label-like blocks (2-8 words, no section-number prefix, no terminal punct) overlapping a layout figure region; >=3 on a page => figure-internal labels, suppress. <3 preserved (likely real content). All figure-label leakage removed; heading count unchanged (73). 2. Byline/footnote math unwrap: engines wrap affiliation superscripts ($^{1}$), daggers ($\dagger$/$∗\dagger$), separators ($,$) as inline math. _unwrap_byline_math (applied in _text_block_to_markdown) unwraps $...$ whose inner is purely superscript groups/digits/daggers/commas to ../†/‡/∗/,. Real math ($x^2$, $\alpha$, $\sigma_{i,t}$) keeps letter variables after strip => preserved. Covers byline + regular text + footnote markers (all $^{N}$ -> N). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../perceives/pipeline/stages/pdf/assembly.py | 256 +++++++++++++++++- 1 file changed, 252 insertions(+), 4 deletions(-) diff --git a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py index 94824753c..86e15f0cd 100644 --- a/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py +++ b/apps/negentropy-perceives/src/negentropy/perceives/pipeline/stages/pdf/assembly.py @@ -185,6 +185,14 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: formula_text_signatures.setdefault( formula.page_number, [] ).append(sig) + # 跨引擎表格 loose 回声的内容级去重指纹: + # - table_row_label_tokens:每张 grid 表各行首列标签 token 集 + # (如 {ours,wang,chen,...}),用于"标记密集回声 + 行标签重叠"判定。 + # - table_cell_tokens:每张 grid 表全单元格 token 集(所有行所有列), + # 用于"无标记的纯文本单元格回声"判定(如 Table 3/4 的问句+对象拼接)。 + # bbox/页码级去重对跨引擎回声失效(坐标系/页码错位),改用内容级重叠。 + table_row_label_tokens: list[set[str]] = [] + table_cell_tokens: list[set[str]] = [] if input_data.tables: for table in input_data.tables.tables: md = table.markdown.strip() if table.markdown else "" @@ -192,6 +200,31 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: fp = _extract_table_fingerprint(md) if fp: table_extraction_fingerprints.add(fp) + # 提取不依赖 md 首字符:table.markdown 常以 caption 起手。 + labels: set[str] = set() + cells_toks: set[str] = set() + for _ln in md.split("\n"): + _ln = _ln.strip() + if _ln.startswith("|") and not set( + _ln.replace("|", "") + .replace("-", "") + .replace(":", "") + .strip() + ) <= {" "}: + _cells = [c.strip() for c in _ln.split("|") if c.strip()] + if _cells: + labels.update( + t.lower() + for t in re.findall(r"[A-Za-z]{3,}", _cells[0]) + ) + for _c in _cells: + cells_toks.update( + t.lower() for t in re.findall(r"[A-Za-z]{3,}", _c) + ) + if len(labels) >= 2: + table_row_label_tokens.append(labels) + if len(cells_toks) >= 4: + table_cell_tokens.append(cells_toks) if input_data.text and input_data.text.blocks: for block in input_data.text.blocks: text = block.text.strip() @@ -202,9 +235,38 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: if len(core) > 10: text_formula_fingerprints.add(core) + # figure-internal 标签簇预扫(cluster-based 抑制):layout figure region + # 常含多个框图/箭头短标签(如 ``Meta-Agent continuously optimizes the + # Harness``、``Other External Updates``),它们含 ≥2 英文词故不被 + # ``_is_low_content_figure_label`` 捕获而泄漏。但逐条扩展会误伤被过大 + # figure region 吞噬的单条 section 标题/导言(ISSUE-094)。改用簇判定: + # 同一页 figure region 内 ≥3 个"短标签样"块(2-8 个 ≥3 字母英文词、无 + # 章节编号前缀、无句末标点)→ 框图内部标签,主循环中抑制。<3 个则保留。 + _figure_label_cluster_ids: set[int] = set() + if _layout_figure_regions and input_data.text and input_data.text.blocks: + _page_label_idxs: Dict[int, List[int]] = {} + for _bi, _lb in enumerate(input_data.text.blocks): + if not _block_overlaps_special(_lb, _layout_figure_regions): + continue + _lt = (_lb.text or "").strip() + _lwords = re.findall(r"[A-Za-z]{3,}", _lb.text or "") + if not (2 <= len(_lwords) <= 8): + continue + if re.match(r"^(?:\d+(?:\.\d+)*|[A-Z])\s+[A-Za-z]{2,}", _lt): + continue + if re.search(r"[.!?][\"')\]]*\s*$", _lt): + continue + _page_label_idxs.setdefault(_lb.page_number, []).append(_bi) + for _idxs in _page_label_idxs.values(): + if len(_idxs) >= 3: + _figure_label_cluster_ids.update(_idxs) + # 文本块(反向去重:跳过落入专用 Stage 区域的文本块) if input_data.text and input_data.text.blocks: - for block in input_data.text.blocks: + for _blk_idx, block in enumerate(input_data.text.blocks): + # figure-internal 标签簇抑制(见上方 _figure_label_cluster_ids 预扫) + if _blk_idx in _figure_label_cluster_ids: + continue if _block_overlaps_special( block, special_regions, iou_threshold=0.3 ): @@ -270,6 +332,24 @@ async def _run(self, input_data: AssemblyInput) -> StageResult[AssemblyOutput]: # 页码列,Markdown 无可靠的章节锚点 if _is_toc_table_text(block.text): continue + # 跨引擎表格 loose 回声抑制(内容级):第二引擎把同一表格另抽 + # 成"标记符号密集"的散落文本(如 ``Wang et al. $\triangle$ ✓ + # $\triangle$ ...``),其 bbox/页码与 grid 表错位,绕过上方空间 + # 重叠去重与 pipe 指纹去重。改用内容级联合判定,精准匹配到具体 + # grid 表的行标签,避免误伤未被 table_extraction 覆盖的独立表格: + # A) 标记密集且与某 grid 表行标签 token 重叠 ≥3 → 回声; + # B) 标记密集且英文词 ≤2(如 ``Ours △ △ ✓ ✓ ✓ ✓ ✓ ✓`` 纯单元 + # 格回声)→ 回声(正文不可能 ≤2 词且 ≥4 表格标记)。 + # 跨引擎表格 loose 回声抑制(内容级,三层联合): + # A) 标记密集 + 与某 grid 表首列行标签重叠 ≥3 → 回声; + # B) 标记密集 + 英文词 ≤2(纯单元格回声如 ``Ours △△ ✓✓ ...``); + # C) 无标记的纯文本单元格回声:去停用词后 ≥85% 内容 token 落在 + # 某 grid 表全单元格 token 集内且内容词 ≥8(正文段总有超出 + # 表格词表之外的内容词,故高重叠率即回声)。 + if table_cell_tokens and _block_is_table_echo( + block.text, table_row_label_tokens, table_cell_tokens + ): + continue # 作者署名行(含 ∗†‡ 或邮箱标记,或多作者 affiliation 模式) # 误识为 heading 时降级为正文段落,保留信息但脱离标题层级 if _is_author_byline(block): @@ -1986,6 +2066,132 @@ def _extract_table_fingerprint(table_text: str) -> str: return "" +# 跨引擎表格 loose 回声的标记符号集:△/×/✓/●/■ 等 Unicode 形,以及 +# \triangle / \times / \surd / \checkmark 等 LaTeX 形。正文几乎不含这些 +# 标记,故"标记数 ≥ 英文词数"可作为低假阳性的表格回声信号。 +_TABLE_ECHO_UNICODE_RE = re.compile(r"[△▵×✓✔●◼■◦○◯✗☐]") +_TABLE_ECHO_LATEX_RE = re.compile(r"\\(?:triangle|times|surd|checkmark)\b") +_TABLE_ECHO_WORD_RE = re.compile(r"[A-Za-z]{2,}") + + +def _is_table_echo_text_block(text: str) -> bool: + """检测跨引擎表格 loose 回声文本块(与 grid 表内容重复的散落单元格文本)。 + + 多引擎融合时,第二引擎常把同一表格抽成"标记符号密集"的 loose 文本 + (如 ``Ours $\\triangle$ $\\triangle$ ✓ ✓ ✓ ✓ ✓ ✓``),其 bbox 与 + table_extraction 表格 bbox 坐标系不一致:既绕过 ``_block_overlaps_special`` + 的空间重叠去重(L234 grid-overlap),又因不以 ``|`` 起手而绕过 pipe-gated + 指纹去重(L250)。本函数以"表格标记符号数 ≥4 且 ≥英文词数"识别此类回声: + 正文段落标记数近乎 0,而回声行(标签 + 一排 △/×/✓)标记数远大于词数。 + 调用方须再以"同页已存在 grid 表"门控,确保内容已由 table_extraction 结构化 + 保留,仅抑制冗余回声,避免误删唯一内容(如引擎漏检的无网格表)。 + """ + markers = len(_TABLE_ECHO_UNICODE_RE.findall(text)) + len( + _TABLE_ECHO_LATEX_RE.findall(text) + ) + if markers < 4: + return False + # 计词前先剔除 LaTeX 标记宏(\triangle/\times/...),否则其字母序列 + # (triangle/times)会被 [A-Za-z]{3,} 同时计入"词",使标记密集的回声 + # 被误判为 prose 而漏放。 + cleaned = _TABLE_ECHO_LATEX_RE.sub(" ", text) + return markers >= len(_TABLE_ECHO_WORD_RE.findall(cleaned)) + + +# tier-C 纯文本单元格回声判定时剔除的高频英文虚词,使"内容词"重叠率能区分 +# 表格单元格拼接(内容词几乎全在表格词表内)与正文段落(总有表外内容词)。 +_TABLE_ECHO_STOPWORDS = frozenset( + { + "the", + "and", + "for", + "with", + "that", + "this", + "from", + "are", + "was", + "were", + "its", + "their", + "can", + "but", + "not", + "all", + "any", + "has", + "had", + "have", + "they", + "them", + "than", + "then", + "such", + "these", + "those", + "there", + "where", + "which", + "while", + "into", + "onto", + "over", + "under", + "also", + "more", + "most", + "some", + "each", + "both", + "very", + "only", + "same", + "other", + "what", + "when", + "how", + "why", + "who", + } +) + + +def _block_is_table_echo( + text: str, + row_label_tokens: list[set[str]], + cell_tokens: list[set[str]], +) -> bool: + """判断文本块是否为某张 grid 表的跨引擎 loose 回声(内容级三层判定)。 + + A) 标记符号密集(``_is_table_echo_text_block``)且与某 grid 表首列行标签 + token 重叠 ≥3 → 标记型回声(如 Table 1/5 的 △/×/✓ 数据行拼接); + B) 标记符号密集且英文词 ≤2 → 纯单元格回声(如 ``Ours △ △ ✓ ✓ ✓ ✓ ✓ ✓``); + C) 无标记的纯文本单元格回声(如 Table 3/4 的问句+对象拼接):去停用词后 + 内容词 ≥8 且 ≥85% 落在某 grid 表全单元格 token 集内——正文段总有超出 + 表格词表之外的内容词,故高重叠率即回声。 + 三层互斥叠加,内容由 table_extraction 的 grid 表结构化保留,仅抑冗余。 + """ + btok = set(t.lower() for t in re.findall(r"[A-Za-z]{3,}", text)) + if not btok: + return False + if _is_table_echo_text_block(text): + if any(len(btok & lab) >= 3 for lab in row_label_tokens): + return True + if len(btok) <= 2: + return True + # tier C:纯文本单元格回声(无标记符号的问句/对象/缺口拼接)。去停用词后 + # 内容词 ≥5 且 ≥90% 落在某 grid 表全单元格 token 集内——正文段总有超出表格 + # 词表之外的内容词(shows/however/importantly 等),故高重叠率即回声。≥5 词 + # 避免极短短语误杀,0.9 容忍 1-2 个 OCR 噪声词的松弛。 + content = btok - _TABLE_ECHO_STOPWORDS + if len(content) >= 5: + threshold = len(content) * 0.9 + for cells in cell_tokens: + if len(content & cells) >= threshold: + return True + return False + + def _compute_iou( a: Tuple[float, float, float, float], b: Tuple[float, float, float, float], @@ -2616,7 +2822,9 @@ def _text_block_to_markdown(block: TextBlock) -> str: text = block.text if text.startswith("#"): text = "\\" + text - return text + # 解包被引擎误裹为 inline math 的上标/匕首号/逗号(作者署名、脚注标记), + # 仅作用于纯上标/符号/标点的 $...$,真正含字母变量的数学式原样保留。 + return _unwrap_byline_math(text) def _table_caption_to_paragraph(block: TextBlock) -> str: @@ -2628,9 +2836,49 @@ def _table_caption_to_paragraph(block: TextBlock) -> str: return f"**{text}**" +_BYLINE_MATH_INNER_STRIP_RE = re.compile( + r"\\(?:dagger|ddagger|ast|circ|star)\b" + r"|\^\{[^}]*\}" + r"|\^[A-Za-z0-9+\-]+" + r"|[\s0-9,;()*∗\[\]\-]" +) + + +def _unwrap_byline_math(text: str) -> str: + """解包作者署名行中被误裹为 inline math 的上标/匕首号/逗号。 + + 引擎(docling/mineru)常把 affiliation 上标(``$^{1}$``)、匕首号 + (``$\\dagger$`` / ``$∗\\dagger$``)、分隔逗号(``$,$``)抽成 inline math, + 致渲染怪异。对 inner 仅含上标组 / 数字 / ∗ / 匕首号 / 逗号等非变量字符的 + ``$...$`` 解包:``^{1}``→``1``、``\\dagger``→``†``、``\\ddagger``→``‡``、 + ``\\ast``→``∗``,并去 ``$``。含字母变量的真正数学式(``$x^2$``、``$\\alpha$``) + 经 strip 后仍残留字母 → 原样保留,不受影响。 + """ + + def _repl(m: re.Match) -> str: + inner = m.group(1) + if _BYLINE_MATH_INNER_STRIP_RE.sub("", inner): + return m.group(0) # 残留字母变量 → 真正数学式,保留 + cleaned = ( + inner.replace("\\dagger", "†") + .replace("\\ddagger", "‡") + .replace("\\ast", "∗") + .replace("\\circ", "∘") + .replace("\\star", "⋆") + ) + cleaned = re.sub(r"\^\{([^}]*)\}", r"\1", cleaned) + cleaned = re.sub(r"\^([A-Za-z0-9+\-]+)", r"\1", cleaned) + return cleaned + + return re.sub(r"\$([^$]+)\$", _repl, text) + + def _byline_to_paragraph(block: TextBlock) -> str: - """把作者署名从 heading 降级为纯文本段落(保留信息,去掉 # 层级)。""" - return block.text.strip() + """把作者署名从 heading 降级为纯文本段落(保留信息,去掉 # 层级)。 + + 同时解包被引擎误裹为 inline math 的上标/匕首号/逗号(见 _unwrap_byline_math)。 + """ + return _unwrap_byline_math(block.text.strip()) def _is_toc_table_text(text: str) -> bool: From 287e264f2a08a989887729d9fdb2a605d049860c Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 8 Jul 2026 11:17:21 +0800 Subject: [PATCH 17/81] =?UTF-8?q?style(negentropy-ui):=20=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=20UI=20=E5=85=A8=E5=B1=80=E5=AF=B9=E9=BD=90=20Routine?= =?UTF-8?q?/Scheduler=20=E8=AE=BE=E8=AE=A1=E8=A7=84=E8=8C=83;=20(#1070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 AGENTS.md「UI Table 设计规范」将全站表格收敛到 Routine/Scheduler 黄金标准: 固定列宽(table-fixed + colgroup 百分比)、单行不折(truncate)、溢出 Tooltip(TextTooltip)。 - Dashboard TaskTable、ApiDocPanel 参数表、EntityListPanel 实体表、 McpServerCard Schema 表:完整改造为基准范式(table-fixed + colgroup + TextTooltip), 保留各表原有交互语义(sticky 表头/滚动区/选中高亮/无限滚动锚点/右对齐数值)。 - ContentExplorer:对齐基准范式,Preview 由 line-clamp 折叠简化为单行截断 + Tooltip, 同步重写单测(4 例全绿)。 - 共享 table-styles.ts 令牌向基准对齐(rounded-xl、去表头 bg、text-xs、border/60), 两处 div-grid 表格(Documents / Knowledge Base)长文本列 title 升级为 TextTooltip。 验证:pnpm typecheck / lint(--max-warnings=0) / 239 例单测全绿。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- .../dashboard/_components/TaskTable.tsx | 90 +++++++++++----- .../mcp/_components/McpServerCard.tsx | 81 ++++++++------ .../apis/_components/ApiDocPanel.tsx | 94 +++++++++------- .../base/_components/ContentExplorer.tsx | 100 ++++++------------ .../negentropy-ui/app/knowledge/base/page.tsx | 17 ++- .../app/knowledge/documents/page.tsx | 37 ++++--- .../graph/_components/EntityListPanel.tsx | 85 ++++++++------- .../components/ui/table-styles.ts | 22 ++-- .../unit/knowledge/ContentExplorer.test.tsx | 79 ++++---------- 9 files changed, 314 insertions(+), 291 deletions(-) diff --git a/apps/negentropy-ui/app/(home)/dashboard/_components/TaskTable.tsx b/apps/negentropy-ui/app/(home)/dashboard/_components/TaskTable.tsx index 68ae49112..ba8120473 100644 --- a/apps/negentropy-ui/app/(home)/dashboard/_components/TaskTable.tsx +++ b/apps/negentropy-ui/app/(home)/dashboard/_components/TaskTable.tsx @@ -2,6 +2,8 @@ import { useMemo } from "react"; +import { TextTooltip } from "@/components/ui/TextTooltip"; + import type { DashboardFilters, ScheduledTaskDTO } from "../_lib/types"; interface TaskTableProps { @@ -35,6 +37,13 @@ function relativeFromNow(iso: string | null): string { } } +/** 触发器展示串(cron 表达式 / 间隔秒 / oneshot),对齐 SchedulerTaskTable.triggerText。 */ +function triggerText(t: ScheduledTaskDTO): string { + if (t.trigger_type === "cron") return t.cron_expr ?? "cron"; + if (t.trigger_type === "interval") return `${t.interval_seconds}s`; + return "oneshot"; +} + function StatusDots({ statuses }: { statuses: string[] }) { const slots = [0, 1, 2].map((i) => statuses[i] ?? null); return ( @@ -62,27 +71,39 @@ export function TaskTable({ tasks, filters, onSelect }: TaskTableProps) { const filtered = useMemo(() => applyClientFilters(tasks, filters), [tasks, filters]); return ( -
-
+
+
Tasks ({filtered.length})
- - - - - - - - - - +
TaskHandlerTriggerLastNextRecentEnabled
+ {/* 固定列宽(合计 100%,随容器等比缩放;超长内容由 TextTooltip + truncate 恢复全文): + Task 28 · Handler 16 · Trigger 16 · Last 11 · Next 11 · Recent 8 · Enabled 10。 + 7 列须与下方 7 个 + + + + + + + + + + + + + + + + + {filtered.length === 0 ? ( - @@ -91,27 +112,46 @@ export function TaskTable({ tasks, filters, onSelect }: TaskTableProps) { onSelect(t)} - className="cursor-pointer border-b border-border last:border-b-0 hover:bg-muted/30" + className="cursor-pointer border-b border-border/60 transition-colors last:border-0 hover:bg-muted/40" > - + + + - - - - - -
严格对齐。colgroup 内不得夹带空白文本节点(hydration 报错)。 */} +
TaskHandlerTriggerLastNextRecentEnabled
+ No tasks match current filters.
-
{t.display_name || t.key}
-
{t.key}
+
+ {/* Task 双行:display_name(主)+ key(次),各行独立截断 + 悬浮全文。 */} +
+ + {t.display_name || t.key} + +
+
+ + {t.key} + +
+
+ + {t.handler_kind} + + + + {triggerText(t)} + + + {relativeFromNow(t.last_fire_at)} {t.handler_kind} - {t.trigger_type === "cron" ? t.cron_expr : t.trigger_type === "interval" ? `${t.interval_seconds}s` : "oneshot"} + + {relativeFromNow(t.next_fire_at)} {relativeFromNow(t.last_fire_at)}{relativeFromNow(t.next_fire_at)} + + {t.enabled ? "Enabled" : "Disabled"} diff --git a/apps/negentropy-ui/app/interface/mcp/_components/McpServerCard.tsx b/apps/negentropy-ui/app/interface/mcp/_components/McpServerCard.tsx index 7352c28b1..94fe3915f 100644 --- a/apps/negentropy-ui/app/interface/mcp/_components/McpServerCard.tsx +++ b/apps/negentropy-ui/app/interface/mcp/_components/McpServerCard.tsx @@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown"; import { defaultRemarkPlugins, defaultRehypePlugins } from "@/utils/markdown-plugins"; import { JsonViewer } from "@/components/ui/JsonViewer"; import { SortableCardWrapper, SortableDragHandle } from "@/components/ui/SortableCardWrapper"; +import { TextTooltip } from "@/components/ui/TextTooltip"; import { useAuth } from "@/components/providers/AuthProvider"; const MARKDOWN_CONTENT_CLASS = [ @@ -378,48 +379,68 @@ function SchemaSection({ {rows.length > 0 ? ( -
- - - - - - - - +
+
- Field - - Type - - Required - - Description - - Constraints -
+ {/* 固定列宽(合计 100%):Field 22 · Type 14 · Required 10 · Description 32 · Constraints 22。 + 5 列须与下方 5 个 + + + + + + + + + + + + + - + {rows.map((row) => ( - - + - - - - ))} diff --git a/apps/negentropy-ui/app/knowledge/apis/_components/ApiDocPanel.tsx b/apps/negentropy-ui/app/knowledge/apis/_components/ApiDocPanel.tsx index a3de4e0e3..4f823ca26 100644 --- a/apps/negentropy-ui/app/knowledge/apis/_components/ApiDocPanel.tsx +++ b/apps/negentropy-ui/app/knowledge/apis/_components/ApiDocPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { ApiEndpoint, getMethodColor } from "@/features/knowledge/utils/api-specs"; +import { TextTooltip } from "@/components/ui/TextTooltip"; import { CodeExample } from "./CodeExample"; interface ApiDocPanelProps { @@ -38,61 +39,78 @@ export function ApiDocPanel({ endpoint }: ApiDocPanelProps) {

参数

-
-
严格对齐;colgroup 内不得夹带空白文本节点。 */} +
FieldTypeRequiredDescriptionConstraints
- {row.path} +
+ + + {row.path} + + - {row.type} + + + {row.type} + + {row.required ? ( Yes ) : ( No )} - {row.description || "-"} + + {row.description ? ( + + {row.description} + + ) : ( + "-" + )} - {row.constraints || "-"} + + {row.constraints ? ( + + {row.constraints} + + ) : ( + "-" + )}
- - - - - - - +
+
- 名称 - - 位置 - - 类型 - - 必填 - - 描述 -
+ {/* 固定列宽(合计 100%):名称 20 · 位置 10 · 类型 16 · 必填 8 · 描述 46。 + 5 列须与下方 5 个 + + + + + + + + + + + + + - + {endpoint.parameters.map((param) => ( - - - - - ))} diff --git a/apps/negentropy-ui/app/knowledge/base/_components/ContentExplorer.tsx b/apps/negentropy-ui/app/knowledge/base/_components/ContentExplorer.tsx index 1d29972f8..34686fc98 100644 --- a/apps/negentropy-ui/app/knowledge/base/_components/ContentExplorer.tsx +++ b/apps/negentropy-ui/app/knowledge/base/_components/ContentExplorer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { TextTooltip } from "@/components/ui/TextTooltip"; import { KnowledgeItem } from "@/features/knowledge"; interface ContentExplorerProps { @@ -11,24 +11,8 @@ interface ContentExplorerProps { } export function ContentExplorer({ items, loading, error, offset = 0 }: ContentExplorerProps) { - const [expandedState, setExpandedState] = useState<{ - id: string; - contextToken: string; - } | null>(null); - - const contextToken = `${offset}:${items.length}:${items[0]?.id ?? "empty"}`; - - const toggleRow = (id: string) => { - setExpandedState((prev) => { - if (prev?.id === id && prev.contextToken === contextToken) { - return null; - } - return { id, contextToken }; - }); - }; - return ( -
+

Knowledge Content

@@ -47,69 +31,45 @@ export function ContentExplorer({ items, loading, error, offset = 0 }: ContentEx

No items found.

) : (
-
严格对齐;colgroup 内不得夹带空白文本节点。 */} +
名称位置类型必填描述
- {param.name} + + + + {param.name} + + - {param.in} + + + {param.in} + - {param.type} - {param.enum && ( - - ({param.enum.join(", ")}) + + + + {param.type} + {param.enum && ( + + {" "}({param.enum.join(", ")}) + + )} - )} + + {param.required ? ( ) : ( )} - {param.description} - {param.default !== undefined && ( - - (默认: {String(param.default)}) + + + + {param.description} + {param.default !== undefined && ( + + {" "}(默认: {String(param.default)}) + + )} - )} +
+
+ {/* 固定列宽(合计 100%):# 10 · Content Preview 65 · Created At 25。 + 3 列须与下方 3 个 + + + + - - - - + + + + - - {items.map((item, index) => { - const isExpanded = - expandedState?.id === item.id && - expandedState.contextToken === contextToken; - return ( + + {items.map((item, index) => ( - - - - ); - })} + ))}
严格对齐;colgroup 内不得夹带空白文本节点。 */} +
#Content PreviewCreated At
#Content PreviewCreated At
+ {offset + index + 1} - + + {/* 单行截断 + 悬浮全文(对齐 Routine/Scheduler 表格规范)。 */} + + {item.content} + - {new Date(item.created_at).toLocaleString()} + + + + {new Date(item.created_at).toLocaleString()} + +
diff --git a/apps/negentropy-ui/app/knowledge/base/page.tsx b/apps/negentropy-ui/app/knowledge/base/page.tsx index 3b0773ae6..ec07e20be 100644 --- a/apps/negentropy-ui/app/knowledge/base/page.tsx +++ b/apps/negentropy-ui/app/knowledge/base/page.tsx @@ -47,6 +47,7 @@ import { KnowledgeNav } from "@/components/ui/KnowledgeNav"; import { Button } from "@/components/ui/Button"; import { AnimatedList } from "@/components/ui/AnimatedList"; import { outlineButtonClassName } from "@/components/ui/button-styles"; +import { TextTooltip } from "@/components/ui/TextTooltip"; import { navPillClassName, navRailContainerClassName } from "@/components/ui/nav-styles"; import { tableBodyClassName, @@ -947,13 +948,19 @@ export default function KnowledgeBasePage() { className="col-span-3 min-w-0 text-left" onClick={() => syncQueryState({ view: "corpus", corpusId: selectedCorpusId, tab: "document-chunks", documentId: doc.id })} > -

- {effectiveDocumentName(doc)} -

+ +

+ {effectiveDocumentName(doc)} +

+
{/* Source */} -
- {sourceType} +
+ +
+ {sourceType} +
+
{/* Size */}
diff --git a/apps/negentropy-ui/app/knowledge/documents/page.tsx b/apps/negentropy-ui/app/knowledge/documents/page.tsx index 5d66d230e..459f864ec 100644 --- a/apps/negentropy-ui/app/knowledge/documents/page.tsx +++ b/apps/negentropy-ui/app/knowledge/documents/page.tsx @@ -29,6 +29,7 @@ import { Check, Pencil, X } from "lucide-react"; import { KnowledgeNav } from "@/components/ui/KnowledgeNav"; import { Pagination } from "@/components/ui/Pagination"; +import { TextTooltip } from "@/components/ui/TextTooltip"; import { outlineButtonClassName } from "@/components/ui/button-styles"; import { tableBodyClassName, @@ -560,13 +561,12 @@ export default function DocumentsPage() { ) : (
-

- {effectiveDocumentName(doc)} -

-

+ +

+ {effectiveDocumentName(doc)} +

+ +

{doc.content_type || "Unknown"}

@@ -593,16 +593,15 @@ export default function DocumentsPage() {
{/* 所属语料库 - col-span-2;库文档(corpus_id=null)显示 Library 徽标 */} -
+
{doc.corpus_id ? ( - - {getCorpusName(doc.corpus_id)} - + + + {getCorpusName(doc.corpus_id)} + + ) : ( - + Library )} @@ -614,8 +613,12 @@ export default function DocumentsPage() {
{/* Created By - col-span-1 */} -
- {displayUser(doc.created_by, doc.created_by_name)} +
+ +
+ {displayUser(doc.created_by, doc.created_by_name)} +
+
{/* Created At - col-span-1 */} diff --git a/apps/negentropy-ui/app/knowledge/graph/_components/EntityListPanel.tsx b/apps/negentropy-ui/app/knowledge/graph/_components/EntityListPanel.tsx index 2a1974100..7fd65ff07 100644 --- a/apps/negentropy-ui/app/knowledge/graph/_components/EntityListPanel.tsx +++ b/apps/negentropy-ui/app/knowledge/graph/_components/EntityListPanel.tsx @@ -7,6 +7,7 @@ import { } from "@/features/knowledge"; import { Pagination } from "@/components/ui/Pagination"; +import { TextTooltip } from "@/components/ui/TextTooltip"; import { useInfiniteList, type OffsetFetcher } from "@/hooks/useInfiniteList"; import { useInfiniteScrollSentinel, useScrollPageSync } from "@/hooks/useInfiniteScrollSentinel"; @@ -167,25 +168,24 @@ export function EntityListPanel({ 暂无实体数据

) : ( -
- +
+
+ {/* 固定列宽(合计 100%):名称 32 · 类型 22 · 社区 18 · 置信度 14 · 提及 14。 + 5 列须与下方 5 个 + + + + + + - - - - - - + + + + + + @@ -196,48 +196,57 @@ export function EntityListPanel({ i % ENTITY_PAGE_SIZE === 0 ? Math.floor(i / ENTITY_PAGE_SIZE) + 1 : undefined } onClick={() => onSelectEntity(entity.id)} - className={`cursor-pointer border-b border-border hover:bg-muted ${ + className={`cursor-pointer border-b border-border/60 transition-colors last:border-0 hover:bg-muted/40 ${ selectedEntityId === entity.id ? "bg-blue-50 dark:bg-blue-900/20" : "" }`} > - - - - - diff --git a/apps/negentropy-ui/components/ui/table-styles.ts b/apps/negentropy-ui/components/ui/table-styles.ts index 04a517bc1..0ae4ab001 100644 --- a/apps/negentropy-ui/components/ui/table-styles.ts +++ b/apps/negentropy-ui/components/ui/table-styles.ts @@ -1,26 +1,26 @@ /** - * 表格视觉令牌 —— 向 HeroUI Table 风格对齐(纯 Tailwind 实现,无第三方依赖)。 + * 表格视觉令牌 —— 全局向 Routine / Scheduler 参考表格对齐(纯 Tailwind,无第三方依赖)。 * - * 设计参考:https://beta.heroui.com/docs/components/table - * 关键收敛点: - * - 去除类 Excel 的竖向分隔线(`border-r`),HeroUI 表格无竖线,观感差异最大; - * - 弱化表头为小号大写眼纹(uppercase + tracking),降低视觉权重; - * - 行分隔线柔和、统一 hover 反馈;圆角容器收束整体。 + * 设计参考:[[RoutineTable]] / [[SchedulerTaskTable]](手写 `
严格对齐;colgroup 内不得夹带空白文本节点。 */} +
- 名称 - - 类型 - - 社区 - - 置信度 - - 提及 -
名称类型社区置信度提及
- {entity.name} + + + + {entity.name} + + - + + {/* 类型:色点 + label,单行截断,全文悬浮。 */} +
- - {entity.entity_type} - - + + + {entity.entity_type} + + +
+ {entity.community_id != null ? ( - +
- - C-{entity.community_id} - - + + + C-{entity.community_id} + + +
) : ( - - + )}
+ {entity.confidence.toFixed(2)} + {entity.mention_count}
` 黄金标准)。 + * 关键收敛点(与 AGENTS.md「UI Table 设计规范」一致): + * - 容器 `rounded-xl`(非 2xl)、无投影,收束整体; + * - 表头弱化为小号大写眼纹(text-xs + uppercase + tracking)、**无背景填充**(透出 card 底); + * - 行分隔线柔和(border/60)、统一 hover 反馈。 * * 仅承载「视觉」类名;布局类(grid/flex/col-span 等)由各调用方组合, * 避免强抽象一个跨页通用的 DataTable 组件(两处表格的行内交互差异较大)。 */ -/** 表格外层容器:圆角 + 边框 + 卡片底 + 轻投影。 */ +/** 表格外层容器:圆角 + 边框 + 卡片底(对齐参考表,去投影)。 */ export const tableContainerClassName = - "overflow-hidden rounded-2xl border border-border bg-card shadow-sm"; + "overflow-hidden rounded-xl border border-border bg-card"; /** 表头行视觉(不含布局;调用方再组合 grid/flex)。 */ export const tableHeaderClassName = - "border-b border-border bg-muted/40 px-4 py-3 text-caption font-medium uppercase tracking-overline text-text-muted"; + "border-b border-border px-4 py-2.5 text-xs font-medium uppercase tracking-overline text-text-secondary"; -/** 表体:柔和的横向行分隔线。 */ -export const tableBodyClassName = "divide-y divide-border"; +/** 表体:柔和的横向行分隔线(与参考表 border-border/60 一致)。 */ +export const tableBodyClassName = "divide-y divide-border/60"; /** 数据行视觉(不含布局/grid):统一内边距与 hover 反馈。 */ export const tableRowClassName = "px-4 py-3 transition-colors hover:bg-muted/40"; diff --git a/apps/negentropy-ui/tests/unit/knowledge/ContentExplorer.test.tsx b/apps/negentropy-ui/tests/unit/knowledge/ContentExplorer.test.tsx index 921d65178..925410e2f 100644 --- a/apps/negentropy-ui/tests/unit/knowledge/ContentExplorer.test.tsx +++ b/apps/negentropy-ui/tests/unit/knowledge/ContentExplorer.test.tsx @@ -1,5 +1,4 @@ import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { ContentExplorer } from "@/app/knowledge/base/_components/ContentExplorer"; import type { KnowledgeItem } from "@/features/knowledge"; @@ -13,72 +12,38 @@ const makeItem = (id: string, content: string): KnowledgeItem => ({ }); describe("ContentExplorer", () => { - it("默认以折叠态渲染内容", () => { + it("渲染内容条目并按 offset 连续编号", () => { render( , ); - - const toggle = screen.getByTestId("content-toggle-item-1"); - const body = screen.getByTestId("content-body-item-1"); - - expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect(body.className).toContain("line-clamp-2"); + expect(screen.getByText("11")).toBeInTheDocument(); + expect(screen.getByText("12")).toBeInTheDocument(); + expect(screen.getByText("短内容 A")).toBeInTheDocument(); + expect(screen.getByText("短内容 B")).toBeInTheDocument(); }); - it("支持点击展开/收起,且同一时间仅展开一行", async () => { - render( - , - ); - - const firstToggle = screen.getByTestId("content-toggle-item-1"); - const secondToggle = screen.getByTestId("content-toggle-item-2"); - const firstBody = screen.getByTestId("content-body-item-1"); - const secondBody = screen.getByTestId("content-body-item-2"); - - await userEvent.click(firstToggle); - expect(firstToggle).toHaveAttribute("aria-expanded", "true"); - expect(firstBody.className).toContain("whitespace-pre-wrap"); - expect(firstBody.className).not.toContain("line-clamp-2"); - - await userEvent.click(secondToggle); - expect(firstToggle).toHaveAttribute("aria-expanded", "false"); - expect(secondToggle).toHaveAttribute("aria-expanded", "true"); - expect(secondBody.className).toContain("whitespace-pre-wrap"); - expect(firstBody.className).toContain("line-clamp-2"); + it("内容单元格单行截断(truncate),超长全文经 Tooltip 恢复", () => { + const longContent = "很长的一段内容".repeat(40); + render(); - await userEvent.click(secondToggle); - expect(secondToggle).toHaveAttribute("aria-expanded", "false"); - expect(secondBody.className).toContain("line-clamp-2"); + const cell = screen.getByText(longContent); + expect(cell.className).toContain("truncate"); }); - it("数据更新后会重置展开状态", async () => { - const { rerender } = render( - , - ); - - const firstToggle = screen.getByTestId("content-toggle-item-1"); - await userEvent.click(firstToggle); - expect(firstToggle).toHaveAttribute("aria-expanded", "true"); + it("空数据显示占位文案", () => { + render(); + expect(screen.getByText("No items found.")).toBeInTheDocument(); + }); - rerender( - , - ); + it("loading 与 error 态各自正确呈现", () => { + const { rerender } = render(); + // loading 骨架(3 条脉冲块) + expect(document.querySelectorAll(".animate-pulse").length).toBe(3); - const newToggle = screen.getByTestId("content-toggle-item-3"); - expect(newToggle).toHaveAttribute("aria-expanded", "false"); + rerender(); + expect(screen.getByText("boom")).toBeInTheDocument(); }); }); From 37e9031abbeff121ffb6942796880e3669dbb6e5 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 8 Jul 2026 14:04:34 +0800 Subject: [PATCH 18/81] =?UTF-8?q?feat(patrol):=20PDF=20=E5=B7=A1=E6=A3=80?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=90=BD=E5=BA=93=20+=20Documents=E3=80=8C?= =?UTF-8?q?=E5=B7=A1=E6=A3=80=E7=8A=B6=E6=80=81=E3=80=8D=E5=88=97=20+=20?= =?UTF-8?q?=E3=80=8C=E9=87=8D=E7=BD=AE=E4=B8=BA=E6=9C=AA=E6=8B=9F=E5=90=88?= =?UTF-8?q?=E3=80=8D=E4=BA=8C=E6=AC=A1=E5=B7=A1=E6=A3=80=20(#1071)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(patrol): PDF 巡检状态落库 + selector 改读列 + 重置为未拟合 API; - KnowledgeDocument 新增 patrol_status/patrol_score/patrol_routine_id/patrol_updated_at 列 + 索引;迁移 0092 含从 memories 回填存量 done/unfixable(幂等 + downgrade DROP 红线) - 写入路径 dual-write:spawn 写 in_progress、终态 _upsert_status 写 done/unfixable、cancelled 双守卫回退 NULL;DB 列为权威读源(Memory TAG_STATUS 暂保留写入,Phase 2 deprecate) - selector _select_next_pending_doc 改读 patrol_status IS NULL(替换 Memory skip_ids);_has_running_patrol 保持读 routines 表防 in_progress 残留卡死全系统 - 新增 DocumentStorageService.reset_patrol_status:在跑 409 拒绝 / 取消终态 Routine 解除 selector 门 / 清列 + 清 Memory TAG_STATUS·TAG_UNFIXABLE;双路由(corpus + 库文档) - DocumentResponse + _build_document_response + 详情端点透出 patrol 四字段 - PatrolMemoryStore.clear_doc_legacy_memories 清文档级遗留记忆 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * feat(patrol-ui): Documents 列表新增「巡检状态」列 + 「重置为未拟合」操作; - 列表顺势由旧 div+grid-cols-13(全仓无定义、列宽靠隐式网格自适应的隐患表)重构为
+ 黄金标准,修复隐患并对齐 CLAUDE.md 表格规范 - 新增 PatrolStatusBadge:4 态(未巡检/正在巡检/巡检失败/拟合成功+分数)配色对齐 routineStatusClass 与巡检语义表,非 PDF 行显示「—」 - Actions 新增「重置为未拟合」按钮(仅 PDF + done/unfixable 显示),useConfirmDialog 确认 → resetDocumentPatrol → listRefresh;409 时 toast 提示先取消在跑巡检 - KnowledgeDocument 类型加 patrol 四字段;knowledge-api 新增 resetDocumentPatrol 客户端函数 - BFF 新增 reset-patrol 路由(corpus + 库文档两份)透传后端 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * test(patrol): 巡检状态落库/重置/回填测试覆盖; - 单测:selector 改读 patrol_status IS NULL(替代 skip_ids expanding)、spawn 写 in_progress、cancelled 双守卫回退、_has_running_patrol 仍读 routines(白盒防回归) - 集成(真实 PG):spawn 写列 in_progress、终态 finalize dual-write done/unfixable + score、cancelled 回退、reset 清列 + 取消终态 Routine + 重选、reset 在跑 409、迁移 0092 从 memories 回填 - selector 隔离测试改用单 doc 视角门控判定(_doc_is_pending_candidate),鲁棒于共享测试库 negentropy_test 累积数据导致的 ORDER BY LIMIT 1 选中不确定 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * docs(patrol): 沉淀 PDF 巡检状态落库方案文档; - 新建 docs/.agents/pdf-fidelity-patrol-status.md:背景动机、四态机、写入路径三处(含 Mermaid 时序)、selector 迁移、重置 API 契约(含流程图)、dual-write→Phase 2 路线、边界风险、验证 - knowledge-map 索引新增该方案文档行 - issue.md 追加 ISSUE-158(表因/根因/处理/防范/同类影响) 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * test(patrol): 修复 reset 测试 AsyncSessionLocal 未指向测试库致 CI 失败; - 根因:DocumentStorageService.reset_patrol_status 内部用全局 AsyncSessionLocal 开会话(import 时绑定到 settings.database_url 的非测试库);CI 中该库无 knowledge_documents schema → UndefinedTableError,且与测试 db_engine 跨事件循环 → attached to a different loop。本地因 DB 环境巧合通过,CI 暴露。 - 修复:两个 reset 集成测试加 monkeypatch 形参,把 negentropy.storage.service.AsyncSessionLocal 指到测试 factory(_sf(db_engine)),与既有 monkeypatch registry.AsyncSessionLocal 范式一致;移除未用的 import pytest。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .../[documentId]/reset-patrol/route.ts | 16 + .../[documentId]/reset-patrol/route.ts | 13 + .../_components/PatrolStatusBadge.tsx | 70 +++ .../app/knowledge/documents/page.tsx | 568 ++++++++++-------- .../negentropy-ui/features/knowledge/index.ts | 1 + .../features/knowledge/utils/knowledge-api.ts | 35 ++ .../0092_pdf_fidelity_patrol_status_column.py | 109 ++++ .../engine/routine/patrol_memory.py | 32 +- .../handlers/pdf_fidelity_patrol.py | 53 +- .../src/negentropy/knowledge/_shared.py | 4 + .../negentropy/knowledge/routes/documents.py | 64 ++ .../negentropy/knowledge/routes/library.py | 10 + .../src/negentropy/knowledge/schemas.py | 6 + .../src/negentropy/models/perception.py | 21 + .../src/negentropy/storage/service.py | 78 +++ .../test_pdf_fidelity_patrol_handler.py | 47 +- .../test_pdf_fidelity_patrol_integration.py | 224 ++++++- docs/.agents/issue.md | 10 + docs/.agents/knowledge-map.md | 1 + docs/.agents/pdf-fidelity-patrol-status.md | 151 +++++ 20 files changed, 1235 insertions(+), 278 deletions(-) create mode 100644 apps/negentropy-ui/app/api/knowledge/base/[corpusId]/documents/[documentId]/reset-patrol/route.ts create mode 100644 apps/negentropy-ui/app/api/knowledge/documents/[documentId]/reset-patrol/route.ts create mode 100644 apps/negentropy-ui/app/knowledge/documents/_components/PatrolStatusBadge.tsx create mode 100644 apps/negentropy/src/negentropy/db/migrations/versions/0092_pdf_fidelity_patrol_status_column.py create mode 100644 docs/.agents/pdf-fidelity-patrol-status.md diff --git a/apps/negentropy-ui/app/api/knowledge/base/[corpusId]/documents/[documentId]/reset-patrol/route.ts b/apps/negentropy-ui/app/api/knowledge/base/[corpusId]/documents/[documentId]/reset-patrol/route.ts new file mode 100644 index 000000000..59b0dbdac --- /dev/null +++ b/apps/negentropy-ui/app/api/knowledge/base/[corpusId]/documents/[documentId]/reset-patrol/route.ts @@ -0,0 +1,16 @@ +import { proxyPost } from "../../../../../_proxy"; + +/** + * POST /api/knowledge/base/{corpusId}/documents/{documentId}/reset-patrol + * 重置 corpus 文档 PDF 巡检态为「未巡检」(二次巡检入口)。 + */ +export async function POST( + request: Request, + context: { params: Promise<{ corpusId: string; documentId: string }> }, +) { + const { corpusId, documentId } = await context.params; + return proxyPost( + request, + `/knowledge/base/${corpusId}/documents/${documentId}/reset-patrol`, + ); +} diff --git a/apps/negentropy-ui/app/api/knowledge/documents/[documentId]/reset-patrol/route.ts b/apps/negentropy-ui/app/api/knowledge/documents/[documentId]/reset-patrol/route.ts new file mode 100644 index 000000000..fb666a32e --- /dev/null +++ b/apps/negentropy-ui/app/api/knowledge/documents/[documentId]/reset-patrol/route.ts @@ -0,0 +1,13 @@ +import { proxyPost } from "../../../_proxy"; + +/** + * POST /api/knowledge/documents/{documentId}/reset-patrol + * 重置库文档 PDF 巡检态为「未巡检」(二次巡检入口)。 + */ +export async function POST( + request: Request, + context: { params: Promise<{ documentId: string }> }, +) { + const { documentId } = await context.params; + return proxyPost(request, `/knowledge/documents/${documentId}/reset-patrol`); +} diff --git a/apps/negentropy-ui/app/knowledge/documents/_components/PatrolStatusBadge.tsx b/apps/negentropy-ui/app/knowledge/documents/_components/PatrolStatusBadge.tsx new file mode 100644 index 000000000..381af63aa --- /dev/null +++ b/apps/negentropy-ui/app/knowledge/documents/_components/PatrolStatusBadge.tsx @@ -0,0 +1,70 @@ +/** + * PDF Fidelity Patrol 巡检态徽标(Documents 列表「巡检状态」列用)。 + * + * 4 态映射(对齐后端 ``knowledge_documents.patrol_status`` 列,SSOT): + * - ``null``/缺省 → 未巡检(muted 中性) + * - ``in_progress`` → 正在巡检(sky + 脉冲点) + * - ``unfixable`` → 巡检失败(red) + * - ``done`` → 拟合成功(emerald)+ 拟合分数 + * + * 配色对齐黄金标准 ``app/interface/routine/_components/status-style.ts``(routineStatusClass) + * 与巡检语义表 ``features/scheduler/patrol-reason.ts``;分数上色复用 ``scoreColorClass``。 + */ +import { scoreColorClass } from "@/components/transcript/status-shared"; +import { cn } from "@/lib/utils"; + +export type PatrolStatus = "in_progress" | "done" | "unfixable" | null | undefined; + +const BADGE_BASE = + "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold whitespace-nowrap"; + +/** 巡检态 → 徽标配色(与 routineStatusClass / PATROL_REASON_STYLE 同款 -500/15 口径)。 */ +export function patrolStatusBadgeClass(status: PatrolStatus): string { + switch (status) { + case "in_progress": + return "bg-sky-500/15 text-sky-800 dark:text-sky-200"; + case "done": + return "bg-emerald-500/15 text-emerald-800 dark:text-emerald-200"; + case "unfixable": + return "bg-red-500/15 text-red-800 dark:text-red-200"; + default: + return "bg-muted/60 text-text-secondary"; + } +} + +/** 巡检态 → 中文标签(对齐巡检模块术语)。 */ +export function patrolStatusLabel(status: PatrolStatus): string { + switch (status) { + case "in_progress": + return "正在巡检"; + case "done": + return "拟合成功"; + case "unfixable": + return "巡检失败"; + default: + return "未巡检"; + } +} + +export function PatrolStatusBadge({ + status, + score, + className, +}: { + status: PatrolStatus; + score?: number | null; + className?: string; +}) { + const showScore = score != null && (status === "done" || status === "unfixable"); + return ( + + {status === "in_progress" && ( + + )} + {patrolStatusLabel(status)} + {showScore && ( + · {score} + )} + + ); +} diff --git a/apps/negentropy-ui/app/knowledge/documents/page.tsx b/apps/negentropy-ui/app/knowledge/documents/page.tsx index 459f864ec..c000c044e 100644 --- a/apps/negentropy-ui/app/knowledge/documents/page.tsx +++ b/apps/negentropy-ui/app/knowledge/documents/page.tsx @@ -11,6 +11,7 @@ import { useRouter } from "next/navigation"; import { toast } from "@/lib/activity-toast"; import { KnowledgeDocument, + KnowledgeError, DocumentTranslationMeta, fetchAllDocuments, deleteDocument, @@ -18,6 +19,7 @@ import { translateDocuments, importDocumentUrl, importDocumentFile, + resetDocumentPatrol, fetchCorpora, CorpusRecord, formatRelativeTime, @@ -25,11 +27,12 @@ import { effectiveDocumentName, useInlineDocumentRename, } from "@/features/knowledge"; -import { Check, Pencil, X } from "lucide-react"; +import { Check, Pencil, RotateCcw, X } from "lucide-react"; import { KnowledgeNav } from "@/components/ui/KnowledgeNav"; import { Pagination } from "@/components/ui/Pagination"; import { TextTooltip } from "@/components/ui/TextTooltip"; +import { useConfirmDialog } from "@/components/ui/useConfirmDialog"; import { outlineButtonClassName } from "@/components/ui/button-styles"; import { tableBodyClassName, @@ -42,6 +45,7 @@ import { useInfiniteList, type OffsetFetcher } from "@/hooks/useInfiniteList"; import { useInfiniteScrollSentinel, useScrollPageSync } from "@/hooks/useInfiniteScrollSentinel"; import { useHeartbeatPoll } from "@/hooks/useHeartbeatPoll"; import { ImportDocumentDialog } from "./_components/ImportDocumentDialog"; +import { PatrolStatusBadge, patrolStatusLabel } from "./_components/PatrolStatusBadge"; const APP_NAME = process.env.NEXT_PUBLIC_AGUI_APP_NAME || "negentropy"; /** 文档列表每页条数(偏移分页粒度 + 无限滚动加载粒度 + 页码跳页粒度)。 */ @@ -112,6 +116,12 @@ function isTranslatable(doc: KnowledgeDocument): boolean { return getTranslationMeta(doc)?.status !== "processing"; } +/** 是否为 PDF 文档(PDF Fidelity Patrol 仅针对 PDF;非 PDF 文档巡检状态列显示「—」)。 */ +function isPdfDocument(doc: KnowledgeDocument): boolean { + if (doc.content_type?.toLowerCase().includes("pdf")) return true; + return doc.original_filename.toLowerCase().endsWith(".pdf"); +} + export default function DocumentsPage() { const [corpora, setCorpora] = useState([]); const [deleteConfirm, setDeleteConfirm] = useState(null); @@ -352,6 +362,36 @@ export default function DocumentsPage() { } }; + // 「重置为未拟合」:清 PDF 巡检态为「未巡检」,Scheduler 后续轮次对其二次巡检。 + // 在跑(running/paused)巡检时后端返回 409(code=PATROL_IN_PROGRESS)——提示先取消在跑巡检。 + const { confirm: confirmResetPatrol, confirmDialog: resetPatrolDialog } = useConfirmDialog(); + const [resettingId, setResettingId] = useState(null); + const handleResetPatrol = async (doc: KnowledgeDocument) => { + const ok = await confirmResetPatrol({ + title: "重置为未拟合", + message: + "将该文档的 PDF 巡检态清回「未巡检」,Scheduler 会在后续轮次对其重新巡检与拟合。该文档已有的终态巡检 Routine 将被取消。", + confirmLabel: "重置", + cancelLabel: "取消", + destructive: true, + }); + if (!ok) return; + setResettingId(doc.id); + try { + await resetDocumentPatrol(doc.corpus_id, doc.id, { appName: APP_NAME }); + listRefresh(); + toast.success("已重置为未拟合,等待 Scheduler 二次巡检"); + } catch (err) { + if (err instanceof KnowledgeError && err.code === "PATROL_IN_PROGRESS") { + toast.error("该文档正在巡检,请先取消在跑巡检再重置"); + } else { + toast.error(err instanceof Error ? err.message : "重置失败"); + } + } finally { + setResettingId(null); + } + }; + const getCorpusName = (corpusId: string | null) => { if (!corpusId) return null; const corpus = corpora.find((c) => c.id === corpusId); @@ -459,250 +499,297 @@ export default function DocumentsPage() {
- {/* 表头 */} -
-
- -
-
-
File Name
-
Size
-
File Hash
-
Corpus
-
Translation
-
Created By
-
Created At
-
Updated At
-
Actions
-
-
- - {/* 内容 — 滚动容器同时作为无限滚动哨兵 / 滚动联动 observer 的 root。 */} + {/* 内容 — 滚动容器同时作为无限滚动哨兵 / 滚动联动 observer 的 root。 + 黄金标准:
+ 百分比列宽(与 RoutineTable 一致, + 修复旧 grid-cols-13 未定义隐患),sticky 保留固定表头。 */}
- {loading && documents.length === 0 ? ( -
- Loading documents... -
- ) : error ? ( -
{error}
- ) : documents.length === 0 ? ( -
- No documents uploaded yet -
- ) : ( -
- {documents.map((doc, i) => ( -
- {/* 勾选 - 固定宽 */} -
- toggleSelect(doc.id)} - disabled={!isTranslatable(doc)} - className="rounded disabled:opacity-30" - title={ - isTranslatable(doc) - ? "Select for translation" - : "Not translatable (library document, markdown not ready, already a translation, or translating)" - } - /> -
-
- {/* 文件名 - col-span-3,支持就地重命名(写 display_name) */} -
- {getFileIcon(doc.content_type)} - {editingId === doc.id ? ( -
- setEditDraft(e.target.value)} - onKeyDown={(e) => handleKeyDown(e, doc)} - placeholder="留空则使用源名称" - maxLength={255} - disabled={renaming} - aria-label="编辑文件名称" - className="flex-1 min-w-0 h-6 px-1.5 text-sm rounded border border-primary/50 bg-transparent focus:outline-none focus:ring-1 focus:ring-primary" - /> - - +
+ + {/* 勾选 */} + {/* File Name */} + {/* 巡检状态 */} + {/* Size */} + {/* File Hash */} + {/* Corpus */} + {/* Translation */} + {/* Created By */} + {/* Created At */} + {/* Updated At */} + {/* Actions */} + + + + + + + + + + + + + + + + + + {loading && documents.length === 0 ? ( + + + + ) : error ? ( + + + + ) : documents.length === 0 ? ( + + + + ) : ( + documents.map((doc, i) => ( + + {/* 勾选 */} + + {/* 文件名(支持就地重命名,写 display_name) */} + + {/* 巡检状态(仅 PDF 文档;PDF Fidelity Patrol 态 SSOT:patrol_status 列) */} + + {/* 大小 */} + + {/* File Hash */} + + {/* 所属语料库;库文档(corpus_id=null)显示 Library 徽标 */} + + {/* Translation */} + + {/* Created By */} + + {/* Created At */} + + {/* Updated At(按最终修改时间倒序,故置于 Created At 之后) */} + + {/* 操作 */} + + + )) + )} + +
+ + File Name巡检状态SizeFile HashCorpusTranslationCreated ByCreated AtUpdated AtActions
+ Loading documents... +
{error}
+ No documents uploaded yet +
+ toggleSelect(doc.id)} + disabled={!isTranslatable(doc)} + className="rounded disabled:opacity-30" + title={ + isTranslatable(doc) + ? "Select for translation" + : "Not translatable (library document, markdown not ready, already a translation, or translating)" + } + /> + +
+ {getFileIcon(doc.content_type)} + {editingId === doc.id ? ( +
+ setEditDraft(e.target.value)} + onKeyDown={(e) => handleKeyDown(e, doc)} + placeholder="留空则使用源名称" + maxLength={255} + disabled={renaming} + aria-label="编辑文件名称" + className="flex-1 min-w-0 h-6 px-1.5 text-sm rounded border border-primary/50 bg-transparent focus:outline-none focus:ring-1 focus:ring-primary" + /> + + +
+ ) : ( +
+
+ +

+ {effectiveDocumentName(doc)} +

+
+

+ {doc.content_type || "Unknown"} +

+
+ +
+ )}
- ) : ( -
-
- -

- {effectiveDocumentName(doc)} -

-
-

- {doc.content_type || "Unknown"} -

-
-
+ {isPdfDocument(doc) ? ( + - - + + + ) : ( + + )} + + {formatFileSize(doc.file_size)} + {truncateHash(doc.file_hash)} +
+ {doc.corpus_id ? ( + + + {getCorpusName(doc.corpus_id)} + + + ) : ( + + Library + + )}
- )} - - - {/* 大小 - col-span-1 */} -
- {formatFileSize(doc.file_size)} -
- - {/* File Hash - col-span-1 */} -
- {truncateHash(doc.file_hash)} -
- - {/* 所属语料库 - col-span-2;库文档(corpus_id=null)显示 Library 徽标 */} -
- {doc.corpus_id ? ( - - - {getCorpusName(doc.corpus_id)} - +
+
{renderTranslationCell(doc)}
+
+ +
+ {displayUser(doc.created_by, doc.created_by_name)} +
- ) : ( - - Library - - )} - - - {/* Translation - col-span-2 */} -
- {renderTranslationCell(doc)} -
- - {/* Created By - col-span-1 */} -
- -
- {displayUser(doc.created_by, doc.created_by_name)} -
-
-
- - {/* Created At - col-span-1 */} -
- {formatRelativeTime(doc.created_at ?? undefined)} -
- - {/* Updated At - col-span-1(按最终修改时间倒序,故置于 Created At 之后) */} -
- {formatRelativeTime(doc.updated_at ?? undefined)} -
- - {/* 操作 - col-span-1 */} -
- {deleteConfirm === doc.id ? ( -
- - - +
+ {formatRelativeTime(doc.created_at ?? undefined)} + + {formatRelativeTime(doc.updated_at ?? undefined)} + +
+ {deleteConfirm === doc.id ? ( +
+ + + +
+ ) : ( + <> + + + {isPdfDocument(doc) && + (doc.patrol_status === "done" || + doc.patrol_status === "unfixable") && ( + + )} + + + )}
- ) : ( - <> - - - - - )} - - - - ))} - - )} +
{/* 无限滚动哨兵:进入视口即追加下一页(hasMore 为否时 hook 自动停观察)。 */}
@@ -744,6 +831,9 @@ export default function DocumentsPage() { } onSuccess={() => setIsImportDialogOpen(false)} /> + + {/* 「重置为未拟合」确认对话框(命令式 useConfirmDialog) */} + {resetPatrolDialog}
); } diff --git a/apps/negentropy-ui/features/knowledge/index.ts b/apps/negentropy-ui/features/knowledge/index.ts index b53adf51f..c92337fcf 100644 --- a/apps/negentropy-ui/features/knowledge/index.ts +++ b/apps/negentropy-ui/features/knowledge/index.ts @@ -79,6 +79,7 @@ export { updateDocumentChunk, regenerateDocumentChunkFamily, refreshDocumentMarkdown, + resetDocumentPatrol, translateDocuments, deleteDocument, downloadDocument, diff --git a/apps/negentropy-ui/features/knowledge/utils/knowledge-api.ts b/apps/negentropy-ui/features/knowledge/utils/knowledge-api.ts index d375e432b..1993770da 100644 --- a/apps/negentropy-ui/features/knowledge/utils/knowledge-api.ts +++ b/apps/negentropy-ui/features/knowledge/utils/knowledge-api.ts @@ -1358,6 +1358,20 @@ export interface KnowledgeDocument { markdown_extract_error?: string | null; archived?: boolean; metadata?: Record; + /** + * PDF Fidelity Patrol 巡检态(SSOT:knowledge_documents.patrol_status 列)。 + * - `null`/缺省 = 未巡检过 + * - `in_progress` = 正在巡检 + * - `unfixable` = 巡检失败 + * - `done` = 拟合成功 + */ + patrol_status?: "in_progress" | "done" | "unfixable" | null; + /** 巡检 best_score 峰值(done/unfixable 携带)。 */ + patrol_score?: number | null; + /** 当前巡检态归属 Routine(cancelled 回退幂等守卫)。 */ + patrol_routine_id?: string | null; + /** 巡检态最后写入时间(ISO 字符串)。 */ + patrol_updated_at?: string | null; } export interface KnowledgeDocumentDetail extends KnowledgeDocument { @@ -1685,6 +1699,27 @@ export async function refreshDocumentMarkdown( return handleKnowledgeError(res); } +/** + * 重置文档 PDF 巡检态为「未巡检」(Documents 页「重置为未拟合」按钮)。 + * + * 后端清 ``patrol_status`` 列 + 取消该 doc 的终态巡检 Routine(解除 selector 门)+ + * 清 Memory TAG_STATUS/TAG_UNFIXABLE;成功返回更新后的文档。在跑(running/paused)巡检 + * 时后端返回 409(``code=PATROL_IN_PROGRESS``)——调用方应捕获并提示用户先取消在跑巡检。 + */ +export async function resetDocumentPatrol( + corpusId: string | null, + documentId: string, + params?: { appName?: string }, +): Promise { + const base = documentApiBase(corpusId, documentId); + const res = await fetch(`${base}/reset-patrol`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ app_name: params?.appName }), + }); + return handleKnowledgeError(res); +} + /** * 批量翻译文档(Documents 页 Translate 按钮)。 * diff --git a/apps/negentropy/src/negentropy/db/migrations/versions/0092_pdf_fidelity_patrol_status_column.py b/apps/negentropy/src/negentropy/db/migrations/versions/0092_pdf_fidelity_patrol_status_column.py new file mode 100644 index 000000000..b231a27d3 --- /dev/null +++ b/apps/negentropy/src/negentropy/db/migrations/versions/0092_pdf_fidelity_patrol_status_column.py @@ -0,0 +1,109 @@ +"""knowledge_documents 新增 PDF 巡检态列 + 从 memories 回填存量状态 + +Revision ID: 0092 +Revises: 0091 +Create Date: 2026-07-08 00:00:00.000000+00:00 + +设计动机: + 「PDF Fidelity Patrol」文档级巡检状态(``done|unfixable``)原存 ``memories`` 表 + ``metadata`` JSONB(``tag=pdf-fidelity-status``),仅 1 个生产读者(巡检 selector 的 + ``get_skip_doc_ids``),且 Memory 受衰减治理可被清理——状态并非与文档生命周期绑定的 + 持久事实。新增 ``knowledge_documents`` 物理列作为权威源(SSOT),获得: + + 1. 4 态机(含 ``NULL``=未巡检 / ``in_progress``=巡检中),而 Memory 仅 ``done|unfixable``; + 2. 索引化查询(selector / 列表展示); + 3. 与文档生命周期一致的可见性(Documents 列表「巡检状态」列 + 拟合分数); + 4. 解锁「重置已拟合→未拟合」二次巡检 API(清除列 + 取消终态 Routine)。 + + 写入路径(spawn→in_progress / 终态→done|unfixable / cancelled→回退 NULL)见 + ``engine/schedulers/handlers/pdf_fidelity_patrol.py`` 与 ``engine/routine/patrol_memory.py``。 + 详见 [docs/.agents/pdf-fidelity-patrol-status.md](../../../../../docs/.agents/pdf-fidelity-patrol-status.md)。 + +幂等性: + ``ADD COLUMN IF NOT EXISTS`` / ``CREATE INDEX IF NOT EXISTS``;回填 ``UPDATE`` 带 + ``kd.patrol_status IS NULL`` 守卫,重跑安全(不覆盖已由巡检新写入的态)。 + +数据保全(downgrade 红线): + patrol 态可由重跑巡检确定性再生(终态 Routine 经 ``_finalize_terminal_patrols`` 重沉淀), + 故 downgrade ``DROP COLUMN`` 可接受——**不回写回 memories**(dual-write 期 memories 仍保留 + ``TAG_STATUS`` 副本;Phase 2 deprecate 后亦无读者)。 + +References: +[1] 0076_seed_pdf_fidelity_patrol_task.py — 巡检系统任务种子。 +[2] 0091_pdf_fidelity_patrol_interval_600s.py — 巡检节奏 600s。 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0092" +down_revision: str | None = "0091" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "negentropy" +TABLE = f"{SCHEMA}.knowledge_documents" + +# 巡检态列定义(与模型 perception.py:KnowledgeDocument 对齐)。 +_ADD_COLUMNS_SQL = f""" + ALTER TABLE {TABLE} + ADD COLUMN IF NOT EXISTS patrol_status VARCHAR(20), + ADD COLUMN IF NOT EXISTS patrol_score INTEGER, + ADD COLUMN IF NOT EXISTS patrol_routine_id UUID + REFERENCES {SCHEMA}.routines(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS patrol_updated_at TIMESTAMPTZ +""" + +# 从 memories 回填存量 done/unfixable(含 score/routine_id),按 doc 取最新一条 status 记忆。 +# score/routine_id 经 NULLIF 守卫:JSON null → ->> 为 NULL → NULLIF 安全;routine_id 另加 uuid +# 正则守卫防脏数据 cast 失败阻断迁移。 +_BACKFILL_SQL = f""" + UPDATE {TABLE} AS kd + SET patrol_status = m.meta->>'status', + patrol_score = NULLIF(m.meta->>'score', '')::int, + patrol_routine_id = CASE + WHEN m.meta->>'routine_id' + ~ '^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}$' + THEN (m.meta->>'routine_id')::uuid + END, + patrol_updated_at = NOW() + FROM ( + SELECT DISTINCT ON (metadata->>'doc_id') + metadata AS meta, + metadata->>'doc_id' AS doc_id + FROM {SCHEMA}.memories + WHERE user_id = 'system' + AND metadata->>'tag' = 'pdf-fidelity-status' + AND metadata->>'doc_id' IS NOT NULL + AND metadata->>'status' IN ('done', 'unfixable') + ORDER BY metadata->>'doc_id', created_at DESC + ) AS m + WHERE kd.id::text = m.doc_id + AND kd.patrol_status IS NULL +""" + +_CREATE_INDEX_SQL = f"CREATE INDEX IF NOT EXISTS ix_knowledge_documents_patrol_status ON {TABLE} (patrol_status)" + +_DROP_INDEX_SQL = f"DROP INDEX IF EXISTS {SCHEMA}.ix_knowledge_documents_patrol_status" + +_DROP_COLUMNS_SQL = f""" + ALTER TABLE {TABLE} + DROP COLUMN IF EXISTS patrol_updated_at, + DROP COLUMN IF EXISTS patrol_routine_id, + DROP COLUMN IF EXISTS patrol_score, + DROP COLUMN IF EXISTS patrol_status +""" + + +def upgrade() -> None: + op.execute(sa.text(_ADD_COLUMNS_SQL)) + op.execute(sa.text(_BACKFILL_SQL)) + op.execute(sa.text(_CREATE_INDEX_SQL)) + + +def downgrade() -> None: + # 红线:patrol 态可由重跑巡检再生;不回写 memories。DROP 仅删本迁移新增列。 + op.execute(sa.text(_DROP_INDEX_SQL)) + op.execute(sa.text(_DROP_COLUMNS_SQL)) diff --git a/apps/negentropy/src/negentropy/engine/routine/patrol_memory.py b/apps/negentropy/src/negentropy/engine/routine/patrol_memory.py index 7029ed304..f4baf55f6 100644 --- a/apps/negentropy/src/negentropy/engine/routine/patrol_memory.py +++ b/apps/negentropy/src/negentropy/engine/routine/patrol_memory.py @@ -143,7 +143,11 @@ async def _add(self, *, tag: str, content: str, memory_type: str, metadata: dict ) async def _upsert_status(self, *, doc_id: str, status: str, score: int | None, routine_id: str) -> None: - """文档级状态 upsert:先删旧 status 记忆再写新(同事务,幂等)。""" + """文档级状态 upsert:先删旧 status 记忆再写新(同事务,幂等)。 + + dual-write:同时落 ``knowledge_documents`` 巡检态列(SSOT 读源)与 Memory TAG_STATUS + (过渡期保留,Phase 2 deprecate)。两写同会话同事务,一致 commit / 一致回滚。 + """ await self._db.execute( sa.text( "DELETE FROM negentropy.memories " @@ -164,6 +168,15 @@ async def _upsert_status(self, *, doc_id: str, status: str, score: int | None, r "decay_override": _DECAY_LONG, }, ) + # TODO(phase2): deprecate 上述 Memory TAG_STATUS 写(读侧已迁至 patrol_status 列),仅保留列写。 + await self._db.execute( + sa.text( + "UPDATE negentropy.knowledge_documents " + "SET patrol_status = :st, patrol_score = :sc, " + "patrol_routine_id = CAST(:rid AS uuid), patrol_updated_at = NOW() " + "WHERE id = CAST(:doc_id AS uuid)" + ).bindparams(st=status, sc=score, rid=routine_id, doc_id=doc_id) + ) async def record_done(self, *, doc_id: str, score: int | None, routine_id: str) -> None: await self._upsert_status(doc_id=doc_id, status="done", score=score, routine_id=routine_id) @@ -219,6 +232,8 @@ async def record_pattern(self, *, doc_id: str, defect_type: str, fix_summary: st async def get_skip_doc_ids(self) -> set[str]: """已 done / unfixable 的文档 id 集合(selector 跳过)。""" + # NOTE: selector 已迁至读 ``knowledge_documents.patrol_status`` 列(迁移 0092); + # 本方法仅过渡期/测试用,Phase 2 随 Memory TAG_STATUS deprecate 一并移除。 rows = await self._db.execute( sa.text( "SELECT DISTINCT metadata->>'doc_id' AS doc_id FROM negentropy.memories " @@ -228,6 +243,21 @@ async def get_skip_doc_ids(self) -> set[str]: ) return {r[0] for r in rows.fetchall() if r[0]} + async def clear_doc_legacy_memories(self, doc_id: str) -> None: + """清除某 doc 的 TAG_STATUS + TAG_UNFIXABLE 记忆(「重置为未拟合」用)。 + + 解除该 doc 的终态沉淀与区域级避让,使其可被 selector 重新选中做二次巡检。 + 不清 TAG_PATTERN / TAG_BASELINE——它们是跨 doc 的方法 / 基线知识,非该 doc 状态。 + """ + for tag in (TAG_STATUS, TAG_UNFIXABLE): + await self._db.execute( + sa.text( + "DELETE FROM negentropy.memories " + "WHERE app_name = :app AND user_id = :u " + "AND metadata->>'tag' = :tag AND metadata->>'doc_id' = :doc" + ).bindparams(app=self._app, u=_SYSTEM_USER, tag=tag, doc=doc_id) + ) + async def get_unfixable_regions(self, doc_id: str) -> list[dict[str, Any]]: """某文档已标记 unfixable 的区域(注入巡检会话避让)。""" rows = await self._db.execute( diff --git a/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py b/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py index 825b3a4bb..0c35149ae 100644 --- a/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py +++ b/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py @@ -154,13 +154,9 @@ async def _run_patrol_tick(*, task_key: str) -> HandlerResult: }, ) - # 选下一份待检 PDF + # 选下一份待检 PDF(巡检态 SSOT:knowledge_documents.patrol_status 列) async with AsyncSessionLocal() as db: - from negentropy.engine.routine.patrol_memory import PatrolMemoryStore - - store = PatrolMemoryStore(db) - skip_ids = await store.get_skip_doc_ids() - doc = await _select_next_pending_doc(db, skip_ids=skip_ids) + doc = await _select_next_pending_doc(db) if doc is None: return HandlerResult( status="ok", @@ -319,7 +315,19 @@ async def _finalize_terminal_patrols(db) -> int: rid = uuid.UUID(str(routine_id)) if routine_status == "cancelled": - # 用户干预:不沉淀状态记忆(文档保持可被重新选中),仅标记避免每 tick 重扫。 + # 用户干预:回退文档巡检态为 NULL(可被 selector 重新选中)。 + # 双守卫——仅回退「由本 cancelled routine 在 spawn 时写入的 in_progress」, + # 绝不覆盖已被同 doc 另一更高分 Routine finalize 写成的 done/unfixable。 + if doc_id_cfg: + await db.execute( + sa.text( + "UPDATE negentropy.knowledge_documents " + "SET patrol_status = NULL, patrol_score = NULL, " + "patrol_routine_id = NULL, patrol_updated_at = NOW() " + "WHERE id = CAST(:doc_id AS uuid) AND patrol_routine_id = :rid " + "AND patrol_status = 'in_progress'" + ).bindparams(doc_id=str(doc_id_cfg), rid=rid) + ) await _mark_memory_persisted(db, rid) count += 1 continue @@ -580,8 +588,8 @@ async def _has_running_patrol(db) -> bool: # --------------------------------------------------------------------------- -async def _select_next_pending_doc(db, *, skip_ids: set[str]) -> dict[str, Any] | None: - """选最早入库、未 done/unfixable 的 PDF 文档(content_type=pdf 且转换已完成)。 +async def _select_next_pending_doc(db, *, skip_ids: set[str] | None = None) -> dict[str, Any] | None: + """选最早入库、未巡检(``patrol_status IS NULL``)的 PDF 文档(content_type=pdf 且转换已完成)。 两道守卫(缺一不可): - **命名门控**:``display_name`` 或 ``metadata->>'title'`` 至少有一个非空,否则跳过。 @@ -593,13 +601,12 @@ async def _select_next_pending_doc(db, *, skip_ids: set[str]) -> dict[str, Any] (``config->>'doc_id'`` 为 SSOT 指针)。排除 cancelled 使「取消」成为合法复位——被取消的冗余 Routine 不再阻塞同 doc 以当前有效名重建(见 ``_collapse_superseded_patrols``)。 - 与 ``skip_ids``(done/unfixable 终态语义)正交互补。 + 巡检态 SSOT 为 ``knowledge_documents.patrol_status`` 列(NULL=未巡检入选,``in_progress``/ + ``done``/``unfixable`` 跳过)。``skip_ids`` 形参已废弃(过渡兼容,忽略),见迁移 0092 与 + ``docs/.agents/pdf-fidelity-patrol-status.md``。 """ - params: dict[str, Any] = {"app": settings.app_name} - exclude_clause = "" - if skip_ids: - exclude_clause = "AND id::text NOT IN :skip" - params["skip"] = tuple(skip_ids) + # TODO(phase2): 移除 skip_ids 形参(巡检态已迁至 patrol_status 列,Memory TAG_STATUS deprecate 后删)。 + del skip_ids # 过渡期保留签名兼容,不再使用。 sql = ( "SELECT id, content_uri, original_filename, display_name, metadata->>'title' " @@ -607,6 +614,7 @@ async def _select_next_pending_doc(db, *, skip_ids: set[str]) -> dict[str, Any] "WHERE app_name = :app " "AND COALESCE(content_type,'') ILIKE '%pdf%' " "AND markdown_extract_status = 'completed' " + "AND patrol_status IS NULL " "AND COALESCE(NULLIF(display_name, ''), NULLIF(metadata->>'title', '')) IS NOT NULL " "AND NOT EXISTS (" " SELECT 1 FROM negentropy.routines r " @@ -614,13 +622,9 @@ async def _select_next_pending_doc(db, *, skip_ids: set[str]) -> dict[str, Any] " AND r.config->>'doc_id' = knowledge_documents.id::text " " AND r.status <> 'cancelled'" ") " - f"{exclude_clause} " "ORDER BY created_at ASC LIMIT 1" ) - stmt = sa.text(sql) - if skip_ids: - stmt = stmt.bindparams(sa.bindparam("skip", expanding=True)) - row = await db.execute(stmt, params) + row = await db.execute(sa.text(sql), {"app": settings.app_name}) r = row.fetchone() if not r: return None @@ -797,6 +801,15 @@ async def _create_and_start_patrol_routine( ) db.add(routine) await db.flush() + # 巡检态落库(SSOT 列):spawn 即 in_progress;清空历史 score。同事务随 _run_patrol_tick commit。 + await db.execute( + sa.text( + "UPDATE negentropy.knowledge_documents " + "SET patrol_status = 'in_progress', patrol_score = NULL, " + "patrol_routine_id = :rid, patrol_updated_at = NOW() " + "WHERE id = CAST(:doc_id AS uuid)" + ).bindparams(rid=routine.id, doc_id=str(doc["id"])) + ) return routine.id diff --git a/apps/negentropy/src/negentropy/knowledge/_shared.py b/apps/negentropy/src/negentropy/knowledge/_shared.py index 72b17f8c3..48982d4d4 100644 --- a/apps/negentropy/src/negentropy/knowledge/_shared.py +++ b/apps/negentropy/src/negentropy/knowledge/_shared.py @@ -752,6 +752,10 @@ def _build_document_response( markdown_extract_error=doc.markdown_extract_error, archived=archived, metadata=doc.metadata_ or {}, + patrol_status=getattr(doc, "patrol_status", None), + patrol_score=getattr(doc, "patrol_score", None), + patrol_routine_id=getattr(doc, "patrol_routine_id", None), + patrol_updated_at=(doc.patrol_updated_at.isoformat() if getattr(doc, "patrol_updated_at", None) else None), ) diff --git a/apps/negentropy/src/negentropy/knowledge/routes/documents.py b/apps/negentropy/src/negentropy/knowledge/routes/documents.py index 35c5d60a0..8d5a28eda 100644 --- a/apps/negentropy/src/negentropy/knowledge/routes/documents.py +++ b/apps/negentropy/src/negentropy/knowledge/routes/documents.py @@ -229,6 +229,10 @@ async def _get_document_detail_impl( metadata=doc.metadata_ or {}, markdown_content=markdown_content, markdown_uri=doc.markdown_uri, + patrol_status=getattr(doc, "patrol_status", None), + patrol_score=getattr(doc, "patrol_score", None), + patrol_routine_id=getattr(doc, "patrol_routine_id", None), + patrol_updated_at=(doc.patrol_updated_at.isoformat() if getattr(doc, "patrol_updated_at", None) else None), ) @@ -325,6 +329,66 @@ async def update_document( return await _update_document_impl(document_id=document_id, corpus_id=corpus_id, payload=payload) +async def _reset_document_patrol_impl( + *, + document_id: UUID, + corpus_id: UUID | None, + app_name: str | None, +) -> DocumentResponse: + """重置文档 PDF 巡检态为「未巡检」,使其可被 Scheduler 二次巡检。 + + - 与 :func:`get_document_detail` 一致的 ``corpus_id`` / ``app_name`` 权限校验。 + - 在跑(running/paused)巡检 → 409(不杀在跑任务)。 + - 取消终态巡检 Routine(解除 selector NOT EXISTS 门)+ 清 ``patrol_status`` 列 + + 清 Memory TAG_STATUS/TAG_UNFIXABLE;详见 ``DocumentStorageService.reset_patrol_status``。 + """ + resolved_app = _resolve_app_name(app_name) + + from negentropy.storage.service import DocumentStorageService + + try: + doc = await DocumentStorageService().reset_patrol_status( + document_id=document_id, + corpus_id=corpus_id, + app_name=resolved_app, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "PATROL_IN_PROGRESS", "message": str(exc)}, + ) from exc + + if not doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "DOCUMENT_NOT_FOUND", "message": "Document not found"}, + ) + + name_map = await _resolve_user_display_names([doc.created_by]) if doc.created_by else {} + source_uri = _resolve_document_source_uri(doc) + archived = False + if source_uri and doc.corpus_id is not None: + service = _get_service() + archived_set = await service.get_archived_source_uris( + pairs=[(doc.corpus_id, source_uri)], + app_name=resolved_app, + ) + archived = (doc.corpus_id, source_uri) in archived_set + + logger.info("api_reset_document_patrol", document_id=str(document_id)) + return _build_document_response(doc, name_map, archived=archived) + + +@router.post("/base/{corpus_id}/documents/{document_id}/reset-patrol", response_model=DocumentResponse) +async def reset_document_patrol( + corpus_id: UUID, + document_id: UUID, + app_name: str | None = Query(default=None), +) -> DocumentResponse: + """重置文档 PDF 巡检态为「未巡检」(二次巡检入口)。""" + return await _reset_document_patrol_impl(document_id=document_id, corpus_id=corpus_id, app_name=app_name) + + async def _refresh_document_markdown_impl( *, document_id: UUID, diff --git a/apps/negentropy/src/negentropy/knowledge/routes/library.py b/apps/negentropy/src/negentropy/knowledge/routes/library.py index 6b91b6e55..1d23eb5a8 100644 --- a/apps/negentropy/src/negentropy/knowledge/routes/library.py +++ b/apps/negentropy/src/negentropy/knowledge/routes/library.py @@ -32,6 +32,7 @@ _get_document_asset_impl, _get_document_detail_impl, _refresh_document_markdown_impl, + _reset_document_patrol_impl, _update_document_impl, ) from negentropy.knowledge.schemas import ( @@ -245,6 +246,15 @@ async def update_library_document( return await _update_document_impl(document_id=document_id, corpus_id=None, payload=payload) +@router.post("/documents/{document_id}/reset-patrol", response_model=DocumentResponse) +async def reset_library_document_patrol( + document_id: UUID, + app_name: str | None = Query(default=None), +) -> DocumentResponse: + """重置文档 PDF 巡检态为「未巡检」(库文档二次巡检入口)。""" + return await _reset_document_patrol_impl(document_id=document_id, corpus_id=None, app_name=app_name) + + @router.delete("/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_library_document( document_id: UUID, diff --git a/apps/negentropy/src/negentropy/knowledge/schemas.py b/apps/negentropy/src/negentropy/knowledge/schemas.py index 776b45903..5cb1c8999 100644 --- a/apps/negentropy/src/negentropy/knowledge/schemas.py +++ b/apps/negentropy/src/negentropy/knowledge/schemas.py @@ -623,6 +623,12 @@ class DocumentResponse(BaseModel): markdown_extract_error: str | None = None archived: bool = False metadata: dict[str, Any] = Field(default_factory=dict) + # PDF Fidelity Patrol 巡检态(SSOT:knowledge_documents.patrol_status 列) + # NULL=未巡检 / in_progress=巡检中 / unfixable=失败 / done=拟合成功 + patrol_status: str | None = None + patrol_score: int | None = None + patrol_routine_id: UUID | None = None + patrol_updated_at: str | None = None class Config: from_attributes = True diff --git a/apps/negentropy/src/negentropy/models/perception.py b/apps/negentropy/src/negentropy/models/perception.py index 123568f34..703007794 100644 --- a/apps/negentropy/src/negentropy/models/perception.py +++ b/apps/negentropy/src/negentropy/models/perception.py @@ -137,6 +137,26 @@ class KnowledgeDocument(Base, UUIDMixin, TimestampMixin): # Phase 2: 来源追踪外键 source_id: Mapped[UUID | None] = mapped_column(fk("doc_sources", ondelete="SET NULL"), nullable=True) + # PDF Fidelity Patrol 巡检态(SSOT:文档级巡检状态权威源) + # NULL=未巡检 / in_progress=巡检中 / unfixable=失败 / done=拟合成功 + # 详见 docs/.agents/pdf-fidelity-patrol-status.md 与迁移 0092。 + patrol_status: Mapped[str | None] = mapped_column( + String(20), + nullable=True, + comment="PDF 巡检态:NULL=未巡检/in_progress=巡检中/unfixable=失败/done=拟合成功", + ) + patrol_score: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="巡检 best_score 峰值") + patrol_routine_id: Mapped[UUID | None] = mapped_column( + fk("routines", ondelete="SET NULL"), + nullable=True, + comment="当前巡检态归属 Routine(cancelled 回退的幂等守卫)", + ) + patrol_updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="巡检态最后写入时间(可观测)", + ) + __table_args__ = ( UniqueConstraint("corpus_id", "file_hash", name="uq_knowledge_documents_corpus_hash"), # 库文档去重:UNIQUE(corpus_id, file_hash) 对 NULL 行豁免(PG 视 NULL 互异), @@ -153,6 +173,7 @@ class KnowledgeDocument(Base, UUIDMixin, TimestampMixin): Index("ix_knowledge_documents_status", "status"), Index("ix_knowledge_documents_markdown_extract_status", "markdown_extract_status"), Index("ix_knowledge_documents_source_id", "source_id"), + Index("ix_knowledge_documents_patrol_status", "patrol_status"), {"schema": NEGENTROPY_SCHEMA}, ) diff --git a/apps/negentropy/src/negentropy/storage/service.py b/apps/negentropy/src/negentropy/storage/service.py index c5551e16e..4397abb24 100644 --- a/apps/negentropy/src/negentropy/storage/service.py +++ b/apps/negentropy/src/negentropy/storage/service.py @@ -727,6 +727,84 @@ async def update_document_display_name( ) return doc + async def reset_patrol_status( + self, + *, + document_id: UUID, + corpus_id: UUID | None = None, + app_name: str | None = None, + ) -> KnowledgeDocument | None: + """重置文档巡检态为「未巡检」(``patrol_status=NULL``),使其可被 Scheduler 二次巡检。 + + - 与 :meth:`get_document` 一致的 ``corpus_id`` / ``app_name`` 权限校验。 + - 若该 doc 存在 ``running``/``paused`` 巡检 Routine → 抛 ``ValueError``(路由映射 409, + 不杀在跑任务;调用方应先取消在跑巡检)。 + - 取消该 doc 的非 cancelled 终态 Routine(``succeeded``/``failed``),解除 selector + ``NOT EXISTS`` 门——否则重置后仍被挡、无法被重新选中(关键约束)。取消范式镜像 + ``_collapse_superseded_patrols``:置 ``outcome_propagated=true`` 防聚合态回写污染。 + - 清 ``patrol_status``/``patrol_score``/``patrol_routine_id`` 列 + 清 Memory + ``TAG_STATUS``/``TAG_UNFIXABLE``(区域级避让一并解除,二次巡检重试这些区域)。 + + Returns: + 更新后的 ``KnowledgeDocument``;若文档不存在或权限不匹配返回 ``None`` + """ + from negentropy.engine.routine.patrol_memory import PatrolMemoryStore + + doc_id_str = str(document_id) + async with AsyncSessionLocal() as db: + conditions = [KnowledgeDocument.id == document_id] + if corpus_id: + conditions.append(KnowledgeDocument.corpus_id == corpus_id) + if app_name: + conditions.append(KnowledgeDocument.app_name == app_name) + + stmt = select(KnowledgeDocument).where(*conditions) + result = await db.execute(stmt) + doc = result.scalar_one_or_none() + if not doc: + return None + + # 1) running/paused 在跑 → 拒绝(不杀在跑任务) + running = await db.execute( + text( + "SELECT 1 FROM negentropy.routines " + "WHERE config->>'patrol' = 'true' AND config->>'doc_id' = :doc " + "AND status IN ('running', 'paused') LIMIT 1" + ).bindparams(doc=doc_id_str) + ) + if running.fetchone(): + raise ValueError("patrol routine in progress; cancel it before reset") + + # 2) 取消非 cancelled 终态 Routine(解除 selector NOT EXISTS 门),幂等 + await db.execute( + text( + "UPDATE negentropy.routines " + "SET status = 'cancelled', termination_reason = 'patrol_reset', " + " config = COALESCE(config, '{}'::jsonb) " + " || jsonb_build_object('outcome_propagated', true) " + "WHERE config->>'patrol' = 'true' AND config->>'doc_id' = :doc " + "AND status IN ('succeeded', 'failed')" + ).bindparams(doc=doc_id_str) + ) + + # 3) 清巡检态列(SSOT) + await db.execute( + text( + f"UPDATE {NEGENTROPY_SCHEMA}.knowledge_documents " + "SET patrol_status = NULL, patrol_score = NULL, " + " patrol_routine_id = NULL, patrol_updated_at = NOW() " + "WHERE id = :did" + ).bindparams(did=document_id) + ) + + # 4) 清 Memory TAG_STATUS + TAG_UNFIXABLE(区域级避让) + await PatrolMemoryStore(db).clear_doc_legacy_memories(doc_id_str) + + await db.commit() + await db.refresh(doc) + logger.info("document_patrol_reset", doc_id=doc_id_str) + return doc + async def delete_blob(self, *, content_uri: str) -> bool: """删除任意 blob URI;失败时仅记录日志。""" try: diff --git a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_handler.py b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_handler.py index a1b1f6ec6..1a2872a4d 100644 --- a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_handler.py +++ b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_handler.py @@ -119,16 +119,19 @@ def test_select_next_pending_doc_none_when_empty(): assert asyncio.run(patrol._select_next_pending_doc(db, skip_ids={"a", "b"})) is None -def test_select_next_pending_doc_skip_set_passes_expanding_param(): - """skip 非空时走 expanding bindparam;FakeDB 不解析 SQL,仅断言不抛且参数透传。""" +def test_select_next_pending_doc_reads_patrol_status_column(): + """selector 读 patrol_status 列(SSOT):emitted SQL 含 ``patrol_status IS NULL``, + 不再使用 Memory skip_ids(迁移 0092 后废弃,形参过渡兼容)。""" import asyncio db = _FakeDB(fetchone=None) asyncio.run(patrol._select_next_pending_doc(db, skip_ids={"x1", "x2"})) assert db.executed # 发出了一次 execute - _stmt, params = db.executed[0] + stmt, params = db.executed[0] assert params["app"] # 含 app_name 绑定 - assert "skip" in params + assert "patrol_status IS NULL" in stmt # 4 态语义:仅未巡检入选 + assert "skip" not in params # skip_ids 已废弃 + assert "NOT IN" not in stmt # 不再走 Memory skip 路径 def test_select_next_pending_doc_sql_contains_per_doc_uniqueness_guard(): @@ -144,6 +147,7 @@ def test_select_next_pending_doc_sql_contains_per_doc_uniqueness_guard(): stmt = db.executed[0][0] # 命名门控:display_name 或 metadata->>'title' 至少一个非空(杜绝原始文件名兜底) assert "COALESCE(NULLIF(display_name, ''), NULLIF(metadata->>'title', '')) IS NOT NULL" in stmt + assert "patrol_status IS NULL" in stmt # 巡检态 SSOT 列(仅未巡检入选) assert "NOT EXISTS" in stmt assert "config->>'patrol'" in stmt assert "config->>'doc_id'" in stmt @@ -277,6 +281,41 @@ def test_finalize_terminal_patrols_branches_present(): assert hasattr(patrol, "_mark_memory_persisted") +def test_create_and_start_patrol_routine_writes_in_progress_column(): + """白盒:spawn 巡检 Routine 时同步写 ``patrol_status='in_progress'`` 列(SSOT)。""" + import inspect + + body = inspect.getsource(patrol._create_and_start_patrol_routine) + assert "patrol_status = 'in_progress'" in body + assert "patrol_routine_id = :rid" in body + assert "knowledge_documents" in body + + +def test_finalize_cancelled_resets_in_progress_with_double_guard(): + """白盒:cancelled 分支回退文档巡检态为 NULL,带 routine_id + in_progress 双守卫(防误覆盖 done/unfixable)。""" + import inspect + + body = inspect.getsource(patrol._finalize_terminal_patrols) + # cancelled 分支回退列 + assert "patrol_status = NULL" in body + # 双守卫:仅回退本 routine 在 spawn 时写的 in_progress + assert "patrol_routine_id = :rid" in body + assert "patrol_status = 'in_progress'" in body + + +def test_has_running_patrol_still_reads_routines_table(): + """白盒:_has_running_patrol 仍读 routines 表(SSOT:真实执行态),不读 patrol_status 列。 + + 防回归——若误改成读列,routine 崩溃卡死会致 in_progress 残留而永久 SKIP 全系统巡检。 + """ + import inspect + + body = inspect.getsource(patrol._has_running_patrol) + assert "negentropy.routines" in body + assert "status = 'running'" in body + assert "patrol_status" not in body + + # --------------------------------------------------------------------------- # _build_patrol_routine:构造巡检 Routine(回归 no_progress_patience 误读 settings 致全量异常) # --------------------------------------------------------------------------- diff --git a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py index 90eeee2f0..9dc5695fc 100644 --- a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py +++ b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py @@ -558,6 +558,177 @@ async def test_select_advances_after_done(db_engine): assert str(next_doc["id"]) != str(doc_a) # 推进:不再选中已合格的 A(修「始终卡 A」根因) +# --------------------------------------------------------------------------- +# 巡检态 SSOT 列(knowledge_documents.patrol_status)+ 「重置为未拟合」API +# --------------------------------------------------------------------------- + + +async def _patrol_column(db_engine, doc_id): + """读取某 doc 的巡检态列 (patrol_status, patrol_score, patrol_routine_id::text)。""" + factory = _sf(db_engine) + async with factory() as db: + return ( + await db.execute( + text( + "SELECT patrol_status, patrol_score, patrol_routine_id::text " + "FROM negentropy.knowledge_documents WHERE id = :d" + ), + {"d": str(doc_id)}, + ) + ).fetchone() + + +async def test_spawn_writes_in_progress_patrol_column(db_engine): + """spawn 巡检 Routine 时同步写 patrol_status='in_progress' + patrol_routine_id(SSOT 列)。""" + doc_id = await _seed_knowledge_pdf(db_engine, filename="patrol-spawn-col.pdf") + routine_id, _ = await _seed_terminal_patrol_with_outcome( + db_engine, status="succeeded", best_score=97, doc_id=str(doc_id) + ) + # finalize 未跑 → 列保持 spawn 时写入的 in_progress + row = await _patrol_column(db_engine, doc_id) + assert row is not None + assert row[0] == "in_progress" + assert row[2] == str(routine_id) + + +async def test_finalize_writes_patrol_status_column(db_engine): + """终态 finalize dual-write:patrol_status 列随 Memory 一致翻为 done/unfixable + score。""" + factory = _sf(db_engine) + doc_done = await _seed_knowledge_pdf(db_engine, filename="patrol-col-done.pdf") + doc_unfix = await _seed_knowledge_pdf(db_engine, filename="patrol-col-unfix.pdf") + await _seed_terminal_patrol_with_outcome(db_engine, status="failed", best_score=97, doc_id=str(doc_done)) + await _seed_terminal_patrol_with_outcome(db_engine, status="failed", best_score=52, doc_id=str(doc_unfix)) + async with factory() as db: + await patrol._finalize_terminal_patrols(db) + await db.commit() + done_row = await _patrol_column(db_engine, doc_done) + unfix_row = await _patrol_column(db_engine, doc_unfix) + assert done_row[0] == "done" and done_row[1] == 97 + assert unfix_row[0] == "unfixable" and unfix_row[1] == 52 + + +async def test_reset_patrol_status_clears_column_and_cancels_routines(db_engine, monkeypatch): + """reset_patrol_status:清 patrol_status 列 + 取消终态 Routine(解除 selector 门)→ 可二次巡检。 + + DocumentStorageService.reset_patrol_status 内部用全局 ``AsyncSessionLocal`` 开会话(import 时 + 绑定到非测试库),CI 中指向无 schema 的库 → UndefinedTableError + 跨事件循环。故 monkeypatch + 把 ``AsyncSessionLocal`` 指到测试 factory(同 registry.AsyncSessionLocal 既有 patch 范式)。 + """ + from negentropy.storage.service import DocumentStorageService + + factory = _sf(db_engine) + # 把 service 内部的全局会话工厂指到测试 db_engine(同 monkeypatch registry.AsyncSessionLocal 范式) + monkeypatch.setattr("negentropy.storage.service.AsyncSessionLocal", factory) + doc_id = await _seed_knowledge_pdf(db_engine, filename="patrol-reset.pdf") + routine_id, _ = await _seed_terminal_patrol_with_outcome( + db_engine, status="failed", best_score=97, doc_id=str(doc_id) + ) + async with factory() as db: + await patrol._finalize_terminal_patrols(db) + await db.commit() + assert (await _patrol_column(db_engine, doc_id))[0] == "done" # 重置前为 done + + await DocumentStorageService().reset_patrol_status(document_id=doc_id, app_name=settings.app_name) + + # 1) 巡检态列清空(patrol_status/score → NULL) + row = await _patrol_column(db_engine, doc_id) + assert row[0] is None and row[1] is None + # 2) 终态 Routine 已取消(解除 selector NOT EXISTS 门),无阻塞 Routine → 可二次巡检 + async with factory() as db: + routine = await db.get(Routine, routine_id) + assert routine.status == "cancelled" + blocking = ( + await db.execute( + text( + "SELECT 1 FROM negentropy.routines " + "WHERE config->>'patrol' = 'true' AND config->>'doc_id' = :d " + "AND status <> 'cancelled' LIMIT 1" + ), + {"d": str(doc_id)}, + ) + ).fetchone() + assert blocking is None + + +async def test_reset_patrol_status_refuses_when_running(db_engine, monkeypatch): + """reset 在跑(running)巡检 Routine 时拒绝(ValueError),不杀在跑任务。""" + import pytest + + from negentropy.storage.service import DocumentStorageService + + factory = _sf(db_engine) + monkeypatch.setattr("negentropy.storage.service.AsyncSessionLocal", factory) + doc_id = await _seed_knowledge_pdf(db_engine, filename="patrol-reset-running.pdf") + routine_id, _ = await _seed_terminal_patrol_with_outcome( + db_engine, status="failed", best_score=97, doc_id=str(doc_id) + ) + # 置 routine 为 running(模拟在跑巡检) + async with factory() as db: + routine = await db.get(Routine, routine_id) + routine.status = "running" + await db.commit() + + with pytest.raises(ValueError): + await DocumentStorageService().reset_patrol_status(document_id=doc_id, app_name=settings.app_name) + + +async def test_migration_0092_backfills_patrol_status_from_memories(db_engine): + """迁移 0092 回填:存量 memories(TAG_STATUS=done, score=97) → knowledge_documents 巡检态列。 + + 按文件路径加载迁移模块(模块名 ``0092_...`` 非合法 Python 标识符,不能直接 import), + seed 一条 memories TAG_STATUS 后跑 ``_BACKFILL_SQL``,断言列被回填为 done/97。 + 幂等守卫 ``patrol_status IS NULL`` 使重跑安全。 + """ + import importlib.util + import json + from pathlib import Path + + factory = _sf(db_engine) + doc_id = await _seed_knowledge_pdf(db_engine, filename="patrol-backfill.pdf") + # 插一条存量 memories TAG_STATUS(done, score=97) + async with factory() as db: + await db.execute( + text( + "INSERT INTO negentropy.memories (user_id, app_name, memory_type, content, metadata) " + "VALUES ('system', :app, 'semantic', :c, CAST(:m AS jsonb))" + ).bindparams( + app=settings.app_name, + c="backfill test", + m=json.dumps( + { + "tag": "pdf-fidelity-status", + "doc_id": str(doc_id), + "status": "done", + "score": 97, + "routine_id": None, + } + ), + ) + ) + await db.commit() + + # 按路径加载迁移模块并执行其回填 SQL + mig_path = ( + Path(patrol.__file__).resolve().parents[3] + / "db" + / "migrations" + / "versions" + / "0092_pdf_fidelity_patrol_status_column.py" + ) + spec = importlib.util.spec_from_file_location("mig_0092_patrol_status", mig_path) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + async with factory() as db: + await db.execute(text(mod._BACKFILL_SQL)) + await db.commit() + + row = await _patrol_column(db_engine, doc_id) + assert row is not None + assert row[0] == "done" # status 回填 + assert row[1] == 97 # score 回填(NULLIF(metadata->>'score','')::int) + + # --------------------------------------------------------------------------- # _finalize_execution:patrol_lifecycle 标记的延迟语义(per-tick 不声称聚合状态终态) # --------------------------------------------------------------------------- @@ -731,18 +902,47 @@ async def _completed_pdf_doc_ids(db_engine) -> set[str]: return {r[0] for r in rows.fetchall()} +async def _doc_is_pending_candidate(db_engine, doc_id) -> bool: + """该 doc 是否满足 ``_select_next_pending_doc`` 的全部门控(单 doc 视角,确定性)。 + + 鲁棒于共享测试库 ``negentropy_test`` 的累积数据——不依赖 ``ORDER BY`` 选中顺序 + (多次运行会残留多份 created_at 相近的 pending 文档,致 ``LIMIT 1`` 选中不确定)。 + """ + factory = _sf(db_engine) + async with factory() as db: + row = ( + await db.execute( + text( + "SELECT 1 FROM negentropy.knowledge_documents kd " + "WHERE kd.id = CAST(:d AS uuid) " + "AND kd.app_name = :app " + "AND COALESCE(kd.content_type, '') ILIKE '%pdf%' " + "AND kd.markdown_extract_status = 'completed' " + "AND kd.patrol_status IS NULL " + "AND COALESCE(NULLIF(kd.display_name, ''), NULLIF(kd.metadata->>'title', '')) IS NOT NULL " + "AND NOT EXISTS (" + " SELECT 1 FROM negentropy.routines r " + " WHERE r.config->>'patrol' = 'true' AND r.config->>'doc_id' = kd.id::text " + " AND r.status <> 'cancelled')" + ).bindparams(d=str(doc_id), app=settings.app_name) + ) + ).fetchone() + return row is not None + + async def test_select_next_pending_doc_one_active_patrol_per_doc_and_cancel_relets(db_engine): - """Fix A:已有非 cancelled 巡检 Routine 的文档不被重选;该 Routine cancelled 后重新入选(自愈复位)。""" + """Fix A:已有非 cancelled 巡检 Routine 的文档不被重选;该 Routine cancelled 后重新入选(自愈复位)。 + + 用单 doc 视角的门控判定(``_doc_is_pending_candidate``)断言,鲁棒于共享测试库的累积数据 + (selector 已迁至读 ``patrol_status`` 列;不再依赖 ``ORDER BY`` 选中顺序或废弃的 ``skip_ids``)。 + """ factory = _sf(db_engine) doc_id = await _seed_pdf_document(db_engine, original_filename="guard.pdf", display_name="Guard Doc") - others = (await _completed_pdf_doc_ids(db_engine)) - {str(doc_id)} # 隔离:仅被测 doc 为候选 - # 1) 无巡检 → 被测 doc 可选 - async with factory() as db: - doc = await patrol._select_next_pending_doc(db, skip_ids=others) - assert doc is not None and str(doc["id"]) == str(doc_id) + # 1) 无巡检 → 被测 doc 是合法候选 + assert await _doc_is_pending_candidate(db_engine, doc_id) is True - # 2) 建活跃巡检 → NOT EXISTS 阻塞被测 doc + # 2) 建活跃巡检 → NOT EXISTS 阻塞 → 不再是候选 await _seed_patrol_routine( db_engine, doc_id=doc_id, @@ -750,11 +950,9 @@ async def test_select_next_pending_doc_one_active_patrol_per_doc_and_cancel_rele display_name="PDF Fidelity Patrol · guard.pdf", status="running", ) - async with factory() as db: - doc = await patrol._select_next_pending_doc(db, skip_ids=others) - assert doc is None or str(doc["id"]) != str(doc_id) + assert await _doc_is_pending_candidate(db_engine, doc_id) is False - # 3) 取消该巡检 → cancelled 排除 → 被测 doc 重新入选(自愈) + # 3) 取消该巡检 → cancelled 排除 → 重新成为候选(自愈复位) async with factory() as db: await db.execute( text( @@ -763,9 +961,7 @@ async def test_select_next_pending_doc_one_active_patrol_per_doc_and_cancel_rele ).bindparams(did=str(doc_id)) ) await db.commit() - async with factory() as db: - doc = await patrol._select_next_pending_doc(db, skip_ids=others) - assert doc is not None and str(doc["id"]) == str(doc_id) + assert await _doc_is_pending_candidate(db_engine, doc_id) is True async def test_collapse_superseded_patrols_cancels_raw_keeps_corrected(db_engine): diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 5102244d0..970661cec 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -3471,3 +3471,13 @@ R7 后浏览器对照 Section 2.1 区域发现两类正交缺陷: - **处理方式**(三层治本,分别实施):**Fix 1 断掉重试循环(denial 缓存 + 终结性 `blocked`)**——`approval.py` 新增 `APPROVAL_DENIAL_TTL_SECONDS=300` + `_stable_hash`(sha1 前 8 位,跨进程稳定)+ `record_approval_denial`/`was_recently_denied`(写/查 `state["approval_denials"][f"{tool}:{args_key}"]`,沿用整字典重赋值契约);ingest.py(denial_key=`{corpus_id}:{text[:256] 哈希}`)/ paper.py(denial_key=`arxiv_id`)在 `should_request_approval` **前**先查缓存,命中直接返回 `{"status":"blocked",...}`、**不再调 `request_approval`**;超时/拒绝分支调用 `record_approval_denial` 并把返回从 `failed` 改为终结性 `blocked`;InternalizationFaculty 指令补 `blocked → 严禁重试,告知用户重新发起`。结构性把循环压到至多 1 次弹窗,**不依赖 LLM 自觉**。**Fix 2 Stop 按钮常驻 + 一键破局**——`Composer.tsx` 新增 `forceShowStop` prop,`showStop = Boolean((isGenerating || forceShowStop) && onCancel)`;`home-body.tsx` 传 `forceShowStop={blocked || 有待决审批}`;`handleCancelRun` 增强——除 `abortRun` 外,把所有 `pendingApprovals` 的 action_id 一并加入 `resolvedApprovalIds`(清空所有弹窗),即便 run 已结束的孤儿态也能即时逃生。**Fix 3 审批策略真正生效 + 自治 faculty 免门控**——3a:`state-delta.ts` 追加 `approval_policy` handler(合法 `{mode:"always"|"per_tool"|"never"}` 透传,非法 fail-soft),修复「选择器死代码」;3b:`faculty_bridge._drive` 在 `runner.run_async` 前用 `service.create_session(state={"approval_policy":{"mode":"never"}}, session_id=...)` 预创建 session,使自治 faculty 调用免审批门,失败降级不阻断主流程。 - **后续防范**:① **HITL 重试必须有结构性兜底**——不能只靠 LLM「看到不要重试」的自觉;任何「外部信号门控 + LLM 驱动」的工具,被拒/超时后应记 negative 决议,工具入口前置查缓存,命中即返终结结果。② **「失败」状态语义需区分可重试与不可重试**——`failed` 是 LLM 的「再试一次」信号;用户拒绝/超时这类**不可重试**的失败须用独立 status(`blocked`)+ 显式反重试文案。③ **前端 forwardedProps 与后端 state_delta 是契约**——新增前端控制项必须确认 BFF `buildStateDeltaFromForwardedProps` 有对应 handler,否则就是「选择器死代码」(UI 有反应、后端无效果)。④ **「自救按钮」的显示条件不能只看 streaming**——模态/阻塞陷阱下连接态常为 idle/blocked,Stop 须基于「有待决异步态」常驻,否则用户被禁用 UI 困死。⑤ **实机排查优先于源码推演**——本 issue 的「循环」结论来自实时 DOM/fiber 读 `pending_approvals` 多 action_id + innerText 计数 `ingest_paper` 126 次,源码侧无任何线索;复杂运行时态必须用浏览器实测验证假设。 - **同类问题影响**:所有「LLM + 外部门控」工具(approval / long-running / 外部 IO 等待)都应补「negative 决议缓存」掐断重试;所有前端 forwardedProps 字段都应核对 BFF state_delta 是否真透传;所有「禁用主按钮 + 异步态」的 UI 都应有常驻 Stop/逃生。改动文件:`agents/approval.py`、`agents/tools/ingest.py`、`agents/tools/paper.py`、`agents/faculties/internalization.py`、`engine/routine/faculty_bridge.py`、`packages/agents-chat-core/src/server/state-delta.ts`、`apps/negentropy-ui/components/ui/Composer.tsx`、`apps/negentropy-ui/app/home-body.tsx` + 对应单测(denial 缓存 / forceShowStop / state-delta 透传 / faculty_bridge 注入)。 + +--- + +## ISSUE-158 PDF 巡检状态仅在 Memory 标记,不精准 / 不可见 / 不可重试(2026-07-08) + +- **表因**:「PDF Fidelity Patrol」对 PDF 文档做高保真自拟合巡检,但**巡检结果完全不存在于文档行**——文档级状态(`done|unfixable`)只以 `negentropy.memories` 表 `tag=pdf-fidelity-status` 标签行存在(无 `in_progress`),且 Memory 受衰减治理可被清理。由此:① Documents 列表无法展示「巡检状态 / 拟合分数」;② 已拟合(done)文档被永久跳过,无入口触发「二次深度巡检」;③ selector 依赖 Memory 标签跳过已完成文档,衰减后语义漂移、状态非与文档生命周期绑定的持久事实。 +- **根因**:文档级聚合状态(巡检态)被错放在 Memory 标签(设计上承载可衰减的语义记忆),而非 `KnowledgeDocument` 主表的持久列——违反「单一事实源」:状态的可观测性(UI 展示)、可重试性(重置入口)、持久性(抗衰减)三个诉求都无法从 Memory 满足。 +- **处理方式**(SSOT 迁移 + UI + 重置 API,详见 [PDF 巡检状态落库方案](pdf-fidelity-patrol-status.md)):① **落库**——`KnowledgeDocument` 新增 `patrol_status`(NULL/in_progress/unfixable/done 四态)/`patrol_score`/`patrol_routine_id`/`patrol_updated_at` 列 + 索引(迁移 0092,含从 memories 回填存量状态)。② **写入路径 dual-write**——spawn 写 `in_progress`、终态 `_upsert_status` 写 done/unfixable、cancelled 双守卫回退 NULL;DB 列为权威读源,Memory TAG_STATUS 暂保留写入(Phase 2 deprecate),不破坏既有集成测试断言。③ **selector 迁移**——`_select_next_pending_doc` 把 `id NOT IN :skip` 换成 `patrol_status IS NULL`,`_has_running_patrol` 保持读 routines 表(防 in_progress 残留卡死全系统)。④ **「重置为未拟合」API**——`DocumentStorageService.reset_patrol_status`(保守策略:在跑 409 拒绝;取消终态 Routine 解除 selector NOT EXISTS 门;清列 + 清 Memory TAG_STATUS/TAG_UNFIXABLE)+ 双路由(corpus + 库文档)。⑤ **前端**——Documents 列表顺势由旧 `div+grid-cols-13`(全仓无定义、列宽靠隐式网格自适应的隐患表)重构为 ` + ` 黄金标准(修复隐患 + 合规 CLAUDE.md 表格规范),新增「巡检状态」列(`PatrolStatusBadge` 四态 + 分数,非 PDF 显示「—」)+ 「重置为未拟合」按钮(`useConfirmDialog` 确认 → `resetDocumentPatrol` → `listRefresh`,409 toast 提示)。 +- **后续防范**:① **文档级聚合状态必须落主表持久列,不能放可衰减的 Memory 标签**——Memory 适合承载可衰减的语义/方法记忆(pattern/baseline/区域避让),不适合承载「文档是否已拟合」这类与文档生命周期绑定、需可观测可重试的事实状态。② **状态迁移须配 dual-write 过渡**——读侧先收敛到新 SSOT(降低风险),写侧暂保留旧路径,灰度观察后再 deprecate,避免一刀切破坏既有测试/调用方。③ **`:param::uuid` cast 会破坏 SQLAlchemy text() 的 bindparam 自动检测**(`::` 触发负向预查失败)——一律用 `CAST(:param AS uuid)`(同 0040 迁移既定范式)。④ **重置类操作要解除 selector 门**——仅清状态列不够,若 selector 有「NOT EXISTS 非 cancelled Routine」并发门,重置须同步把旧终态 Routine 标 cancelled,否则重置后仍被挡。⑤ **共享测试库(negentropy_test 不跨 session 清空)下 selector 测试须用「单 doc 视角门控判定」断言**——`ORDER BY ... LIMIT 1` 在累积数据下选中不确定(多次运行残留多份 created_at 相近的 pending 文档),flaky。 +- **同类问题影响**:任何「把聚合状态错放 Memory / JSONB 标签」的设计都应审视是否需迁主表列(可观测 / 可重试 / 抗衰减诉求);selector 的「ORDER BY LIMIT 1」类测试在共享库下统一改单 doc 判定。改动文件:`models/perception.py`、迁移 `0092`、`engine/routine/patrol_memory.py`、`engine/schedulers/handlers/pdf_fidelity_patrol.py`、`storage/service.py`、`knowledge/routes/documents.py`+`library.py`、`knowledge/schemas.py`+`_shared.py`;前端 `app/knowledge/documents/page.tsx`、`_components/PatrolStatusBadge.tsx`、`features/knowledge/utils/knowledge-api.ts`、BFF 两条 `reset-patrol/route.ts` + 46 个单测/集成测试。 diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index aa5dd3d9a..376ee27c3 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -14,6 +14,7 @@ - [Issues 摘要](issue.md) — 历次问题表因 / 根因 / 处理 / 防范的跨上下文留存 - [PDF 一比一还原质量迭代](pdf-harness-engineering-parity.md) — 学术 PDF → Markdown 端到端保真度提升记录(断字 / 公式 / 标题 / TOC / 图片孤儿) +- [PDF 巡检状态落库方案](pdf-fidelity-patrol-status.md) — 巡检文档级状态从 Memory 标签迁为 `knowledge_documents` 持久列(SSOT)+ Documents 列表「巡检状态」列 + 「重置为未拟合」二次巡检 API - [Development(开发指南)](../concepts/operations/development.md) — 环境搭建、开发工作流、数据库迁移、前后端对接 ## 系统概念与设计 diff --git a/docs/.agents/pdf-fidelity-patrol-status.md b/docs/.agents/pdf-fidelity-patrol-status.md new file mode 100644 index 000000000..3b5d9f67f --- /dev/null +++ b/docs/.agents/pdf-fidelity-patrol-status.md @@ -0,0 +1,151 @@ +# PDF Fidelity Patrol 巡检状态落库方案 + +> 把「PDF Fidelity Patrol(PDF→Markdown 高保真自拟合巡检)」的**文档级巡检状态**从 Memory 标签迁为 `knowledge_documents` 持久列(SSOT),解锁前端「巡检状态」列展示与「重置为未拟合」二次巡检。 +> +> 关联:[PDF 一比一还原质量迭代](./pdf-harness-engineering-parity.md)(perceives 端保真迭代,本文是「巡检状态」机制)、[Issues 摘要](./issue.md)。 + +## 1. 背景与动机 + +巡检 Scheduler(`pdf_fidelity_patrol`,每 600s tick)派生 Routine 对 PDF 文档做拟合巡检。改造前,**文档级巡检状态完全不存在于文档行**——它只以 [`negentropy.memories`](../../apps/negentropy/src/negentropy/models/) 表里 `tag=pdf-fidelity-status` 的标签行存在(值域仅 `done|unfixable`,无 `in_progress`),且 Memory 受衰减治理(`MemoryGovernanceService`)可被清理。由此产生三个问题: + +1. **不精准**:状态并非与文档生命周期绑定的持久事实,selector 依赖 [`get_skip_doc_ids()`](../../apps/negentropy/src/negentropy/engine/routine/patrol_memory.py) 读 Memory 标签跳过已完成文档,Memory 衰减后语义漂移。 +2. **不可见**:[`KnowledgeDocument`](../../apps/negentropy/src/negentropy/models/perception.py) 无任何巡检字段,Documents 列表无法展示巡检进度与拟合分数。 +3. **不可重试**:已拟合(done)文档被永久跳过,无入口让用户主动触发「二次深度巡检」。 + +**目标**:文档级巡检状态迁为 `KnowledgeDocument` 持久列(权威读源 SSOT),Documents 列表新增「巡检状态」列,并提供「重置为未拟合」操作。 + +## 2. 四态机 + +`knowledge_documents.patrol_status` 列的值域(NULL 语义为核心): + +| 列展示(中文) | `patrol_status` | 触发 | 列展示附带的 `patrol_score` | +|---|---|---|---| +| 未巡检过 | `NULL` | 从未终态沉淀(含回填后无历史 status 的文档) | NULL | +| 正在巡检 | `in_progress` | spawn 巡检 Routine 时刻写入 | NULL(清空历史分) | +| 巡检失败 | `unfixable` | 终态沉淀(best_score < 95 阈值 或 契约未 done) | best_score 峰值 | +| 拟合成功 · {score} | `done` | 终态沉淀(best_score ≥ 95 或 契约自报 done) | best_score 峰值 | + +合格阈值常量 [`patrol_qualified_score_threshold=95`](../../apps/negentropy/src/negentropy/config/routine.py)(env `NE_ROUTINE_PATROL_QUALIFIED_SCORE_THRESHOLD`)。非 PDF 文档巡检状态列显示「—」(巡检仅针对 PDF,判据 `content_type ILIKE '%pdf%'`)。 + +```mermaid +stateDiagram-v2 + [*] --> 未巡检: NULL(迁移回填无 status 的文档) + 未巡检 --> 正在巡检: spawn Routine(写 in_progress) + 正在巡检 --> 拟合成功: finalize · best_score≥95 或 契约 done + 正在巡检 --> 巡检失败: finalize · 否则(含首轮崩 best_score=NULL) + 正在巡检 --> 未巡检: Routine cancelled(双守卫回退 NULL) + 拟合成功 --> 未巡检: 用户「重置为未拟合」 + 巡检失败 --> 未巡检: 用户「重置为未拟合」 +``` + +## 3. 写入路径(dual-write 过渡 → Phase 2 SSOT) + +> **策略**:DB 列为权威**读**源(selector / UI 均读列);Memory `TAG_STATUS` 暂保留**写**入(过渡安全网,不破坏既有集成测试断言),Phase 2 再 deprecate。两写同会话同事务,一致 commit / 一致回滚。Memory 的 `TAG_UNFIXABLE`(区域级)/`TAG_PATTERN`/`TAG_BASELINE` 保留不动(非文档级状态,有独立读者)。 + +```mermaid +sequenceDiagram + autonumber + participant S as Scheduler tick + participant H as pdf_fidelity_patrol handler + participant DB as knowledge_documents + participant R as routine_inspector + participant M as PatrolMemoryStore + participant Mem as memories + + S->>H: _run_patrol_tick + H->>DB: _select_next_pending_doc(WHERE patrol_status IS NULL) + DB-->>H: doc + H->>H: _create_and_start_patrol_routine(flush Routine) + H->>DB: UPDATE patrol_status='in_progress', patrol_routine_id=:rid + Note over H,DB: spawn 即 in_progress(SSOT 列) + R->>R: 跑 Claude Code 迭代闭环(worktree + PR + Judge) + R-->>H: Routine 终态(succeeded/failed/cancelled) + H->>M: _finalize_terminal_patrols → persist_terminal_outcome + alt done / unfixable + M->>Mem: upsert TAG_STATUS(dual-write 过渡) + M->>DB: UPDATE patrol_status=done|unfixable, patrol_score=:sc + else cancelled(用户干预) + H->>DB: UPDATE patrol_status=NULL(双守卫:patrol_routine_id=:rid AND in_progress) + end +``` + +三处写入点(均同事务随 tick commit): + +| 时机 | 位置 | 写入 | +|---|---|---| +| spawn | [`_create_and_start_patrol_routine`](../../apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py) flush 后 | `patrol_status='in_progress'` + `patrol_routine_id` + 清 `patrol_score` | +| 终态 done/unfixable | [`_upsert_status`](../../apps/negentropy/src/negentropy/engine/routine/patrol_memory.py)(`record_done`/`record_doc_unfixable`/`persist_terminal_outcome` 复用) | `patrol_status` + `patrol_score`(best_score) + `patrol_routine_id` | +| cancelled | [`_finalize_terminal_patrols`](../../apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py) cancelled 分支 | 回退 NULL(**双守卫** `patrol_routine_id=:rid AND patrol_status='in_progress'`,仅回退本 routine 在 spawn 时写的态,绝不覆盖同 doc 另一更高分 Routine 已 finalize 的 done/unfixable) | + +> **`_has_running_patrol` 保持读 `routines` 表,不读列**——SSOT:它回答「全局是否有真实在跑的巡检」,权威源是 `routines.status`;若改读列,routine 崩溃卡死会致 `in_progress` 残留而永久 SKIP 全系统巡检。 + +## 4. selector 迁移 + +[`_select_next_pending_doc`](../../apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py) 的候选门控(缺一不可): + +1. `content_type ILIKE '%pdf%'`(PDF 文档) +2. `markdown_extract_status = 'completed'`(转换完成) +3. **`patrol_status IS NULL`**(4 态语义:仅未巡检入选;**替换**旧 `id NOT IN :skip` 的 Memory skip_ids 路径) +4. 命名门控(`display_name` 或 `metadata->>'title'` 至少一个非空,杜绝原始文件名兜底) +5. `NOT EXISTS` 非 cancelled 巡检 Routine(一文一活跃巡检并发互斥;与 `patrol_status` 正交保留) + +> `skip_ids` 形参保留为 Optional(过渡兼容,已忽略);`get_skip_doc_ids()` 过渡期保留供测试,Phase 2 随 Memory TAG_STATUS deprecate 一并移除。 + +## 5. 「重置为未拟合」API + +```mermaid +flowchart LR + U[用户点
重置为未拟合] --> Q{该 doc 有
running/paused
巡检 Routine?} + Q -- 是 --> R[409 PATROL_IN_PROGRESS
提示先取消在跑巡检] + Q -- 否 --> C[取消 succeeded/failed
终态 Routine
outcome_propagated=true] + C --> D[清 patrol_status/score/
routine_id 列] + D --> M[清 Memory
TAG_STATUS + TAG_UNFIXABLE] + M --> OK[200 · 列回未巡检
Scheduler 下轮重选] + style R fill:#fecaca,stroke:#b91c11,color:#7f1d1d + style OK fill:#bbf7d0,stroke:#15803d,color:#14532d +``` + +- **保守策略**:在跑(running/paused)巡检 → 409 拒绝(不杀在跑任务);done/unfixable 文档正常重置。 +- **关键约束**:重置必须取消该 doc 的非 cancelled 终态 Routine(解除 selector `NOT EXISTS` 门),否则重置后仍被挡、无法被重新选中。取消范式镜像 [`_collapse_superseded_patrols`](../../apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py)(置 `outcome_propagated=true` 防聚合态回写污染)。 +- **端点**:`POST /knowledge/base/{corpus_id}/documents/{document_id}/reset-patrol`(+ 库文档平行 `POST /knowledge/documents/{document_id}/reset-patrol`);返回更新后的 `DocumentResponse`。 +- **服务层**:[`DocumentStorageService.reset_patrol_status`](../../apps/negentropy/src/negentropy/storage/service.py);Memory 清理经 [`PatrolMemoryStore.clear_doc_legacy_memories`](../../apps/negentropy/src/negentropy/engine/routine/patrol_memory.py)(清 `TAG_STATUS` + `TAG_UNFIXABLE`,不清 `TAG_PATTERN`/`TAG_BASELINE`——跨 doc 方法/基线知识)。 + +## 6. 前端展示 + +- **列表新增「巡检状态」列**:[`documents/page.tsx`](../../apps/negentropy-ui/app/knowledge/documents/page.tsx) 顺势由旧 `div+grid-cols-13`(`grid-cols-13` 全仓无定义、列宽靠隐式网格自适应的隐患表)重构为 `
+ ` 黄金标准(与 [RoutineTable](../../apps/negentropy-ui/app/interface/routine/_components/RoutineTable.tsx) 一致,对齐 CLAUDE.md「UI Table 设计规范」)。 +- **Badge**:[`PatrolStatusBadge`](../../apps/negentropy-ui/app/knowledge/documents/_components/PatrolStatusBadge.tsx) 四态配色对齐 [`routineStatusClass`](../../apps/negentropy-ui/app/interface/routine/_components/status-style.ts)(`bg-{color}-500/15 ...`)与 [巡检语义表](../../apps/negentropy-ui/features/scheduler/patrol-reason.ts);分数用 [`scoreColorClass`](../../apps/negentropy-ui/components/transcript/status-shared.ts) 上色;非 PDF 行显示「—」。 +- **重置按钮**:Actions 单元格内,仅对 `isPdfDocument(doc) && patrol_status ∈ {done, unfixable}` 显示;经 [`useConfirmDialog`](../../apps/negentropy-ui/components/ui/useConfirmDialog.tsx) 确认 → [`resetDocumentPatrol`](../../apps/negentropy-ui/features/knowledge/utils/knowledge-api.ts) → `listRefresh()`;409 时 toast 提示「该文档正在巡检,请先取消在跑巡检再重置」。 + +## 7. 数据迁移(0092) + +[`0092_pdf_fidelity_patrol_status_column.py`](../../apps/negentropy/src/negentropy/db/migrations/versions/0092_pdf_fidelity_patrol_status_column.py): + +1. `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 四列(`patrol_routine_id` 带 `REFERENCES routines(id) ON DELETE SET NULL`)。 +2. **回填**:从 `memories` 取每 doc 最新一条 `TAG_STATUS`(`DISTINCT ON (doc_id) ... ORDER BY created_at DESC`),`NULLIF(metadata->>'score','')::int` + `routine_id` uuid 正则守卫,写回 `patrol_status`/`patrol_score`/`patrol_routine_id`;带 `patrol_status IS NULL` 守卫,幂等可重跑。 +3. `CREATE INDEX IF NOT EXISTS ix_knowledge_documents_patrol_status`。 +4. **downgrade 红线**:patrol 态可由重跑巡检确定性再生(终态 Routine 经 `_finalize_terminal_patrols` 重沉淀),故 `DROP COLUMN` 可接受、**不回写 memories**。 + +## 8. dual-write → Phase 2 路线 + +- **Phase 1(本次)**:DB 列为权威读源;Memory `TAG_STATUS` 仍写(dual-write),`get_skip_doc_ids()` 过渡保留。代码中以 `# TODO(phase2)` 标注。 +- **Phase 2(独立 PR,待列写稳定观察后)**:删 `_upsert_status` 的 Memory 写分支 + `get_skip_doc_ids()` + `skip_ids` 形参,改测试断言到列,完成 `TAG_STATUS` deprecate。 + +> 选 dual-write 而非 clean-cut:保留 5 处依赖 Memory 的集成测试断言不炸([`test_pdf_fidelity_patrol_integration.py`](../../apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py)),读侧单一事实源立即收敛,可灰度观察后移除,可逆。 + +## 9. 边界与风险 + +| 风险 | 处置 | +|---|---| +| 并发 spawn 两 tick 选同一 NULL 文档 | `_has_running_patrol`(全局互斥)+ interval=完成+600s + NOT EXISTS 门三重挡;spawn 列写在 Routine flush 后同事务,Routine 行先于列可见 | +| dual-write 漂移 | 两写同会话同事务一致回滚;列 UPDATE 命中 0 行(doc 已硬删)时 Memory 仍写为良性孤儿 | +| Memory 衰减 | 读侧已迁到列,衰减不再影响 selector 正确性(迁移核心收益);务必同 PR 上线「列读 + dual-write」 | +| reset 与在跑 Routine 冲突 | 409 拒绝(保守),不静默杀任务 | +| 回填对无 status 文档 | TAG_STATUS 缺失 → 列 NULL = 未巡检;历史「巡检过但 memory 已衰减」文档被当未巡检重选 = 期望行为(重新拟合) | +| `patrol_routine_id` FK SET NULL | Routine 硬删时列置 NULL,`patrol_status` 保留 done/unfixable 不影响 selector;reset 仍可清 | +| `:param::uuid` cast 破坏 SQLAlchemy text() bindparam 检测 | 一律用 `CAST(:param AS uuid)`(同 [display_name 回填迁移](../../apps/negentropy/src/negentropy/db/migrations/versions/0040_add_knowledge_document_display_name.py) 的既定范式) | + +## 10. 验证 + +- **迁移**:测试库 `uv run alembic upgrade head`(含 0092)→ 抽查 PDF 文档行回填;集成测试 `test_migration_0092_backfills_patrol_status_from_memories` 锁回填逻辑。 +- **后端**:`uv run pytest tests/unit_tests/engine/test_pdf_fidelity_patrol_handler.py tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py -q`(46 用例:spawn 写 in_progress、终态写 done/unfixable、cancelled 回退、selector 读列、reset 清列+取消 Routine、reset 在跑 409、迁移回填)。 +- **实机**:起后端 + 引擎,触发 patrol tick → 观察目标 PDF 文档 `patrol_status` 由 NULL→`in_progress`→终态 + `patrol_score`;前端对 done 文档点「重置为未拟合」→ 列回「未巡检」→ 下 tick 重新选中(二次巡检)。 From 3da332bff5a407447546c7d46eecd6fd30d44fac Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 8 Jul 2026 15:32:25 +0800 Subject: [PATCH 19/81] =?UTF-8?q?fix(patrol):=20reconcile=20=E5=B7=A1?= =?UTF-8?q?=E6=A3=80=E6=80=81=E4=BB=A5=E6=9C=80=E6=96=B0=20updated=5Fat=20?= =?UTF-8?q?=E9=9D=9E=20cancelled=20=E7=BB=88=E6=80=81=20Routine=20?= =?UTF-8?q?=E4=B8=BA=E6=9D=83=E5=A8=81=EF=BC=88#1071=20=E5=90=8E=E7=BB=AD?= =?UTF-8?q?=E8=A1=A5=E6=95=91=EF=BC=89=20(#1072)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(patrol): 巡检态以最新非 cancelled 终态 Routine 为权威校正; - 根因(实机查 PG 定位):文档「Code as Agent Harness」有 3 个巡检 Routine(cancelled/95、succeeded/95、cancelled/2)。_finalize_terminal_patrols 按 finalize 顺序 last-write-wins 写列,routine③ 先以 failed 终态写入 unfixable/2 覆盖了更早 routine② 的 done/95;随后 _collapse_superseded_patrols 取消 routine③ 但不回写状态,列停留 unfixable/2,迁移 0092 又从陈旧 Memory 回填。 - 修复:新增 _reconcile_patrol_status,每 tick 在 collapse 后以「每 doc 最新的非 cancelled(succeeded/failed)终态 Routine,按 created_at DESC」为权威重算列(cancelled=被取代/放弃,非真实结论);succeeded 或 best_score≥阈值→done,否则 unfixable;跳过有 running/paused Routine 的 doc(保留 spawn 的 in_progress);幂等仅变化时写。 - 迁移 0093 用同 SQL 部署时一次性修复存量受污染数据。 - 2 个集成测试覆盖(winner 覆盖 stale cancelled / 跳过 running);方案文档 §3.1+§7、issue ISSUE-159 沉淀。 - dry-run 验证:bug doc winner=succeeded/95 → 校正为 done/95。selector 已读列(patrol_status IS NULL),done 仍被正确排除。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * fix(patrol): reconcile 按 updated_at 判定最新 Routine(修 created_at 误判); - 根因(用户指正):_reconcile_patrol_status 用 created_at DESC 选 winner,但 created_at 仅是 Routine 的 spawn 时间;当多个非 cancelled 终态 Routine「完成顺序与创建顺序相反」时(先创建后完成的 succeeded vs 后创建先完成的 failed),created_at 会误选创建更晚但完成更早的 failed → unfixable,覆盖了真正最近完成的 succeeded → done。 - 修复:reconcile + 迁移 0093 的 ORDER BY 改 updated_at DESC。updated_at 是 Routine 终态达成(最后一次状态变更)时间,代表「最近一次巡检结论」,与用户「以最后一次巡检结论为准」语义一致。 - 新增测试 test_reconcile_picks_latest_by_updated_not_created:构造 created/updated 反序的两条非 cancelled 终态 Routine,断言 updated_at 最新者(succeeded/95)胜出(非 created_at 误判的 unfixable/52)。 - 方案文档 §3.1 + ISSUE-159 同步更新。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- ...093_reconcile_patrol_status_from_winner.py | 88 ++++++++++++ .../handlers/pdf_fidelity_patrol.py | 68 +++++++++ .../test_pdf_fidelity_patrol_integration.py | 131 ++++++++++++++++++ docs/.agents/issue.md | 10 ++ docs/.agents/pdf-fidelity-patrol-status.md | 17 ++- 5 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 apps/negentropy/src/negentropy/db/migrations/versions/0093_reconcile_patrol_status_from_winner.py diff --git a/apps/negentropy/src/negentropy/db/migrations/versions/0093_reconcile_patrol_status_from_winner.py b/apps/negentropy/src/negentropy/db/migrations/versions/0093_reconcile_patrol_status_from_winner.py new file mode 100644 index 000000000..bf4dcd6b6 --- /dev/null +++ b/apps/negentropy/src/negentropy/db/migrations/versions/0093_reconcile_patrol_status_from_winner.py @@ -0,0 +1,88 @@ +"""reconcile:以「每 doc 最新的非 cancelled 终态 Routine」校正存量 patrol_status 列 + +Revision ID: 0093 +Revises: 0092 +Create Date: 2026-07-08 12:00:00.000000+00:00 + +设计动机: + 0092 把文档级巡检态从 ``memories`` 迁到 ``knowledge_documents.patrol_status`` 列并从 Memory 回填。 + 但回填源(Memory ``TAG_STATUS``)本身受 ``_finalize_terminal_patrols`` 的 last-write-wins + + ``_collapse_superseded_patrols`` 不回写状态缺陷污染:一个先以 ``failed`` 终态写入 ``unfixable``、 + 随后被 collapse 取消的 Routine,会把更早 ``succeeded`` 的 ``done`` 覆盖成 ``unfixable`` + (实测:succeeded/95 被 failed/2 覆盖)。回填把这条陈旧 Memory 落到了列。 + + 本迁移以**权威源 = routines 表**重算列:每 doc 取最新的**非 cancelled**(succeeded/failed) + 终态 Routine(cancelled = 被取代/放弃,非真实结论),按 **``updated_at DESC``** 取最新—— + ``updated_at`` 是终态达成时间,代表「最近一次巡检结论」(``created_at`` 仅 spawn 时间,完成顺序 + 与创建顺序不一致时会误判)。``succeeded`` 或 ``best_score ≥ 95`` → ``done``,否则 ``unfixable``。与 + ``engine/schedulers/handlers/pdf_fidelity_patrol.py::_reconcile_patrol_status`` 同语义 + (后者每 tick 持续校正;阈值默认 95 = ``patrol_qualified_score_threshold``)。 + +幂等性: + 纯 ``UPDATE``,重跑只会把列收敛到同一终态;带「跳过 running/paused Routine 的 doc」守卫, + 不回退 spawn 写的 in_progress。``patrol_status``/``patrol_routine_id`` 已与 winner 一致时不动。 + +数据保全(downgrade 红线): + patrol 态可由重跑巡检确定性再生(终态 Routine 经 ``_finalize_terminal_patrols`` + + ``_reconcile_patrol_status`` 重沉淀),故 downgrade 为 no-op(校正后数据正确,无意义回退)。 + +References: +[1] 0092_pdf_fidelity_patrol_status_column.py — 列与首次(受污染)回填。 +[2] engine/schedulers/handlers/pdf_fidelity_patrol.py::_reconcile_patrol_status — 持续校正。 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0093" +down_revision: str | None = "0092" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "negentropy" + +# 阈值默认 95(config.routine.patrol_qualified_score_threshold);迁移为确定性快照,硬编码默认值。 +_THRESHOLD = 95 + +_RECONCILE_SQL = f""" + WITH winner AS ( + SELECT DISTINCT ON (r.config->>'doc_id') + r.config->>'doc_id' AS doc_id, + r.id AS rid, + r.status, + r.best_score, + CASE WHEN r.status = 'succeeded' OR r.best_score >= {_THRESHOLD} + THEN 'done' ELSE 'unfixable' END AS new_status + FROM {SCHEMA}.routines r + WHERE r.config->>'patrol' = 'true' + AND r.status IN ('succeeded', 'failed') + AND r.config->>'doc_id' IS NOT NULL + ORDER BY r.config->>'doc_id', r.updated_at DESC + ) + UPDATE {SCHEMA}.knowledge_documents kd + SET patrol_status = w.new_status, + patrol_score = w.best_score, + patrol_routine_id = w.rid, + patrol_updated_at = NOW() + FROM winner w + WHERE kd.id::text = w.doc_id + AND NOT EXISTS ( + SELECT 1 FROM {SCHEMA}.routines rr + WHERE rr.config->>'patrol' = 'true' + AND rr.config->>'doc_id' = kd.id::text + AND rr.status IN ('running', 'paused') + ) + AND (kd.patrol_status IS DISTINCT FROM w.new_status + OR kd.patrol_routine_id IS DISTINCT FROM w.rid) +""" + + +def upgrade() -> None: + op.execute(sa.text(_RECONCILE_SQL)) + + +def downgrade() -> None: + # 红线:patrol 态可由重跑巡检 + _reconcile_patrol_status 确定性再生;校正后数据正确,无意义回退。 + pass diff --git a/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py b/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py index 0c35149ae..481080267 100644 --- a/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py +++ b/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py @@ -135,11 +135,16 @@ async def _run_patrol_tick(*, task_key: str) -> HandlerResult: finalized = await _finalize_terminal_patrols(db) propagated = await _propagate_patrol_outcomes(db) collapsed = await _collapse_superseded_patrols(db) + # 校正巡检态列:以「每 doc 最新的非 cancelled 终态 Routine」为权威,修 finalize + # last-write-wins + collapse 不回写状态导致的污染(如 succeeded/95 被 failed/2 覆盖)。 + reconciled = await _reconcile_patrol_status(db) await db.commit() if propagated: logger.info("patrol_outcomes_propagated", count=propagated) if collapsed: logger.info("patrol_superseded_collapsed", count=collapsed) + if reconciled: + logger.info("patrol_status_reconciled", count=reconciled) # 跳过并发(独立短事务,避免长读) async with AsyncSessionLocal() as db: @@ -571,6 +576,69 @@ async def _collapse_superseded_patrols(db) -> int: return result.rowcount or 0 +# --------------------------------------------------------------------------- +# 巡检态校正:以「每 doc 最新的非 cancelled 终态 Routine」为权威(修 last-write-wins 污染) +# --------------------------------------------------------------------------- + + +async def _reconcile_patrol_status(db) -> int: + """以每 doc「最新的**非 cancelled** 终态 Routine」为权威,校正 ``knowledge_documents.patrol_status`` 列。 + + 缺陷背景:``_finalize_terminal_patrols`` 按 finalize 顺序 last-write-wins 写列(不看 Routine + 新旧),而 ``_collapse_superseded_patrols`` 取消冗余 Routine 时**不回写状态**。实测:一个先以 + ``failed`` 终态写入 ``unfixable``、随后被 collapse 取消的 Routine,会把更早 ``succeeded`` 的 + ``done`` 覆盖成 ``unfixable``(如 succeeded/95 被 failed/2 覆盖)。 + + 语义:``cancelled`` Routine 非真实结论(被取代 / 用户放弃),故 winner 仅取 ``succeeded``/``failed`` + (非 cancelled)终态 Routine,按 **``updated_at DESC``** 取最新——``updated_at`` 是 Routine 终态 + 达成(最后一次状态变更)时间,代表「最近一次巡检结论」;``created_at`` 仅是 spawn 时间, + 完成顺序与创建顺序不一致时会误判(先创建后完成的 succeeded 应胜过后创建先完成的 failed)。 + - ``succeeded`` 或 ``best_score ≥ patrol_qualified_score_threshold`` → ``done``;否则 ``unfixable``。 + - 跳过「有 running/paused Routine」的 doc(spawn 写的 in_progress 为其当前真实态,不可回退)。 + - 幂等:仅在 ``patrol_status`` / ``patrol_routine_id`` 变化时写(避免每 tick 刷新 ``patrol_updated_at``)。 + + 列为 UI / selector 的唯一读源(迁移 0092 SSOT);Memory ``TAG_STATUS`` 为 Phase 1 过渡,本函数不改。 + 返回实际校正行数。 + """ + threshold = settings.routine.patrol_qualified_score_threshold + result = await db.execute( + sa.text( + """ + WITH winner AS ( + SELECT DISTINCT ON (r.config->>'doc_id') + r.config->>'doc_id' AS doc_id, + r.id AS rid, + r.status, + r.best_score, + CASE WHEN r.status = 'succeeded' OR r.best_score >= :threshold + THEN 'done' ELSE 'unfixable' END AS new_status + FROM negentropy.routines r + WHERE r.config->>'patrol' = 'true' + AND r.status IN ('succeeded', 'failed') + AND r.config->>'doc_id' IS NOT NULL + ORDER BY r.config->>'doc_id', r.updated_at DESC + ) + UPDATE negentropy.knowledge_documents kd + SET patrol_status = w.new_status, + patrol_score = w.best_score, + patrol_routine_id = w.rid, + patrol_updated_at = NOW() + FROM winner w + WHERE kd.id::text = w.doc_id + AND NOT EXISTS ( + SELECT 1 FROM negentropy.routines rr + WHERE rr.config->>'patrol' = 'true' + AND rr.config->>'doc_id' = kd.id::text + AND rr.status IN ('running', 'paused') + ) + AND (kd.patrol_status IS DISTINCT FROM w.new_status + OR kd.patrol_routine_id IS DISTINCT FROM w.rid) + """ + ).bindparams(threshold=threshold) + ) + return result.rowcount or 0 + + # --------------------------------------------------------------------------- # 并发跳过 # --------------------------------------------------------------------------- diff --git a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py index 9dc5695fc..5842b8737 100644 --- a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py +++ b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py @@ -729,6 +729,137 @@ async def test_migration_0092_backfills_patrol_status_from_memories(db_engine): assert row[1] == 97 # score 回填(NULLIF(metadata->>'score','')::int) +async def _set_patrol_routine_best_score(db_engine, routine_id, score) -> None: + """测试辅助:置一条 Routine 的 best_score(_seed_patrol_routine 不带 score)。""" + factory = _sf(db_engine) + async with factory() as db: + await db.execute( + text("UPDATE negentropy.routines SET best_score = :s WHERE id = CAST(:r AS uuid)").bindparams( + s=score, r=str(routine_id) + ) + ) + await db.commit() + + +async def test_reconcile_picks_non_cancelled_winner_over_stale_cancelled(db_engine): + """reconcile:succeeded/95(非 cancelled)覆盖被污染的 stale unfixable/2(来自已 cancelled Routine)。 + + 复现实测缺陷:一个先以 failed 写入 unfixable/2、随后被 collapse 取消的 Routine,把更早 succeeded/95 + 的 done 覆盖成 unfixable/2。reconcile 以「最新非 cancelled 终态 Routine」为权威校正回 done/95。 + """ + factory = _sf(db_engine) + doc_id = await _seed_pdf_document(db_engine, original_filename="reconcile.pdf", display_name="Reconcile Doc") + # routine A:succeeded/95(非 cancelled → 权威 winner) + rid_a = await _seed_patrol_routine(db_engine, doc_id=doc_id, title="t-a", display_name="d-a", status="succeeded") + await _set_patrol_routine_best_score(db_engine, rid_a, 95) + # routine B:cancelled/2(曾以 failed 写入 unfixable/2,后被 collapse 取消) + rid_b = await _seed_patrol_routine(db_engine, doc_id=doc_id, title="t-b", display_name="d-b", status="cancelled") + # 列被污染为 stale unfixable/2(指向已 cancelled 的 routine B) + async with factory() as db: + await db.execute( + text( + "UPDATE negentropy.knowledge_documents " + "SET patrol_status='unfixable', patrol_score=2, patrol_routine_id=CAST(:r AS uuid) " + "WHERE id=CAST(:d AS uuid)" + ).bindparams(r=str(rid_b), d=str(doc_id)) + ) + await db.commit() + + async with factory() as db: + n = await patrol._reconcile_patrol_status(db) + await db.commit() + assert n >= 1 + + row = await _patrol_column(db_engine, doc_id) + assert row[0] == "done" and row[1] == 95 # winner A(succeeded/95) + assert str(row[2]) == str(rid_a) + + +async def test_reconcile_skips_doc_with_running_routine(db_engine): + """reconcile 跳过有 running Routine 的 doc——保留 spawn 写的 in_progress,不回退到旧终态。""" + factory = _sf(db_engine) + doc_id = await _seed_pdf_document( + db_engine, original_filename="reconcile-running.pdf", display_name="Reconcile Running" + ) + # 旧 failed 终态 Routine(若 reconcile 误选它会写 unfixable,覆盖 in_progress) + rid_old = await _seed_patrol_routine(db_engine, doc_id=doc_id, title="t-old", display_name="d-old", status="failed") + await _set_patrol_routine_best_score(db_engine, rid_old, 52) + # 当前 running Routine(spawn 写的 in_progress) + rid_run = await _seed_patrol_routine( + db_engine, doc_id=doc_id, title="t-run", display_name="d-run", status="running" + ) + async with factory() as db: + await db.execute( + text( + "UPDATE negentropy.knowledge_documents " + "SET patrol_status='in_progress', patrol_routine_id=CAST(:r AS uuid) " + "WHERE id=CAST(:d AS uuid)" + ).bindparams(r=str(rid_run), d=str(doc_id)) + ) + await db.commit() + + async with factory() as db: + await patrol._reconcile_patrol_status(db) + await db.commit() + + row = await _patrol_column(db_engine, doc_id) + assert row[0] == "in_progress" # 未被旧 failed/52 覆盖 + assert str(row[2]) == str(rid_run) + + +async def _set_routine_timestamps(db_engine, routine_id, *, created_at, updated_at) -> None: + """测试辅助:置一条 Routine 的 created_at / updated_at(控完成顺序 vs 创建顺序)。""" + factory = _sf(db_engine) + async with factory() as db: + await db.execute( + text( + "UPDATE negentropy.routines SET created_at = :c, updated_at = :u WHERE id = CAST(:r AS uuid)" + ).bindparams(c=created_at, u=updated_at, r=str(routine_id)) + ) + await db.commit() + + +async def test_reconcile_picks_latest_by_updated_not_created(db_engine): + """reconcile 按 ``updated_at``(终态达成时间)而非 ``created_at`` 判定最新 Routine。 + + 场景(完成顺序与创建顺序相反):routine P「先创建(succeeded/95)后完成」、routine Q「后创建 + (failed/52)先完成」。``created_at DESC`` 会误选 Q(创建更晚)→ unfixable/52(错); + ``updated_at DESC`` 选 P(最近完成)→ done/95(对,代表最近一次巡检结论)。 + """ + factory = _sf(db_engine) + doc_id = await _seed_pdf_document( + db_engine, original_filename="reconcile-order.pdf", display_name="Reconcile Order" + ) + # P:succeeded/95,created 早(07-01)但 updated 晚(07-08,最后完成) + rid_p = await _seed_patrol_routine(db_engine, doc_id=doc_id, title="t-p", display_name="d-p", status="succeeded") + await _set_patrol_routine_best_score(db_engine, rid_p, 95) + await _set_routine_timestamps( + db_engine, + rid_p, + created_at=datetime(2026, 7, 1, 0, 0, 0, tzinfo=UTC), + updated_at=datetime(2026, 7, 8, 10, 0, 0, tzinfo=UTC), + ) + # Q:failed/52,created 晚(07-05)但 updated 早(07-06,先完成) + rid_q = await _seed_patrol_routine(db_engine, doc_id=doc_id, title="t-q", display_name="d-q", status="failed") + await _set_patrol_routine_best_score(db_engine, rid_q, 52) + await _set_routine_timestamps( + db_engine, + rid_q, + created_at=datetime(2026, 7, 5, 0, 0, 0, tzinfo=UTC), + updated_at=datetime(2026, 7, 6, 10, 0, 0, tzinfo=UTC), + ) + + async with factory() as db: + n = await patrol._reconcile_patrol_status(db) + await db.commit() + assert n >= 1 + + row = await _patrol_column(db_engine, doc_id) + # updated_at DESC → P(07-08 最近完成)胜出 → done/95(非 created_at 误判的 unfixable/52) + assert row[0] == "done" and row[1] == 95 + assert str(row[2]) == str(rid_p) + + # --------------------------------------------------------------------------- # _finalize_execution:patrol_lifecycle 标记的延迟语义(per-tick 不声称聚合状态终态) # --------------------------------------------------------------------------- diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 970661cec..8036e2a94 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -3481,3 +3481,13 @@ R7 后浏览器对照 Section 2.1 区域发现两类正交缺陷: - **处理方式**(SSOT 迁移 + UI + 重置 API,详见 [PDF 巡检状态落库方案](pdf-fidelity-patrol-status.md)):① **落库**——`KnowledgeDocument` 新增 `patrol_status`(NULL/in_progress/unfixable/done 四态)/`patrol_score`/`patrol_routine_id`/`patrol_updated_at` 列 + 索引(迁移 0092,含从 memories 回填存量状态)。② **写入路径 dual-write**——spawn 写 `in_progress`、终态 `_upsert_status` 写 done/unfixable、cancelled 双守卫回退 NULL;DB 列为权威读源,Memory TAG_STATUS 暂保留写入(Phase 2 deprecate),不破坏既有集成测试断言。③ **selector 迁移**——`_select_next_pending_doc` 把 `id NOT IN :skip` 换成 `patrol_status IS NULL`,`_has_running_patrol` 保持读 routines 表(防 in_progress 残留卡死全系统)。④ **「重置为未拟合」API**——`DocumentStorageService.reset_patrol_status`(保守策略:在跑 409 拒绝;取消终态 Routine 解除 selector NOT EXISTS 门;清列 + 清 Memory TAG_STATUS/TAG_UNFIXABLE)+ 双路由(corpus + 库文档)。⑤ **前端**——Documents 列表顺势由旧 `div+grid-cols-13`(全仓无定义、列宽靠隐式网格自适应的隐患表)重构为 `
+ ` 黄金标准(修复隐患 + 合规 CLAUDE.md 表格规范),新增「巡检状态」列(`PatrolStatusBadge` 四态 + 分数,非 PDF 显示「—」)+ 「重置为未拟合」按钮(`useConfirmDialog` 确认 → `resetDocumentPatrol` → `listRefresh`,409 toast 提示)。 - **后续防范**:① **文档级聚合状态必须落主表持久列,不能放可衰减的 Memory 标签**——Memory 适合承载可衰减的语义/方法记忆(pattern/baseline/区域避让),不适合承载「文档是否已拟合」这类与文档生命周期绑定、需可观测可重试的事实状态。② **状态迁移须配 dual-write 过渡**——读侧先收敛到新 SSOT(降低风险),写侧暂保留旧路径,灰度观察后再 deprecate,避免一刀切破坏既有测试/调用方。③ **`:param::uuid` cast 会破坏 SQLAlchemy text() 的 bindparam 自动检测**(`::` 触发负向预查失败)——一律用 `CAST(:param AS uuid)`(同 0040 迁移既定范式)。④ **重置类操作要解除 selector 门**——仅清状态列不够,若 selector 有「NOT EXISTS 非 cancelled Routine」并发门,重置须同步把旧终态 Routine 标 cancelled,否则重置后仍被挡。⑤ **共享测试库(negentropy_test 不跨 session 清空)下 selector 测试须用「单 doc 视角门控判定」断言**——`ORDER BY ... LIMIT 1` 在累积数据下选中不确定(多次运行残留多份 created_at 相近的 pending 文档),flaky。 - **同类问题影响**:任何「把聚合状态错放 Memory / JSONB 标签」的设计都应审视是否需迁主表列(可观测 / 可重试 / 抗衰减诉求);selector 的「ORDER BY LIMIT 1」类测试在共享库下统一改单 doc 判定。改动文件:`models/perception.py`、迁移 `0092`、`engine/routine/patrol_memory.py`、`engine/schedulers/handlers/pdf_fidelity_patrol.py`、`storage/service.py`、`knowledge/routes/documents.py`+`library.py`、`knowledge/schemas.py`+`_shared.py`;前端 `app/knowledge/documents/page.tsx`、`_components/PatrolStatusBadge.tsx`、`features/knowledge/utils/knowledge-api.ts`、BFF 两条 `reset-patrol/route.ts` + 46 个单测/集成测试。 + +--- + +## ISSUE-159 巡检态被「先 failed 后 cancelled」的 Routine 污染:succeeded/95 显示成 巡检失败·2(2026-07-08,ISSUE-158 续) + +- **表因**:文档「Code as Agent Harness」(`2605.18747v1.pdf`)多次巡检、最近一次 `succeeded`/95,但 Documents 列「巡检状态」显示「巡检失败 · 2」。用户预期:以最后一次巡检结论为准 → 应显示「拟合成功 · 95」。 +- **根因**(实机查 PG 定位,非推演):该 doc 有 3 个巡检 Routine——① `cancelled`/95(`redrive_reset`)、② `succeeded`/95(success,真实拟合成功)、③ `cancelled`/2(`superseded_patrol`)。缺陷链:`_finalize_terminal_patrols` 按 **finalize 顺序 last-write-wins** 写列(不看 Routine 新旧/是否会被取消);routine ③ 先以 `failed` 终态 finalize(best_score=2 → `unfixable/2`),**覆盖**了更早 routine ② 的 `done/95`;随后 `_collapse_superseded_patrols` 把 routine ③ 标 `cancelled`(`superseded_patrol`),但**取消时不回写巡检态**。于是列停留在 routine ③ 的 `unfixable/2`,迁移 0092 又从这条陈旧 Memory 回填了列。**核心:finalize 的写入顺序 ≠ Routine 的时间序,且 cancelled Routine 的污染写入不会被纠正。** +- **处理方式**(权威源切到 routines 表 + 每 tick 校正,详见 [方案 §3.1](pdf-fidelity-patrol-status.md)):新增 `_reconcile_patrol_status(db)`,每 tick 在 `_collapse_superseded_patrols` 之后运行,以**每 doc 最新的非 cancelled 终态 Routine**(`succeeded`/`failed`,按 **`updated_at DESC`**)为权威重算列——`updated_at` 是终态达成时间、代表「最近一次巡检结论」(`created_at` 仅 spawn 时间,完成顺序与创建顺序相反时会误判);`cancelled` = 被取代/放弃、非真实结论,故排除;`succeeded` 或 `best_score ≥ patrol_qualified_score_threshold` → `done`,否则 `unfixable`;**跳过有 `running`/`paused` Routine 的 doc**(spawn 写的 `in_progress` 是当前真实态,不可回退到旧终态);幂等(仅变化时写)。迁移 `0093` 用同 SQL 在部署时一次性修复存量受污染数据。dry-run 验证:该 bug doc 的 winner = routine ②(`succeeded`/95)→ 校正为 `done/95`。 +- **后续防范**:① **last-write-wins 的状态机须有「权威源校正」兜底**——当写入顺序(finalize 时序)与语义顺序(Routine 时间序/优先级)不一致、且有「事后作废」(collapse 取消)动作时,仅靠写入方维护状态必然漂移;须有一个以**权威事实表**(此处 routines)为源、按**语义优先级**(非 cancelled > cancelled、最新 > 旧)重算的 idempotent 校正步骤,定期或事件触发运行。② **「取消/作废」类操作必须回写其曾经污染的派生态**——collapse 取消一个已 finalize 写过状态的 Routine 时,若不纠正状态,该 Routine 的陈旧写入会永久残留;reconcile 兜底比「在取消点逐一回写」更鲁棒(取消路径多、易漏)。③ **状态语义须明确「cancelled 不算结论」**——巡检态以非 cancelled 终态为准;cancelled 是过程态(被取代/用户放弃),不应作为文档拟合结论。④ **排查须读真实数据**——本 issue 的「routine ③ 先 failed 后 cancelled」结论来自直接查 PG 的 routines(status/best_score/termination_reason/created_at)+ 列值 + Memory,源码推演无线索。 +- **同类问题影响**:任何「派生态由多源写入 + 事后作废」的状态机(如多 Routine 聚合态、多 attempt 任务态)都应审视是否需 reconcile 兜底;selector 已读列(`patrol_status IS NULL`),校正后 done/95 仍被正确排除(done ≠ NULL),二次巡检须用户「重置为未拟合」。改动文件:`engine/schedulers/handlers/pdf_fidelity_patrol.py`(`_reconcile_patrol_status` + tick 调用)、迁移 `0093`、方案文档 §3.1/§7 + 2 个集成测试。 diff --git a/docs/.agents/pdf-fidelity-patrol-status.md b/docs/.agents/pdf-fidelity-patrol-status.md index 3b5d9f67f..5912a892d 100644 --- a/docs/.agents/pdf-fidelity-patrol-status.md +++ b/docs/.agents/pdf-fidelity-patrol-status.md @@ -79,6 +79,19 @@ sequenceDiagram > **`_has_running_patrol` 保持读 `routines` 表,不读列**——SSOT:它回答「全局是否有真实在跑的巡检」,权威源是 `routines.status`;若改读列,routine 崩溃卡死会致 `in_progress` 残留而永久 SKIP 全系统巡检。 +### 3.1 巡检态校正 reconcile(权威 = 最新非 cancelled 终态 Routine) + +`_finalize_terminal_patrols` 的 last-write-wins + `_collapse_superseded_patrols` 取消冗余 Routine 时**不回写状态**,会污染列:一个先以 `failed` 终态写入 `unfixable`、随后被 collapse 取消的 Routine,会把更早 `succeeded` 的 `done` 覆盖成 `unfixable`(实测:succeeded/95 被 failed/2 覆盖,ISSUE-159)。 + +[`_reconcile_patrol_status`](../../apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py) 每 tick 在 collapse 之后运行,以**权威源 = routines 表**重算列: + +- 每 doc 取**最新的非 cancelled 终态 Routine**(`succeeded`/`failed`,按 **`updated_at DESC`**)——`cancelled` = 被取代/放弃,非真实结论;`updated_at` 是终态达成时间(最后一次状态变更),代表「最近一次巡检结论」(`created_at` 仅 spawn 时间,完成顺序与创建顺序不一致时会误判)。 +- `succeeded` 或 `best_score ≥ patrol_qualified_score_threshold` → `done`;否则 `unfixable`。 +- **跳过有 `running`/`paused` Routine 的 doc**(spawn 写的 `in_progress` 是当前真实态,不可回退到旧终态)。 +- 幂等:仅在 `patrol_status`/`patrol_routine_id` 变化时写(不每 tick 刷新 `patrol_updated_at`)。 + +存量受污染数据由迁移 [`0093`](../../apps/negentropy/src/negentropy/db/migrations/versions/0093_reconcile_patrol_status_from_winner.py)(同 SQL)在部署时一次性修复;之后由 tick reconcile 持续维持。 + ## 4. selector 迁移 [`_select_next_pending_doc`](../../apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py) 的候选门控(缺一不可): @@ -116,13 +129,15 @@ flowchart LR - **Badge**:[`PatrolStatusBadge`](../../apps/negentropy-ui/app/knowledge/documents/_components/PatrolStatusBadge.tsx) 四态配色对齐 [`routineStatusClass`](../../apps/negentropy-ui/app/interface/routine/_components/status-style.ts)(`bg-{color}-500/15 ...`)与 [巡检语义表](../../apps/negentropy-ui/features/scheduler/patrol-reason.ts);分数用 [`scoreColorClass`](../../apps/negentropy-ui/components/transcript/status-shared.ts) 上色;非 PDF 行显示「—」。 - **重置按钮**:Actions 单元格内,仅对 `isPdfDocument(doc) && patrol_status ∈ {done, unfixable}` 显示;经 [`useConfirmDialog`](../../apps/negentropy-ui/components/ui/useConfirmDialog.tsx) 确认 → [`resetDocumentPatrol`](../../apps/negentropy-ui/features/knowledge/utils/knowledge-api.ts) → `listRefresh()`;409 时 toast 提示「该文档正在巡检,请先取消在跑巡检再重置」。 -## 7. 数据迁移(0092) +## 7. 数据迁移(0092 建列 + 0093 校正) [`0092_pdf_fidelity_patrol_status_column.py`](../../apps/negentropy/src/negentropy/db/migrations/versions/0092_pdf_fidelity_patrol_status_column.py): 1. `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 四列(`patrol_routine_id` 带 `REFERENCES routines(id) ON DELETE SET NULL`)。 2. **回填**:从 `memories` 取每 doc 最新一条 `TAG_STATUS`(`DISTINCT ON (doc_id) ... ORDER BY created_at DESC`),`NULLIF(metadata->>'score','')::int` + `routine_id` uuid 正则守卫,写回 `patrol_status`/`patrol_score`/`patrol_routine_id`;带 `patrol_status IS NULL` 守卫,幂等可重跑。 3. `CREATE INDEX IF NOT EXISTS ix_knowledge_documents_patrol_status`。 + +> 0092 回填源(Memory)本身受 finalize last-write-wins 污染(见 §3.1)。[`0093_reconcile_patrol_status_from_winner.py`](../../apps/negentropy/src/negentropy/db/migrations/versions/0093_reconcile_patrol_status_from_winner.py) 紧随其后,以 routines 表为权威重算列(同 §3.1 reconcile SQL),一次性修复存量受污染数据。 4. **downgrade 红线**:patrol 态可由重跑巡检确定性再生(终态 Routine 经 `_finalize_terminal_patrols` 重沉淀),故 `DROP COLUMN` 可接受、**不回写 memories**。 ## 8. dual-write → Phase 2 路线 From 0abb6f3830b85afe8c6ef62730db286385eca5ef Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 8 Jul 2026 16:38:32 +0800 Subject: [PATCH 20/81] =?UTF-8?q?docs(AGENTS):=20=E6=96=B0=E5=A2=9E=20UI?= =?UTF-8?q?=20=E8=A1=A8=E5=8D=95=E8=AE=BE=E8=AE=A1=E8=A7=84=E8=8C=83?= =?UTF-8?q?=EF=BC=88label=20=E4=B8=8E=E8=BE=93=E5=85=A5=E6=8E=A7=E4=BB=B6?= =?UTF-8?q?=E5=90=8C=E8=A1=8C=E4=B8=94=20label=20=E5=8D=A0=201/12=20?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6=EF=BC=89;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index cfc4ae9d1..c734bd37e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,4 +58,6 @@ 1. **样式一致性**:保持全局 UI 表格风格的一致性; 2. **列宽固定与对齐**:表格列宽必须固定。不同表格中具有相同属性的列应采用相同的固定列宽,列的设计宽度应与其实际内容的长度相匹配; 3. **溢出处理与 Tooltip**:列名与单元格内容默认禁止折行(保持单行显示)。超出列宽的部分使用省略号(`...`)物理截断,并配置 Tooltip 悬浮展示完整内容; +- **UI Form Design Norms(UI 表单设计规范)**: + 1. **字段 label 与输入控件对一致性**:表单中字段的 label 与输入控件应处在同一行(不要各占一行),此外 label 一律仅占 1/12 的宽度; - **Reference Specifications (IEEE)**:为保障工程决策的可追溯性与学术严谨性,核心引用需遵循 [reference-specifications.md](docs/.agents/reference-specifications.md)IEEE 标准引用格式; From 634bf5c9dae15807a745689ce12829a1e22a99e3 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 8 Jul 2026 22:05:04 +0800 Subject: [PATCH 21/81] =?UTF-8?q?fix(patrol):=20=E7=A7=BB=E9=99=A4=20=5Fse?= =?UTF-8?q?lect=5Fnext=5Fpending=5Fdoc=20=E5=91=BD=E5=90=8D=E9=97=A8?= =?UTF-8?q?=E6=8E=A7=EF=BC=8C=E6=9C=AA=E5=B7=A1=E6=A3=80=E6=97=A0=E6=A0=87?= =?UTF-8?q?=E9=A2=98=20PDF=20=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=AF=AF=E5=88=A4?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E6=97=A0=E5=BE=85=E6=A3=80=E3=80=8D;=20(#107?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:selector 除正确的 patrol_status IS NULL 外,曾附加「命名门控」(display_name / metadata.title 非空预筛),致未巡检但无标题的 PDF 被永久跳过,Scheduler 恒报 「无待检 PDF 文档」(实证:线上库 2 份未巡检 PDF 全被该门挡住)。 设计层:命名门控把「要不要巡检」与「Routine 叫什么名」两个正交关注耦合。系统本已解耦 命名——_doc_display_title 三级兜底 + _collapse_superseded_patrols「原始文件名兜底自愈」 (更优名源出现时取消旧 Routine、下 tick 以更优名重建)。故命名门控为 #1071 自身引入 collapse 自愈后的遗留物,其「名创建即定格」理由已自相矛盾。 变更: - 移除 _select_next_pending_doc 的命名门控谓词(patrol_status IS NULL + NOT EXISTS 两道门保留) - docstring 改述为正交分解语义 - 翻转 test_pdf_fidelity_patrol_handler 单测断言(命名门控 存在→不存在,防回归) - 新增集成回归:未巡检且无 display_name/title 的 PDF 满足 selector 资格谓词 - 同步 docs/.agents/pdf-fidelity-patrol-status.md selector 门控清单 副作用:仅剩原始文件名(含 arxiv-ID 如 2603.05344v3.pdf)作 Routine 名的文档也会被巡检 (名字为展示性,collapse 自愈会在更优名源出现后重建)。不触碰 patrol_status 四态语义 / unfixable 终态 / reset / reconcile / collapse。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- .../handlers/pdf_fidelity_patrol.py | 23 ++++++------ .../test_pdf_fidelity_patrol_handler.py | 11 +++--- .../test_pdf_fidelity_patrol_integration.py | 36 +++++++++++++++++-- docs/.agents/pdf-fidelity-patrol-status.md | 5 +-- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py b/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py index 481080267..f975e29af 100644 --- a/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py +++ b/apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py @@ -659,19 +659,21 @@ async def _has_running_patrol(db) -> bool: async def _select_next_pending_doc(db, *, skip_ids: set[str] | None = None) -> dict[str, Any] | None: """选最早入库、未巡检(``patrol_status IS NULL``)的 PDF 文档(content_type=pdf 且转换已完成)。 - 两道守卫(缺一不可): - - **命名门控**:``display_name`` 或 ``metadata->>'title'`` 至少有一个非空,否则跳过。 - 巡检 Routine 名字在创建时刻定格(``_doc_display_title`` 三级解析),无更优名源时会兜底成 - ``original_filename``(如 ``2603.05344v3.pdf``)——本门控从源头杜绝「原始文件名兜底」巡检。 - 新导入经 Fix B(Perceives 标题透传 + 回填)自动获得 ``metadata.title``;存量无标题文档待 - 用户改名 / 重新抽取后入选(绝不以原始文件名兜底发起巡检)。 + 巡检资格判定的两道正交守卫: + - **巡检态门控**(SSOT):``patrol_status IS NULL`` = 未巡检入选;``in_progress``/``done``/ + ``unfixable`` 均跳过(终态文档须用户「重置为未拟合」回 NULL 方可二次巡检)。 - **一文一活跃巡检**(Fix A):``NOT EXISTS`` 排除已有**非 cancelled** 巡检 Routine 的文档 (``config->>'doc_id'`` 为 SSOT 指针)。排除 cancelled 使「取消」成为合法复位——被取消的冗余 - Routine 不再阻塞同 doc 以当前有效名重建(见 ``_collapse_superseded_patrols``)。 + Routine 不再阻塞同 doc 重建(见 ``_collapse_superseded_patrols``);此门亦承担防重试死循环职责。 - 巡检态 SSOT 为 ``knowledge_documents.patrol_status`` 列(NULL=未巡检入选,``in_progress``/ - ``done``/``unfixable`` 跳过)。``skip_ids`` 形参已废弃(过渡兼容,忽略),见迁移 0092 与 - ``docs/.agents/pdf-fidelity-patrol-status.md``。 + 命名关注**正交下沉**至 ``_doc_display_title``(``display_name`` → ``metadata.title`` → + ``original_filename`` 三级兜底,复用纯函数 SSOT ``resolve_effective_display_name``):巡检资格不因 + 缺名而被否决——仅剩原始文件名(含 arxiv-ID 如 ``2603.05344v3.pdf``)时仍发起巡检,Routine 名暂以 + 文件名兜底;待更优名源出现(用户改名 / Fix B 标题回填),``_collapse_superseded_patrols`` 自愈取消 + 旧 Routine、下一 tick 以更优名重建。历史「命名门控」(要求 display_name/metadata.title 非空)曾在此 + 预筛,致未巡检但无标题文档被永久跳过而 Scheduler 误报「无待检 PDF 文档」,已移除。 + + ``skip_ids`` 形参已废弃(过渡兼容,忽略),见迁移 0092 与 ``docs/.agents/pdf-fidelity-patrol-status.md``。 """ # TODO(phase2): 移除 skip_ids 形参(巡检态已迁至 patrol_status 列,Memory TAG_STATUS deprecate 后删)。 del skip_ids # 过渡期保留签名兼容,不再使用。 @@ -683,7 +685,6 @@ async def _select_next_pending_doc(db, *, skip_ids: set[str] | None = None) -> d "AND COALESCE(content_type,'') ILIKE '%pdf%' " "AND markdown_extract_status = 'completed' " "AND patrol_status IS NULL " - "AND COALESCE(NULLIF(display_name, ''), NULLIF(metadata->>'title', '')) IS NOT NULL " "AND NOT EXISTS (" " SELECT 1 FROM negentropy.routines r " " WHERE r.config->>'patrol' = 'true' " diff --git a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_handler.py b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_handler.py index 1a2872a4d..0a366251e 100644 --- a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_handler.py +++ b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_handler.py @@ -135,18 +135,19 @@ def test_select_next_pending_doc_reads_patrol_status_column(): def test_select_next_pending_doc_sql_contains_per_doc_uniqueness_guard(): - """Fix A + 命名门控:emitted SQL 必含「命名门控」+「一文一活跃巡检」NOT EXISTS 守卫(排除 cancelled)。 + """Fix A:emitted SQL 必含「一文一活跃巡检」NOT EXISTS 守卫(排除 cancelled);命名门控已移除(防回归)。 - FakeDB 不解析 SQL,仅以串存在性守护不变量防回归——后续重构若误删命名门控 / NOT EXISTS / - 把 cancelled 纳入阻塞,本断言即失败。真实 SQL 语义由集成测试覆盖。 + FakeDB 不解析 SQL,仅以串存在性守护不变量防回归——后续重构若误删 NOT EXISTS / 把 cancelled 纳入 + 阻塞,或误加回「命名门控」(display_name/metadata.title 非空预筛,会误排未巡检但无标题文档致 + Scheduler 误报「无待检」),本断言即失败。真实 SQL 语义由集成测试覆盖。 """ import asyncio db = _FakeDB(fetchone=None) asyncio.run(patrol._select_next_pending_doc(db, skip_ids=set())) stmt = db.executed[0][0] - # 命名门控:display_name 或 metadata->>'title' 至少一个非空(杜绝原始文件名兜底) - assert "COALESCE(NULLIF(display_name, ''), NULLIF(metadata->>'title', '')) IS NOT NULL" in stmt + # 命名门控已移除:巡检资格不再预筛 display_name/metadata.title(命名下沉至 _doc_display_title) + assert "COALESCE(NULLIF(display_name, ''), NULLIF(metadata->>'title', '')) IS NOT NULL" not in stmt assert "patrol_status IS NULL" in stmt # 巡检态 SSOT 列(仅未巡检入选) assert "NOT EXISTS" in stmt assert "config->>'patrol'" in stmt diff --git a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py index 5842b8737..80733fb5e 100644 --- a/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py +++ b/apps/negentropy/tests/unit_tests/engine/test_pdf_fidelity_patrol_integration.py @@ -508,8 +508,8 @@ async def test_finalize_multiple_routines_same_doc_best_wins(db_engine): async def _seed_knowledge_pdf(db_engine, *, filename): """落库一条 knowledge_documents(pdf + completed);返回其 id。 - 带 ``display_name``:命名门控(``_select_next_pending_doc``)要求文档至少有 display_name - 或 metadata.title 之一,否则被跳过——测试文档须具名才可被选中(测试的是推进逻辑,非命名)。 + 带 ``display_name``:历史命名门控已移除(巡检资格不再预筛 display_name/metadata.title,命名 + 下沉至 ``_doc_display_title``),此处仍具名仅为测试可读性 / 与存量 fixture 一致。 """ from negentropy.config import settings from negentropy.models.perception import KnowledgeDocument @@ -558,6 +558,38 @@ async def test_select_advances_after_done(db_engine): assert str(next_doc["id"]) != str(doc_a) # 推进:不再选中已合格的 A(修「始终卡 A」根因) +async def test_select_next_pending_doc_includes_untitled_pdf(db_engine): + """回归:未巡检但 ``display_name``/``metadata.title`` 均空的 PDF 也应入选(命名门控已移除)。 + + 复现并锁死「Scheduler 误报无待检 PDF 文档」根因——历史命名门控(display_name/metadata.title + 非空预筛)把这类文档永久跳过。测试库累积且 selector 全局取最早一份(``skip_ids`` 已废弃不可 + drain),故不直接断言 ``selector()==该 doc``,而是断言「该 no-title doc 满足 selector 的资格 + 谓词」(等价于 selector 在无更早 pending 时会选中它)。 + """ + # 无 display_name / 无 metadata.title(命名门控移除前的「被误排」形态) + doc_id = await _seed_pdf_document(db_engine, original_filename="patrol-untitled.pdf") + + factory = _sf(db_engine) + async with factory() as db: + eligible = await db.execute( + text( + "SELECT 1 FROM negentropy.knowledge_documents " + "WHERE id = :d " + " AND app_name = :app " + " AND COALESCE(content_type,'') ILIKE '%pdf%' " + " AND markdown_extract_status = 'completed' " + " AND patrol_status IS NULL " + " AND NOT EXISTS (" + " SELECT 1 FROM negentropy.routines r " + " WHERE r.config->>'patrol' = 'true' " + " AND r.config->>'doc_id' = knowledge_documents.id::text " + " AND r.status <> 'cancelled')" + ), + {"d": doc_id, "app": settings.app_name}, + ) + assert eligible.fetchone() is not None # no-title 未巡检 PDF 资格通过(命名门控不再拦截) + + # --------------------------------------------------------------------------- # 巡检态 SSOT 列(knowledge_documents.patrol_status)+ 「重置为未拟合」API # --------------------------------------------------------------------------- diff --git a/docs/.agents/pdf-fidelity-patrol-status.md b/docs/.agents/pdf-fidelity-patrol-status.md index 5912a892d..4eaa06ffc 100644 --- a/docs/.agents/pdf-fidelity-patrol-status.md +++ b/docs/.agents/pdf-fidelity-patrol-status.md @@ -99,9 +99,10 @@ sequenceDiagram 1. `content_type ILIKE '%pdf%'`(PDF 文档) 2. `markdown_extract_status = 'completed'`(转换完成) 3. **`patrol_status IS NULL`**(4 态语义:仅未巡检入选;**替换**旧 `id NOT IN :skip` 的 Memory skip_ids 路径) -4. 命名门控(`display_name` 或 `metadata->>'title'` 至少一个非空,杜绝原始文件名兜底) -5. `NOT EXISTS` 非 cancelled 巡检 Routine(一文一活跃巡检并发互斥;与 `patrol_status` 正交保留) +4. `NOT EXISTS` 非 cancelled 巡检 Routine(一文一活跃巡检并发互斥;与 `patrol_status` 正交保留) +> **命名关注正交下沉**至 [`_doc_display_title`](../../apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py)(`display_name` → `metadata.title` → `original_filename` 三级兜底,复用 `resolve_effective_display_name`):巡检资格不因缺名而被否决。历史「命名门控」(要求 `display_name`/`metadata.title` 非空)曾在此预筛,致未巡检但无标题文档被永久跳过而 Scheduler 误报「无待检 PDF 文档」,已移除——仅剩原始文件名(含 arxiv-ID 如 `2603.05344v3.pdf`)时仍发起巡检,Routine 名暂以文件名兜底,待更优名源出现(用户改名 / Fix B 标题回填)由 [`_collapse_superseded_patrols`](../../apps/negentropy/src/negentropy/engine/schedulers/handlers/pdf_fidelity_patrol.py) 自愈取消旧 Routine、下 tick 以更优名重建。 +> > `skip_ids` 形参保留为 Optional(过渡兼容,已忽略);`get_skip_doc_ids()` 过渡期保留供测试,Phase 2 随 Memory TAG_STATUS deprecate 一并移除。 ## 5. 「重置为未拟合」API From 8cc4977f5d98fa4dc20b06bdd45adf0eec332120 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 8 Jul 2026 23:10:18 +0800 Subject: [PATCH 22/81] =?UTF-8?q?style(negentropy-ui):=20=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E5=86=85=E8=81=94=E5=8C=96=E4=B8=8E=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=80=A7=E5=AF=B9=E9=BD=90=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E8=A7=84=E8=8C=83=20(#1074)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(negentropy-ui): 新增表单/表格共享原语(Field/Input/Select/Textarea/TruncatedCell)与列宽注册表; Field:水平表单字段原语(label 1/12 + 控件 11/12,溢出截断 Tooltip,variant=check 处理勾选/开关,FieldContext 自动 label↔控件关联);InlineField:紧凑同质组内联;Input/Select/Textarea:baseInputCls SSOT 统一 4 套历史样式约定;TruncatedCell:去重 ~20 处 TextTooltip+truncate 写法(三形态:纯文本/尾图标/复合);table-styles:表头加 whitespace-nowrap + TABLE_COL_WIDTHS 语义列宽注册表。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang * style(negentropy-ui): 表单内联化(label 1/12 同行)+ 表格系统性对齐设计规范; 落实 AGENTS.md「UI 表单/表格设计规范」: - 表单:~25 个表单由垂直堆叠改为水平内联(label col-span-1 + 控件 col-span-11),覆盖 Tier-1 配置抽屉(Routine/Scheduler/Repository/Mcp/Skill/Tool/Agent)、动态 API 字段(7 组件)、知识库对话框(Corpus/AddSource/Replace/Import/CreateWiki 等)、长尾面板(ManifestField/graph/wiki/memory) - 表格:表头加 whitespace-nowrap;违规表 base/page 语料 Documents 表转
+,Actions 改单行 + More 溢出 DropdownMenu(抽 CorpusDocRowActions);9 合规表抽取 TruncatedCell - 同步更新 KnowledgeBasePage 测试:文档删除经 More 菜单触发(反映新溢出 UX),语料库删除保持直接按钮 - 转换源无 label 的区块级控件(Payload JSON/System Prompt 等)仅换基类样式,最小干预 验证:typecheck + lint(--max-warnings=0)+ vitest 989/989 全绿 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .../dashboard/_components/TaskTable.tsx | 13 +- .../agents/_components/AgentFormDrawer.tsx | 306 ++++++------ .../mcp/_components/McpServerCard.tsx | 44 +- .../mcp/_components/McpServerFormDrawer.tsx | 224 ++++----- .../_components/RepositoryFormDrawer.tsx | 200 ++++---- .../routine/_components/RoutineEditDrawer.tsx | 459 ++++++++---------- .../routine/_components/RoutineTable.tsx | 147 +++--- .../scheduler/_components/ManifestField.tsx | 72 +-- .../_components/SchedulerExecutionPanel.tsx | 42 +- .../_components/SchedulerTaskFormDialog.tsx | 163 +++---- .../_components/SchedulerTaskTable.tsx | 51 +- .../skills/_components/SkillFormDrawer.tsx | 427 ++++++++-------- .../_components/SkillScheduleDialog.tsx | 42 +- .../tools/_components/ToolFormDrawer.tsx | 146 +++--- .../apis/_components/ApiDocPanel.tsx | 46 +- .../_components/form-fields/CheckboxInput.tsx | 24 +- .../_components/form-fields/CorpusSelect.tsx | 62 ++- .../_components/form-fields/JsonInput.tsx | 35 +- .../_components/form-fields/NumberInput.tsx | 23 +- .../_components/form-fields/SelectInput.tsx | 27 +- .../_components/form-fields/TextInput.tsx | 22 +- .../_components/form-fields/TextareaInput.tsx | 22 +- .../base/_components/AddSourceDialog.tsx | 36 +- .../base/_components/ContentExplorer.tsx | 21 +- .../base/_components/CorpusFormDialog.tsx | 225 +++------ .../base/_components/CorpusSettingsPanel.tsx | 54 +-- .../base/_components/ReplaceSourceDialog.tsx | 12 +- .../negentropy-ui/app/knowledge/base/page.tsx | 312 +++++++----- .../_components/ImportDocumentDialog.tsx | 16 +- .../graph/_components/CorpusSelector.tsx | 14 +- .../graph/_components/EntityListPanel.tsx | 12 +- .../graph/_components/ModelConfigPanel.tsx | 26 +- .../graph/_components/PathExplorer.tsx | 26 +- .../CreateWikiPublicationDialog.tsx | 45 +- .../_components/catalog/CreateNodeDialog.tsx | 29 +- .../_components/CoreBlockEditorDrawer.tsx | 45 +- apps/negentropy-ui/components/ui/Field.tsx | 185 +++++++ apps/negentropy-ui/components/ui/Input.tsx | 41 ++ apps/negentropy-ui/components/ui/Select.tsx | 46 ++ apps/negentropy-ui/components/ui/Textarea.tsx | 30 ++ .../components/ui/TruncatedCell.tsx | 87 ++++ .../components/ui/table-styles.ts | 40 +- .../unit/knowledge/KnowledgeBasePage.test.tsx | 6 +- 43 files changed, 1942 insertions(+), 1963 deletions(-) create mode 100644 apps/negentropy-ui/components/ui/Field.tsx create mode 100644 apps/negentropy-ui/components/ui/Input.tsx create mode 100644 apps/negentropy-ui/components/ui/Select.tsx create mode 100644 apps/negentropy-ui/components/ui/Textarea.tsx create mode 100644 apps/negentropy-ui/components/ui/TruncatedCell.tsx diff --git a/apps/negentropy-ui/app/(home)/dashboard/_components/TaskTable.tsx b/apps/negentropy-ui/app/(home)/dashboard/_components/TaskTable.tsx index ba8120473..9e9a95355 100644 --- a/apps/negentropy-ui/app/(home)/dashboard/_components/TaskTable.tsx +++ b/apps/negentropy-ui/app/(home)/dashboard/_components/TaskTable.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { TextTooltip } from "@/components/ui/TextTooltip"; +import { TruncatedCell } from "@/components/ui/TruncatedCell"; import type { DashboardFilters, ScheduledTaskDTO } from "../_lib/types"; @@ -127,16 +128,8 @@ export function TaskTable({ tasks, filters, onSelect }: TaskTableProps) { - - + + diff --git a/apps/negentropy-ui/app/interface/agents/_components/AgentFormDrawer.tsx b/apps/negentropy-ui/app/interface/agents/_components/AgentFormDrawer.tsx index 23ffd9bd9..74304073f 100644 --- a/apps/negentropy-ui/app/interface/agents/_components/AgentFormDrawer.tsx +++ b/apps/negentropy-ui/app/interface/agents/_components/AgentFormDrawer.tsx @@ -15,11 +15,15 @@ import { useRef, type ReactNode, } from "react"; -import { Button } from "@/components/ui/Button"; -import { ErrorBanner } from "@/components/ui/ErrorState"; import { BaseDrawer } from "@/components/ui/BaseDrawer"; +import { Button } from "@/components/ui/Button"; import { CollapsibleSection } from "@/components/ui/CollapsibleSection"; +import { ErrorBanner } from "@/components/ui/ErrorState"; +import { Field } from "@/components/ui/Field"; +import { Input } from "@/components/ui/Input"; import { LlmModelSelect } from "@/components/ui/LlmModelSelect"; +import { Select } from "@/components/ui/Select"; +import { Textarea } from "@/components/ui/Textarea"; import { useConfirmDialog } from "@/components/ui/useConfirmDialog"; import { fetchModelConfigs, @@ -40,13 +44,6 @@ interface AgentFormDrawerProps { agent: Agent | null; } -/* ── Shared style constants ── */ -const INPUT = - "w-full rounded-md border border-border bg-input px-3 py-1.5 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"; -const MONO = - "w-full rounded-md border border-border bg-input px-3 py-1.5 text-sm font-mono text-foreground outline-none focus:ring-1 focus:ring-ring"; -const LABEL = "mb-1.5 block text-xs font-medium text-text-muted"; - function SectionLabel({ children }: { children: ReactNode }) { return (
@@ -433,60 +430,51 @@ export function AgentFormDrawer({ )} {/* Identity */} - Identity -
-
- - + Identity + + setField("name", e.target.value)} - className={INPUT} placeholder="my-agent" required /> -
-
- - + + setField("display_name", e.target.value)} - className={INPUT} placeholder="My Agent" /> -
-
-
- -
- - {t.handler_kind} - - - - {triggerText(t)} - - {relativeFromNow(t.last_fire_at)}