实现 arXiv 提交边栏戳清洗组件

冻结 0004,新增严格整行删除组件和测试,并记录 5 份论文的只读验证结果。同步 ClinDB 清洗范围,并忽略本地 reference 调研副本。
This commit is contained in:
2026-08-22 15:02:21 +08:00
parent 3edeeaf30e
commit 8c23ac5521
9 changed files with 580 additions and 33 deletions
+3
View File
@@ -24,6 +24,9 @@ experiments/
*.cleaned.md *.cleaned.md
report.json report.json
# Local third-party research checkouts
/reference/
# Editor and operating-system files # Editor and operating-system files
.DS_Store .DS_Store
.idea/ .idea/
+16 -13
View File
@@ -7,8 +7,8 @@
profile 表达论文、政务文档、RAG、文档对比等不同需求。 profile 表达论文、政务文档、RAG、文档对比等不同需求。
仓库当前已从纯文档治理进入第一版核心实现阶段:已经提供可安装的 Python 内存处理包和测试,用于验证 仓库当前已从纯文档治理进入第一版核心实现阶段:已经提供可安装的 Python 内存处理包和测试,用于验证
组件组合、精确修改和审计协议。仓库仍不提供真实清洗规则、命令行工具、文件读写适配器或生产接口, 组件组合、精确修改和审计协议,并已有一个严格整行匹配的论文清洗组件。仓库仍不提供完整规则、命令行工具、
因此目前还不是拿来即可清洗文档的成品工具。 文件读写适配器或生产接口,因此目前还不是拿来即可完成整篇文档清洗的成品工具。
## 当前阶段 ## 当前阶段
@@ -22,7 +22,7 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
- `research-wiki/scratch/markdown-cleaning-ecosystem-research-2026-08-20.md` 完成首轮生态与架构调研。 - `research-wiki/scratch/markdown-cleaning-ecosystem-research-2026-08-20.md` 完成首轮生态与架构调研。
- 2026-08-21 完成师姐项目(ClinDB-ReviewBench5 份论文 Markdown 的问题审计 - 2026-08-21 完成师姐项目(ClinDB-ReviewBench5 份论文 Markdown 的问题审计
`research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md`),并确定其第一版清洗范围 `research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md`),并确定其第一版清洗范围
`research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md`9确定性规则)。 `research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md`8自动清洗候选)。
- 2026-08-21 明确本项目定位为实验室共用库;GovDoc 和论文清洗都是使用场景,不是核心边界。 - 2026-08-21 明确本项目定位为实验室共用库;GovDoc 和论文清洗都是使用场景,不是核心边界。
- 2026-08-21 批准并冻结 `research-wiki/design/0002-composable-cleaning-pipeline.md`,确定只接收 Markdown、 - 2026-08-21 批准并冻结 `research-wiki/design/0002-composable-cleaning-pipeline.md`,确定只接收 Markdown、
项目显式组装组件、单轮修改加最终只读复查的总体组织方式。 项目显式组装组件、单轮修改加最终只读复查的总体组织方式。
@@ -30,6 +30,9 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
Python 内存自动清洗核心:组件只提出确定可执行的精确修改,不同时建设独立检查、人工建议或真实清洗规则。 Python 内存自动清洗核心:组件只提出确定可执行的精确修改,不同时建设独立检查、人工建议或真实清洗规则。
- 2026-08-22 按 `0003` 实现第一版内存核心和测试:包括不可变数据契约、组件基类、原子修改执行器、 - 2026-08-22 按 `0003` 实现第一版内存核心和测试:包括不可变数据契约、组件基类、原子修改执行器、
顺序流水线和最终稳定性复查;58 项测试以及 Ruff、mypy 检查均通过。 顺序流水线和最终稳定性复查;58 项测试以及 Ruff、mypy 检查均通过。
- 2026-08-22 批准并实现 `research-wiki/design/0004-arxiv-submission-stamp-component.md`:新增严格整行匹配的
arXiv 提交边栏戳删除组件;5 份论文只读复核只命中 sim 和 springer 各一处,两处合法参考文献保持不变,
第二次运行零修改,源文件没有变化。
- 2026-08-21 完成 HTML 表格清洗专题调研 - 2026-08-21 完成 HTML 表格清洗专题调研
`research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md`):核实 Pandoc 表格 `research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md`):核实 Pandoc 表格
方言能力边界、Turndown 不处理合并单元格、Docling Markdown 导出重复合并单元格内容、MinerU 全 方言能力边界、Turndown 不处理合并单元格、Docling Markdown 导出重复合并单元格内容、MinerU 全
@@ -38,9 +41,9 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
冻结的 design 记录和带日期的 scratch 笔记保留当时的旧名。 冻结的 design 记录和带日期的 scratch 笔记保留当时的旧名。
当前已有只处理内存字符串的底层执行机制和函数级契约,运行时只依赖 Python 标准库。组件可以针对当前 当前已有只处理内存字符串的底层执行机制和函数级契约,运行时只依赖 Python 标准库。组件可以针对当前
Markdown 快照提出精确修改,流水线负责原子应用、审计记录、失败隔离和最终稳定性复查。仓库尚无任何正式 Markdown 快照提出精确修改,流水线负责原子应用、审计记录、失败隔离和最终稳定性复查。当前唯一正式组件只删除
清洗组件,因此不能把测试专用假组件或核心执行成功理解为已经具备论文、GovDoc、表格或图片清洗能力。 完整匹配的 arXiv 提交边栏戳,不能把这一项能力理解为已经具备完整论文、GovDoc、表格或图片清洗能力。
调研报告中的解析器、内部 IR、具体 profile 和真实清洗规则仍是候选方案,尚未批准。 调研报告中的解析器、内部 IR、具体 profile 和其他真实清洗规则仍是候选方案,尚未批准。
## 服务对象与复用目标 ## 服务对象与复用目标
@@ -87,7 +90,7 @@ mdpolish/
├── CLAUDE.md ├── CLAUDE.md
├── README.md ├── README.md
├── pyproject.toml # Python 包、构建和开发检查的唯一配置 ├── pyproject.toml # Python 包、构建和开发检查的唯一配置
├── src/mdpolish/ # 第一版内存核心;不含真实清洗组件 ├── src/mdpolish/ # 第一版内存核心和已批准的业务组件
├── tests/ # 核心契约和组合行为测试 ├── tests/ # 核心契约和组合行为测试
├── data/ # 本地项目数据;Git 忽略,未来按项目分区 ├── data/ # 本地项目数据;Git 忽略,未来按项目分区
└── research-wiki/ └── research-wiki/
@@ -108,10 +111,10 @@ mdpolish/
3. `research-wiki/README.md`,确认文档应放在哪里; 3. `research-wiki/README.md`,确认文档应放在哪里;
4. 与任务直接相关的 `research-wiki/design/` 记录。 4. 与任务直接相关的 `research-wiki/design/` 记录。
第一版核心的当前机制见 `research-wiki/explanation/first-executable-core.md`。下一项实质工作应从已经审计的问题中 第一版核心的当前机制见 `research-wiki/explanation/first-executable-core.md`,首个真实组件见
选择一个边界明确、能够唯一修复的真实清洗规则,新增 design 说明其语义、适用范围、误改风险和验收样例 `research-wiki/explanation/arxiv-submission-stamp.md`。下一项候选是 ClinDB 范围中的 HTML 实体双重转义
经批准后再实现。解析器、CLI、文件适配器、profile 格式和独立检查能力仍需分别设计,不能从当前核心存在推导为 但必须先用新 design 确定只在哪些 HTML 范围替换、如何避开字面示例以及实体替换边界。解析器、CLI、文件适配器、
已经获批。 profile 格式和独立检查能力仍需分别设计,不能从当前核心或单个组件存在推导为已经获批。
## 当前可用检查 ## 当前可用检查
@@ -135,6 +138,6 @@ find research-wiki -maxdepth 2 -type f | sort
git status --short git status --short
``` ```
上述安装和三项基础验收已于 2026-08-22 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 9 个源码与 上述安装和三项基础验收已于 2026-08-22 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 12 个源码与
测试文件无问题,pytest 共 58 项测试通过。`requires-python` 仍以 `pyproject.toml` 声明的 Python 3.11 及以上为准; 测试文件无问题,pytest 共 87 项测试通过。`requires-python` 仍以 `pyproject.toml` 声明的 Python 3.11 及以上为准;
本次结果不等于已经在每个受支持版本上完成兼容性验证。 本次结果不等于已经在每个受支持版本上完成兼容性验证。
@@ -0,0 +1,219 @@
# 0004:arXiv 提交边栏戳自动清洗组件
## 状态
已批准并冻结(2026-08-22)。
本设计使用 `0003` 已实现的组件、精确修改和流水线契约,不改变核心接口。它只决定第一个真实清洗组件的
识别边界、删除语义、代码位置和验收方式。
## 1. 问题与可观察现象
ClinDB-ReviewBench 的论文转换结果中,有两份 Markdown 保留了 arXiv 提交页的独立边栏戳:
- Statistics in Medicine/arXiv 文档第 1 行;
- Springer/arXiv 文档第 18 行。
这类行只包含 arXiv 编号、分类和提交日期,不是论文正文。现有只读审计同时确认,Springer 文档后部还有两处
合法参考文献包含 `arXiv preprint arXiv:…`。如果只搜索 `arXiv:` 子串并删除整行,会误删参考文献。
第一版内存核心已经能够安全应用精确删除,但测试中只有假组件。现在需要一个范围足够小的真实组件,验证业务规则
能否遵守快照绑定、原子应用、审计记录和幂等约束,而不立即引入 HTML parser、文件适配器或项目 profile。
本规则在 ClinDB 范围中的权威编号为 H1,见
[`CLINDB_REVIEWBENCH_CLEANING_SCOPE.md`](../reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md)。
## 2. 目标与非目标
目标:
- 只删除完整一行的 arXiv 提交边栏戳;
- 保留所有未完整满足目标行模式的 arXiv 参考文献、正文和链接;
- 保留原文已有的换行风格,不顺带整理空行;
- 每个删除位置产生可追踪的候选修改和实际改动记录;
- 使用合成样例验证确定性、反向用例和幂等性;
- 在本地 5 份论文 Markdown 上做只读、纯内存复核。
非目标:
- 不删除一般的 arXiv 引用、论文元数据、封面页或作者信息;
- 不识别旧式 arXiv 编号,也不把本组件扩展为通用参考文献清洗器;
- 不识别或保护围栏代码、行内代码及其他 Markdown 块结构;
- 不提供可配置正则表达式或“删除任意匹配行”的通用组件;
- 不实现独立检查、待审标记、人工建议或问题报告;
- 不读取文件、目录、PDF、图片、环境变量或网络;
- 不建立 profile 格式、CLI、文件输出或审计文件;
- 不实现 HTML 实体、Word 批注、行号、表格等其他 ClinDB 规则。
## 3. 组件身份与代码边界
组件元数据固定为:
| 项目 | 决定 |
| --- | --- |
| Python 类名 | `ArxivSubmissionStampComponent` |
| 组件标识 | `paper.arxiv_submission_stamp` |
| 初始版本 | `1.0.0` |
| 参数 | 空;第一版不允许调用方替换模式或放宽边界 |
| 适用范围 | PDF/arXiv 论文转换产生的独立提交戳行;严格整行匹配;排除所有相似文本 |
新增文件范围:
```text
src/mdpolish/components/
├── __init__.py
└── arxiv_submission_stamp.py
tests/
└── test_arxiv_submission_stamp.py
```
`arxiv_submission_stamp.py` 只依赖 `component.py``models.py`,不导入 `edits.py``pipeline.py`。组件只提出
`ProposedChange`,仍由公共流水线应用。`components/__init__.py` 导出该组件,但顶层 `mdpolish/__init__.py`
不新增快捷导出,避免把业务组件和核心契约混在同一命名空间。
本轮不新增运行依赖,也不创建共享 Markdown parser 或通用行匹配框架。以后第二个组件出现重复定位需求时,
再用实际重复代码判断是否需要抽取辅助模块。
## 4. 什么算目标行
组件按 Markdown 的物理行扫描。去掉行尾的 `\n``\r\n` 或单独 `\r` 后,整行必须匹配以下语义:
```text
arXiv:<四位年份>.<数字编号>v<数字版本> [<ASCII 分类>] <1 至 31 的日> <英文月份缩写> <四位年份>
```
对应第一版正则表达式:
```regex
^arXiv:[0-9]{4}\.[0-9]+v[0-9]+ \[[A-Za-z0-9_.-]+\] (?:[1-9]|[12][0-9]|3[01]) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4}$
```
这里有意使用 ASCII 字符范围和月份白名单,不使用 Python 默认的 Unicode `\w`。目标是识别已知提交戳格式,
不是验证 arXiv 元数据的真实性,也不校验日期是否真实存在。例如 `31 Feb` 仍满足形状规则;组件不访问外部日历或
arXiv 服务。
以下差异均不匹配:
- 行首或行尾存在空格;
- 前面有列表、引用或标题标记;
- 缺少版本、分类或日期;
- 使用旧式编号;
- `arXiv:` 出现在句子、链接或参考文献中;
- 月份不是表中 12 个英文缩写之一。
严格拒绝相似文本的代价是可能漏掉格式稍有变化的边栏戳。第一版接受漏删,不通过自动 `strip()`、大小写忽略或
宽松日期模式提高命中率。以后真实样本出现新格式时,应补证据、更新组件版本和测试,而不是悄悄放宽模式。
第一版不解析围栏代码、HTML 注释、YAML front matter 或其他 Markdown 块。只要某个物理行完整满足上述模式,
无论它处于什么 Markdown 结构中都会命中。当前范围不要求保护这些结构;以后出现需要保留的真实反例时,应重新
评审识别边界,不能在实现中临时增加例外。
## 5. 精确删除与换行语义
每个命中行产生一个 `ProposedChange`,其中只有一个删除型 `TextEdit`
- 范围从该行第一个字符开始;
- 如果该行带 `\n``\r\n``\r`,范围同时包含它自己的行尾;
- 如果末行没有行尾,只删除该行文字;
- `expected_text` 是范围内的完整原文;
- `replacement` 是空字符串;
- 修改理由固定为“删除完整匹配的 arXiv 提交边栏戳”。
该规则带来以下可预测结果:
| 输入形态 | 删除后的边界 |
| --- | --- |
| `stamp\n正文` | `正文` |
| `正文\nstamp\n后文` | `正文\n后文` |
| `正文\r\nstamp\r\n后文` | `正文\r\n后文` |
| `正文\nstamp` | `正文\n` |
| 只有 `stamp` | 空字符串 |
末行没有行尾时保留前一行已有的行尾。它是原文的一部分,不是多余空白;这样也能保证相邻多个命中行的删除范围
互不重叠。组件不合并前后空行,不统一换行符,也不改变未命中的任何字符。
多个候选按原文位置从前到后返回。公共执行器仍把当前组件的全部候选作为一个原子批次:任何候选过期、冲突或
原文不符时,本次组件修改全部不应用。
## 6. 审计、确定性与幂等性
组件不生成随机 ID,不读取外部状态。相同 Markdown、组件版本和空参数必须产生相同顺序、相同范围、相同理由的
候选修改。
每个命中行单独成为一个候选修改,便于审计记录把一条删除对应到一个原始物理行。流水线分配确定性候选引用,
实际 `Change` 继续记录组件身份、理由、原文、空替换和批次前后哈希。
成功删除后,目标行已经不存在;同一组件在最终复查和再次运行时均不得产生新修改。这里的幂等性同时通过组件
测试和单组件 `Pipeline` 测试验证,不增加组件自己的 `transform()` 快捷入口。
## 7. 方案比较
### 7.1 全局删除含 `arXiv:` 的行
实现最短,但会删除合法参考文献和正文,已有反向样本已经证明不可接受。不采用。
### 7.2 允许项目传入正则表达式
看似通用,实际把误删边界交给每个调用方,并使相同组件版本可以表现出完全不同的语义。第一版也没有配置或
profile 契约。不采用。
### 7.3 严格整行匹配,不识别 Markdown 块结构
修法唯一、定位精确,只使用标准库即可实现,已知参考文献不会命中。代价是围栏代码或其他 Markdown 块内如果恰好
出现完整目标行也会被删除;当前范围没有保护这些结构的需求,因此不为假设场景增加扫描逻辑。采用。
### 7.4 先引入 Markdown parser
完整 parser 可以更准确识别代码、HTML 和其他块,但为删除两个格式固定的物理行引入运行依赖和方言选择,代价
明显超过收益。不采用;复杂表格组件另行设计 parser。
## 8. 测试与验收
合成测试至少覆盖:
- 空 Markdown 和完全不含目标的 Markdown 返回零修改 `success`
- 目标行位于首行、中间、带行尾的末行和不带行尾的末行;
- `\n``\r\n` 和单独 `\r` 三种行尾保持原有风格;
- 同一文档含多个目标行,候选和 `Change` 按原文顺序记录且批次原子应用;
- 合法参考文献 `arXiv preprint arXiv:…` 保留;
- 行首/行尾空格、缺字段、旧式编号、错误月份和其他相似行保留;
- 审计记录包含固定组件标识、版本、理由、删除原文和批次哈希;
- 单组件 `Pipeline` 成功后最终复查无残留候选;
- 对成功输出再次运行,内容不变且没有实际 `Change`
- 相同输入重复运行得到相同有序结果。
基础检查继续使用根目录 README 的唯一命令,并要求 Ruff、mypy、pytest 全部通过。
实现完成后,对本地 5 份论文 Markdown 做一次只读、纯内存验证:
1. 不复制、不改名、不写回任何真实文档;
2. 不把原文片段加入测试、日志或提交;
3. 预期只命中 sim 第 1 行和 springer 第 18 行,共两处;
4. 复核 springer 两处合法 arXiv 参考文献保持原样;
5. 复核除两条完整目标行外没有其他 diff;
6. 只记录输入文件范围、组件版本、命中数量、未命中反例数量和验证结论。
如果真实验证结果与上述计数不一致,停止实施收尾,回到 design 或 reference 核对原因,不能放宽测试来适配结果。
## 9. 风险与代价
- **严格模式会漏删变体:** 这是有意选择;没有证据的新格式保持原样。
- **整行相同的正文仍可能误删:** 严格的完整格式降低风险,但无法证明未来正文不会独立引用同一字符串;
因此组件定位为论文转换规则,不进入尚不存在的全局默认 profile。
- **不保护围栏代码或其他 Markdown 块:** 其中如果出现完整目标行也会命中;当前范围接受这一代价,出现真实反例后
再重新评审,不在本组件内预建通用保护机制。
- **真实数据验证不进入自动测试:** 避免提交客户或项目材料;合成测试负责稳定契约,真实材料只做本地只读复核。
- **第一条真实规则覆盖面很小:** 它优先验证扩展和审计闭环,不追求清洗率。下一候选是 HTML 实体双重转义,
仍需单独 design 确定 HTML 范围和实体替换边界。
## 10. 批准后的实施边界
批准本设计只授权:
1. 创建第 3 节列出的组件和测试文件;
2. 按第 4 至 6 节实现无外部依赖的精确删除组件;
3. 运行第 8 节合成测试和本地 5 份论文的只读、纯内存验证;
4. 根据真实实现更新 README 当前阶段和 `explanation/` 当前机制。
批准不授权实现其他清洗规则、共享 parser、profile、文件读写、CLI、独立检查或人工建议;不授权保存真实清洗
输出、修改真实数据、提交、推送或发布。
@@ -0,0 +1,76 @@
# arXiv 提交边栏戳为什么能自动删除
## 1. 可观察的问题
部分 arXiv 论文转换为 Markdown 后,会把提交页边栏中的编号、分类和日期留下来,形成一整行独立文字。它不是
论文正文,却会进入后续分块、检索和对比。与此同时,论文参考文献也可能包含 `arXiv:`;只要见到这个子串就删行,
会损坏合法引用。
当前组件只处理前一种格式固定的独立行。它的决策来自已批准的
[`0004-arxiv-submission-stamp-component.md`](../design/0004-arxiv-submission-stamp-component.md),项目范围编号为 H1。
精确模式、类名和返回对象以
[`arxiv_submission_stamp.py`](../../src/mdpolish/components/arxiv_submission_stamp.py) 及其
[`测试`](../../tests/test_arxiv_submission_stamp.py) 为准。
## 2. 当前识别边界
组件逐个读取物理行,只在整行同时具有以下结构时提出删除:
```text
arXiv:<新版数字编号和版本> [<ASCII 分类>] <日> <英文月份缩写> <四位年份>
```
首尾空格、列表或引用前缀、缺少版本、旧式编号、错误月份以及句子中的 `arXiv:` 都不会命中。组件没有参数,
调用方不能传入更宽松的正则表达式改变同一版本的语义。
当前版本有意不解析 Markdown 块结构。围栏代码、HTML 注释或其他块中如果存在一行完整目标文字,同样会被删除。
这是 `0004` 明确接受的代价,不是实现遗漏。以后出现必须保留的真实反例时,需要重新评审识别边界并更新组件版本。
## 3. 删除如何保持原文边界
每个命中行产生一个候选修改和一个删除型文本编辑。删除范围包含该行自己的 `\n``\r\n` 或单独 `\r`
没有行尾的末行只删除文字,不拿走前一行已有的行尾。
| 输入位置 | 当前行为 |
| --- | --- |
| 首行且有行尾 | 连同行尾删除,后续正文成为首行 |
| 文档中间 | 连同目标行自己的行尾删除,前后内容保持两行 |
| 末行且没有行尾 | 只删除目标文字,保留前一行原有行尾 |
| 多个目标行 | 每行一个候选,按原文顺序记录,作为一个组件批次原子应用 |
组件不整理空行、不统一换行符,也不改变未命中的字符。候选修改绑定当前快照哈希和准确原文,仍由公共修改执行器
验证和应用;组件本身没有文件读写或独立 `transform()`
## 4. 审计与稳定性
组件标识为 `paper.arxiv_submission_stamp`,版本为 `1.0.0`,参数为空。每条实际删除记录固定理由,并保留删除原文、
原始范围、组件位置以及批次修改前后的哈希。
删除完成后目标行已经不存在。流水线最终复查不应再得到候选修改;把成功输出再次交给同一组件,也应保持原文不变
且产生零条实际改动。
## 5. 已完成验证
合成测试覆盖严格匹配、反向引用、首行/中间/末行、三种行尾、多个命中、围栏中仍删除、审计字段、确定性和
第二次运行零修改。测试只使用短小的虚构字符串,不含真实论文片段。
2026-08-22 又对本地 5 份 ClinDB-ReviewBench Markdown 做了只读、纯内存复核:
| 复核项 | 结果 |
| --- | --- |
| 输入范围 | dmp、jama、ejhf、sim、springer 各 1 份,共 5 份 |
| 实际删除 | sim 1 行、springer 1 行,其余 0 行,共 2 行 |
| 合法反向样例 | springer 的 2 处 `arXiv preprint arXiv:` 修改前后均保留 |
| 第二次运行 | 5 份合计 0 条修改 |
| 源文件复读 | 5/5 与处理前内存内容一致,没有回写 |
本次没有保存清洗后 Markdown,没有把真实原文复制进测试、日志或仓库。安装、静态检查和完整测试命令仍只在根目录
[`README.md`](../../README.md#当前可用检查) 维护。
## 6. 剩余边界
这个组件只证明第一条严格删除规则能够在公共核心上闭环,不表示论文已经清洗完成。HTML 实体、Word 批注、手稿
行号、断词、表格和参考文献间距仍未实现;文件输出、profile 和批处理也不存在。
如果出现新的提交戳格式,默认行为是保留。必须先补充真实证据、反向样例和 design,再决定是否放宽模式,不能为了
提高命中数量直接修改正则表达式。
@@ -6,7 +6,8 @@
旧位置还可能在文本变化后误中另一段内容;同一批修改发生重叠时,按不同顺序执行也可能得到不同结果。 旧位置还可能在文本变化后误中另一段内容;同一批修改发生重叠时,按不同顺序执行也可能得到不同结果。
当前核心把“判断应该改什么”和“安全地执行修改”分开:组件只描述绑定当前文本的精确修改,公共执行器统一 当前核心把“判断应该改什么”和“安全地执行修改”分开:组件只描述绑定当前文本的精确修改,公共执行器统一
验证并应用。这样可以在不引入真实清洗规则、文件读写或 Markdown parser 的情况下,先让组合与审计协议可运行 验证并应用。核心建立时先不引入真实清洗规则、文件读写或 Markdown parser让组合与审计协议独立可运行
现在第一个真实组件已经在这套协议上完成验证,没有改变核心接口。
已经实现的范围来自已批准的 已经实现的范围来自已批准的
[`0003-first-executable-core-architecture.md`](../design/0003-first-executable-core-architecture.md)。精确类名、字段和 [`0003-first-executable-core-architecture.md`](../design/0003-first-executable-core-architecture.md)。精确类名、字段和
@@ -132,15 +133,17 @@
## 8. 当前验证和剩余边界 ## 8. 当前验证和剩余边界
核心测试使用短小的假组件,不包含或复制真实文档。测试已经覆盖空文本、中文和组合 Unicode、插入/删除/替换、 核心测试继续使用短小的假组件,不包含或复制真实文档。它们覆盖空文本、中文和组合 Unicode、插入/删除/替换、
范围冲突、过期哈希、批次原子性、组件连锁影响、错误阶段、审计关联和成功结果再次运行等行为。 范围冲突、过期哈希、批次原子性、组件连锁影响、错误阶段、审计关联和成功结果再次运行等行为。首个真实组件另用
合成样例测试,并在本地真实材料上只读复核;机制与结果见
[`arxiv-submission-stamp.md`](arxiv-submission-stamp.md)。
实际可用的安装与验收命令、最近一次验证日期和结果只在根目录 实际可用的安装与验收命令、最近一次验证日期和结果只在根目录
[`README.md`](../../README.md#当前可用检查) 维护。 [`README.md`](../../README.md#当前可用检查) 维护。
当前仍然没有: 当前仍然没有:
- 论文、GovDocHTML 表格或其他真实清洗组件; - 除严格删除 arXiv 提交边栏戳外的其他论文、GovDocHTML 表格清洗组件;
- 独立文档检查、人工建议或审核流程; - 独立文档检查、人工建议或审核流程;
- Markdown parser、AST 或共享业务中间表示; - Markdown parser、AST 或共享业务中间表示;
- 文件读写、CLI、批处理、项目 profile 格式和生产集成; - 文件读写、CLI、批处理、项目 profile 格式和生产集成;
@@ -1,8 +1,8 @@
# ClinDB-ReviewBench 清洗目标(第一版) # ClinDB-ReviewBench 清洗目标(第一版)
> 性质:reference——本项目清洗范围的权威查询事实。 > 性质:reference——本项目清洗范围的权威查询事实。
> 权威关系:本文只定义 ClinDB-ReviewBench 第一"洗什么、不洗什么";清洗语义的方案比较与批准记录 > 权威关系:本文只定义 ClinDB-ReviewBench 第一批自动清洗"洗什么、不洗什么";清洗语义的方案比较与批准记录
> 属于 `research-wiki/design/`(尚未建立),实现后的运行方式属于 `explanation/` 与 `guides/`。 > 属于 `research-wiki/design/`,实现后的运行方式属于 `explanation/` 与 `guides/`。未来只读检查不属于当前批次。
> 依据:`research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md`(问题编号 AH 沿用该审计)。 > 依据:`research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md`(问题编号 AH 沿用该审计)。
## 1. 项目定位 ## 1. 项目定位
@@ -16,22 +16,22 @@ ClinDB-ReviewBench 是师姐的论文清洗项目。`data/` 下当前 5 份 DOI
## 2. 第一版清洗目标 ## 2. 第一版清洗目标
第一版只做"全自动、规则确定、可安全执行"的问题(审计第一档,共 9 类)。 第一版只做"全自动、规则确定、可安全执行"的问题(审计第一档中的 8 类自动修改)。
判定标准是三条同时满足:模式可用确定规则描述;不依赖对正文语义的理解;改错可以从 diff 直接看出。 判定标准是三条同时满足:模式可用确定规则描述;不依赖对正文语义的理解;改错可以从 diff 直接看出。
| # | 问题(审计编号) | 规则要点 | 触发范围(本轮实测) | | # | 问题(审计编号) | 规则要点 | 触发范围(本轮实测) |
|---|---|---|---| |---|---|---|---|
| 1 | HTML 实体双重转义(D2 | `&amp;gt;``>``&amp;lt;``<``&amp;amp;``&`,还原一层;幂等 | dmp L27/L33 共 23 处、ejhf L143 共 8 处,全部在 `<table>` 行内 | | 1 | HTML 实体双重转义(D2 | `&amp;gt;``>``&amp;lt;``<``&amp;amp;``&`,还原一层;幂等 | dmp L27/L33 共 23 处、ejhf L143 共 8 处,全部在 `<table>` 行内 |
| 2 | arXiv 边栏戳(H1 | 整行匹配 `^arXiv:\d{4}\.\d+v\d+ \[[\w.-]+\] \d{1,2} \w{3} \d{4}$` 才删除;编号条目内的 "arXiv preprint arXiv:…" 不在行首、不受影响 | sim L1、springer L18springer L143/L152 是合法参考文献,必须不误删 | | 2 | arXiv 边栏戳(H1 | 只有整行满足 `design/0004-arxiv-submission-stamp-component.md` 第 4 节的严格格式才删除;编号条目内的 "arXiv preprint arXiv:…" 不受影响 | sim L1、springer L18springer L143/L152 是合法参考文献,必须不误删 |
| 3 | Word 审阅批注(B2 | 以 `Commented [xx]:` 开头的行及其批注正文整块删除 | jama L155157 共 2 处 | | 3 | Word 审阅批注(B2 | 以 `Commented [xx]:` 开头的行及其批注正文整块删除 | jama L155157 共 2 处 |
| 4 | 手稿行号(B1) | 仅剥离**单调递增序列**的行首 `^\d{1,3} ` 与标题内 `^#{1,6} \d{1,3} `;序列中断即停并标记待审,防止误伤正文数字(如 "35 pediatric experts" | jama 128 行正文 + 6 个标题(L127/149/151/165/193/195 | | 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 句子被切断 | | 5 | 跑动页眉(C4) | 同一文本行原样重复 ≥2 次(且非正文引用对象)判为页眉,删除并把被切断的上下文段落接回 | dmp L73/L191"MSOFA Score for Critical Care Triage"),L71→L75 句子被切断 |
| 6 | 单行 HTML 表格展开(D1 | 无 rowspan/colspan 的表转多行 GFM;含合并属性的表保留 HTML 但按 `<tr>` 换行缩进;内容一字不改 | 全部 9 个表:dmp 7、ejhf 1、springer 1 | | 6 | 单行 HTML 表格展开(D1 | 无 rowspan/colspan 的表转多行 GFM;含合并属性的表保留 HTML 但按 `<tr>` 换行缩进;内容一字不改 | 全部 9 个表:dmp 7、ejhf 1、springer 1 |
| 7 | 跨页断词(E2) | 行尾连字符 + 下一行首小写字母 → 合并;仅在拼出的词能通过英文词表校验时执行,否则保留原样并标记 | dmp L125/127thresh-olds)、sim L87/89possi-bly)、L217/219cre-ated | | 7 | 跨页断词(E2) | 行尾连字符 + 下一行首小写字母 → 合并;仅在拼出的词能通过英文词表校验时执行,否则保留原样且不提出修改 | dmp L125/127thresh-olds)、sim L87/89possi-bly)、L217/219cre-ated |
| 8 | 参考文献分隔统一(G3) | `^\d+\. ` 条目之间统一一个空行 | dmp refs 1933、springer refs 919(连续堆叠段) | | 8 | 参考文献分隔统一(G3) | `^\d+\. ` 条目之间统一一个空行 | dmp refs 1933、springer refs 919(连续堆叠段) |
| 9 | 图片断链校验(H3,仅校验) | 检查 `../images/*.jpg` 引用的文件是否存在,输出断链报告;不移动、不复制、不改写任何图片 | 全部 8 处引用(dmp 1、ejhf 1、sim 2、springer 4 |
第 9 项是只读校验:它修复不了任何东西,输出的是"哪些文档的图片资产已损坏"清单。 本表只包含当前自动清洗核心能够承载的修改。原审计中的图片断链校验修复不了 Markdown,已移到第 3 节等待
未来独立 Inspector 设计,不计入这 8 类自动清洗目标。
## 3. 明确不洗(第一版非目标) ## 3. 明确不洗(第一版非目标)
@@ -48,18 +48,20 @@ ClinDB-ReviewBench 是师姐的论文清洗项目。`data/` 下当前 5 份 DOI
"自动检测+人工确认",等第一档验证后再立项。 "自动检测+人工确认",等第一档验证后再立项。
- **封面页(H2**:ejhf L1–11 的仓库封面区第一版不删——它是整块连续的正文区,删除逻辑 - **封面页(H2**:ejhf L1–11 的仓库封面区第一版不删——它是整块连续的正文区,删除逻辑
与页眉类噪声不同,归入后续批次。 与页眉类噪声不同,归入后续批次。
- **图片断链校验(H3)**:全部 8 处图片引用是否存在属于未来只读检查;当前核心不读取图片资产、不输出断链
报告,也不移动、复制或改写图片。需要该结果时先新增平行 Inspector 设计。
- **一切内容改写**:原文写作瑕疵、欧式千分位、拼写(含 "Conounder")不属于转换噪声,永不由清洗工具修改。 - **一切内容改写**:原文写作瑕疵、欧式千分位、拼写(含 "Conounder")不属于转换噪声,永不由清洗工具修改。
## 4. 输入输出边界 ## 4. 输入输出边界
- 输入:`data/<转换结果目录>/markdowns/*.md`,原文件只读; - 项目输入位于 `data/<转换结果目录>/markdowns/*.md`,原文件只读;
- 输出:清洗结果写入独立输出位置(具体目录待 design 批准后确定),不回写、不覆盖原文件; - 当前核心只接收内存 Markdown 字符串并返回内存结果;文件输出位置和保存流程尚未设计,不回写、不覆盖原文件;
- 图片资产只校验存在性,不复制进仓库、不修改路径(路径改写是后续批次的独立决策) - 当前批次不读取图片资产;未来 Inspector 和路径改写分别设计
- 每处修改必须可追踪(规则编号 + 原文/改后对照),保证审计和回滚 - 每处实际修改必须在内存结果中记录组件、理由、原文改后内容和批次哈希,保证可追踪
## 5. 验收口径(第一版) ## 5. 验收口径(第一版)
- 上述 9 类问题在本轮 5 份文件上的触发处全部按规则处理,处理数与审计报告的实测数字一致; - 上述 8 类问题在本轮 5 份文件上的触发处全部按规则处理,处理数与审计报告的实测数字一致;
- 5 份文件中未被任何规则命中的正文零变更——除表中列出的触发处外不得有任何其他 diff; - 5 份文件中未被任何规则命中的正文零变更——除表中列出的触发处外不得有任何其他 diff;
- 幂等性:同一输入清洗两次,第二次产出与第一次完全一致; - 幂等性:同一输入清洗两次,第二次产出与第一次完全一致;
- 规则 2arXiv 戳)在 springer 上的验收必须包含反向用例:L143/L152 参考文献原文保留; - 规则 2arXiv 戳)在 springer 上的验收必须包含反向用例:L143/L152 参考文献原文保留;
@@ -68,6 +70,6 @@ ClinDB-ReviewBench 是师姐的论文清洗项目。`data/` 下当前 5 份 DOI
## 6. 与审计报告的编号对应 ## 6. 与审计报告的编号对应
本文的 9 类规则对应 `scratch/data-5papers-cleaning-audit-2026-08-21.md` 的决策清单行: 本文的 8自动清洗规则对应 `scratch/data-5papers-cleaning-audit-2026-08-21.md` 的决策清单行:
D2、H1、B2、B1、C4、D1、E2、G3H3。该审计是 scratch 材料,本文引用其编号仅为便于追溯, D2、H1、B2、B1、C4、D1、E2、G3H3 保留为未来只读检查候选。该审计是 scratch 材料,本文引用其编号
权威以本文为准。 仅为便于追溯,权威以本文为准。
+5
View File
@@ -0,0 +1,5 @@
"""Approved business cleaning components."""
from mdpolish.components.arxiv_submission_stamp import ArxivSubmissionStampComponent
__all__ = ["ArxivSubmissionStampComponent"]
@@ -0,0 +1,81 @@
"""Remove exact arXiv submission stamp lines from converted papers."""
from __future__ import annotations
import re
from collections.abc import Iterator, Mapping
from types import MappingProxyType
from mdpolish.component import Component
from mdpolish.models import DocumentSnapshot, ProposedChange, TextEdit, TextSpan
_STAMP_PATTERN = re.compile(
r"^arXiv:[0-9]{4}\.[0-9]+v[0-9]+ \[[A-Za-z0-9_.-]+\] "
r"(?:[1-9]|[12][0-9]|3[01]) "
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4}$"
)
_EMPTY_PARAMETERS: Mapping[str, object] = MappingProxyType({})
_REASON = "删除完整匹配的 arXiv 提交边栏戳"
def _physical_line_ranges(markdown: str) -> Iterator[tuple[int, int, int]]:
"""Yield content start, content end, and full line end for CR/LF line endings."""
line_start = 0
position = 0
while position < len(markdown):
character = markdown[position]
if character == "\n":
yield line_start, position, position + 1
position += 1
line_start = position
elif character == "\r":
line_end = position + 2 if position + 1 < len(markdown) and markdown[position + 1] == "\n" else position + 1
yield line_start, position, line_end
position = line_end
line_start = position
else:
position += 1
if line_start < len(markdown):
yield line_start, len(markdown), len(markdown)
class ArxivSubmissionStampComponent(Component):
"""Delete physical lines that exactly match the approved arXiv stamp format."""
@property
def component_id(self) -> str:
return "paper.arxiv_submission_stamp"
@property
def version(self) -> str:
return "1.0.0"
@property
def parameters(self) -> Mapping[str, object]:
return _EMPTY_PARAMETERS
@property
def applicability(self) -> str:
return "处理 PDF/arXiv 论文转换产生的独立提交戳行,要求严格整行匹配,排除所有相似文本。"
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
proposals: list[ProposedChange] = []
for line_start, content_end, line_end in _physical_line_ranges(snapshot.markdown):
if _STAMP_PATTERN.fullmatch(snapshot.markdown[line_start:content_end]) is None:
continue
expected_text = snapshot.markdown[line_start:line_end]
edit = TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(line_start, line_end),
expected_text=expected_text,
replacement="",
)
proposals.append(
ProposedChange(
snapshot_sha256=snapshot.sha256,
reason=_REASON,
edits=(edit,),
)
)
return tuple(proposals)
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import pytest
import mdpolish
from mdpolish import Pipeline, RunStatus
from mdpolish.components import ArxivSubmissionStampComponent
STAMP = "arXiv:2104.12345v2 [stat.ME] 31 Dec 2021"
OTHER_STAMP = "arXiv:2301.7v1 [cs.AI] 1 Jan 2023"
REASON = "删除完整匹配的 arXiv 提交边栏戳"
def transform(markdown: str) -> mdpolish.TransformResult:
return Pipeline([ArxivSubmissionStampComponent()]).transform(markdown)
def test_component_metadata_and_package_export() -> None:
result = transform("")
assert result.status is RunStatus.SUCCESS
assert result.components[0].component_id == "paper.arxiv_submission_stamp"
assert result.components[0].version == "1.0.0"
assert result.components[0].parameters == ()
assert result.components[0].applicability
assert not hasattr(mdpolish, "ArxivSubmissionStampComponent")
@pytest.mark.parametrize("markdown", ["", "普通正文", "中文\nCafe\u0301\n🙂\n"])
def test_no_target_returns_unchanged_success(markdown: str) -> None:
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == markdown
assert result.changes == ()
@pytest.mark.parametrize(
("markdown", "expected"),
[
(f"{STAMP}\n正文", "正文"),
(f"正文\n{STAMP}\n后文", "正文\n后文"),
(f"正文\n{STAMP}", "正文\n"),
(STAMP, ""),
(f"正文\r\n{STAMP}\r\n后文", "正文\r\n后文"),
(f"正文\r{STAMP}\r后文", "正文\r后文"),
],
)
def test_deletes_target_at_approved_line_boundaries(markdown: str, expected: str) -> None:
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == expected
assert len(result.changes) == 1
def test_multiple_targets_are_reported_in_source_order_with_one_atomic_batch() -> None:
markdown = f"{STAMP}\n保留\n{OTHER_STAMP}"
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == "保留\n"
assert [change.before for change in result.changes] == [f"{STAMP}\n", OTHER_STAMP]
assert [change.proposal_ref.proposal_index for change in result.changes] == [0, 1]
assert [change.span.start for change in result.changes] == sorted(change.span.start for change in result.changes)
assert {change.before_sha256 for change in result.changes} == {result.input_sha256}
assert {change.after_sha256 for change in result.changes} == {result.current_sha256}
def test_adjacent_targets_use_non_overlapping_delete_ranges() -> None:
result = transform(f"{STAMP}\n{OTHER_STAMP}")
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == ""
assert len(result.changes) == 2
assert result.changes[0].span.end == result.changes[1].span.start
@pytest.mark.parametrize(
"line",
[
f" {STAMP}",
f"{STAMP} ",
f"- {STAMP}",
f"> {STAMP}",
"1. Example. arXiv preprint arXiv:2104.12345v2 [stat.ME], 2021.",
"See arXiv:2104.12345v2 for details.",
"arXiv:2104.12345 [stat.ME] 31 Dec 2021",
"arXiv:hep-ph/9901001 [hep-ph] 31 Dec 1999",
"arXiv:2104.12345v2 31 Dec 2021",
"arXiv:2104.12345v2 [stat.ME] 0 Dec 2021",
"arXiv:2104.12345v2 [stat.ME] 32 Dec 2021",
"arXiv:2104.12345v2 [stat.ME] 31 December 2021",
"arXiv:2104.12345v2 [统计] 31 Dec 2021",
],
)
def test_similar_arxiv_text_is_preserved(line: str) -> None:
markdown = f"前文\n{line}\n后文"
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == markdown
assert result.changes == ()
def test_matching_line_inside_fenced_code_is_not_protected() -> None:
markdown = f"```text\n{STAMP}\n```\n"
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == "```text\n```\n"
assert len(result.changes) == 1
def test_change_audit_records_identity_reason_source_and_batch_hashes() -> None:
result = transform(f"{STAMP}\n正文")
assert result.status is RunStatus.SUCCESS
change = result.changes[0]
assert change.component_id == "paper.arxiv_submission_stamp"
assert change.component_version == "1.0.0"
assert change.component_position == 0
assert change.proposal_ref.component_position == 0
assert change.proposal_ref.proposal_index == 0
assert change.edit_index == 0
assert change.reason == REASON
assert change.before == f"{STAMP}\n"
assert change.after == ""
assert change.before_sha256 == result.input_sha256
assert change.after_sha256 == result.current_sha256
def test_successful_output_is_stable_and_second_run_has_no_changes() -> None:
pipeline = Pipeline([ArxivSubmissionStampComponent()])
first = pipeline.transform(f"{STAMP}\n正文")
assert first.status is RunStatus.SUCCESS
assert first.output_markdown == "正文"
second = pipeline.transform(first.output_markdown)
assert second.status is RunStatus.SUCCESS
assert second.output_markdown == "正文"
assert second.changes == ()
assert second.residual_proposals == ()
def test_same_input_produces_same_ordered_result() -> None:
pipeline = Pipeline([ArxivSubmissionStampComponent()])
markdown = f"{STAMP}\n正文\n{OTHER_STAMP}\n"
assert pipeline.transform(markdown) == pipeline.transform(markdown)