diff --git a/.gitignore b/.gitignore index 0f71e77..84059b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,30 @@ -# 数据与产物 +# Python and local tools +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +dist/ +build/ +*.egg-info/ +# Real documents and private datasets +data/ +datasets/ +uploads/ +*.pdf +*.doc +*.docx + +# Generated and experimental outputs +artifacts/ +outputs/ +experiments/ *.cleaned.md report.json -__pycache__/ -*.pyc -.venv/ -dist/ -*.egg-info/ + +# Editor and operating-system files +.DS_Store +.idea/ +.vscode/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..455e949 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,127 @@ +# AGENTS.md + +> [!IMPORTANT] +> **当前是文档治理基础阶段,不是已实现的清洗工具。** +> +> 1. 本仓库研究政务文档 PDF→Markdown 清洗问题;当前没有可运行实现。 +> 2. 真实文档和外部数据默认只读,不修改、不复制、不提交。 +> 3. 面向用户的说明使用简体中文;代码、命令、路径和标识符使用英文。 +> 4. `AGENTS.md` 与 `CLAUDE.md` 是同步镜像,除第一行标题外正文必须一致。 + +## 0. 事实权威 + +发生冲突时,以对应的唯一权威为准,不把不同版本拼成新的说法。 + +| 事实类型 | 唯一权威 | +|---|---| +| 当前阶段与已经完成的工作 | 根目录 `README.md` | +| Wiki 分类、冻结规则与更新机制 | `research-wiki/README.md` | +| 已批准的选择、权衡与否决方案 | `research-wiki/design/` 中对应编号记录 | +| 当前有效的清洗机制与原因 | `research-wiki/explanation/`;没有文档时就是尚未确定 | +| 参数、输入输出和运行行为 | 未来的代码与测试;代码无法表达的事实才进入 `reference/` | +| 可复现的操作与排障步骤 | `research-wiki/guides/` | +| Agent 协作与执行规范 | 本文件及其同步镜像 | + +路径、参数、命令、指标口径和当前进度不得维护多个权威版本。发现冲突时,先确认权威,再修复过期内容。 + +## 1. 项目定位与当前阶段 + +`govdoc-md-cleaner` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、 +比较候选方案并积累可复核证据。 + +当前只建立文档治理基础。源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系均未获批准、 +也未实现。不得因为 README 中描述了目标,就把目标写成已经存在的能力。 + +本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。 + +## 2. 数据与外部材料 + +已知外部真实材料位于 `/home/lihaoze/gov_test_data`。除非用户另行明确授权,执行以下边界: + +- 真实文档、审核材料、历史产出和客户可识别内容只读; +- 不向外部数据目录回写清洗结果,不修改文件名和目录结构; +- 不把原文、软链接、大型结果或可还原客户信息的片段提交到本仓库; +- 读取外部仓库或数据不等于获得修改、发布或迁移它们的权限; +- 新增实验输出目录前,先在 design 中明确保存位置、保留周期与脱敏要求。 + +任何删除、覆盖、移动或公开真实材料的动作,都必须先得到用户对具体范围的明确确认。 + +## 3. 开始工作前 + +1. 阅读根目录 `README.md`、本文件、`research-wiki/README.md` 和相关 design; +2. 查看当前分支、工作区状态和最近提交,已有改动默认属于用户; +3. 区分当前事实、候选方案和未来目标; +4. 明确问题、输入范围、非目标、成功标准和验证方法; +5. 涉及真实材料时,再次确认只读来源、允许写入的位置和敏感信息边界。 + +不存在的命令、脚本、目录或测试不得当作可用能力引用。 + +## 4. 设计与确认门 + +以下事项实施前必须新增 design,并等待用户明确批准: + +- 定义或改变清洗语义、规则优先级、误删容忍度和保真原则; +- 定义输入输出格式、统计口径、评估指标或标注方法; +- 创建源码/测试/实验目录,引入运行依赖或确定公共命令行接口; +- 改变 Wiki 分类、事实权威、数据边界或跨仓职责; +- 修改、删除、移动或公开真实数据和大型实验产出; +- 提交、推送、创建 PR、发布,或修改其他仓库与外部系统。 + +纯文档勘误、只读调查和已批准设计范围内的机械实施,不需要重复建立设计。 + +design 草稿可以在评审中修改;批准后冻结。决策发生变化时创建下一编号,用 `supersedes` 指向旧记录, +不回写历史让它看起来从未改变。 + +## 5. 未来实现的质量底线 + +当实现获得批准后: + +- 正确性和正文保真优先于清洗率与性能;不确定内容默认保留; +- 清洗步骤应可追踪、可复现,并能解释每类修改的依据; +- 算法逻辑与文件读写分层,核心处理尽量保持确定性; +- 公共接口提供类型信息,复杂逻辑说明原因和不变量; +- 显式覆盖空文档、编码异常、OCR 重复、表格、图片、极端长度和幂等性; +- 不吞异常、不静默降级、不用放宽断言掩盖失败; +- 未经真实证据支持,不把局部样本结果概括为一般结论。 + +这些是未来实施约束,不表示当前已经存在任何代码或测试。 + +## 6. 文档规则 + +完整分类规则以 `research-wiki/README.md` 为准: + +- `design/`:动工前的选择、代价与否决方案;批准后冻结; +- `explanation/`:当前有效机制及原因;事实变化时更新; +- `reference/`:代码无法完整表达的稳定查询事实; +- `guides/`:经过实际验证的操作步骤; +- `scratch/`:调研、计划和未收敛草稿,不作为当前事实引用。 + +新文档从实际问题或读者可观察的现象开始,写清理由、适用边界、代价和验证状态。 +易漂移的路径、参数、命令和指标只链接到唯一权威,不复制出第二份。 + +## 7. 验证与报告 + +- 只报告本轮真实执行过的检查;未运行或无法验证的内容明确写“未验证”; +- 声称完成前检查实际目录、文档镜像、Git diff 和工作区状态; +- 未来实验必须能追溯输入范围、版本、参数、环境、指标和输出位置; +- 性能结论同时记录数据规模、运行条件、计时范围和重复次数; +- 区分“实现可运行”“指标改善”和“适合生产”,三者不能互相替代。 + +当前有效的基础检查命令只以根目录 `README.md` 为准。 + +## 8. Git 与文件操作 + +- 已有改动默认属于用户,不覆盖、不回滚、不混入无关变更; +- 不执行 `git reset --hard`、`git checkout --` 等不可恢复操作; +- 删除前确认具体目标,优先保证可从 Git 或备份恢复; +- 提交前检查 staged diff、敏感信息、大文件和验证结果; +- 未经用户明确要求,不提交、不推送、不创建 PR、不发布; +- 不在提交信息、源码或文档中添加 AI 署名。 + +## 9. 沟通与协作 + +- 像同事协作一样,用直接、可读的中文说明判断、变化和风险; +- 问什么答什么,一次聚焦当前问题,不把未经请求的后续工作一起推进; +- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户; +- 进度和最终报告必须对应真实工具输出,不把计划描述成结果; +- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3b75e68 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# CLAUDE.md + +> [!IMPORTANT] +> **当前是文档治理基础阶段,不是已实现的清洗工具。** +> +> 1. 本仓库研究政务文档 PDF→Markdown 清洗问题;当前没有可运行实现。 +> 2. 真实文档和外部数据默认只读,不修改、不复制、不提交。 +> 3. 面向用户的说明使用简体中文;代码、命令、路径和标识符使用英文。 +> 4. `AGENTS.md` 与 `CLAUDE.md` 是同步镜像,除第一行标题外正文必须一致。 + +## 0. 事实权威 + +发生冲突时,以对应的唯一权威为准,不把不同版本拼成新的说法。 + +| 事实类型 | 唯一权威 | +|---|---| +| 当前阶段与已经完成的工作 | 根目录 `README.md` | +| Wiki 分类、冻结规则与更新机制 | `research-wiki/README.md` | +| 已批准的选择、权衡与否决方案 | `research-wiki/design/` 中对应编号记录 | +| 当前有效的清洗机制与原因 | `research-wiki/explanation/`;没有文档时就是尚未确定 | +| 参数、输入输出和运行行为 | 未来的代码与测试;代码无法表达的事实才进入 `reference/` | +| 可复现的操作与排障步骤 | `research-wiki/guides/` | +| Agent 协作与执行规范 | 本文件及其同步镜像 | + +路径、参数、命令、指标口径和当前进度不得维护多个权威版本。发现冲突时,先确认权威,再修复过期内容。 + +## 1. 项目定位与当前阶段 + +`govdoc-md-cleaner` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、 +比较候选方案并积累可复核证据。 + +当前只建立文档治理基础。源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系均未获批准、 +也未实现。不得因为 README 中描述了目标,就把目标写成已经存在的能力。 + +本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。 + +## 2. 数据与外部材料 + +已知外部真实材料位于 `/home/lihaoze/gov_test_data`。除非用户另行明确授权,执行以下边界: + +- 真实文档、审核材料、历史产出和客户可识别内容只读; +- 不向外部数据目录回写清洗结果,不修改文件名和目录结构; +- 不把原文、软链接、大型结果或可还原客户信息的片段提交到本仓库; +- 读取外部仓库或数据不等于获得修改、发布或迁移它们的权限; +- 新增实验输出目录前,先在 design 中明确保存位置、保留周期与脱敏要求。 + +任何删除、覆盖、移动或公开真实材料的动作,都必须先得到用户对具体范围的明确确认。 + +## 3. 开始工作前 + +1. 阅读根目录 `README.md`、本文件、`research-wiki/README.md` 和相关 design; +2. 查看当前分支、工作区状态和最近提交,已有改动默认属于用户; +3. 区分当前事实、候选方案和未来目标; +4. 明确问题、输入范围、非目标、成功标准和验证方法; +5. 涉及真实材料时,再次确认只读来源、允许写入的位置和敏感信息边界。 + +不存在的命令、脚本、目录或测试不得当作可用能力引用。 + +## 4. 设计与确认门 + +以下事项实施前必须新增 design,并等待用户明确批准: + +- 定义或改变清洗语义、规则优先级、误删容忍度和保真原则; +- 定义输入输出格式、统计口径、评估指标或标注方法; +- 创建源码/测试/实验目录,引入运行依赖或确定公共命令行接口; +- 改变 Wiki 分类、事实权威、数据边界或跨仓职责; +- 修改、删除、移动或公开真实数据和大型实验产出; +- 提交、推送、创建 PR、发布,或修改其他仓库与外部系统。 + +纯文档勘误、只读调查和已批准设计范围内的机械实施,不需要重复建立设计。 + +design 草稿可以在评审中修改;批准后冻结。决策发生变化时创建下一编号,用 `supersedes` 指向旧记录, +不回写历史让它看起来从未改变。 + +## 5. 未来实现的质量底线 + +当实现获得批准后: + +- 正确性和正文保真优先于清洗率与性能;不确定内容默认保留; +- 清洗步骤应可追踪、可复现,并能解释每类修改的依据; +- 算法逻辑与文件读写分层,核心处理尽量保持确定性; +- 公共接口提供类型信息,复杂逻辑说明原因和不变量; +- 显式覆盖空文档、编码异常、OCR 重复、表格、图片、极端长度和幂等性; +- 不吞异常、不静默降级、不用放宽断言掩盖失败; +- 未经真实证据支持,不把局部样本结果概括为一般结论。 + +这些是未来实施约束,不表示当前已经存在任何代码或测试。 + +## 6. 文档规则 + +完整分类规则以 `research-wiki/README.md` 为准: + +- `design/`:动工前的选择、代价与否决方案;批准后冻结; +- `explanation/`:当前有效机制及原因;事实变化时更新; +- `reference/`:代码无法完整表达的稳定查询事实; +- `guides/`:经过实际验证的操作步骤; +- `scratch/`:调研、计划和未收敛草稿,不作为当前事实引用。 + +新文档从实际问题或读者可观察的现象开始,写清理由、适用边界、代价和验证状态。 +易漂移的路径、参数、命令和指标只链接到唯一权威,不复制出第二份。 + +## 7. 验证与报告 + +- 只报告本轮真实执行过的检查;未运行或无法验证的内容明确写“未验证”; +- 声称完成前检查实际目录、文档镜像、Git diff 和工作区状态; +- 未来实验必须能追溯输入范围、版本、参数、环境、指标和输出位置; +- 性能结论同时记录数据规模、运行条件、计时范围和重复次数; +- 区分“实现可运行”“指标改善”和“适合生产”,三者不能互相替代。 + +当前有效的基础检查命令只以根目录 `README.md` 为准。 + +## 8. Git 与文件操作 + +- 已有改动默认属于用户,不覆盖、不回滚、不混入无关变更; +- 不执行 `git reset --hard`、`git checkout --` 等不可恢复操作; +- 删除前确认具体目标,优先保证可从 Git 或备份恢复; +- 提交前检查 staged diff、敏感信息、大文件和验证结果; +- 未经用户明确要求,不提交、不推送、不创建 PR、不发布; +- 不在提交信息、源码或文档中添加 AI 署名。 + +## 9. 沟通与协作 + +- 像同事协作一样,用直接、可读的中文说明判断、变化和风险; +- 问什么答什么,一次聚焦当前问题,不把未经请求的后续工作一起推进; +- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户; +- 进度和最终报告必须对应真实工具输出,不把计划描述成结果; +- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。 diff --git a/README.md b/README.md index fd29ead..62e08d9 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,111 @@ # govdoc-md-cleaner -政务文档(招标 / 投标 / 采购 / 合同)**PDF→Markdown 产物**的规则化清洗工具。 +实验室共用的 Markdown 清洗研究与基础工具库。仓库名沿用了最初的 GovDoc 场景,但项目不属于 GovDoc +专用组件,也不只服务政务文档。 -上游解析管线(MinerU / OCR)把 PDF 转成 Markdown 后,会带入大量非正文噪音: -逐页重复的页眉页脚、页码行、目录点线、死链图片引用、压成单行的 HTML 表格、 -行尾硬换行双空格,以及 OCR 重复崩坏段。本工具用一套 **YAML 声明、按序应用、 -逐条统计** 的规则把它们清掉,输出可直接进入比对 / RAG / 审核管线的干净 Markdown。 +本仓库面向实验室内不同项目复用,用于清洗 PDF、DOCX、OCR、网页等上游管线生成的 Markdown,统一解决 +格式噪声、结构损坏、内容异常、来源追踪和多用途派生问题。各项目共享通用清洗能力,再通过独立配置或 +profile 表达论文、政务文档、RAG、文档对比等不同需求。 -## 数据来源 +仓库当前仍处于研究和方案设计阶段:用于澄清问题、记录设计选择、积累可复核证据,并在方案获得确认后 +再建立实现。它目前不是可安装的 Python 包,也不提供命令行工具或生产接口。 -清洗目标为 `/home/lihaoze/gov_test_data`(律所上传的真实政务文件的筛选子集, -详见其 `compare/readme.md`),未来会持续接入更多批次的 Markdown 文件。 -**本项目只包含清洗代码与规则,不包含任何业务数据**——测试数据不入库。 +## 当前阶段 -## 清洗规则(rules/default.yaml) +2026-08-20 已完成仓库重置与最小文档治理骨架: -| 顺序 | 规则 | 处理对象 | 示例 | -|---|---|---|---| -| 10 | `strip_page_lines` | 页码行 | `第 3 页 共 53 页`、`第4页共4页`、`Page 3 of 10`、`- 4 -` | -| 20 | `strip_repeated_short_lines` | 页眉页脚 + OCR 崩坏 | 全篇重复 ≥3 次的短行;连续刷屏 ≥5 次(实测"审计程序"×1359) | -| 30 | `strip_toc_dots` | 目录点线 | `第一章 投标邀请函 ………… 2` → 保留标题,删点线页码 | -| 40 | `drop_images` | 死链图片 | `![](images/xxx.jpg)`、base64 内嵌图 | -| 50 | `normalize_tables` | HTML 表格 | `
…` 单行压缩 → Markdown 管道表格 | -| 60 | `strip_stray_html` | 散落标签 | `
` | -| 70 | `rstrip_lines` | 行尾空白 | MinerU 每行行尾的双空格硬换行 | -| 80 | `collapse_blank_lines` | 空行折叠 | 连续空行 → 1 行;裁掉文首文末 | +- 原有 Python 实现、YAML 规则、测试和打包配置已经移除; +- `AGENTS.md` 与 `CLAUDE.md` 提供同步的协作规则; +- `research-wiki/` 按文档生命周期区分设计、说明、参考、指南和草稿; +- `research-wiki/design/0001-repository-foundation.md` 记录本次基础架构选择。 +- `research-wiki/reference/GOVDOC_SAAS_CLEANING_SCOPE.md` 保存 45 份外部测试 Markdown 的只读问题审计; +- `research-wiki/scratch/markdown-cleaning-ecosystem-research-2026-08-20.md` 完成首轮生态与架构调研。 +- 2026-08-21 完成师姐项目(ClinDB-ReviewBench)5 份论文 Markdown 的问题审计 + (`research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md`),并确定其第一版清洗范围 + (`research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md`,9 类确定性规则)。 +- 2026-08-21 明确本项目定位为实验室共用库;GovDoc 和论文清洗都是使用场景,不是核心边界。 -### 防误伤设计(来自真实数据踩坑) +当前没有清洗算法、可执行命令、运行依赖、测试套件或已批准的输入输出契约。调研报告中的技术组合、 +内部 IR、profiles 和实施路线均是候选方案,尚未批准。 +目录存在只代表文档落点已经建立,不代表相应能力已经完成。 -- **protect 正则**:`投标人:(公章)`、`法定代表人签名:`、`日期: 年 月 日`、 - `致:xxx`、声明函结尾句——标书里逐章重复,**是正文模板不是页眉**,默认保护。 -- **内容形态豁免**:编号条款 `(1)…`、`1、…`、`一、…`、列表/表格行、 - `乙方:xxx` 字段行——平行结构天然重复,引擎侧直接不参与页眉判定。 -- **burst 检测**:同一短行连续刷屏 ≥5 次(间隔 ≤2 行)判为 OCR 重复崩坏, - 直接删除(003-10 案例出现"审计程序"连续 1359 行)。 +## 服务对象与复用目标 -## 安装 +当前已经明确的使用场景包括: -```bash -pip install -e . # 或直接 python -m cleaner.cli(仅需 PyYAML) -``` +- **论文清洗**:`data/` 中现有内容来自师姐的项目,包含论文 PDF 的 Markdown、JSON、图片等转换产物; +- **GovDoc**:政务、招投标、采购、合同等文档的清洗、比对和 RAG 前处理; +- **未来实验室项目**:后续可以继续接入其他需要 Markdown 质量检查、规范化或用途派生的项目数据。 -## 使用 +当前用例只用于发现真实问题和验证通用能力,不能反过来限定库的设计。核心代码不得依赖论文 DOI、 +GovDoc 目录、具体客户名称或某一转换器的固定输出路径。 -```bash -# 单文件:清洗 + 打印统计 -md-clean single input.md -o output.md --diff +## 面向复用的设计原则 -# 批量:递归清洗目录下所有 .md,输出到平行目录 + JSON 报告 -md-clean batch /path/to/uploads -o /path/to/uploads_cleaned \ - --pattern "*.md" --report report.json +- **通用核心**:编码检查、Markdown/HTML 结构解析、异常检测、可审计变换、资产校验和来源追踪; +- **输入适配器**:不同 PDF/OCR/DOCX/HTML 转换器通过 adapter 接入,不把某个上游工具写死; +- **项目 profile**:论文、GovDoc、对比、RAG、公开脱敏等规则独立组合,不互相污染默认行为; +- **保真优先**:不确定内容默认保留或进入人工确认,不能为了格式整齐改写业务或学术内容; +- **可复现**:规则、配置、输入哈希、输出和每次变更都可以追踪; +- **可扩展**:新增项目应主要增加 adapter、detector、transformer 或 profile,而不是复制一套清洗器。 -# 用自定义规则集(复制 default.yaml 改参数即可) -md-clean batch uploads/ --rules rules/strict.yaml -``` +## `data/` 的职责 -`--diff` 输出 unified diff,`report.json` 记录每个文件每条规则的命中数, -清洗过程完全可审计、可回滚(重跑即得原结果的对照)。 +`data/` 是实验室项目的本地数据工作区。当前存放师姐论文清洗项目的输入和转换产物,未来可能继续加入 +其他项目的数据。新增数据时应逐步按项目命名空间组织,例如 `data//...`,避免不同项目的 +输入、产物和评测结果混在一起。 -## 项目结构 +数据目录与通用库保持以下边界: -``` +- `data/` 已被 Git 忽略,不作为库源码、公开 fixture 或发布包的一部分; +- 默认把项目数据视为只读输入,清洗结果写到独立输出位置,不覆盖原文件; +- 数据可以推动通用规则设计,但项目专属规则必须进入对应 profile; +- 测试需要的公开样例应单独制作脱敏、最小化 fixture,不能直接复制真实项目文档; +- `/home/lihaoze/gov_test_data` 等仓库外真实材料同样保持只读,不复制、不修改、不提交。 + +任何清洗语义、规则格式、评估指标、代码目录或运行依赖,都应先形成设计记录并获得确认。研究结果进入 +具体项目或生产系统前,还需要在使用方范围内独立验证。 + +## 目录结构 + +```text govdoc-md-cleaner/ -├── cleaner/ -│ ├── cleaner.py # 引擎:按序应用规则,输出 CleanResult(text, stats) -│ ├── rules.py # 规则实现 + REGISTRY + YAML 加载 -│ └── cli.py # single / batch 两个子命令 -├── rules/ -│ └── default.yaml # 默认规则集(顺序、开关、参数、protect 列表) -├── tests/ -│ └── test_rules.py # 每条规则的最小样例 + 防误伤回归 -└── docs/ - └── RULES.md # 规则编写指南(新增规则的方法) +├── AGENTS.md +├── CLAUDE.md +├── README.md +├── data/ # 本地项目数据;Git 忽略,未来按项目分区 +└── research-wiki/ + ├── README.md + ├── design/ # 方案选择与冻结决策 + ├── explanation/ # 当前有效机制及原因 + ├── reference/ # 需要准确查询的稳定事实 + ├── guides/ # 已实际验证的操作步骤 + └── scratch/ # 调研笔记和未收敛草稿 ``` -## 新增一条规则 +## 开始工作 -1. `cleaner/rules.py`:实现 `_your_rule(text, params, stats) -> text`,注册进 `REGISTRY`; -2. `rules/default.yaml`:追加 `- name: your_rule / order: / params:`; -3. `tests/test_rules.py`:加一个真实数据浓缩出的最小样例。 +进入仓库后依次阅读: -原则:**规则只删噪音不删正文**;拿不准的形态默认保留,靠 protect/exempt 收紧。 +1. 本文件,确认当前阶段; +2. `AGENTS.md` 或 `CLAUDE.md`,确认协作与安全边界; +3. `research-wiki/README.md`,确认文档应放在哪里; +4. 与任务直接相关的 `research-wiki/design/` 记录。 -## 测试 +下一项实质工作开始前,应以现有论文数据、GovDoc 审计和生态调研为输入新增下一编号的 design,明确 +通用核心与项目 profile 的边界、第一阶段范围、技术选型、输入输出、验证方法和非目标,并等待批准。 + +## 当前可用检查 ```bash -python -m unittest discover tests -v +# 两份 Agent 入口除标题外必须一致;无输出且退出码为 0 表示通过 +diff -u <(tail -n +2 AGENTS.md) <(tail -n +2 CLAUDE.md) + +# 查看当前 Wiki 中实际存在的文档 +find research-wiki -maxdepth 2 -type f | sort + +# 检查本地变更 +git status --short ``` -## License - -MIT +当前没有测试命令;在真实实现和测试体系获批并落地前,不应声明测试通过。 diff --git a/cleaner/__init__.py b/cleaner/__init__.py deleted file mode 100644 index 2785bf4..0000000 --- a/cleaner/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""govdoc-md-cleaner: 政务文档 Markdown 清洗工具包。 - -针对 PDF→Markdown 转换产物(MinerU / OCR 管线输出)的常见脏数据, -提供基于 YAML 规则的、可复现、可审计的清洗能力。 -""" - -from cleaner.cleaner import MarkdownCleaner, CleanResult, load_rules -from cleaner.cli import main - -__version__ = "0.1.0" -__all__ = ["MarkdownCleaner", "CleanResult", "load_rules", "main"] diff --git a/cleaner/cleaner.py b/cleaner/cleaner.py deleted file mode 100644 index 7764ed9..0000000 --- a/cleaner/cleaner.py +++ /dev/null @@ -1,56 +0,0 @@ -"""清洗引擎:按规则顺序应用,输出清洗后文本 + 统计报告。""" - -from __future__ import annotations - -import difflib -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional - -from cleaner.rules import REGISTRY, Rule, load_rules - - -@dataclass -class CleanResult: - """一次清洗的结果:产物 + 可审计的统计。""" - - text: str - stats: Dict[str, int] = field(default_factory=dict) - rules_applied: List[str] = field(default_factory=list) - - @property - def total_hits(self) -> int: - return sum(v for k, v in self.stats.items() if k != "collapsed_blanks") - - -class MarkdownCleaner: - def __init__(self, rules: Optional[List[Rule]] = None, rules_path: Optional[Path] = None): - self.rules = rules if rules is not None else load_rules(rules_path) - - def clean_text(self, text: str) -> CleanResult: - stats: Dict[str, int] = {} - applied: List[str] = [] - for rule in self.rules: - if not rule.enabled: - continue - func = REGISTRY[rule.name] - text = func(text, rule.params, stats) - applied.append(rule.name) - return CleanResult(text=text, stats=stats, rules_applied=applied) - - def clean_file(self, src: Path, dst: Optional[Path] = None) -> CleanResult: - raw = Path(src).read_text(encoding="utf-8") - result = self.clean_text(raw) - if dst is not None: - Path(dst).parent.mkdir(parents=True, exist_ok=True) - Path(dst).write_text(result.text, encoding="utf-8") - return result - - @staticmethod - def diff(before: str, after: str, context: int = 1) -> str: - return "\n".join( - difflib.unified_diff( - before.splitlines(), after.splitlines(), - fromfile="before", tofile="after", lineterm="", n=context, - ) - ) diff --git a/cleaner/cli.py b/cleaner/cli.py deleted file mode 100644 index d656411..0000000 --- a/cleaner/cli.py +++ /dev/null @@ -1,89 +0,0 @@ -"""命令行入口。 - -用法: - md-clean single [-o output.md] [--diff] [--rules rules.yaml] - md-clean batch [-o outdir] [--pattern "*.md"] [--report report.json] -""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import List, Optional - -from cleaner.cleaner import MarkdownCleaner -from cleaner.rules import load_rules - - -def _build(path: Optional[Path]) -> MarkdownCleaner: - return MarkdownCleaner(rules=load_rules(path)) - - -def cmd_single(args: argparse.Namespace) -> int: - cleaner = _build(args.rules) - src = Path(args.input) - raw = src.read_text(encoding="utf-8") - result = cleaner.clean_text(raw) - if args.output: - Path(args.output).parent.mkdir(parents=True, exist_ok=True) - Path(args.output).write_text(result.text, encoding="utf-8") - print(f"已写入 {args.output}") - if args.diff: - print(MarkdownCleaner.diff(raw, result.text)) - print(json.dumps(result.stats, ensure_ascii=False, indent=2)) - return 0 - - -def cmd_batch(args: argparse.Namespace) -> int: - cleaner = _build(args.rules) - src_dir = Path(args.directory) - files = sorted(p for p in src_dir.rglob(args.pattern) if p.is_file()) - if not files: - print(f"在 {src_dir} 下未找到匹配 {args.pattern} 的文件", file=sys.stderr) - return 1 - out_dir = Path(args.output) if args.output else src_dir.parent / (src_dir.name + "_cleaned") - report = [] - for f in files: - rel = f.relative_to(src_dir) - dst = out_dir / rel - result = cleaner.clean_file(f, dst) - report.append({"file": str(rel), "stats": result.stats}) - hits = result.total_hits - print(f"[ok] {rel} 命中 {hits} 处") - if args.report: - Path(args.report).parent.mkdir(parents=True, exist_ok=True) - Path(args.report).write_text( - json.dumps({"files": report}, ensure_ascii=False, indent=2), encoding="utf-8" - ) - print(f"报告已写入 {args.report}") - print(f"共清洗 {len(files)} 个文件 → {out_dir}") - return 0 - - -def main(argv: Optional[List[str]] = None) -> int: - parser = argparse.ArgumentParser(prog="md-clean", description="政务文档 Markdown 清洗工具") - sub = parser.add_subparsers(dest="command", required=True) - - p1 = sub.add_parser("single", help="清洗单个文件") - p1.add_argument("input", help="输入 .md 文件") - p1.add_argument("-o", "--output", help="输出路径(缺省打印统计不写文件)") - p1.add_argument("--diff", action="store_true", help="打印 unified diff") - p1.add_argument("--rules", type=Path, help="规则 YAML 路径") - - p2 = sub.add_parser("batch", help="批量清洗目录(递归)") - p2.add_argument("directory", help="输入目录") - p2.add_argument("-o", "--output", help="输出目录(缺省 _cleaned)") - p2.add_argument("--pattern", default="*.md", help="文件 glob(默认 *.md)") - p2.add_argument("--report", help="清洗报告 JSON 输出路径") - p2.add_argument("--rules", type=Path, help="规则 YAML 路径") - - args = parser.parse_args(argv) - if args.command == "single": - return cmd_single(args) - return cmd_batch(args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/cleaner/rules.py b/cleaner/rules.py deleted file mode 100644 index 5e0a25a..0000000 --- a/cleaner/rules.py +++ /dev/null @@ -1,281 +0,0 @@ -"""规则模型:一条清洗规则 = 名称 + 开关 + 参数 + 应用顺序。 - -规则用 YAML 声明(rules/*.yaml),引擎按 order 依次应用, -这样清洗过程可复现、可 diff、可回滚。 -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Callable, Dict, List, Optional - -import yaml - - -@dataclass -class Rule: - """一条清洗规则。 - - name: 唯一标识(报告里引用) - order: 应用顺序,小的先执行 - enabled: 开关,方便对某个用例单独关掉 - params: 传给处理函数的额外参数 - """ - - name: str - order: int = 100 - enabled: bool = True - params: Dict[str, Any] = field(default_factory=dict) - - @staticmethod - def from_dict(d: Dict[str, Any]) -> "Rule": - return Rule( - name=d["name"], - order=int(d.get("order", 100)), - enabled=bool(d.get("enabled", True)), - params=dict(d.get("params") or {}), - ) - - -# --------------------------------------------------------------------------- -# 每条规则的具体实现。函数签名统一为 (text, params, stats) -> text。 -# stats 是 {rule_name: 删改行数},用于生成清洗报告。 -# --------------------------------------------------------------------------- - -RuleFunc = Callable[[str, Dict[str, Any], Dict[str, int]], str] - -# 页码类:第X页 共Y页 / 第X页共Y页 / Page x of y / - 3 - 等 -_RE_PAGE_CN = re.compile( - r"^[ \t]*第\s*[0-90-9]+\s*页\s*(?:[,,/]?\s*共\s*[0-90-9]+\s*页)?[ \t]*$" -) -_RE_PAGE_EN = re.compile( - r"^[ \t]*(?:[-–—]?\s*Page\s+\d+(?:\s+of\s+\d+)?\s*[-–—]?" - r"|[-–—]\s*\d{1,4}\s*[-–—]" - r"|\d+\s*/\s*\d+)[ \t]*$", - re.IGNORECASE, -) -# 纯页码数字行(单独一行只有 1-4 位数字,且不是标题编号场景) -_RE_PAGE_BARE = re.compile(r"^[ \t]*\d{1,4}[ \t]*$") - - -def _strip_page_lines(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str: - keep_bare_numbers = bool(params.get("keep_bare_numbers", True)) - out, n = [], 0 - for line in text.splitlines(): - if _RE_PAGE_CN.match(line) or _RE_PAGE_EN.match(line): - n += 1 - continue - if not keep_bare_numbers and _RE_PAGE_BARE.match(line): - n += 1 - continue - out.append(line) - stats["page_lines"] = stats.get("page_lines", 0) + n - return "\n".join(out) - - -# 页眉/页脚:同一短行在全篇重复出现 >= N 次(默认 3),视为页眉页脚删除。 -# 两道保险避免误伤正文: -# 1. protect 正则 —— 标书里逐章重复的模板行(签章/日期/声明结尾) -# 2. 内容形态行直接豁免 —— 编号条款 "(1)…"/"1、…"/"一、…"/列表/表格行 -# 在平行结构的标书里天然重复,但它们是正文不是页眉。 -_RE_CONTENT_LIKE = re.compile( - r"^[\d0-9((\[【\-–—*•·①-⑳一二三四五六七八九十百第章节条款、,.。::|]" - r"|[一-鿿]{1,6}[::]\s*\S" # "乙方:xxx" / "地址:xxx" 这类字段行 - r"|^[一-鿿A-Za-z]{1,6}[::]\s*$" # "乙方:" / "注:" 字段标签行 -) - - -def _strip_repeated_short_lines( - text: str, params: Dict[str, Any], stats: Dict[str, int] -) -> str: - threshold = int(params.get("threshold", 3)) - max_len = int(params.get("max_len", 40)) - # 同一短行在文中连续出现 >= burst_limit 次(间隔 <= burst_gap 行)视为 - # OCR 重复崩坏(如 003-10 案例"审计程序"连续刷屏 1359 次),无论阈值直接删。 - burst_limit = int(params.get("burst_limit", 5)) - burst_gap = int(params.get("burst_gap", 2)) - protect = [re.compile(p) for p in params.get("protect", [])] - lines = text.splitlines() - counts: Dict[str, int] = {} - positions: Dict[str, List[int]] = {} - for i, line in enumerate(lines): - s = line.strip() - if ( - 0 < len(s) <= max_len - and not s.startswith("#") - and not _RE_CONTENT_LIKE.match(s) - and "![" not in s - ): - counts[s] = counts.get(s, 0) + 1 - positions.setdefault(s, []).append(i) - repeated = { - s - for s, c in counts.items() - if c >= threshold and not any(rx.search(s) for rx in protect) - } - # OCR 崩坏连续段:即使该行被 protect/内容豁免,连续刷屏也删 - for s, pos in positions.items(): - best_run = run = 1 - for a, b in zip(pos, pos[1:]): - run = run + 1 if b - a <= burst_gap else 1 - best_run = max(best_run, run) - if best_run >= burst_limit: - repeated.add(s) - if not repeated: - return text - out, n = [], 0 - for line in lines: - if line.strip() in repeated: - n += 1 - continue - out.append(line) - stats["header_footer_lines"] = stats.get("header_footer_lines", 0) + n - return "\n".join(out) - - -# 目录点线:标题文字 ………… 12 / ······ 3 之类(… U+2026 也算;# 前缀可选) -_RE_TOC_DOTS = re.compile( - r"^(#{1,6}\s+)?.*?[ \t]*[\.。·•‧…]{6,}[ \t]*[\d0-9]*[ \t]*$" -) - - -def _strip_toc_dots(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str: - out, n = [], 0 - for line in text.splitlines(): - m = _RE_TOC_DOTS.match(line) - if m: - # 去掉点线和页码,保留标题文字;纯点线+页码的目录行整行删 - title = re.sub(r"[ \t]*[\.。·•‧…]{6,}[ \t]*[\d0-9]*[ \t]*$", "", line).rstrip() - if title.strip() and not re.fullmatch(r"[\.。·•‧…\d0-9\s]+", title): - out.append(title) - n += 1 - continue - out.append(line) - stats["toc_dot_lines"] = stats.get("toc_dot_lines", 0) + n - return "\n".join(out) - - -# 图片引用:![](images/xxx.jpg) —— 图片目录不在交付物里,引用是死链 -_RE_IMAGE = re.compile(r"[ \t]*!\[[^\]]*\]\([^)]*\)[ \t]*") - - -def _drop_images(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str: - placeholder = params.get("placeholder") # None=整行删除;否则替换为占位文本 - out, n = [], 0 - for line in text.splitlines(): - if _RE_IMAGE.fullmatch(line): - n += 1 - if placeholder: - out.append(str(placeholder)) - continue - new = _RE_IMAGE.sub("", line) - if new != line: - n += 1 - line = new.rstrip() - out.append(line) - stats["image_refs"] = stats.get("image_refs", 0) + n - return "\n".join(out) - - -# HTML 表格规范化:
压缩为合法 Markdown 管道表格 -_RE_TABLE = re.compile(r"]*>(.*?)
", re.DOTALL | re.IGNORECASE) -_RE_TR = re.compile(r"]*>(.*?)", re.DOTALL | re.IGNORECASE) -_RE_TD = re.compile(r"]*>(.*?)", re.DOTALL | re.IGNORECASE) - - -def _cell_text(raw: str) -> str: - cell = re.sub(r"", " ", raw, flags=re.IGNORECASE) - cell = re.sub(r"<[^>]+>", "", cell) - return " ".join(cell.split()).replace("|", "\\|") - - -def _table_to_md(tbl_html: str) -> str: - rows: List[List[str]] = [] - for tr in _RE_TR.findall(tbl_html): - cells = [_cell_text(td) for td in _RE_TD.findall(tr)] - if cells: - rows.append(cells) - if not rows: - return "" - width = max(len(r) for r in rows) - rows = [r + [""] * (width - len(r)) for r in rows] - lines = ["| " + " | ".join(rows[0]) + " |", "|" + "---|" * width] - lines.extend("| " + " | ".join(r) + " |" for r in rows[1:]) - return "\n".join(lines) - - -def _normalize_tables(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str: - def _sub(m: re.Match[str]) -> str: - md = _table_to_md(m.group(1)) - return md if md else "" - - new, n = _RE_TABLE.subn(_sub, text) - # 残缺兜底:文档截断导致 未闭合时,把剩余 行也转掉 - if "" + m.group(0) + "
"), - tail, - ) - tail = re.sub(r"]*>", "", tail) - new = head + tail - n += 1 - stats["html_tables"] = stats.get("html_tables", 0) + n - return new - - -# 表格内
会被上面规则拍平;这里处理散落的 HTML 换行/空白标签 -def _strip_stray_html(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str: - new = re.sub(r"", " ", text, flags=re.IGNORECASE) - if new != text: - stats["stray_html"] = stats.get("stray_html", 0) + text.count(" str: - out, n = [], 0 - for line in text.splitlines(): - stripped = line.rstrip() - if stripped != line: - n += 1 - out.append(stripped) - stats["trailing_ws_lines"] = stats.get("trailing_ws_lines", 0) + n - return "\n".join(out) - - -# 连续空行压成一行;文首文末空白裁掉 -def _collapse_blank_lines( - text: str, params: Dict[str, Any], stats: Dict[str, int] -) -> str: - text = re.sub(r"[ \t]*\n(?:[ \t]*\n){2,}", "\n\n", text) - text = text.strip("\n") + "\n" if text.strip() else "" - stats["collapsed_blanks"] = stats.get("collapsed_blanks", 1) - return text - - -REGISTRY: Dict[str, RuleFunc] = { - "strip_page_lines": _strip_page_lines, - "strip_repeated_short_lines": _strip_repeated_short_lines, - "strip_toc_dots": _strip_toc_dots, - "drop_images": _drop_images, - "normalize_tables": _normalize_tables, - "strip_stray_html": _strip_stray_html, - "rstrip_lines": _rstrip_lines, - "collapse_blank_lines": _collapse_blank_lines, -} - - -def load_rules(path: Optional[Path] = None) -> List[Rule]: - """从 YAML 加载规则;未指定路径时用包内默认规则。""" - if path is None: - path = Path(__file__).resolve().parent.parent / "rules" / "default.yaml" - data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} - rules = [Rule.from_dict(d) for d in data.get("rules", [])] - unknown = [r.name for r in rules if r.name not in REGISTRY] - if unknown: - raise ValueError(f"未知规则: {unknown},可用规则: {sorted(REGISTRY)}") - return sorted(rules, key=lambda r: r.order) diff --git a/docs/RULES.md b/docs/RULES.md deleted file mode 100644 index 2882943..0000000 --- a/docs/RULES.md +++ /dev/null @@ -1,45 +0,0 @@ -# 规则编写指南 - -一条清洗规则 = `cleaner/rules.py` 里的一个函数 + `rules/*.yaml` 里的一条声明。 - -## 函数签名 - -```python -def _your_rule(text: str, params: dict, stats: dict) -> str: - ... - stats["your_hits"] = stats.get("your_hits", 0) + n # 计入报告 - return new_text -``` - -- 输入输出都是**整篇文本**;引擎按 `order` 从小到大依次调用。 -- `params` 来自 YAML,改参数不用改代码。 -- `stats` 的 key 会出现在 `report.json`,命名用蛇形复数(如 `page_lines`)。 - -## YAML 声明 - -```yaml -rules: - - name: your_rule # 必须与 REGISTRY 键一致,加载时校验 - order: 55 # 应用顺序;同段处理尽量插在相关规则之间 - enabled: true # 某用例不适用的规则可单关 - params: - threshold: 3 - protect: ["正则1", "正则2"] -``` - -## 设计守则(从 gov_test_data 踩坑总结) - -1. **只删噪音,不删正文**。拿不准的形态默认保留,宁可漏删不可误删。 -2. **重复 ≠ 页眉**。标书是平行模板文档:签章栏、日期栏、声明结尾句、 - 编号条款都会重复出现。判定页眉前先过: - - `protect` 正则(业务模板白名单) - - 内容形态豁免(编号/列表/表格/字段行) - - burst 检测(连续刷屏才是 OCR 崩坏) -3. **每个规则独立可测**。tests/ 里用真实数据浓缩的最小样例做回归, - 尤其是防误伤样例(protect 命中、编号条款保留)。 -4. **统计必须可见**。每条规则报告命中数,批量清洗后扫一眼 report.json - 就能发现某条规则突然命中异常(多半是误伤)。 - -## 已知规则明细 - -见 `rules/default.yaml` 内注释与 README 规则表。 diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 0dac4ab..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,21 +0,0 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "govdoc-md-cleaner" -version = "0.1.0" -description = "政务文档(招标/投标/采购/合同)PDF→Markdown 产物的清洗工具" -readme = "README.md" -requires-python = ">=3.10" -license = { text = "MIT" } -dependencies = ["PyYAML>=6.0"] - -[project.scripts] -md-clean = "cleaner.cli:main" - -[tool.setuptools.packages.find] -include = ["cleaner*"] - -[tool.setuptools.package-data] -cleaner = ["../rules/*.yaml"] diff --git a/research-wiki/README.md b/research-wiki/README.md new file mode 100644 index 0000000..de4385c --- /dev/null +++ b/research-wiki/README.md @@ -0,0 +1,93 @@ +# research-wiki 文档治理说明 + +> **更新触发点:** 新增、删除或改变 Wiki 分类,修改 design 冻结方式、事实权威或数据边界时, +> 必须在同一变更中更新本文件。 + +清洗研究会同时产生不同寿命的材料:某次方案选择需要保留当时的判断,当前机制需要随实现更新, +运行步骤需要反复验证,探索笔记则可能很快失效。它们不能使用同一种维护方式。 + +## 1. 目录与生命周期 + +| 内容 | 目录 | 维护方式 | +|---|---|---| +| 动工前的方案比较、选择和代价 | `design/` | 批准后冻结;改变时新增下一编号 | +| 当前有效的机制、数据流和原因 | `explanation/` | 事实变化时同步更新 | +| 代码无法完整表达的稳定查询事实 | `reference/` | 权威事实变化时更新 | +| 可复现运行、验证与排障步骤 | `guides/` | 操作变化时更新并重新验证 | +| 调研笔记、计划和未收敛草稿 | `scratch/` | 不作为当前事实;由项目负责人决定去留 | +| 当前阶段和已完成工作 | 根目录 `README.md` | 阶段变化时更新 | + +根目录 `AGENTS.md` 与 `CLAUDE.md` 是协作者入口,不放入 Wiki。 +空分类使用 `.gitkeep` 保留,不创建只写未来设想的占位文档。 + +## 2. 常青层与记录层 + +`explanation/`、`reference/`、`guides/` 属于常青层:现实改变后直接更新原文,使其继续描述当前事实。 + +`design/` 属于记录层:它保存决策发生时的问题、方案、理由和代价。批准后即使结论后来被替代, +也保留原文并新增一份 design。 + +判断文档去向时可以问:三个月后发现它不再正确,是应该改掉原文,还是保留原判断并记录新决定? +前者进入常青层,后者进入 `design/`。 + +## 3. design:先记录选择,再实施 + +以下事项需要 design: + +- 清洗语义、保真边界、规则顺序或冲突处理方式; +- 输入输出契约、评估指标、标注方法或实验方案; +- 重要依赖、公共接口、源码结构或数据目录; +- 事实权威、数据安全和跨仓职责的改变; +- 一旦选错会造成大面积返工或使实验作废的决策。 + +文件使用 `NNNN-short-title.md`,编号四位递增,英文短名只用于稳定引用。建议结构如下: + +1. 状态; +2. 问题与可观察现象; +3. 目标与非目标; +4. 候选方案; +5. 决定与理由; +6. 风险和边界; +7. 实施与验收。 + +草稿可以在评审期间修改。批准后冻结;如果决策改变,新文档必须写明 `supersedes: NNNN`,并保留旧文档。 + +## 4. explanation:解释当前为什么这样工作 + +这里解释已经生效的算法流程、数据流、边界、适用条件和代价。第一节从实际问题、失败案例或总览开始。 + +未批准或未实现的机制必须明确标注为候选方案,并链接到对应 design,不能通过 explanation 绕过确认门。 + +## 5. reference:准确查询事实 + +这里保存需要稳定查询、但未来代码和测试不能完整表达的事实,例如外部格式约束、共享字段语义、 +经验证的第三方行为或数据集的脱敏统计。 + +函数签名、默认参数和数据结构如果已由代码或测试表达,不在这里维护第二份副本。 + +## 6. guides:完成一项已验证操作 + +guide 必须来自实际运行,至少包含前置条件、准确命令、预期结果、验证日期和失败后的判断方式。 +尚不存在的脚本或预想中的流程不能写成指南。 + +## 7. scratch:容纳尚未收敛的材料 + +调研摘记、比较表、执行计划和 design 草稿可以放在 `scratch/`。它们可以支持连续协作, +但不能作为当前机制或已批准决定引用。Agent 不自动删除 scratch 内容。 + +## 8. 数据与实验材料 + +- 客户原文、审核文件、历史大结果和软链接不属于 Wiki; +- 外部真实数据默认只读,不因研究需要复制进仓库; +- 实验产出只有在目录和脱敏规则获批后才可保存; +- 小型汇总进入 Git 前必须确认无法还原客户内容; +- 每个实验应能追溯输入范围、版本、参数、环境、指标和输出位置。 + +## 9. 写作与审查 + +- 面向没有参加过讨论、但具备相关技术背景的读者; +- 从真实问题或读者可见现象开始,不先堆术语; +- 结论写清理由、代价、适用边界和验证状态; +- 参数、路径、命令、指标和当前阶段只维护一个权威版本; +- 不把目标写成已完成,不泄露真实文档内容,不用文档替代测试; +- 简单事实用短段落,只有比较关系确实更清楚时才使用表格或图。 diff --git a/research-wiki/design/0001-repository-foundation.md b/research-wiki/design/0001-repository-foundation.md new file mode 100644 index 0000000..dd8d5c1 --- /dev/null +++ b/research-wiki/design/0001-repository-foundation.md @@ -0,0 +1,105 @@ +# 0001:文档治理基础架构 + +## 状态 + +已批准。2026-08-20,项目负责人明确要求清除现有代码,并参考 +[`Bepr4/GovDoc-compare`](https://github.com/Bepr4/GovDoc-compare) 建立最基础的 Wiki 与 Agent 入口框架。 + +## 1. 问题 + +仓库此前已经包含一个可运行的 Python 清洗器、YAML 规则和测试,但尚未先建立研究问题、清洗边界、 +评估方法与决策记录。继续在实现上迭代会让“当前代码做了什么”和“我们为什么应该这样做”混在一起。 + +项目需要先回到可审查的治理基础:明确事实放在哪里、设计何时冻结、真实数据如何隔离,以及不同 Agent +进入仓库时遵循哪一份规则。 + +## 2. 目标与非目标 + +目标: + +- 清除既有应用代码和工程配置,避免把旧实现默认为新基线; +- 为人类与编码 Agent 提供一致的仓库入口; +- 区分冻结决策、当前说明、稳定事实、验证指南和临时草稿; +- 明确外部真实文档的只读与不入库边界; +- 只建立当前确实需要的最小文档骨架。 + +非目标: + +- 本次不定义新的清洗算法、规则 schema 或评估指标; +- 不创建源码、测试、实验或 CI 空壳; +- 不保留旧实现作为默认兼容基线; +- 不读取、复制、修改或清洗真实业务文档; +- 不提交、不推送,也不修改参考仓库。 + +## 3. 方案比较 + +### 方案 A:文档优先的最小骨架(采用) + +保留 Git 历史,删除现有实现,建立 README、同步 Agent 入口和五类 Wiki 目录。结构足以约束下一步研究, +同时不会提前固定语言、依赖、接口和实验契约。 + +### 方案 B:保留旧代码并补 Wiki + +迁移成本较低,但旧实现会继续被误解为已批准基线,与“清除所有代码”的明确要求冲突。 + +### 方案 C:同时创建完整工程空壳 + +可以提前提供 `src/`、`tests/`、配置和 CI,但这些选择尚无设计依据。空目录还容易让 README 把目标状态写成现状。 + +## 4. 决定 + +采用方案 A,并借鉴参考仓库的文档生命周期,而不是复制其算法、代码或与 GovDoc-SaaS 相关的具体约束。 + +```text +govdoc-md-cleaner/ +├── AGENTS.md +├── CLAUDE.md +├── README.md +└── research-wiki/ + ├── README.md + ├── design/ + ├── explanation/ + ├── reference/ + ├── guides/ + └── scratch/ +``` + +- 根 README 是当前阶段和完成进度的唯一权威; +- `AGENTS.md` 与 `CLAUDE.md` 除首行标题外逐字一致; +- `research-wiki/README.md` 定义文档分类和更新规则; +- `design/` 批准后冻结,变化通过下一编号记录; +- 当前不加入镜像检查脚本,因为用户要求清空代码;先使用 README 中的 shell 检查命令验证。 + +## 5. 数据与恢复边界 + +- `/home/lihaoze/gov_test_data` 视为外部只读材料;本次不访问其内容; +- 原 Python 包、规则、测试和配置从工作树删除,但仍可从 Git 历史恢复; +- `.git` 历史保留,不执行重写历史或远端操作; +- 新实现只有在后续 design 明确范围并获批后才建立。 + +## 6. 风险与控制 + +- **文档镜像漂移:** 当前以 `diff` 命令人工检查;需要自动化时另建设计; +- **空目录被视为能力:** README 明确当前没有实现,空分类只保存 `.gitkeep`; +- **旧实现被无意恢复:** 后续设计需要重新说明需求和验收标准,不从历史代码推断契约; +- **客户数据误入库:** Agent 规则和 `.gitignore` 同时声明边界,提交前仍需检查实际 diff; +- **治理过重:** 本次只保留一个必要 design,不创建教程、API 文档或未来工程占位。 + +## 7. 实施与验收 + +实施内容: + +1. 删除 `cleaner/`、`rules/`、`tests/`、旧 `docs/` 与 `pyproject.toml`; +2. 重写根 README 和忽略规则; +3. 创建正文镜像的 `AGENTS.md` 与 `CLAUDE.md`; +4. 创建 Wiki 入口、首个 design 和五类目录; +5. 检查目录中不存在应用代码,验证两份入口正文一致并审查 Git diff。 + +验收条件: + +- 工作树中不存在原 Python、YAML 规则、测试或打包配置; +- 两份 Agent 入口除首行外无差异; +- Wiki 分类与维护方式有唯一说明; +- README 只声明实际存在的能力和可运行检查; +- 没有访问或写入外部真实数据; +- 没有提交、推送或修改远端状态。 diff --git a/research-wiki/explanation/.gitkeep b/research-wiki/explanation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/guides/.gitkeep b/research-wiki/guides/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md b/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md new file mode 100644 index 0000000..381ab72 --- /dev/null +++ b/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md @@ -0,0 +1,73 @@ +# ClinDB-ReviewBench 清洗目标(第一版) + +> 性质:reference——本项目清洗范围的权威查询事实。 +> 权威关系:本文只定义 ClinDB-ReviewBench 第一版"洗什么、不洗什么";清洗语义的方案比较与批准记录 +> 属于 `research-wiki/design/`(尚未建立),实现后的运行方式属于 `explanation/` 与 `guides/`。 +> 依据:`research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md`(问题编号 A–H 沿用该审计)。 + +## 1. 项目定位 + +ClinDB-ReviewBench 是师姐的论文清洗项目。`data/` 下当前 5 份 DOI 命名的论文 Markdown +(JAMA、EJHF、Statistics in Medicine/arXiv、Springer/arXiv、Disaster Med Public Health Preparedness) +是它的首批输入,未来会继续扩充同源转换产物。 + +本仓库(govdoc-md-cleaner)为该项目的数据提供清洗能力;ClinDB-ReviewBench 通过 profile +表达论文场景的规则组合,不把论文专属规则写进通用核心。 + +## 2. 第一版清洗目标 + +第一版只做"全自动、规则确定、可安全执行"的问题(审计第一档,共 9 类)。 +判定标准是三条同时满足:模式可用确定规则描述;不依赖对正文语义的理解;改错可以从 diff 直接看出。 + +| # | 问题(审计编号) | 规则要点 | 触发范围(本轮实测) | +|---|---|---|---| +| 1 | HTML 实体双重转义(D2) | `&gt;`→`>`、`&lt;`→`<`、`&amp;`→`&`,还原一层;幂等 | dmp L27/L33 共 23 处、ejhf L143 共 8 处,全部在 `` 行内 | +| 2 | arXiv 边栏戳(H1) | 整行匹配 `^arXiv:\d{4}\.\d+v\d+ \[[\w.-]+\] \d{1,2} \w{3} \d{4}$` 才删除;编号条目内的 "arXiv preprint arXiv:…" 不在行首、不受影响 | sim L1、springer L18;springer L143/L152 是合法参考文献,必须不误删 | +| 3 | Word 审阅批注(B2) | 以 `Commented [xx]:` 开头的行及其批注正文整块删除 | jama L155–157 共 2 处 | +| 4 | 手稿行号(B1) | 仅剥离**单调递增序列**的行首 `^\d{1,3} ` 与标题内 `^#{1,6} \d{1,3} `;序列中断即停并标记待审,防止误伤正文数字(如 "35 pediatric experts") | jama 128 行正文 + 6 个标题(L127/149/151/165/193/195) | +| 5 | 跑动页眉(C4) | 同一文本行原样重复 ≥2 次(且非正文引用对象)判为页眉,删除并把被切断的上下文段落接回 | dmp L73/L191("MSOFA Score for Critical Care Triage"),L71→L75 句子被切断 | +| 6 | 单行 HTML 表格展开(D1) | 无 rowspan/colspan 的表转多行 GFM;含合并属性的表保留 HTML 但按 `` 换行缩进;内容一字不改 | 全部 9 个表:dmp 7、ejhf 1、springer 1 | +| 7 | 跨页断词(E2) | 行尾连字符 + 下一行首小写字母 → 合并;仅在拼出的词能通过英文词表校验时执行,否则保留原样并标记 | dmp L125/127(thresh-olds)、sim L87/89(possi-bly)、L217/219(cre-ated) | +| 8 | 参考文献分隔统一(G3) | `^\d+\. ` 条目之间统一一个空行 | dmp refs 19–33、springer refs 9–19(连续堆叠段) | +| 9 | 图片断链校验(H3,仅校验) | 检查 `../images/*.jpg` 引用的文件是否存在,输出断链报告;不移动、不复制、不改写任何图片 | 全部 8 处引用(dmp 1、ejhf 1、sim 2、springer 4) | + +第 9 项是只读校验:它修复不了任何东西,输出的是"哪些文档的图片资产已损坏"清单。 + +## 3. 明确不洗(第一版非目标) + +以下问题已确认存在但**不在**第一版范围内,避免实施时范围蔓延: + +- **内容级缺失(A1–A4)**:截断、表格整体缺失、图转表格、句子丢失。清洗无法恢复内容, + 处置方式(重新转换 / 标记 / 人工补录)由项目负责人决定,不由清洗管线代劳。 +- **修订语义(B3)**:"defined identified by as" 等修订残留词对,哪个词是定稿词需对照定稿版判断。 +- **占位符与坏日期(B4/B5)**:等待定稿填充,清洗只可检测。 +- **乱码修复(C3/E1)**:泰文标题 `## ่วง`、`ينDS Hospital`,需要人工对照 PDF 替换。 +- **表格结构修复(D3–D5)**:空单元格、错误合并行、OCR 表头错字,需人工对照原文。 +- **空行断句合并(E3)**、**层级重建(C1)**、**标题合并(C2)**、**孤行公式编号(F1)**、 + **公式空格与纠错(F2–F4)**、**上标风格统一(G1)**、**数字格式(G2)**:属于第二档 + "自动检测+人工确认",等第一档验证后再立项。 +- **封面页(H2)**:ejhf L1–11 的仓库封面区第一版不删——它是整块连续的正文区,删除逻辑 + 与页眉类噪声不同,归入后续批次。 +- **一切内容改写**:原文写作瑕疵、欧式千分位、拼写(含 "Conounder")不属于转换噪声,永不由清洗工具修改。 + +## 4. 输入输出边界 + +- 输入:`data/<转换结果目录>/markdowns/*.md`,原文件只读; +- 输出:清洗结果写入独立输出位置(具体目录待 design 批准后确定),不回写、不覆盖原文件; +- 图片资产只校验存在性,不复制进仓库、不修改路径(路径改写是后续批次的独立决策); +- 每处修改必须可追踪(规则编号 + 原文/改后对照),保证审计和回滚。 + +## 5. 验收口径(第一版) + +- 上述 9 类问题在本轮 5 份文件上的触发处全部按规则处理,处理数与审计报告的实测数字一致; +- 5 份文件中未被任何规则命中的正文零变更——除表中列出的触发处外不得有任何其他 diff; +- 幂等性:同一输入清洗两次,第二次产出与第一次完全一致; +- 规则 2(arXiv 戳)在 springer 上的验收必须包含反向用例:L143/L152 参考文献原文保留; +- 规则 4(行号)验收必须包含反向用例:正文中的 "35 pediatric experts"、"10 sites"、"4 continents" + 等数字开头/含数字短语不受影响。 + +## 6. 与审计报告的编号对应 + +本文的 9 类规则对应 `scratch/data-5papers-cleaning-audit-2026-08-21.md` 的决策清单行: +D2、H1、B2、B1、C4、D1、E2、G3、H3。该审计是 scratch 材料,本文引用其编号仅为便于追溯, +权威以本文为准。 diff --git a/research-wiki/reference/GOVDOC_SAAS_CLEANING_SCOPE.md b/research-wiki/reference/GOVDOC_SAAS_CLEANING_SCOPE.md new file mode 100644 index 0000000..9ba4a06 --- /dev/null +++ b/research-wiki/reference/GOVDOC_SAAS_CLEANING_SCOPE.md @@ -0,0 +1,276 @@ +# 7 组对比测试文档 Markdown 清洗审计与实施规范 + +- 审计日期:2026-08-20 +- 审计目录:`/home/lihaoze/gov_test_data/compare` +- 清洗对象:7 个测试用例中 `uploads/` 下的 45 份 Markdown +- 背景资料:`compare/readme.md` 与各用例 `README.md` +- 本次操作:只读审计;没有修改任何原始测试文档 + +## 1. 结论摘要 + +这批 Markdown 目前不适合直接作为“语义可靠”的清洗后基准。问题并不只是在空格、换行和标题层级,而是同时存在以下几类高风险污染: + +1. **内容级污染**:至少 32/45 份文件命中了保守的模型幻觉特征词,合计 1,703 次;典型内容包括 `The quick brown fox...`、勾股定理说明、`The image contains...`、`The steps are:` 等与投标文件无关的文本。 +2. **图像内容丢失**:45/45 份文件都有图片引用,共 8,471 个,但目录中没有任何图片资产;其中 4,012 个引用只是字面量 `data:image/...;base64...`,并不包含可解码数据。 +3. **表格结构损坏**:41 份文件含原始 HTML 表格,共 7,550 个 `
`;35/41 份存在 `
//` 101,760,`` 101,019。 +- `` 656,575。 +- 35/41 份含 HTML 表格的文件至少有一类标签不平衡;7 份连 table 层级都不平衡。 + +001 最严重: + +- `file_0`:`table=1315/1124`。 +- `file_1`:`table=1281/1080`。 + +清洗要求: + +1. 使用容错 HTML 解析器构建 DOM,记录解析器自动补齐了哪些标签;禁止用正则直接替换所有表格标签。 +2. 对每个表格校验 `rowspan/colspan` 展开后的网格是否矩形、行列数是否与表头一致、单元格顺序是否可追溯。 +3. 简单矩形表格可转为 GFM;包含合并单元格、斜线表头、嵌套结构的表格应保留规范化 HTML,或另存为结构化 JSON。强行转 GFM 会丢失语义。 +4. 一行一个完整 HTML 表格应格式化为多行结构,避免长行拖垮分块器和 diff,但换行必须发生在 DOM 节点之间。 +5. 类似 `004-2/file_0` 第 613 行中“国家、职位、导演、客户”等明显不符合资格审查表语境的表头,应回源确认,不能只修标签。 + +### 4.5 T002:GFM 表格行列数不一致 + +优先级:P1。 + +007 四份文件共有 203 个连续 GFM 表格块、2,792 个表格行,至少 60 个表格块出现不同行拥有不同列数: + +- `file_0`:36 个块,21 个不一致。 +- `file_1`:55 个块,20 个不一致。 +- `file_2`:42 个块,7 个不一致。 +- `file_3`:70 个块,12 个不一致,另有 2 个块缺分隔行。 + +此外,`002-21/file_7` 有一个 584 行的大型 GFM 表格,同时出现 3 列和 5 列;`002-21/file_3` 也有不一致的孤立表格块。 + +这通常是把 Word/PDF 合并单元格硬投影到 GFM 的结果。清洗器应先恢复二维网格;无法无损表达的表格应改用规范 HTML/JSON,而不是补若干空 `|` 来“通过语法检查”。 + +### 4.6 H001:标题层级扁平、标题断裂和错误标题 + +优先级:P1。 + +问题表现: + +- 001—006 几乎把所有章节都输出为 `#`,没有文档层级。 +- 存在 `#零星维修...`、`#(正本)` 等缺少空格的标题。 +- 存在只有 `#` 的空标题。 +- 同一个标题被版面换行切成多个连续一级标题,例如项目名称被拆成 2—3 行。 +- 普通正文、图片识别结果和幻觉句子也被错误标成标题。 + +清洗要求: + +1. 每个逻辑文档保留一个主标题 H1;主要章为 H2,节为 H3,依次递进,不跳级。 +2. 使用编号模式(“第一章”“一、”“(一)”“1.”)、目录结构、相邻上下文和重复页眉信息共同推断层级。 +3. 连续短标题行在确认属于同一版面标题后合并;不能只根据行长合并。 +4. 修复 `#标题` 为 `# 标题`,删除空标题;被判为正文的行去掉标题标记。 +5. 标题文本中的项目编号、公司名、年份不得因规范化而改变。 + +### 4.7 P001:段落、硬换行和词内断裂 + +优先级:P1。 + +151,616 行以两个空格结尾,主要来自 002—006 的转换器。这会把几乎每个物理行强制为 Markdown `
`,使原本同一段的文字被切碎。还存在: + +- `物 业`、`项 目`、`服\n务项目` 等版面换行造成的词内空格或断行。 +- 页码、目录页码和正文混在同一行。 +- 句子被错误拼接成超长段,或每句话都被拆成独立段。 +- 项目编号中的连字符被转义成 `440442\-2025\-00435`,同一标识在不同文件中形式不一致。 + +清洗要求: + +1. 先识别标题、列表、表格、地址、编号、签名区,再做段落重组。 +2. 普通段落内部把版面软换行合并为空或一个空格;中文字符之间通常直接合并,中英/数字边界按规则保留空格。 +3. 列表项、诗行、地址、落款、表格单元格内的有意义换行不得统一删除。 +4. `\-` 仅在确认是转换器多余转义时还原;项目编号和负号的字符本身必须保留。 +5. Unicode 采用 NFC,不建议全局 NFKC;NFKC 可能改变罗马数字、圈号、单位和兼容字符的法律原貌。 + +### 4.8 D001:页眉、页脚、目录页码和文档边界 + +优先级:P1。 + +已检测到至少 1,166 个“独立页码样式候选”,但其中也会混入 `0/1000` 等评分或限制值,所以不能仅凭正则删除。明确例子包括 `1 / 390`、`368 / 390`、`4 / 6`。 + +清洗要求: + +- 只有在同一文本出现在多页相近顶部/底部位置,或页码形成合理序列时,才判定为页眉页脚。 +- `clean_fidelity.md` 可通过元数据保留页码映射;`clean_compare.md` 删除纯版式页码和重复运行标题。 +- 002 中 `file_16`—`file_19` 开头分别表现为符合性审查、综合文件、商务部分、技术部分,可能是同一投标人的分卷/分册。清洗器必须保存原始 `fileIndex` 和卷册边界,不能自行拼接。 +- 目录文本可转换为结构化章节导航;目录点线和页码不应参与正文相似度。 + +### 4.9 D002:重复内容与模板内容 + +优先级:P1,但严禁盲删。 + +重复既可能是转换器错误,也可能是投标文件真实模板。典型统计: + +- 001 两份文件非空行重复比例分别为 81.8% 和 84.1%。同一“综合单价分析表”及说明出现约 900 次。 +- `002-21/file_3` 非空行重复比例 44.8%,含“装约买箱箱装”等乱码高频重复。 +- `002-21/file_15` 中 `Agree to be` 重复 813 次。 +- `002-21/file_5` 中 `- The steps are:` 重复 818 次。 +- `002-21/file_20` 中目录式条目“施工等级 282”“工期保证措施 282”分别重复 165、164 次。 + +处理原则: + +1. 明确的幻觉/转换器退化重复应回源替换或隔离。 +2. 重复页眉、页脚、页码在对比版删除。 +3. 合法合同条款、报价表说明和表头在忠实版保留。对比版可把同一文档内重复模板块标为同一 `template_block_id`,在相似度计算时降权,而不是直接删除。 +4. 跨文档共同出现的合法招标模板本来就是对比目标的一部分,不应因“重复”而全部抹除。项目需要明确是检测抄袭、检测共同模板,还是两者分别评分。 + +### 4.10 L001:Word/PDF 转换遗留语法 + +优先级:P1/P2。 + +包括: + +- 007 `file_1` 有 171 个 `(#_Toc...)` Word 目录链接,但全库没有对应显式锚点。 +- 001 有 2,594 个 `
`,用 HTML 仅表达居中版式。 +- 575 个行内数学片段中有不少重复的 `\textcolor`、`\rightarrow`、`\cdots` 和勾股定理幻觉。 +- 20 个私用区字符主要是 U+F0B7、U+F070、U+F0D8,常见于 Wingdings/项目符号映射。 +- 10 个 `�` 出现在 7 份文件中,已经无法靠 Unicode 规范化恢复原字符。 + +处理要求: + +- Word TOC 链接要么根据清洗后标题生成真实 slug,要么移除链接只保留目录文字;不得保留悬空锚点。 +- 纯版式 `
` 转成语义标题/段落;居中信息可进样式元数据。 +- 私用区字符按原字体映射或源文件回查,不能一律删除。 +- `�` 必须逐处回源,无法回源时产生显式未解决项。 +- LaTeX 只有在源文件确实包含公式/符号时保留;重复生成的视觉符号应由原图或文本含义替代。 + diff --git a/research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md b/research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md new file mode 100644 index 0000000..f6903fa --- /dev/null +++ b/research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md @@ -0,0 +1,225 @@ +# data/ 五份论文 Markdown 审计报告(2026-08-21) + +> 状态:scratch 草稿,供评审。清洗力度由师姐逐项决定,本报告只列问题、证据和可选处置,不做力度决策。 + +## 1. 结论摘要 + +- 五份文件均为英文学术论文的 PDF→Markdown 转换产物,分属 5 个来源(JAMA、EJHF、Statistics in Medicine/arXiv、Springer/arXiv、Disaster Med Public Health Preparedness),噪声特征差异很大,不能用一套固定规则覆盖。 +- 最严重的问题不是格式噪声,而是**内容丢失**:2 份文件中途截断,1 份(JAMA)所有表格整体缺失。这类问题清洗无法恢复,只能重新转换或人工补录。 +- JAMA 文件的原始 PDF 是**未定稿的 Word 修订稿**,行号、审阅批注、修订残留词对全部泄漏进正文,是五份中污染最重的。 +- 格式类噪声(双重转义实体 31 处、孤行公式编号 12 处、空行断句、标题层级扁平、引用上标混用等)确定可清洗,风险低。 +- 少数问题涉及**语义改变**(数学公式被 OCR 改错、乱码替换专名),自动清洗有改错正文的风险,建议人工确认。 + +## 2. 审计范围与方法 + +- 对象:`data/*/markdowns/*.md` 共 5 份,逐行人工通读;行号均指 markdowns 下的 md 文件。 +- 辅助统计(本轮实际执行):标题层级计数、图片/表格/实体/批注/arXiv 戳的 grep 计数。 +- 判定原则:只把"转换管线引入的噪声"列为清洗对象;原文自身的写作瑕疵(如语法错误)不属于转换噪声,单独列出并标注"不建议清洗"。 +- 未验证项:未与原 PDF 逐页比对内容完整性,"缺失"结论基于文内自引用(如正文提到 Table 2 但全文无 Table 2)和文件截断位置。 + +## 3. 总体统计 + +| 文件(下文简称) | 行数 | H1 | H2 | H3+ | 图片 | HTML表格 | 双重转义 | 孤行编号 | 批注 | arXiv边栏戳 | +|---|---|---|---|---|---|---|---|---|---|---| +| dmp (Disaster Med) | 208 | 0 | 11 | 0 | 1 | 7 | 23 | 0 | 0 | 0 | +| jama (JAMA 2024) | 349 | 2 | 10 | 0 | 0 | 0 | 0 | 0 | 2 | 0 | +| ejhf (EJHF 2020) | 143 | 1 | 13 | 0 | 1 | 1 | 8 | 0 | 0 | 0 | +| sim (Stat Med/arXiv) | 247 | 2 | 11 | 0 | 2 | 0 | 0 | 12 | 0 | 1 | +| springer (ICU LSTM) | 154 | 1 | 12 | 0 | 4 | 1 | 0 | 1 | 0 | 1 | + +补充:jama 有 128 行以"行号+空格"开头(手稿行号泄漏);springer 的 3 处 arXiv 字符串中 2 处是参考文献的正常引用,仅 1 处是边栏戳。 + +## 4. 问题清单 + +每项标注:【确定】= 确定是转换噪声,可安全清洗;【语义】= 涉及内容判断,建议人工确认;【丢失】= 内容缺失,清洗不可恢复。处置选项仅供师姐选择。 + +### A. 内容级问题(最严重) + +**A1【丢失】两份文件中途截断** +- ejhf 第 143 行在 Table 1 HTML 表格中间戛然而止("Medical history at randomization, no. (%)" 行后为空单元格)。Table 1 后半、Tables 2–4、全部图注、参考文献整体缺失。 +- sim 第 247 行止于方法 M5 描述中间,且结尾 URL 损坏(`https://cran.r-project.org/web/p6$^{10}$`)。第 3 节(结果)、4、5 节、参考文献、附录缺失。 +- 处置选项:a) 退回重新转换;b) 接受现状并在元数据标记"截断";c) 人工补录缺失部分。清洗管线本身无法解决。 + +**A2【丢失】jama 全部表格缺失** +- 正文多处引用 Table、Box 1、eTables 1–3,但全文 0 个表格、0 张图片。摘要性内容(如各器官系统阈值表)完全丢失。 +- 处置选项:同 A1。 + +**A3【丢失】dmp 的 FIGURE 1 流程图被转成 HTML 表格** +- 第 83 行:流程图(决策树)被输出为单行 HTML 表格,图形语义尽失,仅 FIGURE 2(第 111 行)保留为图片。 +- 处置选项:a) 保留现状(有总比没有好);b) 重新转换该页;c) 人工用图片替换。 + +**A4【丢失】sim 第 93 行句子开头缺失** +- "/unpenalized) regression models" 以斜杠开头,前文整句丢失。 +- 处置选项:标记为损坏片段,或对照原文补录。 + +### B. 草稿/修订痕迹泄漏(仅 jama,原稿是 Word 修订稿) + +**B1【确定】手稿行号泄漏(128 行)** +- 正文行以行号开头:"25 increase in the Sequential..."(第 115 行)、"26 with suspected infection"(第 116 行)等;标题也带行号:"## 116 Results/recommandations"(第 149 行)、"## 117 Criteria..."(第 151 行)、"## 143 Organ dysfunction..."(第 165 行)。 +- 处置选项:a) 剥离行首行号(注意与正常编号列表、年份区分);b) 连同批注一起整体退回,要求提供定稿版。 + +**B2【确定】审阅批注泄漏(2 处)** +- 第 155–157 行:"Commented [LS1]: Why highlighted?"、"Commented [SW2R1]: To make sure we use capital letters..."。 +- 处置选项:整段删除。注意第 292 行还有一句删除文字与保留文字混杂的句子("Appropriate process and balancing measures Efforts to enhance..."),需人工断句。 + +**B3【语义】Word 修订残留词对(约 7 处)** +- "defined identified by as"(第 91 行)、"defined identified using by"(第 153 行)、"defined identified as in sepsis-septic patients"(第 116 行)、"defined indicate by as"(第 163 行)、"was were derived"(第 306 行)、"The new-Phoenix"(第 197 行)等——是"插入词+删除词"并存的痕迹。 +- 哪个词是最终保留词需要对照定稿判断,自动二选一有风险。处置选项:a) 人工逐处确认;b) 退回要定稿。 + +**B4【确定】未填占位符** +- "XX societies"(第 101 行)、"XX-microbiological testing and YY-antibiotics"(第 302 行)、"Endorsing societies: To be populated after acceptance"(第 317 行)。 +- 处置选项:保留并标记待补,或等定稿填充后再清洗。 + +**B5【确定】日期损坏** +- 第 85 行 "Revision date: December 3122, 2023"。 +- 处置选项:对照原文改为 2023 年内正确日期(人工)。 + +### C. 标题结构问题 + +**C1【确定】标题层级扁平** +- dmp:0 个 H1,11 个 H2 全部同级(含 Introduction/Methods/Results 等,第 1,3,7,59,67,103,137,153 行)。 +- ejhf:主章节与子章节同为 H2("## Methods" 第 51 行、"## Study population" 第 53 行),无层级区分。 +- 处置选项:按章节编号/语义推断层级(2.1 → H3),或统一降级保持平级。前者需人工核对推断结果。 + +**C2【确定】标题被拆分成两行** +- jama:标题拆成两个 H1(第 1–2 行 "International Consensus Criteria..." / "The Phoenix Pediatric Sepsis Criteria",实为正副标题)。 +- sim:主标题拆成两个 H1(第 3–4 行 "Conounder selection..." / "treatment effect estimators",且首词 "Conounder" 是 OCR 错字,原文应为 Confounder);2.2 节标题拆两行(第 183/185 行 "…full matching on the propensity" / "## score")。 +- 处置选项:合并为单标题;sim 主标题的 "Conounder" 拼写需对照原文确认(【语义】)。 + +**C3【语义】乱码标题** +- dmp 第 47 行 `## ่วง`(泰文字符)。按位置推断应为 METHODS,但不能凭推断改写正文级内容。 +- 处置选项:人工对照 PDF 改回,或删除该标题。 + +**C4【确定】跑动页眉混入正文且被标为标题** +- dmp 第 73、191 行 "## MSOFA Score for Critical Care Triage" 出现在句中和 REFERENCES 内;第 71→75 行一句话被它从中间切断。 +- 处置选项:删除页眉行并把被切断的句子接回。 + +### D. 表格问题 + +**D1【确定】表格压缩为单行 HTML** +- 全部 9 个表格(dmp 7、ejhf 1、springer 1)都是 `
` 数量不平衡。另有 4 份 007 用例文档使用 GFM 表格,其中 203 个表格块里至少 60 个行列数不一致。 +4. **重复和超长噪声**:出现 261,838 字符的单行点线、数万字符的重复英语句子、LaTeX 箭头/颜色命令和重复汉字。001 两份文件的非空行重复比例分别达到 81.8% 和 84.1%。 +5. **结构扁平化**:全库 29,358 个 Markdown 标题中有 28,736 个是一级标题,占 97.9%;至少 378 个标题缺少空格、只有 `#` 或存在其他明显语法问题。 +6. **字符与格式污染**:有 10 个替换字符 `�`、20 个私用区字符、11 个 NBSP、5 个零宽字符、1,474 个多余的 `\-` 转义,以及 151,616 行尾部双空格。 + +最重要的实施建议是:**不要只产出一份“清洗 Markdown”**。应同时产出: + +- `clean_fidelity.md`:忠实版,保留有法律/业务意义的全部内容,所有修复必须能追溯到源文件。 +- `clean_compare.md`:对比版,从忠实版派生,移除页眉页脚、失效目录页码、转换器噪声和确定的重复版式块,用于文档相似度计算。 +- `audit.json`:记录每一次删除、替换、合并、回源重提取和人工确认。 + +这样可以避免为了提升对比效果而不可逆地破坏原文,也能避免把幻觉文本、通用图片占位符和重复页眉当成“相同内容”。 + +## 2. 审计范围与统计口径 + +### 2.1 纳入和排除 + +纳入: + +- `compare/001-2/uploads/*.md`:2 份 +- `compare/002-21/uploads/*.md`:21 份 +- `compare/003-10/uploads/*.md`:10 份 +- `compare/004-2/uploads/*.md`:2 份 +- `compare/005-3/uploads/*.md`:3 份 +- `compare/006-3/uploads/*.md`:3 份 +- `compare/007-4/uploads/*.md`:4 份 + +排除: + +- 8 份 README:它们是测试说明,不是待清洗输入。 +- `review.json`、`blocks_*.json`、`match_index.json`、`summary.json`:它们是旧输入产生的下游结果,只用于理解测试背景,不能作为清洗真值。 +- `file_*_reviewed.docx`:本次未把它们当作 Markdown 清洗对象;后续可作为辅助核对材料,但是否能作为权威源需单独确认。 + +### 2.2 基础规模 + +45 份输入合计约 36.39 MiB、388,482 个物理行。所有文件都能按 UTF-8 读取,但“能解码”不代表内容无损,文件中仍有 `�` 和私用区字符。 + +下表中的“幻觉特征”使用一组偏保守的固定模板进行计数,包括 `The quick brown fox...`、勾股定理模板、`The image contains...`、`The concept of a concept...`、`The steps are:` 等;因此它只是明确下限,不包含全部乱码和中文重复污染。 + +| 用例 | 文档数 | 大小 MiB | 行数 | HTML 表格 | GFM 表格行 | 图片引用 | 空 Base64 占位 | 幻觉特征 | 最大单行字符数 | 总体判断 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---| +| 001-2 | 2 | 6.41 | 48,450 | 2,596 | 0 | 109 | 0 | 0 | 4,482 | 高风险:表格未闭合、重复率极高、图片定位不可用 | +| 002-21 | 21 | 15.23 | 158,462 | 2,593 | 592 | 2,000 | 0 | 1,530 | 28,624 | 严重:模型幻觉和重复文本最集中 | +| 003-10 | 10 | 9.20 | 116,358 | 1,646 | 2 | 1,793 | 0 | 137 | 261,838 | 严重:存在灾难性长行、乱码、幻觉和 LaTeX 污染 | +| 004-2 | 2 | 0.41 | 5,726 | 109 | 0 | 36 | 0 | 7 | 8,001 | 高风险:OCR 语义错误、缺图、幻觉 | +| 005-3 | 3 | 2.67 | 31,537 | 476 | 0 | 359 | 0 | 7 | 11,599 | 高风险:重复、缺图、表格和少量幻觉 | +| 006-3 | 3 | 1.12 | 14,701 | 130 | 0 | 162 | 0 | 22 | 9,151 | 高风险:含 OCR 文档,仍有明显幻觉和缺图 | +| 007-4 | 4 | 1.35 | 13,248 | 0 | 2,792 | 4,012 | 4,012 | 0 | 5,916 | 严重:图片内容全部为无效占位,表格和 Word 目录损坏 | + +## 3. 对下游“文件对比”功能的直接影响 + +这些污染会系统性扭曲相似度,不只是影响阅读体验: + +- `The quick brown fox...` 等同一幻觉模板出现在多个本来无关的投标文件里,会制造跨文档假阳性匹配。 +- 007 中 4,012 个完全相同的空图片占位符会被当成大量相同行。 +- 001 中同一“综合单价分析表”说明被重复约 900 次;这与 README 中 001、003 的“近似匹配占 99% 以上”和超大 `review.json` 有明显关联。这里只能判断为高度可疑的影响因素,不能在没有重新跑对比的情况下断言它是唯一原因。 +- 平铺成一级标题、把表格压成一行、把页眉页脚混入正文,会改变分块边界,并使段落/句子/近似三档匹配分布失真。 +- 如果把不同手机号、身份证号、图片都统一替换成同一个 `[PHONE]`、`[ID]`、`[IMAGE]`,又会制造新的假相同内容。因此占位符必须保留“不同原值不同 token”的性质。 + +清洗后必须重新生成 `blocks_*.json`、`match_index.json`、`review.json` 和 `summary.json`。旧匹配数量不能作为清洗后的等值验收条件,应保留为“脏输入性能基线”,另建“干净输入语义基线”。`fileIndex` 和测试用例映射必须保持不变。 + +## 4. 问题清单与清洗要求 + +### 4.1 C001:固定模板型模型幻觉 + +优先级:P0,阻断语义版交付。 + +已确认的例子包括: + +- `The quick brown fox jumps over the lazy dog.`:全库 1,071 次。 +- `The equation $x^2 + y^2 = z^2$ represents a Pythagorean triple...`:全库 62 次。 +- `The image contains a single character...` +- `The concept of a concept is fundamental in physics...` +- `- The steps are:` +- `Agree to be` +- `汽车法规和商业期刊的出版` + +典型证据: + +- `002-21/file_14` 第 1,164 行:一行内重复 `quick brown fox` 约 411 次。 +- `002-21/file_3` 第 3,804 行:同类重复约 428 次。 +- `002-21/file_5` 第 2,915 行:`The image contains...` 及数字说明被重复扩展。 +- `002-21/file_8` 第 5,379 行:28,624 字符的英语概念重复行。 +- `003-10/file_2` 开头即出现无关英文、勾股定理和中文乱码。 + +清洗要求: + +1. 固定模板命中后先标记其所在段、表格单元格和推定页面,不应只删除匹配到的几个单词。 +2. 有源 PDF 时,按页或按区域重新提取;没有源文件时,将该段放入 `quarantine`,不能把删除后的残缺上下文冒充完整正文。 +3. 黑名单适合做拦截器,不适合做唯一清洗器。应再检测异常语言切换、低词汇多样性、同短语高频循环和超长单行。 +4. 清洗结果中这些已知模板必须为 0;审计文件须记录被移除的原始范围和回源依据。 + +### 4.2 C002:超长重复串和退化输出 + +优先级:P0/P1,视能否回源而定。 + +典型问题: + +- `003-10/file_2` 第 91 行长 261,838 字符,主体是目录点线重复。 +- 同文件第 8,924 行长 19,089 字符,主体是重复的 LaTeX `\rightarrow`。 +- `002-21/file_15` 第 4,898 行长 11,267 字符,主体是重复 `\textcolor{red}{\blacksquare}`。 +- `003-10/file_7` 第 24,211 行含嵌套、重复的 `\textcolor{red}`。 +- 多处出现成千上万次的 `园`、`\cdots`、`...` 或错误短语,例如“建设工程执行”。 + +检测规则建议: + +- 普通文本单行超过 2,000 字符告警,超过 10,000 字符阻断;结构化表格在解析后按单元格重新执行该规则。 +- 同一字符连续 20 次以上、同一 2—20 字符片段连续 10 次以上告警。 +- 单行压缩率异常高、唯一 token 比例过低、相邻重复 n-gram 比例过高时进入隔离。 +- 目录点线只保留为结构化 TOC 信息,不保留数十万字符的视觉填充。 + +不允许简单按固定长度截断,因为长 HTML 表格可能包含真实内容。必须先判断是表格、目录点线、Base64、SVG 还是普通文本。 + +### 4.3 I001:图片引用全部失效 + +优先级:P0。 + +全库有 8,471 个图片引用,本地图片资产为 0: + +- 007:4,012 个 `![](data:image/jpeg;base64...)` 或 PNG 变体。这里的 `...` 是文本,不是被终端隐藏的真实数据,无法解码恢复。 +- 001:109 个目标形如 `page=9,bbox=[...]`,不是标准图片路径,也没有对应裁剪图。 +- 其余:4,350 个 `images/.jpg` 等相对引用,但仓库中不存在相应文件。 +- 7,073/8,471 个图片没有 alt 文本。 + +清洗要求: + +1. 从原始 PDF/DOCX 回源导出图片,使用内容哈希命名,并生成 `assets/manifest.json`。 +2. 对印章、签名、证书、身份证件、扫描表格等“有语义图片”执行 OCR/版面识别,但仍要保留原图引用,不能只留推测文本。 +3. 001 的 `page+bbox` 必须转换为结构化来源坐标,再从对应 PDF 裁剪;不能直接当 Markdown URL。 +4. 如果确实采用纯文本模式,图片位置应写成唯一、可追踪的标记,例如 `[IMAGE_MISSING:007-4:file_1:0001]`,不能用所有文件共享的 `[IMAGE]`,否则会制造假匹配。 +5. 生产级“完整清洗”验收要求 broken reference 为 0。无法回源的图片必须明确标为未解决,不能计作完整通过。 + +### 4.4 T001:HTML 表格损坏和超长单行 + +优先级:P0/P1。 + +41 份文件中共有 7,550 个 HTML 表格。静态标签计数如下: + +- `` 7,550,`
` 7,155。 +- `
` 657,263,`
...
` 单行输出,diff、review、编辑都极难。 +- 处置选项:转 GFM 多行表格(简单表);对含 rowspan/colspan 的复杂表保留 HTML 但格式化缩进。 + +**D2【确定】HTML 实体双重转义(31 处)** +- dmp 23 处(`&gt;400`、`&lt;1.2`、`MAP&lt;70`,第 27/33/39 行等);ejhf 8 处(`A1C&lt;7`、`7≤A1C&lt;8`,第 143 行)。 +- 处置选项:还原一层转义(`&gt;` → `>`)。这是最安全的清洗之一。 + +**D3【确定】合并单元格信息丢失** +- dmp 第 33 行 Table 2 的 Liver 行出现空 ``,跨行合并关系丢失,数值与表头对应断裂。 +- 处置选项:对照 PDF 人工修复合并结构,或标记为低可信表格。 + +**D4【确定】表格行错误合并** +- dmp 第 39 行 Table 3:四个器官系统 "Respiratory Coagulation Liver Cardiovascular" 被挤进一个单元格。 +- 处置选项:人工拆分修复。 + +**D5【确定】表头 OCR 乱码** +- springer 第 111 行 Table 1 表头 "Claesther"(应为 Classifier)。 +- 处置选项:人工改正(单处,低成本)。 + +### E. 文本级损坏 + +**E1【语义】跨脚本字符替换** +- dmp 第 43 行 "atينS Hospital"、第 55 行 "ينDS Hospital"——阿拉伯字符 ين 替换了 "LD"(LDS Hospital 是机构专名)。 +- 处置选项:人工替换回 "LDS"。自动规则可检出非拉丁字符混入,但替换动作建议人工确认。 + +**E2【确定】跨页断词** +- dmp:第 125 行结尾 "…at the relevant thresh" + 第 127 行 "olds of 8 and 11"(单词 thresholds 被页边界切开)。 +- sim:第 87/89 行 "except possi-" / "bly via treatment";第 217/219 行 "cre-" / "ated by permuting"。 +- 处置选项:拼接断词(去连字符合并)。需注意英语中合法的行尾连字符(如 "well-known")不能误合并,建议只合并"行尾连字符+下一行首为小写字母且拼出的词在词表内"的情况,其余保留待审。 + +**E3【确定】句内空行断句** +- 大量段落被空行从句子中间切开:jama 第 103→105、113→115 行;ejhf 第 47–49、57–59、67–69、87–89、113–115、131–133 行;springer 第 16→20 行(中间还被 arXiv 戳隔开,见 H1)、42→44 行。 +- 处置选项:段内合并(前段末无句号且后段首为小写/连接词时拼接)。这是对 RAG 分块影响最大的问题之一。 + +**E4【确定】段落内容错位/孤立行** +- springer 第 63 行孤立 "1"(公式编号漂移,见 F1);第 81–83 行 "…improve the simple Multilayer Perceptron… other deep models." 后接 "including RNNs and MLPs.",段落被错误切开。 +- 处置选项:结合上下文人工归位。 + +### F. 数学公式问题(sim 最重,springer 次之) + +**F1【确定】公式编号漂移成孤行** +- sim 12 处:第 63/70/81/107/117/133/145/153/161/171/179/197 行分别是 "1"、"2"、"(4)"…"(12)";springer 第 63 行 "1"。 +- 处置选项:并入对应公式块或删除(若公式本体已带编号)。需逐处对照,不宜盲目删除。 + +**F2【语义】公式内容被 OCR 改错** +- sim 第 139 行 `$\exp(x) = \exp(x)/\{1+\exp(x)\}$`——这是 expit 函数定义(x↦e^x/(1+e^x)),左边的 $\exp(x)$ 应为 $x$ 或 expit(x)。OCR 错误改变了数学含义,且这种错误会误导下游读者。 +- 处置选项:人工对照原文修复;自动工具只能标记"公式与上下文不符",不宜自动改。 +- sim 第 119 行 "weights w k" 下标丢失,同类。 + +**F3【确定】LaTeX 冗余空格与风格不一** +- sim 全文行内公式带前导空格(`$ \widehat{\psi}_{j} $`)、内部空格过多(`\widehat { \mathrm { E } } ( Y ^ { a } )`,第 150 行)。 +- 处置选项:规范化空格(不改变符号语义的前提下)。风险低但需保守,避免动 `\,` `\;` 等有意义的间距命令。 + +**F4【确定】公式 OCR 字符间距拉宽** +- springer 第 72/78 行 cases 环境里 `\mathrm { s u r v i v o r s ~ g r o u p }` 逐字空格。 +- 处置选项:去除字母间空格(保守做法:只处理 `\mathrm{}` 内的单字母间距模式)。 + +### G. 格式不一致 + +**G1【确定】引用上标风格混用** +- 同一文件内 LaTeX `$^{1,2}$` 与 Unicode 上标(¹²、²⁻⁴、⁴⁹ ⁵⁰,⁵¹)并存:jama(第 115–116 行 Unicode vs 第 107/187 行 `$\geq 2$`)、ejhf(第 45–49 行 LaTeX vs 第 133 行 Unicode)、springer(第 5–6 行 `$ ^{1} $` vs 第 20 行 [1,2,4,3] 方括号)。 +- 处置选项:统一为一种(选哪种由师姐定,取决于下游用途:RAG 检索倾向 Unicode 纯文本,渲染倾向 LaTeX)。 + +**G2【可能】数字格式不一致** +- ejhf 第 55/57 行 "6068 patients" vs "4,091 patients";dmp 第 91 行 "0.81-.85"(小数点前缺 0)。 +- 注意:springer 的 "61.532"、"46.520" 是欧式千分位,可能是原文排版而非转换噪声,不能自动统一。 +- 处置选项:统一千分位与小数风格;欧式写法是否转换需师姐定。 + +**G3【确定】参考文献列表分隔不一致** +- dmp:refs 1–18 空行分隔,19–33 连续堆叠(第 193–208 行);springer:refs 1–8 空行分隔,9–19 连续堆叠(第 140–154 行)。 +- 处置选项:统一为一条一空行。 + +### H. 非正文噪声与资产 + +**H1【确定】arXiv 边栏戳** +- sim 第 1 行 "arXiv:2001.08971v3 [stat.ME] 10 Oct 2020";springer 第 18 行同类戳插在 Introduction 段落中间,把一段话切成三截(第 16/18/20 行)。 +- 处置选项:删除戳行并接回段落。注意别误删参考文献中的合法 "arXiv preprint arXiv:…"(springer 第 143/152 行)。 + +**H2【确定】机构仓库封面页** +- ejhf 第 1–11 行:Glasgow eprints 引用说明、版本声明、`http://eprints.gla.ac.uk/213358/`、"Deposited on: 27 April 2020",以及封面图 `![image](../images/…_sub0.jpg)`(该图是仓库封面,不是论文插图)。 +- 处置选项:整体删除封面区;封面图一并删或移入元数据。对 RAG 是纯噪声。 + +**H3【可能】图片相对路径依赖** +- 全部图片用 `../images/` 相对路径(dmp 第 111 行、ejhf 封面、sim 第 229/231 行、springer 第 65/115/117/119 行)。md 文件一旦脱离原目录结构,图片全部失效。 +- 处置选项:a) 保持现状(目录结构不变时无碍);b) 清洗时把路径改写为部署目标路径;c) 校验引用的图片文件是否存在并报告断链。sim 的 Figure 1 实为左右两个面板(sub0/sub1 两张图)共享一条图注(第 233 行),springer 的 Fig. 2 为三面板(sub1–sub3)——合并还是保持多图由师姐定。 + +## 5. 不建议清洗的内容(保真边界) + +- **原文自身的写作瑕疵**:springer 论文语言明显不通("was went to describe"、"In the other hand"、"section 4 discuss"),这是作者问题不是转换噪声,清洗工具不得改写学术内容。 +- **欧式千分位**(springer "61.532"):疑似原文排版,自动统一有改数风险。 +- **专名与缩写的大小写、期刊缩写风格**:不属于转换噪声。 +- **A 类内容缺失**:不要试图用生成或推测内容"补全"缺失章节——宁可留空标记。 + +## 6. 决策清单(供师姐勾选力度) + +| 编号 | 问题 | 涉及文件 | 建议决策点 | +|---|---|---|---| +| A1/A2/A4 | 截断与表格缺失 | ejhf, sim, jama | 重新转换 / 接受并标记 / 人工补录 | +| A3 | 图转表格 | dmp | 保留 / 重转 / 换图 | +| B1 | 行号剥离 | jama | 剥离 / 退回要定稿 | +| B2 | 批注删除 | jama | 删(基本无争议) | +| B3 | 修订词对 | jama | 人工定稿对照(不宜自动) | +| B4/B5 | 占位符/坏日期 | jama | 保留标记 / 人工修 | +| C1 | 层级重建 | dmp, ejhf | 推断层级 / 保持平级 | +| C2 | 标题合并 | jama, sim | 合并(低风险) | +| C3/C4 | 乱码标题/页眉 | dmp | 人工改 / 删 | +| D1 | 表格展开 | 全部 | GFM / 缩进 HTML / 不动 | +| D2 | 实体还原 | dmp, ejhf | 还原(低风险) | +| D3/D4/D5 | 表格结构修复 | dmp, springer | 人工修 / 标记低可信 | +| E1 | 乱码专名 | dmp | 人工替换 | +| E2 | 断词拼接 | dmp, sim | 保守合并+白名单 | +| E3 | 段内合并 | jama, ejhf, springer | 启用(对 RAG 影响大) | +| E4 | 段落归位 | springer | 人工 | +| F1 | 编号并入 | sim, springer | 对照处理 | +| F2 | 公式纠错 | sim | 人工(含义级) | +| F3/F4 | 公式空格 | sim, springer | 保守规范化 | +| G1 | 上标统一 | jama, ejhf, springer | 定一种风格 | +| G2 | 数字格式 | ejhf, dmp | 统一 / 保留原文 | +| G3 | 参考文献分隔 | dmp, springer | 统一空行 | +| H1 | arXiv 戳 | sim, springer | 删+接段(勿伤引文) | +| H2 | 封面页 | ejhf | 删 | +| H3 | 图片路径 | 全部 | 现状 / 改写 / 断链校验 | + +## 7. 与既有审计的关系 + +`research-wiki/reference/GOVDOC_SAAS_CLEANING_SCOPE.md`(45 份政务文档审计,原名 MARKDOWN_CLEANING_AUDIT.md)中的幻觉、重复、页眉页脚等类别在本批论文中部分复现(C4/D2/H1 对应旧审计的 D001/L001 类),但本批新增了论文特有的类别:修订稿痕迹(B 类)、公式问题(F 类)、引用上标混用(G1)、截断缺失(A 类)。若后续建立通用清洗规则库,B/F/G 类需要论文 profile,不宜进默认规则。 diff --git a/research-wiki/scratch/markdown-cleaning-ecosystem-research-2026-08-20.md b/research-wiki/scratch/markdown-cleaning-ecosystem-research-2026-08-20.md new file mode 100644 index 0000000..8e55f20 --- /dev/null +++ b/research-wiki/scratch/markdown-cleaning-ecosystem-research-2026-08-20.md @@ -0,0 +1,492 @@ +# Markdown 清洗生态调研与通用架构建议 + +> 状态:调研草稿,尚未批准为项目设计。 +> +> 调研日期:2026-08-20。 +> +> 更新方式:候选工具、许可证、实测结果或项目范围变化时更新;形成实施决定后转写为下一编号 design。 + +## 1. 结论先行 + +这个项目不应该重新实现一个“正则表达式合集”,也不应该把 Prettier、mdformat、Unstructured 或某个 +PDF→Markdown 模型直接包装成最终产品。 + +现有工具各自只解决问题的一层: + +- Markdown parser/formatter 能统一语法,但无法知道一句话是不是模型幻觉; +- HTML 容错解析器能补齐标签,但无法保证补出的表格在业务上正确; +- PDF/DOCX 提取器能回源重建,但仍可能 OCR 错误或生成幻觉; +- PII 工具能提供候选实体,但无法自动决定跨文档伪名是否应该一致; +- 文本质量过滤器能发现重复和低熵,却常以“整篇丢弃”为目标,不适合忠实修复文档。 + +因此建议把 `govdoc-md-cleaner` 定位为: + +> **面向多项目的、可审计的文档规范化与派生框架。Markdown 是主要输入输出格式,但核心对象是带来源、 +> 结构、置信度和问题记录的文档,而不是一串待正则替换的文本。** + +推荐的技术组合是: + +| 层 | 首选候选 | 在本项目中的角色 | +|---|---|---| +| 核心语言 | Python | 与文档解析、OCR、隐私工具及现有下游生态衔接 | +| Markdown 解析 | `markdown-it-py` + GFM 插件 | CommonMark/GFM 结构识别、块级行号映射;不负责语义修复 | +| Markdown 输出 | 自有受控 renderer;`mdformat` 只作可选格式化后端 | 保证 profile 输出稳定,避免 formatter 越权改原文 | +| 损坏 HTML | `html5lib`,必要时配合 `lxml` tree builder | 按浏览器规则恢复 DOM;恢复动作必须进入审计 | +| 回源提取 | Docling 作为默认候选 adapter | PDF/DOCX/图片转结构化文档,保留页码、bbox 和 provenance | +| 编码异常 | `ftfy` 作为候选建议器 | 识别/建议 mojibake 修复;默认不静默应用到法律文本 | +| 隐私 | Presidio 可选 adapter + 中文自定义 recognizer | 检测、确定性伪名和图片脱敏;不是默认核心依赖 | +| 重复/退化 | 自有 detector,参考 DataTrove 指标 | 长行、低熵、重复字符/n-gram、语言突变和固定幻觉模板 | +| 多格式转换 | Pandoc 可选 adapter/对照 oracle | DOCX/HTML/Markdown 转换与 AST filter;不作为忠实度权威 | + +`remark/unified` 是 Markdown 原生变换能力最完整的候选,但它会引入 Node.js/TypeScript 运行时;本项目的 +回源、OCR、中文隐私和下游环境更偏 Python,所以建议把 remark 作为设计参照和交叉验证器,而不是第一版核心。 + +这不是最终技术选型。下一步应以本报告为输入编写 design,并用小型 spike 验证关键假设后再批准依赖。 + +## 2. 审计告诉我们的真实问题 + +本报告以 [45 份 Markdown 清洗审计](../reference/MARKDOWN_CLEANING_AUDIT.md) 为本地事实来源。 +审计发现的不是单一格式问题,而是至少五个不同层次的问题: + +1. **字节与字符层**:替换字符、私用区字符、NBSP、零宽字符、多余转义; +2. **Markdown/HTML 语法层**:标题扁平、悬空链接、损坏 HTML/GFM 表格、极端长行; +3. **文档结构层**:段落断裂、阅读顺序错误、页眉页脚混入、图片与表格丢失; +4. **内容可信度层**:模型幻觉、退化重复、OCR 语义错误、无法凭 Markdown 恢复的缺失内容; +5. **用途与合规层**:对比、RAG、受控忠实版、公开脱敏版对内容保留规则不同。 + +其中两个结论直接改变技术路线。 + +第一,Markdown parser 解析成功不能作为质量通过条件。CommonMark 明确规定任意字符序列都是合法文档, +因此绝大多数“脏 Markdown”仍然可以无报错解析;我们必须建立额外的结构、内容和来源验证器 +([CommonMark 0.31.2](https://spec.commonmark.org/0.31.2/))。 + +第二,GFM parser 接受的表格也未必满足我们的忠实度要求。GFM 对正文行缺列会补空单元格,多出的单元格 +会被忽略;这对法律和金额表格可能造成静默丢失,所以项目必须在 parser 之上做严格矩形网格验证 +([GFM 表格规范](https://github.github.io/gfm/#tables-extension-))。 + +## 3. 为什么成熟 formatter 不能直接解决 + +### 3.1 Prettier、mdformat + +[Prettier](https://prettier.io/docs/) 和 [mdformat](https://mdformat.readthedocs.io/) 都是成熟的确定性格式化器。 +它们通过“解析后重新打印”统一标题、列表、换行等书写风格。mdformat 使用 `markdown-it-py`,并提供语法扩展 +和代码围栏 formatter 插件([mdformat 插件文档](https://mdformat.readthedocs.io/en/stable/users/plugins.html))。 + +适合: + +- 已经确认语义正确的 Markdown; +- 统一输出风格; +- 检查幂等性; +- Wiki、README 和开发者手写文档。 + +不适合直接处理本审计数据: + +- formatter 不知道 `The quick brown fox...` 是幻觉; +- 重新打印会扩大 diff,使逐项审计更困难; +- 对 raw HTML、损坏表格和未知扩展可能规范化或转义; +- 它无法从缺失图片引用恢复资产,也无法回到 PDF bbox。 + +建议:只在结构和内容已经通过验证的节点上使用,或作为最终输出的可选 profile;永不直接覆盖原输入。 + +### 3.2 markdownlint、remark-lint + +[remark-lint](https://github.com/remarkjs/remark-lint) 有约 70 条可组合规则,能检查标题跳级、硬换行、 +链接语法和行长等;markdownlint 也有成熟的规则集。这些工具适合开发文档质量门禁,但其规则主要面向 +作者书写风格,不认识 PDF 页、OCR 置信度、表格合并单元格或业务实体。 + +建议:用作本仓 Wiki/README 的 CI,或复用部分规则思想;不作为业务文档清洗引擎。 + +## 4. Markdown AST 候选 + +### 4.1 `remark` / `unified` + +[remark](https://github.com/remarkjs/remark) 提供 Markdown→mdast→Markdown 的完整插件流水线; +[mdast](https://github.com/syntax-tree/mdast) 对 CommonMark、GFM 表格、图片、raw HTML 等节点有稳定模型, +unist 生态还提供位置、source extraction、遍历、lint 和 vfile 消息。它是本次调研中最完整的 +Markdown-native 变换生态。 + +优点: + +- parser、AST、visitor、transformer、lint、stringifier 是同一生态; +- 节点通常带行、列、offset,适合生成诊断; +- GFM、frontmatter、数学、directives 等扩展成熟; +- TypeScript 类型和插件边界清晰。 + +代价: + +- 核心运行时是 Node.js/ESM; +- PDF/DOCX/OCR、中文文本处理和当前下游大多仍在 Python; +- 双运行时会增加部署、版本锁定和跨语言 IR 的维护成本。 + +判断:如果项目只清洗开发者 Markdown,remark 是首选;对当前“文档回源 + 多项目 profile”目标,第一版 +不建议为它引入第二套运行时。可把它用于 conformance 对照或以后提供 TypeScript 前端。 + +### 4.2 `markdown-it-py` + `mdformat` + +[markdown-it-py](https://markdown-it-py.readthedocs.io/en/latest/) 遵循 CommonMark,支持插件、自定义规则和 +GFM 相关扩展;Token 的 `map` 字段提供块级起止行号 +([Token 文档](https://markdown-it-py.readthedocs.io/en/v4.2.0/_modules/markdown_it/token.html))。它活跃、 +MIT、Python 原生,适合本项目第一版。 + +局限也需要明确: + +- 它主要是 parser/HTML renderer,不是完整的 Markdown transformation framework; +- 行号映射主要在块级,细粒度字符 offset 和跨回源 bbox 仍需我们维护; +- raw HTML 会成为特殊 token,表格恢复仍要交给 HTML parser; +- CommonMark 合法不等于文档内容可信。 + +建议:把它用于“识别现有 Markdown 的结构和边界”,再投影到项目自己的 Document IR;不要直接在 token +列表上堆满业务规则。`mdformat` 可为确认安全的 AST 提供稳定输出,但 renderer 行为必须通过回归样本冻结。 + +### 4.3 Pandoc + +[Pandoc](https://pandoc.org/MANUAL.html) 使用 reader→AST→writer 架构,Lua/JSON filter 可以按顺序变换 AST +([Pandoc filter 文档](https://pandoc.org/filters.html))。它的多格式覆盖和长期稳定性很强。 + +适合: + +- DOCX、HTML、Markdown 等格式导入导出; +- 做第二实现的转换对照; +- 用户明确接受 Pandoc 方言规范化的 profile。 + +不适合担任忠实版核心: + +- reader/writer round-trip 会改变原始 Markdown 表达; +- Pandoc AST 不是为逐字符审计和 PDF bbox 设计的; +- 外部二进制与 [GPL-2.0 许可证](https://github.com/jgm/pandoc/blob/main/COPYING.md)需要独立部署评估; +- 不能修复不存在于输入中的图片和内容。 + +判断:可选 adapter,不作为唯一内部表示。 + +### 4.4 Marko、Mistune 等 Python parser + +[Marko](https://marko-py.readthedocs.io/en/latest/) 提供纯 Python CommonMark AST 和扩展机制,Mistune 偏向 +高速渲染。它们都能用于特定场景,但相较 `markdown-it-py`,当前项目更看重现成插件、维护活跃度、 +生态采用以及块级 source map。第一轮 spike 不必同时维护三个 Python parser。 + +## 5. 损坏 HTML 与表格恢复 + +[html5lib](https://html5lib.readthedocs.io/en/stable/) 按 WHATWG 浏览器解析算法处理可能损坏的 HTML, +可以输出 ElementTree 或使用 lxml tree builder;[lxml 的 HTML5 接口](https://lxml.de/4.5/apidoc/lxml.html.html5parser.html) +也支持 fragment 解析。 + +推荐流程: + +1. 从 Markdown AST 中只取 raw HTML fragment,不把整篇 Markdown 当 HTML; +2. 保存原 fragment、source span 和哈希; +3. 使用 html5lib 容错解析并收集 parser errors; +4. 构建显式二维 table grid,展开 `rowspan`/`colspan`; +5. 校验每个输出 cell 都能映射到原节点或 source 区域; +6. 仅无合并单元格的简单矩形表格输出 GFM; +7. 复杂表格输出规范 HTML,并并行保留 JSON grid; +8. parser 自动补齐的标签只说明“语法可恢复”,不能自动标为“语义已验证”。 + +不能采用旧实现那样用正则匹配 `...
`:审计已经证明大量闭合标签缺失,正则既无法正确嵌套, +也会把后续正文吞入表格。 + +## 6. PDF、DOCX 和图片回源候选 + +### 6.1 Docling:默认候选 adapter + +[Docling](https://docling.org/) 支持 PDF、Office、HTML、Markdown、图片等格式,能输出 Markdown 和结构化 +`DoclingDocument`;后者包含表格、层级、bbox 和 provenance +([DoclingDocument 说明](https://github.com/docling-project/docling/blob/main/docs/concepts/docling_document.md))。 +项目是 Python/MIT,OCR 后端可插拔。 + +它与审计需求最匹配的不是“Markdown 看起来更漂亮”,而是能先保存结构化、带位置的中间结果,再由我们 +生成 fidelity/profile 输出。因此建议把 Docling 作为第一批回源 adapter 的基准候选。 + +但它仍不能成为无条件真值:OCR、阅读顺序和表格模型都会出错,VLM 路径也可能生成内容。必须在 001、003 +有原始 PDF 的受控样本上验证字符、数字、表格和图片,不以官方 demo 或总准确率代替本项目测试。 + +### 6.2 Unstructured + +[Unstructured partition](https://docs.unstructured.io/open-source/core-functionality/partitioning) 能把多种格式切成 +`Title`、`NarrativeText`、`ListItem`、`Table` 等元素,一些格式保留页码、坐标和 table HTML,适合作为 +另一种 source adapter 或元素分类对照。 + +其 `cleaners` 不能整体照搬。例如官方实现中的 `clean_dashes` 会替换连字符,`clean_bullets` 会删除项目符号, +`clean_non_ascii_chars` 会丢弃非 ASCII 字符;这对中文法律文本、项目编号和列表结构明显过于激进 +([cleaners 源码](https://github.com/Unstructured-IO/unstructured/blob/main/unstructured/cleaners/core.py))。 + +判断:可评估 partition/metadata;不采用通用 `clean(...)` 作为默认清洗策略。 + +### 6.3 MinerU、Marker、MarkItDown + +- [MinerU](https://github.com/opendatalab/MinerU) 支持 PDF/Office/图片到 Markdown、JSON 和图片资产,能力覆盖广, + 但本地审计已经展示某些现有解析产物中的幻觉与退化;此外它当前是 Apache-2.0 加附加商业与署名条款, + 不是无条件的标准 Apache-2.0([MinerU 许可证](https://github.com/opendatalab/MinerU/blob/master/LICENSE.md))。 +- [Marker](https://github.com/datalab-to/marker) 能输出 Markdown、JSON、HTML 和 chunks,也暴露页/块结构;代码为 + Apache-2.0,但模型权重采用带商业门槛和用途限制的修改版 OpenRAIL-M,必须把代码与模型许可分开审查 + ([Marker 模型许可证](https://github.com/datalab-to/marker/blob/master/MODEL_LICENSE))。 +- [Microsoft MarkItDown](https://github.com/microsoft/markitdown) 是轻量多格式→Markdown 工具,适合低成本文本提取, + 但它的目标不是页级 provenance、复杂表格忠实恢复或审计账本。 + +判断:三者都可成为 benchmark adapter,不应把任何一个输出直接标为 fidelity 真值。第一轮优先比较 +Docling、MinerU 和 Marker 的结构化 JSON,而不是只比较最终 Markdown 的视觉效果。 + +## 7. 编码、内容退化和隐私工具 + +### 7.1 `ftfy` + +[ftfy](https://ftfy.readthedocs.io/en/latest/) 用保守启发式修复 Unicode mojibake,目标之一是避免把正常文本 +误改。它适合发现和建议典型 UTF-8/Windows-1252 误解码。 + +边界: + +- 已经变成 `�` 的原字符信息不在字符串中,ftfy 无法凭空恢复; +- 私用区字符需要字体或源文件映射; +- 中文旧编码误解码和法律文本中的兼容字符仍需专门验证; +- 即使候选看起来合理,也要保留 before/after、置信度和规则版本。 + +建议:作为 detector/candidate fixer;默认 profile 只自动应用有严格前置条件、通过实体保护检查的修复。 + +### 7.2 重复、低熵和幻觉模板 + +[DataTrove](https://github.com/huggingface/datatrove) 是大规模文本过滤/去重框架,已有行重复率、长行比例、 +标点比例、语言分数和 contamination 等统计。它的默认任务是筛掉低质量训练语料,而本项目需要定位并修复 +文档中的局部区域。 + +建议借鉴指标,不把 DataTrove 作为核心依赖: + +- 最大行长与结构白名单; +- 字符/短片段 run-length; +- 唯一字符、token 和 n-gram 比例; +- 压缩率与局部信息熵; +- 相邻和非相邻重复块; +- 文档主要语言与局部语言突变; +- 已知转换器/模型幻觉签名。 + +detector 只产生 issue 和范围。没有可信来源时,默认隔离或人工确认,不自动编写替代内容。 + +### 7.3 Presidio + +[Presidio](https://microsoft.github.io/presidio/) 支持文本、图片和结构化数据中的 PII 检测与匿名化,并允许使用 +正则、校验和、上下文、NER 和自定义 recognizer。官方也明确说明自动检测不能保证找到全部敏感信息。 + +适合: + +- 作为可选 privacy adapter; +- 为中国身份证、统一社会信用代码、手机号、银行账号等实现校验和与上下文 recognizer; +- 用 custom operator 实现稳定、按实体区分的伪名; +- 把文本、表格单元格和图片脱敏放进同一 profile。 + +不适合: + +- 默认把所有候选直接覆盖; +- 把不同值统一变成同一 `[PHONE]`/`[ID]`; +- 认为通用 NER 已覆盖中文政务/合同实体; +- 把 privacy profile 与 fidelity 修复写死在一起。 + +## 8. 推荐的领域无关架构 + +下面是候选架构,不是已批准契约: + +```mermaid +flowchart LR + A[Markdown / HTML / PDF / DOCX / Image] --> B[Immutable source artifact] + B --> C[Preflight detectors] + B --> D[Parser / source adapters] + C --> E[Issue ledger] + D --> F[Document IR] + F --> G[Validation and routing] + E --> G + G --> H[Safe deterministic repair] + G --> I[Source-verified repair] + G --> J[Quarantine / human review] + H --> K[Canonical fidelity document] + I --> K + J --> K + K --> L[Fidelity profile] + K --> M[Retrieval profile] + K --> N[Compare profile] + K --> O[Public/privacy profile] + L --> P[Markdown + assets + audit] + M --> P + N --> P + O --> P +``` + +### 8.1 Immutable source artifact + +任何输入先冻结:原始 bytes、SHA-256、媒体类型、来源 ID 和接收时间。后续全部是新产物,不原地覆盖。 +只有路径而没有内容哈希不足以复现。 + +### 8.2 Document IR + +IR 至少需要表达: + +- block/node 类型、层级和子节点; +- 原始 byte/line/column span; +- 有源文档时的 page、bbox、source artifact hash; +- 文本、表格网格、图片 asset ref 和文档边界; +- parser/extractor、版本和置信度; +- issue、annotation、repair 和 unresolved 关联。 + +不要让 Markdown AST 直接承担全部职责:mdast 或 markdown-it token 不包含完整的 PDF provenance、表格网格、 +修复证据和多 profile 状态。也不要直接把 DoclingDocument 定为公共契约,否则核心会被某个提取器绑定。 + +### 8.3 Detector 与 transformer 分离 + +每条规则先检测,再决定是否变换。建议规则声明: + +- `rule_id` 与版本; +- 支持的 node/input 类型; +- source span 与证据; +- safety level; +- 是否确定性、幂等、可逆; +- 影响文本、数字、实体、结构、资产或下游权重; +- 验收 predicate。 + +安全级别建议: + +| 级别 | 含义 | 默认行为 | +|---|---|---| +| `detect_only` | 只能确认异常,不能确认正确内容 | 记录 issue,不修改 | +| `deterministic` | 不改变语义、前置条件严格 | 可自动执行并记录 | +| `source_verified` | 新内容可回链到可信源区域 | 自动或抽检执行 | +| `heuristic` | 有合理推断但可能误伤 | profile 显式开启或人工确认 | +| `forbidden_without_source` | 金额、编号、缺失正文等无法猜测 | 隔离/未解决 | + +### 8.4 Canonical fidelity 与 profiles + +核心不应硬编码只有 `clean_fidelity.md` 和 `clean_compare.md`。更通用的方式是先生成 canonical fidelity +document,再由 profile 派生: + +| Profile | 目标 | 典型变化 | +|---|---|---| +| `fidelity` | 法律/业务忠实与可回源 | 只含确定性和源验证修复 | +| `retrieval` | 搜索、RAG 和可读分块 | 规范段落、结构化 chunk、保留来源 | +| `compare` | 相似度与模板分析 | 页眉页脚降噪、稳定图片 token、模板标注/降权 | +| `public` | 可共享样例或外部处理 | 确定性伪名、图片/二维码脱敏、严格日志脱敏 | + +未来项目可以增加自己的 profile;GovDoc 的章节模式、投标模板、compare 权重和中国政务字段放在插件/配置包, +不能污染领域无关 core。 + +## 9. 建议的第一版产品边界 + +第一版不要一开始就做“全自动修复所有 Markdown”。建议逐层交付。 + +### M0:只读 audit + +- 接受 Markdown; +- 冻结哈希并解析 CommonMark/GFM/raw HTML 边界; +- 输出 issue、source span、统计和阻断等级; +- 覆盖编码、超长行、重复退化、链接/图片、标题、HTML/GFM 表格和 PII 候选; +- 不改输入,不需要 PDF 模型。 + +这一阶段可以最早验证规则召回、误报、性能和审计 schema,不把修复风险混进来。 + +### M1:安全规范化 + +- 只执行严格确定性的 LF、NFC、尾空白、空标题等修复; +- 每项变更有 source span 和 before/after hash; +- 输出 fidelity Markdown、audit 和 unresolved; +- 强制幂等、确定性和原输入不覆盖。 + +### M2:结构与回源 + +- raw HTML fragment 恢复和 table grid; +- Docling source adapter; +- 图片 asset store 与 manifest; +- 页/区域级 re-extract; +- 标题、列表、段落只在来源或高置信结构证据下恢复。 + +### M3:多用途 profile + +- `retrieval`、`compare`、`public`; +- privacy adapter; +- 模板标注/权重和稳定实体/图片 token; +- 各 profile 的差异可回链到 fidelity。 + +### M4:插件与规模化 + +- 稳定 rule/adapter/profile API; +- 项目专属配置包; +- 并行批处理、缓存、可恢复任务和机器可读报告; +- 再评估 CLI、Python SDK、服务接口和跨语言消费。 + +## 10. 选型 spike 与验收建议 + +进入实现前建议建立下一份 design,并批准两个小型 spike。 + +### Spike A:Markdown/HTML 核心 + +使用脱敏合成 fixture 和审计列出的结构模式,比较: + +- `markdown-it-py` + 自有 IR/renderer; +- remark/mdast 作为对照; +- html5lib 与 lxml recover 对损坏 table fragment 的差异。 + +至少覆盖:未闭合 table、GFM 多/少列、raw HTML 与 Markdown 交错、代码围栏内伪标签、超长行、中文硬换行、 +图片 URL、Word `_Toc`、标题断裂和多余转义。 + +验收关注:source span 完整率、round-trip 语义一致、没有静默 cell 丢失、幂等性、峰值内存和每 MiB 耗时。 + +### Spike B:回源提取 + +只在受控环境抽取 001、003 的风险分层页面,对 Docling、MinerU、Marker 做 A/B: + +- 原文字符和关键实体准确率; +- 表格网格、合并单元格和阅读顺序; +- 图片数量、bbox 和 asset 引用; +- 已知幻觉与重复退化命中; +- CPU/GPU、耗时、峰值内存、模型版本和许可证约束。 + +不能只比较“生成的 Markdown 肉眼是否整齐”,也不能把某个引擎自己的置信度当作金标。 + +### 回归体系 + +- 公开/合成 fixture 进入 Git,复现结构问题但不包含客户原文; +- 真实样本只在外部受控目录运行,以 case/file/page ID 和聚合指标报告; +- P0 页面 100% 人工核对,其他页面风险分层抽样; +- 金额、日期、项目编号、公司名、身份证候选做前后对账; +- 每个 transformer 测幂等、确定性、边界和反例; +- fidelity、retrieval、compare、public 分别验收,不能用单一“清洗率”。 + +## 11. 明确不建议的路线 + +- 不恢复旧版逐行正则清洗器作为默认基线; +- 不对原文件直接运行 Prettier/mdformat 并覆盖; +- 不用正则解析或补齐 HTML 表格; +- 不把 parser 无报错当成 Markdown 正确; +- 不对全文执行 `clean_extra_whitespace`、`clean_dashes`、全局 NFKC 或非 ASCII 删除; +- 不因重复就删除合同条款,不因语言突变就自动删除段落; +- 不用生成模型补写缺失文字、表格单元格或图片说明; +- 不让不同实体、图片和缺失区域坍缩成同一个通用 token; +- 不把某个 PDF 提取器的 Markdown 直接当权威真值; +- 不把 GovDoc 的投标/采购规则写进通用 core。 + +## 12. 下一份 design 需要决定的事项 + +1. 是否批准“Python core + adapter/profile”方向; +2. 第一阶段是否只做 Markdown audit,暂不引入 PDF/OCR 重依赖; +3. 内部 IR 的最小字段和版本策略; +4. audit/unresolved 的事件粒度与敏感信息保存边界; +5. `fidelity`、`retrieval`、`compare`、`public` 哪些进入第一版; +6. Markdown dialect 是 CommonMark + GFM,还是还要支持 frontmatter、math、directives; +7. Docling/MinerU/Marker 的 benchmark 范围和许可证审查责任; +8. 真实数据输出目录、保留周期、人工审核和脱敏规则; +9. Python SDK、CLI、配置文件和插件 API 哪些属于首个可交付范围。 + +在这些事项获得批准前,本报告只代表调研判断,不代表依赖、schema 或产品行为已经确定。 + +## 13. 主要资料来源 + +本次优先使用官方文档、规范和上游仓库,GitHub 活跃度与许可证检查日期为 2026-08-20: + +- [CommonMark 规范](https://spec.commonmark.org/0.31.2/) +- [GitHub Flavored Markdown 规范](https://github.github.io/gfm/) +- [markdown-it-py 文档](https://markdown-it-py.readthedocs.io/en/latest/) +- [mdformat 文档](https://mdformat.readthedocs.io/en/stable/) +- [remark](https://github.com/remarkjs/remark) 与 [mdast](https://github.com/syntax-tree/mdast) +- [Pandoc filters](https://pandoc.org/filters.html) +- [Docling](https://docling.org/) 与 [DoclingDocument](https://github.com/docling-project/docling/blob/main/docs/concepts/docling_document.md) +- [Unstructured partition/cleaning](https://docs.unstructured.io/open-source/core-functionality/partitioning) +- [html5lib](https://html5lib.readthedocs.io/en/stable/) +- [ftfy](https://ftfy.readthedocs.io/en/latest/) +- [Presidio](https://microsoft.github.io/presidio/) +- [DataTrove](https://github.com/huggingface/datatrove) +- [MinerU](https://github.com/opendatalab/MinerU) +- [Marker](https://github.com/datalab-to/marker) +- [MarkItDown](https://github.com/microsoft/markitdown) diff --git a/rules/default.yaml b/rules/default.yaml deleted file mode 100644 index 8d7b760..0000000 --- a/rules/default.yaml +++ /dev/null @@ -1,55 +0,0 @@ -# 政务文档 Markdown 默认清洗规则集 -# 每条规则: name(必填,须在引擎 REGISTRY 中注册) / order(应用顺序) / enabled / params -# 针对 PDF→Markdown 管线(MinerU/OCR)产物的典型脏数据。 - -rules: - # 1) 页码行:"第 X 页 共 Y 页" / "第X页共4页" / "Page 3 of 10" / "3 / 10" - - name: strip_page_lines - order: 10 - params: - keep_bare_numbers: true # 单独一行纯数字(可能是页码也可能是编号),默认保留 - - # 2) 页眉页脚 + OCR 重复崩坏行: - # a) 同一短行全篇重复 >= threshold 次(如逐页出现的项目名、"正本") - # b) 同一短行连续刷屏 >= burst_limit 次(OCR 崩坏,如"审计程序"×1359) - # protect 列出"重复但属于正文模板"的保护正则(标书里逐章出现的签章/日期栏) - # 内容形态行(编号条款/列表/表格行)天然重复,已在引擎侧豁免 - - name: strip_repeated_short_lines - order: 20 - params: - threshold: 3 - max_len: 40 - burst_limit: 5 - burst_gap: 2 - protect: - - "公章" # 投标人:(公章) - - "签名|签字|盖章" # 法定代表人签名: - - "日期|年.*月.*日" # 日期: / 日期: 年 月 日 - - "^致[::]" # 致:xxx(投标函收件人) - - "负责|声明" # 声明函固定结尾句(中小企业声明函等) - - # 3) 目录点线:"第一章 投标邀请函 ……………… 2"(保留标题文字,去掉点线和页码) - - name: strip_toc_dots - order: 30 - - # 4) 图片引用:![](images/xxx.jpg) 为死链,整行删除 - - name: drop_images - order: 40 - params: - placeholder: null # 需要保留位置时改为 "[图]" 之类 - - # 5) HTML 表格 → Markdown 管道表格(
单行压缩形态) - - name: normalize_tables - order: 50 - - # 6) 散落的
标签 - - name: strip_stray_html - order: 60 - - # 7) 行尾空白(MinerU 输出每行带双空格硬换行符) - - name: rstrip_lines - order: 70 - - # 8) 连续空行压为 1 行,裁掉文首文末空白 - - name: collapse_blank_lines - order: 80 diff --git a/tests/test_rules.py b/tests/test_rules.py deleted file mode 100644 index 49d5b17..0000000 --- a/tests/test_rules.py +++ /dev/null @@ -1,154 +0,0 @@ -"""清洗规则单元测试。 - -fixtures 里是各脏数据模式的最小样例,跑一遍断言规则命中且正文无损。 -""" - -import sys -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from cleaner.cleaner import MarkdownCleaner -from cleaner.rules import ( - _drop_images, - _normalize_tables, - _rstrip_lines, - _strip_page_lines, - _strip_repeated_short_lines, - _strip_toc_dots, - load_rules, -) - -S = {} # 每个用例独立 stats - - -def st(): - return {} - - -class TestPageLines(unittest.TestCase): - def test_cn_page(self): - text = "正文A\n第 3 页 共 53 页\n正文B\n第4页共4页\n结尾" - out = _strip_page_lines(text, {}, st()) - self.assertEqual(out, "正文A\n正文B\n结尾") - - def test_en_page(self): - text = "foo\nPage 3 of 10\nbar\n- 4 -\nbaz" - out = _strip_page_lines(text, {"keep_bare_numbers": False}, st()) - self.assertNotIn("Page 3", out) - self.assertNotIn("- 4 -", out) - - def test_bare_number_kept_by_default(self): - text = "条款\n12\n下文" - out = _strip_page_lines(text, {}, st()) - self.assertIn("12", out) - - -class TestRepeatedLines(unittest.TestCase): - def test_header_removed(self): - lines = ["某某采购项目招标文件"] + ["内容%d" % i for i in range(5)] - text = "\n".join(("某某采购项目招标文件 \n" + l) for l in lines) - out = _strip_repeated_short_lines(text, {"threshold": 3, "max_len": 40}, st()) - self.assertNotIn("某某采购项目招标文件", out) - self.assertIn("内容1", out) - - def test_protected_signature_kept(self): - text = "\n".join(["投标人:(公章)"] * 4 + ["正文"]) - out = _strip_repeated_short_lines( - text, {"threshold": 3, "max_len": 40, "protect": ["公章"]}, st() - ) - self.assertIn("投标人:(公章)", out) - - def test_numbered_clause_kept(self): - # 编号条款是正文不是页眉 - text = "\n".join(["(1)乙方须接受甲方监督。"] * 4 + ["正文"]) - out = _strip_repeated_short_lines(text, {"threshold": 3, "max_len": 40}, st()) - self.assertIn("(1)乙方须接受甲方监督。", out) - - def test_ocr_burst_removed(self): - text = "\n".join(["审计程序"] * 30) - out = _strip_repeated_short_lines( - text, - {"threshold": 999, "max_len": 40, "burst_limit": 5, "burst_gap": 2}, - st(), - ) - self.assertNotIn("审计程序", out) - - -class TestTocDots(unittest.TestCase): - def test_toc_line(self): - text = "第一章 投标邀请函 ……………………………………… 2" - out = _strip_toc_dots(text, {}, st()) - self.assertEqual(out, "第一章 投标邀请函") - - def test_plain_toc_line_keeps_text(self): - # 无标题目录行:去掉点线页码,保留"序号+标题"文字 - text = "21 迷交的投标文件 ………………………………………………… 25" - out = _strip_toc_dots(text, {}, st()) - self.assertEqual(out, "21 迷交的投标文件") - - def test_body_with_ellipsis_kept(self): - text = "此处省略部分内容……后续" - out = _strip_toc_dots(text, {}, st()) - self.assertIn("后续", out) - - -class TestImages(unittest.TestCase): - def test_image_line_dropped(self): - text = "![](images/abc.jpg) \n正文" - out = _drop_images(text, {}, st()) - self.assertEqual(out, "正文") - - def test_placeholder(self): - text = "![](images/abc.jpg)\n正文" - out = _drop_images(text, {"placeholder": "[图]"}, st()) - self.assertIn("[图]", out) - - def test_base64_image_dropped(self): - text = "![名称](data:image/png;base64,AAAA)\n正文" - out = _drop_images(text, {}, st()) - self.assertNotIn("base64", out) - - -class TestTables(unittest.TestCase): - def test_table_to_pipe(self): - html = "
序号名称
1保洁
" - out = _normalize_tables(html, {}, st()) - self.assertIn("| 序号 | 名称 |", out) - self.assertIn("| 1 | 保洁 |", out) - self.assertIn("|---|---|", out) - - def test_pipe_escaped(self): - html = "
a|b
" - out = _normalize_tables(html, {}, st()) - self.assertIn("a\\|b", out) - - def test_br_in_cell(self): - html = "

" - out = _normalize_tables(html, {}, st()) - self.assertIn("品 目", out) - - -class TestEngine(unittest.TestCase): - def test_full_pipeline(self): - cleaner = MarkdownCleaner(rules=load_rules()) - raw = ( - "# 标题\n![](images/x.jpg) \n第 1 页 共 2 页 \n" - "正文一段。 \n
a
\n\n\n\n尾部\n" - ) - result = cleaner.clean_text(raw) - self.assertNotIn("images/", result.text) - self.assertNotIn("第 1 页", result.text) - self.assertNotIn("", result.text) - self.assertIn("| a |", result.text) - self.assertNotIn("\n\n\n", result.text) - - def test_default_rules_load(self): - rules = load_rules() - self.assertTrue(len(rules) >= 8) - self.assertEqual(rules, sorted(rules, key=lambda r: r.order)) - - -if __name__ == "__main__": - unittest.main(verbosity=2)