From 8ac4f1dd12006add85637ad676b35436bd1d68cb Mon Sep 17 00:00:00 2001
From: Bepr4 <63661977@qq.com>
Date: Fri, 21 Aug 2026 22:49:03 +0800
Subject: [PATCH 1/5] =?UTF-8?q?=E6=9B=B4=E5=90=8D=20mdpolish=20=E5=B9=B6?=
=?UTF-8?q?=E8=A1=A5=E5=85=85=E6=B8=85=E6=B4=97=E6=B5=81=E6=B0=B4=E7=BA=BF?=
=?UTF-8?q?=E8=AE=BE=E8=AE=A1=E4=B8=8E=E8=B0=83=E7=A0=94=E8=AE=B0=E5=BD=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 仓库由 govdoc-md-cleaner 更名为 mdpolish,更新 README、AGENTS、CLAUDE
及 reference 中的仓库名;冻结的 design 与带日期 scratch 保留旧名
- 冻结 0002:可组合清洗组件与流水线(check/transform、单轮修改加最终复查)
- 新增 0003 草稿:第一版可执行核心架构,待评审
- 新增 HTML 表格清洗专题调研(2026-08-21)
---
AGENTS.md | 25 +-
CLAUDE.md | 25 +-
README.md | 33 +-
research-wiki/README.md | 25 +-
.../0002-composable-cleaning-pipeline.md | 253 ++++++++++++
...0003-first-executable-core-architecture.md | 388 ++++++++++++++++++
.../CLINDB_REVIEWBENCH_CLEANING_SCOPE.md | 2 +-
...-cleaning-ecosystem-research-2026-08-21.md | 143 +++++++
8 files changed, 848 insertions(+), 46 deletions(-)
create mode 100644 research-wiki/design/0002-composable-cleaning-pipeline.md
create mode 100644 research-wiki/design/0003-first-executable-core-architecture.md
create mode 100644 research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md
diff --git a/AGENTS.md b/AGENTS.md
index 455e949..87cc33e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,21 +12,23 @@
发生冲突时,以对应的唯一权威为准,不把不同版本拼成新的说法。
-| 事实类型 | 唯一权威 |
-|---|---|
-| 当前阶段与已经完成的工作 | 根目录 `README.md` |
-| Wiki 分类、冻结规则与更新机制 | `research-wiki/README.md` |
-| 已批准的选择、权衡与否决方案 | `research-wiki/design/` 中对应编号记录 |
-| 当前有效的清洗机制与原因 | `research-wiki/explanation/`;没有文档时就是尚未确定 |
-| 参数、输入输出和运行行为 | 未来的代码与测试;代码无法表达的事实才进入 `reference/` |
-| 可复现的操作与排障步骤 | `research-wiki/guides/` |
-| Agent 协作与执行规范 | 本文件及其同步镜像 |
+
+| 事实类型 | 唯一权威 |
+| ----------------- | ---------------------------------------- |
+| 当前阶段与已经完成的工作 | 根目录 `README.md` |
+| Wiki 分类、冻结规则与更新机制 | `research-wiki/README.md` |
+| 已批准的选择、权衡与否决方案 | `research-wiki/design/` 中对应编号记录 |
+| 当前有效的清洗机制与原因 | `research-wiki/explanation/`;没有文档时就是尚未确定 |
+| 参数、输入输出和运行行为 | 未来的代码与测试;代码无法表达的事实才进入 `reference/` |
+| 可复现的操作与排障步骤 | `research-wiki/guides/` |
+| Agent 协作与执行规范 | 本文件及其同步镜像 |
+
路径、参数、命令、指标口径和当前进度不得维护多个权威版本。发现冲突时,先确认权威,再修复过期内容。
## 1. 项目定位与当前阶段
-`govdoc-md-cleaner` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、
+`mdpolish` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、
比较候选方案并积累可复核证据。
当前只建立文档治理基础。源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系均未获批准、
@@ -120,8 +122,9 @@ design 草稿可以在评审中修改;批准后冻结。决策发生变化时
## 9. 沟通与协作
-- 像同事协作一样,用直接、可读的中文说明判断、变化和风险;
+- 像同事协作一样,用直接、可读的中文说明判断、变化和风险。要用人话讲解!别创造黑话,别堆积信息密度极大的长难句!
- 问什么答什么,一次聚焦当前问题,不把未经请求的后续工作一起推进;
- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户;
- 进度和最终报告必须对应真实工具输出,不把计划描述成结果;
- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 3b75e68..b1a69d4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,21 +12,23 @@
发生冲突时,以对应的唯一权威为准,不把不同版本拼成新的说法。
-| 事实类型 | 唯一权威 |
-|---|---|
-| 当前阶段与已经完成的工作 | 根目录 `README.md` |
-| Wiki 分类、冻结规则与更新机制 | `research-wiki/README.md` |
-| 已批准的选择、权衡与否决方案 | `research-wiki/design/` 中对应编号记录 |
-| 当前有效的清洗机制与原因 | `research-wiki/explanation/`;没有文档时就是尚未确定 |
-| 参数、输入输出和运行行为 | 未来的代码与测试;代码无法表达的事实才进入 `reference/` |
-| 可复现的操作与排障步骤 | `research-wiki/guides/` |
-| Agent 协作与执行规范 | 本文件及其同步镜像 |
+
+| 事实类型 | 唯一权威 |
+| ----------------- | ---------------------------------------- |
+| 当前阶段与已经完成的工作 | 根目录 `README.md` |
+| Wiki 分类、冻结规则与更新机制 | `research-wiki/README.md` |
+| 已批准的选择、权衡与否决方案 | `research-wiki/design/` 中对应编号记录 |
+| 当前有效的清洗机制与原因 | `research-wiki/explanation/`;没有文档时就是尚未确定 |
+| 参数、输入输出和运行行为 | 未来的代码与测试;代码无法表达的事实才进入 `reference/` |
+| 可复现的操作与排障步骤 | `research-wiki/guides/` |
+| Agent 协作与执行规范 | 本文件及其同步镜像 |
+
路径、参数、命令、指标口径和当前进度不得维护多个权威版本。发现冲突时,先确认权威,再修复过期内容。
## 1. 项目定位与当前阶段
-`govdoc-md-cleaner` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、
+`mdpolish` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、
比较候选方案并积累可复核证据。
当前只建立文档治理基础。源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系均未获批准、
@@ -120,8 +122,9 @@ design 草稿可以在评审中修改;批准后冻结。决策发生变化时
## 9. 沟通与协作
-- 像同事协作一样,用直接、可读的中文说明判断、变化和风险;
+- 像同事协作一样,用直接、可读的中文说明判断、变化和风险。要用人话讲解!别创造黑话,别堆积信息密度极大的长难句!
- 问什么答什么,一次聚焦当前问题,不把未经请求的后续工作一起推进;
- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户;
- 进度和最终报告必须对应真实工具输出,不把计划描述成结果;
- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。
+
diff --git a/README.md b/README.md
index 62e08d9..4678a05 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,9 @@
-# govdoc-md-cleaner
+# mdpolish
-实验室共用的 Markdown 清洗研究与基础工具库。仓库名沿用了最初的 GovDoc 场景,但项目不属于 GovDoc
-专用组件,也不只服务政务文档。
+实验室共用的 Markdown 清洗研究与基础工具库。项目不属于 GovDoc 专用组件,也不只服务政务文档。
本仓库面向实验室内不同项目复用,用于清洗 PDF、DOCX、OCR、网页等上游管线生成的 Markdown,统一解决
-格式噪声、结构损坏、内容异常、来源追踪和多用途派生问题。各项目共享通用清洗能力,再通过独立配置或
+格式噪声、结构损坏、内容异常、修改追踪和多用途派生问题。各项目共享通用清洗能力,再通过独立配置或
profile 表达论文、政务文档、RAG、文档对比等不同需求。
仓库当前仍处于研究和方案设计阶段:用于澄清问题、记录设计选择、积累可复核证据,并在方案获得确认后
@@ -24,9 +23,19 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
(`research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md`),并确定其第一版清洗范围
(`research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md`,9 类确定性规则)。
- 2026-08-21 明确本项目定位为实验室共用库;GovDoc 和论文清洗都是使用场景,不是核心边界。
+- 2026-08-21 批准并冻结 `research-wiki/design/0002-composable-cleaning-pipeline.md`,确定只接收 Markdown、
+ 项目显式组装组件、单轮修改加最终只读复查的总体组织方式。
+- 2026-08-21 建立 `research-wiki/design/0003-first-executable-core-architecture.md` 草稿,等待评审第一版
+ Python 内存核心、精确修改协议、错误语义和测试边界。
+- 2026-08-21 完成 HTML 表格清洗专题调研
+ (`research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md`):核实 Pandoc 表格
+ 方言能力边界、Turndown 不处理合并单元格、Docling Markdown 导出重复合并单元格内容、MinerU 全
+ HTML 输出,印证审计 T001 的分流方向。
+- 2026-08-21 仓库由 `govdoc-md-cleaner` 更名为 `mdpolish`,GitHub 远程仓库与本地目录同步改名;
+ 冻结的 design 记录和带日期的 scratch 笔记保留当时的旧名。
-当前没有清洗算法、可执行命令、运行依赖、测试套件或已批准的输入输出契约。调研报告中的技术组合、
-内部 IR、profiles 和实施路线均是候选方案,尚未批准。
+当前没有清洗算法、可执行命令、运行依赖、测试套件或函数级输入输出契约。`0002` 只批准了总体组织方式;
+调研报告中的解析器、内部 IR、具体 profile 和实施路线仍是候选方案,尚未批准。
目录存在只代表文档落点已经建立,不代表相应能力已经完成。
## 服务对象与复用目标
@@ -42,12 +51,12 @@ GovDoc 目录、具体客户名称或某一转换器的固定输出路径。
## 面向复用的设计原则
-- **通用核心**:编码检查、Markdown/HTML 结构解析、异常检测、可审计变换、资产校验和来源追踪;
-- **输入适配器**:不同 PDF/OCR/DOCX/HTML 转换器通过 adapter 接入,不把某个上游工具写死;
+- **通用核心**:只接收 Markdown,提供结构检查、异常检测和可审计变换,不读取 PDF、图片或转换器 JSON;
+- **输入边界**:PDF/OCR/DOCX/HTML 转换和外部材料核验由使用项目或上游流程负责,不写入共用组件契约;
- **项目 profile**:论文、GovDoc、对比、RAG、公开脱敏等规则独立组合,不互相污染默认行为;
- **保真优先**:不确定内容默认保留或进入人工确认,不能为了格式整齐改写业务或学术内容;
- **可复现**:规则、配置、输入哈希、输出和每次变更都可以追踪;
-- **可扩展**:新增项目应主要增加 adapter、detector、transformer 或 profile,而不是复制一套清洗器。
+- **可扩展**:新增项目在自身边界处理上游适配,并主要组合或补充组件和 profile,而不是复制一套清洗器。
## `data/` 的职责
@@ -69,7 +78,7 @@ GovDoc 目录、具体客户名称或某一转换器的固定输出路径。
## 目录结构
```text
-govdoc-md-cleaner/
+mdpolish/
├── AGENTS.md
├── CLAUDE.md
├── README.md
@@ -92,8 +101,8 @@ govdoc-md-cleaner/
3. `research-wiki/README.md`,确认文档应放在哪里;
4. 与任务直接相关的 `research-wiki/design/` 记录。
-下一项实质工作开始前,应以现有论文数据、GovDoc 审计和生态调研为输入新增下一编号的 design,明确
-通用核心与项目 profile 的边界、第一阶段范围、技术选型、输入输出、验证方法和非目标,并等待批准。
+下一项实质工作开始前,应评审并批准 `research-wiki/design/0003-first-executable-core-architecture.md`;
+草稿尚不授权创建源码、测试、依赖或公共接口。
## 当前可用检查
diff --git a/research-wiki/README.md b/research-wiki/README.md
index de4385c..67925c7 100644
--- a/research-wiki/README.md
+++ b/research-wiki/README.md
@@ -8,14 +8,16 @@
## 1. 目录与生命周期
-| 内容 | 目录 | 维护方式 |
-|---|---|---|
-| 动工前的方案比较、选择和代价 | `design/` | 批准后冻结;改变时新增下一编号 |
-| 当前有效的机制、数据流和原因 | `explanation/` | 事实变化时同步更新 |
-| 代码无法完整表达的稳定查询事实 | `reference/` | 权威事实变化时更新 |
-| 可复现运行、验证与排障步骤 | `guides/` | 操作变化时更新并重新验证 |
-| 调研笔记、计划和未收敛草稿 | `scratch/` | 不作为当前事实;由项目负责人决定去留 |
-| 当前阶段和已完成工作 | 根目录 `README.md` | 阶段变化时更新 |
+
+| 内容 | 目录 | 维护方式 |
+| --------------- | --------------- | ------------------ |
+| 动工前的方案比较、选择和代价 | `design/` | 批准后冻结;改变时新增下一编号 |
+| 当前有效的机制、数据流和原因 | `explanation/` | 事实变化时同步更新 |
+| 代码无法完整表达的稳定查询事实 | `reference/` | 权威事实变化时更新 |
+| 可复现运行、验证与排障步骤 | `guides/` | 操作变化时更新并重新验证 |
+| 调研笔记、计划和未收敛草稿 | `scratch/` | 不作为当前事实;由项目负责人决定去留 |
+| 当前阶段和已完成工作 | 根目录 `README.md` | 阶段变化时更新 |
+
根目录 `AGENTS.md` 与 `CLAUDE.md` 是协作者入口,不放入 Wiki。
空分类使用 `.gitkeep` 保留,不创建只写未来设想的占位文档。
@@ -83,11 +85,12 @@ guide 必须来自实际运行,至少包含前置条件、准确命令、预
- 小型汇总进入 Git 前必须确认无法还原客户内容;
- 每个实验应能追溯输入范围、版本、参数、环境、指标和输出位置。
-## 9. 写作与审查
+## 9. 写作与审查(非常重要!!!务必遵守!!!)
-- 面向没有参加过讨论、但具备相关技术背景的读者;
+- 面向没有参加过讨论、但具备相关技术背景的读者,要用人话讲解!别创造黑话,别堆积信息密度极大的长难句!
+- 尽量多使用表格和图,让读者更容易看懂。
- 从真实问题或读者可见现象开始,不先堆术语;
- 结论写清理由、代价、适用边界和验证状态;
- 参数、路径、命令、指标和当前阶段只维护一个权威版本;
- 不把目标写成已完成,不泄露真实文档内容,不用文档替代测试;
-- 简单事实用短段落,只有比较关系确实更清楚时才使用表格或图。
+
diff --git a/research-wiki/design/0002-composable-cleaning-pipeline.md b/research-wiki/design/0002-composable-cleaning-pipeline.md
new file mode 100644
index 0000000..df96587
--- /dev/null
+++ b/research-wiki/design/0002-composable-cleaning-pipeline.md
@@ -0,0 +1,253 @@
+# 0002:可组合的清洗组件与流水线
+
+## 状态
+
+已批准并冻结(2026-08-21)。本设计只确定总体组织方式,不授权创建源码、测试、依赖或公共接口。
+后续如果改变本设计的选择,应新增 design 并以 `supersedes: 0002` 指向本记录,不回写本文件。
+
+## 1. 问题
+
+实验室不同项目面对的 Markdown 问题并不相同。论文项目需要处理手稿行号、批注和 arXiv 边栏戳,
+GovDoc 则更关注幻觉、复杂表格、页眉页脚和隐私。如果把这些规则都塞进一个清洗器,默认行为会越来越难懂,
+项目之间也容易互相影响。
+
+师兄希望采用类似 PyTorch 的组合方式:库提供多个功能独立的组件,各项目按需导入,并自行搭建清洗流程。
+
+## 2. 目标与非目标
+
+目标:
+
+- 每个组件只处理一类清楚、可单独测试的问题;
+- 项目可以直接导入组件,并明确决定组件的组合与顺序;
+- Markdown 是流水线唯一的文档输入,组件不接收图片、PDF、转换器 JSON 或其他项目材料;
+- 每个组件都能先检查,能够安全修复的组件再提供修改行为;
+- 所有组件使用同一套调用方式和结果记录;
+- 每次修改可追踪,输入不被原地覆盖;
+- 每个项目在自己的仓库中保存实际使用的组件、参数和顺序。
+
+非目标:
+
+- 本设计不确定 Python 包名、目录结构、函数签名和第三方依赖;
+- 本设计不批准任何具体清洗规则的实现;
+- 本设计不建设 PDF、图片或转换器 JSON 的读取、适配与回源能力;
+- 本设计不承诺自动修复缺失正文、OCR 语义错误或其他无法确认正确内容的问题。
+
+## 3. 方案比较
+
+- **单体清洗器:** 使用简单,但规则增多后难以复用,也难以解释某个项目实际启用了什么。
+- **单体清洗器加配置:** 可以开关规则,但全部规则仍由同一个入口和执行过程控制,项目之间容易耦合。
+- **独立组件加统一流水线:** 组件可以单独导入和测试,项目显式组合,公共能力仍由统一底座提供。
+
+采用第三种方案。
+
+### 3.1 外部架构参照
+
+本设计借鉴以下项目的职责划分,但这些参照不表示已经选择对应语言、依赖或接口:
+
+| 项目 | 借鉴内容 | 不直接照搬的部分 |
+| --- | --- | --- |
+| [`remark` / `unified`](https://github.com/remarkjs/remark) | 处理器统一组织有序插件,项目显式启用插件,命令行只是处理器之外的入口 | 解析后全量重新输出 Markdown;Node.js 运行时 |
+| [`ESLint`](https://eslint.org/docs/latest/extend/custom-rules) 与 [`rumdl`](https://github.com/rvben/rumdl) | 问题可以携带精确修复,修改后重新检查,并集中处理修改冲突 | 面向开发者文档的默认规则、自动多轮修改、宽松冲突处理和原地覆盖 |
+| [`OpenRewrite`](https://docs.openrewrite.org/concepts-and-explanations/recipes) | 单项变换可以组成有序 Recipe,重视非目标内容的保留 | 第一版即建设庞大的无损语法树和跨语言运行平台 |
+| [`mdformat`](https://mdformat.readthedocs.io/en/stable/users/plugins.html) | 插件安装不等于启用,调用方必须明确选择 | 把全文格式化作为清洗的必经步骤 |
+
+其中 `remark` 最适合作为产品分层参照,ESLint 和 `rumdl` 更适合作为检查、精确修改与复查机制参照。
+本项目保真要求更高,因此结构解析主要用于识别边界和生成证据;能够用精确文本范围表达的修改,不通过全篇
+解析后重新渲染来实现。
+
+## 4. 产品分层与职责
+
+整体产品采用“项目组装、统一执行、边界适配”的分层方式:
+
+```text
+使用项目保存的组件、参数和顺序
+ │
+ ▼
+ Pipeline
+ ┌──────┴──────┐
+ │ │
+ check transform
+ │ │
+ ▼ ▼
+ Issue 汇总 公共修改执行器
+ │
+ 当前 Markdown 快照
+ │
+ ▼
+ Result
+```
+
+- **文档上下文:** 保存当前 Markdown 快照、内容哈希和从当前快照派生的可复用只读分析结果;
+- **组件:** 只负责一种问题的定位、证据和候选处理方式,不读写项目文件;
+- **流水线:** 按使用项目给出的顺序调用组件,管理当前快照、错误、冲突、最终复查和结果汇总;
+- **公共修改执行器:** 验证修改范围与当前快照是否匹配,统一应用非重叠修改并生成实际改动记录;
+- **输入输出适配层:** 负责读取 Markdown,以及把结果输出为文件、终端文本或机器可读报告;
+ 它不实现第二套清洗逻辑。
+
+Python API、命令行、批处理和未来服务如果存在,都应调用同一个流水线核心。第一版不建设自动插件发现、
+任务调度、数据库或 Web 服务。安装了某个组件包也不代表它会自动执行;只有使用项目显式导入并加入流水线的
+组件才会生效。
+
+本节只确定职责边界,不确定源码目录、类名、函数签名和序列化格式。
+
+## 5. 输入边界:只接收 Markdown
+
+流水线只接收 Markdown 文本作为文档输入。组件可以使用项目明确给出的参数,但不能要求或读取图片、PDF、
+转换器 JSON、项目目录或其他外部文档材料。文档上下文中的解析结果和其他缓存也必须从当前 Markdown 快照派生。
+
+图片、原始 PDF 和转换器 JSON 的目录、格式、可信度与对应关系都由使用项目或上游转换流程决定,不进入共用库
+的输入契约。项目需要断链核验、版面坐标、原文比对或回源重提取时,应在自己的边界内处理,不能让通用组件
+依赖某个项目的文件布局或转换器 schema。
+
+因此,共用库可以根据 Markdown 本身检查图片引用语法、固定幻觉特征、截断迹象和表格结构异常,但不能据此
+声称图片文件存在、原文已经缺失或回源修复已经完成。以后如果多个项目证明存在相同的外部材料接入需求,
+再通过新的 design 决定是否增加独立适配能力;本设计不提前预留该接口。
+
+## 6. 一个组件,两种行为
+
+`Component` 对应一种清洗问题,例如 HTML 实体双重转义、arXiv 边栏戳、手稿行号或空图片引用。组件不是按
+`Check` 和 `Transform` 分成两类,而是可以提供两种行为。
+
+### 6.1 `check`:所有组件必须提供
+
+`check` 找出当前组件负责的问题,返回位置、证据和处理能力,但不修改 Markdown。问题分为三种处理状态:
+
+- **仅检查:** 能确认异常,但不知道唯一正确的改法;
+- **建议修改:** 能给出候选改法,但需要项目或人工明确选择,清洗流程不会自动应用;
+- **可自动修复:** 前置条件严格、改法唯一,并且组件明确声明支持自动修改。
+
+没有明确声明“可自动修复”的组件一律按仅检查处理,不能因为问题中包含候选文本就自动修改。
+
+检查通常依靠确定规则实现,例如正则表达式、Markdown/HTML 解析、连续编号判断和重复率统计。
+大语言模型以后可以作为某个组件的可选检查方式,但不进入默认流程,也不能根据模型判断自动改写正文。
+
+### 6.2 `transform`:能够安全修复的组件才提供
+
+`transform` 修改 `check` 已经能够准确定位、且正确处理方式已经明确的问题。例如,HTML 实体组件可以还原一层
+重复转义,arXiv 边栏戳组件可以删除严格匹配的整行。
+
+组件的 `check` 和 `transform` 必须使用同一套定位逻辑,不能出现检查报告了一批位置、清洗时却另行扫描并
+修改另一批内容。推荐由检查结果携带绑定当前输入快照的候选修改,`transform` 只确认并提交这些修改;具体接口
+留到后续设计确定。
+
+检查阶段产生的位置和候选修改不能直接延后应用。只要前一个组件改变了 Markdown,旧结果就只保留为历史证据;
+后续修改必须在当前 Markdown 快照上重新定位。疑似幻觉、内容截断迹象和无法仅凭 Markdown 确认正确结构的
+损坏表格通常只能检查;这类组件不提供 `transform`。
+
+组件至少需要说明自己的标识和版本、参数、能否修改、适用边界,以及修改是否幂等。具体字段和函数签名留到
+接口设计时确定。
+
+## 7. 流水线如何使用组件
+
+组件可以被项目单独调用,也可以按顺序放入 `Pipeline`。流水线提供两种运行方式。
+
+### 7.1 检查流程
+
+项目可以把所有相关组件放进一条流水线并执行 `check`。流水线逐个调用组件的检查行为,汇总发现的问题和
+执行错误,全程不修改 Markdown。
+
+### 7.2 清洗流程
+
+项目阅读检查结果后,再选择真正需要的组件和顺序,执行 `transform`。流水线只调用这些组件的修改行为,
+处理修改范围冲突,并汇总清洗后的 Markdown 和每一处改动。这里选择的是组件及其参数,不是保存第一次检查时
+得到的一批旧位置后直接套用。
+
+下面只说明使用方式,不是已经批准的 Python 接口:
+
+```python
+inspection = Pipeline(all_relevant_components)
+check_result = inspection.check(markdown)
+
+cleaning = Pipeline(selected_components)
+transform_result = cleaning.transform(markdown)
+```
+
+组件的顺序由使用项目决定。共用库不能假设所有组件可以任意交换,也不能根据 Markdown 内容自动选择项目流程。
+
+### 7.3 单轮清洗与最终复查
+
+清洗流程按以下语义执行:
+
+1. 记录输入 Markdown 的内容哈希,建立当前快照;
+2. 按项目给出的顺序,让组件针对当前快照重新检查并产生候选修改;
+3. 公共修改执行器验证候选修改引用的原文、范围和快照,重叠且处理方式不同的修改视为冲突;
+4. 成功应用一个组件的修改后,生成新快照,使旧位置和旧解析缓存失效;下一个组件读取这个新快照;
+5. 每个选中组件只执行一次修改。全部组件结束后,流水线对最终快照再执行一次只读检查,不再应用任何修改;
+6. 如果最终复查发现某个已选组件仍存在可自动修复的问题,说明组件之间产生了连锁影响,本次流水线标记为
+ “未稳定”,由项目调整组件顺序或组成后重新运行。
+
+流水线不能为了消除冲突而暗中调整项目给出的组件顺序,也不能静默选择某一项重叠修改。组件异常、修改冲突
+或最终复查未稳定时,本次清洗整体不算成功;结果仍保留已经发生的内存中修改和失败证据,但输入文件不会因此
+被写入或覆盖。一次组件可提交多少项修改以及失败结果的具体字段留到后续接口设计。
+
+最终复查中仍然存在仅检查或建议修改的问题,不会触发第二轮自动修改;它们继续记录为未解决问题。是否阻止
+结果用于后续流程,由使用项目根据用途和风险另行决定。
+
+## 8. 结果与修改记录
+
+`Result` 不是一个独立业务组件,只是让组件和流水线使用相同的返回形式。第一版需要表达两种结果:
+
+- 检查结果:发现的问题、建议修改和执行错误;
+- 清洗结果:运行状态、清洗后的 Markdown、实际改动、仍未解决的问题、执行错误和最终复查结果。
+
+结果中的三个概念不能混用:
+
+- **问题:** 组件在某个 Markdown 快照中发现的异常,包含位置、证据和处理能力;
+- **候选修改:** 组件针对某条问题提出、但尚未实际执行的精确修改,必须绑定产生它的输入快照;
+- **实际改动:** 公共修改执行器已经应用的修改,记录修改前后内容及对应快照。
+
+每条问题至少应能说明组件标识和版本、问题位置、判断依据以及属于仅检查、建议修改还是可自动修复。每条实际
+改动至少应能说明由哪个组件执行、执行顺序、修改范围、修改前后内容、修改理由以及修改前后的快照标识。
+
+流水线结果还应记录实际组件顺序和参数、输入输出内容哈希、冲突、最终复查发现的问题和组件错误。
+“完成了部分修改”不等于清洗成功;只有所有选中组件完成执行、没有执行错误,而且最终复查没有发现仍可由
+已选组件自动修复的问题,结果才能标记为成功。具体字段和保存格式留到接口设计时确定。
+
+第一版不单独建设 Audit 子系统。清洗结果中的改动记录就是审计依据;以后确实需要保存时,再把这些记录导出
+为机器可读文件。终端文本、JSON 或其他报告只是同一结果的不同表示,不能各自维护不同事实。
+
+## 9. 项目如何组装
+
+共用库提供组件和流水线能力,不根据内容猜测当前属于哪个项目,也不自动选择清洗流程。ClinDB、GovDoc 和
+其他使用方应在各自项目中直接导入所需组件,并保存组件的参数和执行顺序。
+
+项目可以把这种有明确用途的组合称为 profile,但 profile 的权威仍在使用项目中。它只是组件、参数、顺序和
+用途的显式组合,不是共用库根据内容自动推断的标签。安装、注册或能够导入一个组件,都不会使它自动加入 profile。
+
+共用库可以在文档和测试中提供组合示例,但示例不是默认流程,也不代替使用方对清洗范围的决定。某个项目新写
+的组件只有被证明可以复用后,才考虑放回共用库。
+
+组件按用途区分通用能力和项目能力。图片引用语法、表格校验、异常字符等可以作为通用组件;arXiv 边栏戳、
+手稿行号等属于论文组件;GovDoc 的幻觉模板、投标文档页眉页脚和隐私检查属于 GovDoc 组件。
+
+只审计、忠实修复、RAG、文档对比和公开脱敏属于不同使用目的,应由流程明确选择,不写死在组件内部。
+
+## 10. 第一阶段边界
+
+获得批准后,第一阶段先验证以下最小闭环:
+
+1. 组件能够独立执行 `check`;
+2. 检查结果能够区分仅检查、建议修改和可自动修复,建议修改不会被自动应用;
+3. 支持安全修复的组件能够执行 `transform`;
+4. 流水线能够汇总检查结果、让每个选中组件按顺序修改一次、刷新当前快照并记录改动;
+5. 流水线能够识别修改冲突,并通过最终只读复查发现组件之间的连锁影响,失败时不把部分结果报告为成功;
+6. 流水线和所有组件的文档输入都只有 Markdown,不读取外部材料;
+7. 原输入不被覆盖,单个清洗组件和选定流水线都满足幂等要求。
+
+ClinDB 已明确的九类规则中,只依赖 Markdown 的规则可作为首批候选组件,具体流程保存在 ClinDB 项目中。
+GovDoc 可以选择所需组件;疑似幻觉、缺失内容和损坏表格在共用库中只检查,不自动猜测、改写或回源修复。
+
+第一阶段的具体输入输出、验收方法、源码结构和依赖仍需后续设计批准后才能实施。
+
+## 11. 风险与边界
+
+- 组件过细会变成难以理解的正则表达式集合;公共组件应表达完整行为,而不是简单包装一次替换;
+- 文本修改存在先后顺序和范围重叠,不能假设组件可以任意交换;
+- 单轮执行不会自动处理后一个组件新产生的前置问题,必须由最终只读复查明确报告未稳定;
+- 项目流程不能藏进共用库的默认行为,否则使用方无法确认实际启用了哪些规则;
+- 自动发现或仅因安装而启用第三方组件会使结果随环境变化,第一版只允许显式导入和组装;
+- 发现异常不等于知道正确修法,检查结果不能自动变成删除或内容补写;
+- 同一组件的检查和修改逻辑如果发生漂移,会使检查报告失去可信度,必须共用定位逻辑;
+- Markdown AST 适合识别结构,但全篇重新输出可能改变未被组件选中的内容,不作为忠实清洗的默认方式;
+- 位置必须绑定具体输入快照,否则一次修改后继续使用旧范围会改错内容;
+- 修改记录用于解释实际变化,不能代替针对组件和项目流程的测试。
diff --git a/research-wiki/design/0003-first-executable-core-architecture.md b/research-wiki/design/0003-first-executable-core-architecture.md
new file mode 100644
index 0000000..d84add5
--- /dev/null
+++ b/research-wiki/design/0003-first-executable-core-architecture.md
@@ -0,0 +1,388 @@
+# 0003:Mdpolish 第一版可执行核心架构
+
+## 状态
+
+草稿,待批准。本设计细化已冻结的 `0002`,不替代或修改其中的选择。
+
+本设计批准后,才授权创建这里列出的 Python 包、测试和工程配置,并实现不含真实清洗规则的最小核心。
+在批准前,本仓库仍然没有可运行的清洗工具。
+
+## 1. 问题
+
+`0002` 已经确定:项目显式组合组件,流水线只接收 Markdown,组件先检查再提出精确修改,清洗只执行一轮,
+最后进行只读复查。
+
+这些总体原则还不足以开始实现。目前尚未确定:
+
+- 组件通过什么 Python 接口报告问题;
+- 中文文本的位置如何表示;
+- 候选修改怎样绑定当前 Markdown,避免旧位置误改新文本;
+- 多项修改如何保证全部成功或全部不执行;
+- 检查错误、清洗错误和最终未稳定如何返回;
+- 第一版源码、测试和依赖边界是什么。
+
+如果这些问题留给实现时临时决定,组件很容易各自返回不同格式,或者直接生成整篇新 Markdown,最终无法统一
+验证和追踪修改。
+
+## 2. 目标与非目标
+
+目标:
+
+- 建立只处理内存字符串的 Python 核心;
+- 确定快照、问题、候选修改、实际改动和运行结果的职责;
+- 让具体组件只负责定位问题和提出精确修改,不直接改写整篇 Markdown;
+- 让公共修改执行器统一验证范围、冲突、原子性和实际改动记录;
+- 实现组件独立调用、流水线检查、单轮清洗和最终只读复查;
+- 使用测试专用组件验证组合机制,不把真实清洗语义混入架构实现。
+
+非目标:
+
+- 不实现任何面向论文、GovDoc 或其他项目的真实清洗组件;
+- 不引入 Markdown parser、AST、HTML parser 或其他运行依赖;
+- 不读取或写入 Markdown 文件,不提供 CLI、批处理或服务接口;
+- 不建设配置文件、profile 文件格式、插件自动发现或第三方插件市场;
+- 不定义 JSON、数据库或长期审计文件格式;
+- 不读取 PDF、图片、转换器 JSON、项目目录或外部真实材料;
+- 不承诺第一版组件扩展接口已经长期稳定。
+
+## 3. 名称与技术边界
+
+- 项目展示名暂定为 `mdpolish`;
+- Python 分发名和导入名使用小写 `mdpolish`;
+- 源码包位于 `src/mdpolish/`;
+- 第一版支持 Python 3.11 及以上版本;
+- 运行时只使用 Python 标准库;
+- 使用 `pyproject.toml` 管理项目,构建后端采用 Hatchling;
+- 开发检查使用 pytest、Ruff 和 mypy,具体依赖版本只在 `pyproject.toml` 中维护。
+
+选择 Python 3.11 是为了使用现代类型能力,同时不把本地 Python 3.13 环境变成最低要求。第一版不引入解析器,
+是为了先验证组件和修改协议;具体结构规则需要什么解析能力,由后续真实组件 design 决定。
+
+## 4. 方案比较与决定
+
+### 4.1 组件直接返回整篇新 Markdown
+
+接口最简单,但流水线无法确认组件实际改了哪里,也无法统一检查过期位置、范围冲突和部分失败。组件的检查逻辑
+还可能与修改逻辑逐渐分离。
+
+不采用。
+
+### 4.2 组件返回 AST,由流水线重新输出全文
+
+适合格式化器和结构化编译,但会让没有被组件选中的 Markdown 也发生书写形式变化。第一版还需要先选择 parser、
+扩展方言和 renderer,超出了当前最小闭环。
+
+不采用。
+
+### 4.3 组件报告问题及精确文本修改
+
+组件只读取当前快照,返回问题和可选候选修改;公共执行器验证并应用修改。这样可以保留原文、统一审计,
+也可以拒绝过期或重叠修改。
+
+采用此方案。
+
+## 5. 总体结构
+
+```text
+Markdown 字符串
+ │
+ ▼
+DocumentSnapshot
+ │
+ ▼
+Component._check_snapshot()
+ │
+ ├── Issue
+ │ └── 可选 ProposedChange
+ │ └── 一个或多个 TextEdit
+ │
+ ▼
+公共修改执行器
+ │
+ ├── 验证快照、范围、原文和冲突
+ ├── 原子应用当前组件的全部自动修改
+ └── 生成 Change 和新 DocumentSnapshot
+ │
+ ▼
+下一个 Component
+ │
+ ▼
+最终快照只读复查
+ │
+ ▼
+TransformResult
+```
+
+核心分为四层:
+
+- **数据模型:** 不可变地表达快照、范围、问题、修改、错误和结果;
+- **组件基类:** 提供统一的 `check` 和 `transform` 行为,只把问题定位留给具体组件;
+- **修改执行器:** 是唯一能够把 `TextEdit` 应用到 Markdown 的位置;
+- **流水线:** 负责组件顺序、检查错误汇总、清洗失败停止和最终只读复查。
+
+文件读写、终端输出和未来配置不进入这四层。
+
+## 6. 快照与位置
+
+### 6.1 `DocumentSnapshot`
+
+快照至少包含:
+
+- `markdown`:当前完整 Markdown 字符串;
+- `sha256`:`markdown.encode("utf-8")` 的 SHA-256 十六进制摘要。
+
+哈希由库根据 Markdown 计算,调用方不能传入一个自称匹配的哈希。空字符串是合法输入。库不自动改变编码、
+换行符、Unicode 规范形式或文件末尾换行。
+
+第一版快照不包含路径、文件名、PDF、图片、项目 ID、时间戳或任意外部元数据。
+
+### 6.2 `TextSpan`
+
+文本范围使用 Python 字符串下标:
+
+- `start` 包含;
+- `end` 不包含;
+- 必须满足 `0 <= start <= end <= len(markdown)`。
+
+Python 字符串下标是第一版唯一权威位置。行号和列号由快照与下标计算,只用于显示,不作为修改依据。
+这里的位置按 Unicode 码点工作,不按 UTF-8 字节或用户看到的字形数量工作。
+
+## 7. 问题与候选修改
+
+### 7.1 处理能力
+
+问题的处理能力固定为三种:
+
+- `detect_only`:只报告,不包含候选修改;
+- `suggestion`:包含候选修改,但自动清洗不应用;
+- `auto_fix`:包含能够自动应用的候选修改。
+
+`detect_only` 如果携带候选修改,或者 `auto_fix` 没有候选修改,都属于组件契约错误。
+
+### 7.2 `Issue`
+
+一条问题至少包含:
+
+- 产生问题的快照哈希;
+- 组件标识和组件版本;
+- 问题范围;文档级问题可以没有具体范围;
+- 简明说明和可复核证据;
+- 处理能力;
+- 可选的候选修改。
+
+问题只描述某个快照中的事实。快照变化后,它可以继续作为历史记录,但不能直接用于修改新快照。
+
+### 7.3 `ProposedChange` 与 `TextEdit`
+
+一个候选修改表示解决一条问题所需的完整动作,可以包含一个或多个 `TextEdit`。同一候选修改中的编辑必须
+全部应用或全部不应用。
+
+每个 `TextEdit` 至少包含:
+
+- 目标快照哈希;
+- `TextSpan`;
+- `expected_text`:修改前该范围必须准确等于的原文;
+- `replacement`:替换内容。
+
+插入使用 `start == end` 和空 `expected_text`;删除使用空 `replacement`。`replacement` 与
+`expected_text` 完全相同的无效修改视为组件契约错误,不生成虚假的改动记录。
+
+## 8. 组件接口
+
+`Component` 使用抽象基类,而不是仅使用结构化 `Protocol`。公共基类负责保持独立调用与流水线调用的语义一致。
+
+每个具体组件只实现以下扩展点:
+
+- 组件标识、组件版本和当前参数;
+- `_check_snapshot(snapshot)`:读取快照并返回问题,不产生副作用。
+
+组件标识使用稳定的小写字符串;同一语义不能因为改了 Python 类名就更换标识。组件版本使用
+`MAJOR.MINOR.PATCH` 形式。组件参数必须能表示为确定的只读基础数据,流水线将实际参数记录到结果中。
+
+公共基类提供:
+
+- `check(markdown)`:建立快照,执行该组件检查并返回检查结果;
+- `transform(markdown)`:等价于只包含该组件的流水线清洗。
+
+具体组件不得重写公共 `check`、`transform` 或修改执行器。类型声明使用 `final` 标记这些入口;代码评审和测试
+同时检查组件只实现规定扩展点。
+
+组件必须是确定性的:相同 Markdown、组件版本和参数必须产生相同问题及候选修改。组件不能读取文件、网络、
+环境变量、当前时间或随机数,也不能修改传入对象和外部状态。
+
+第一版流水线禁止出现两个相同组件标识的实例。需要用不同参数运行同一组件两次时,应由项目重新考虑组件边界,
+不能依靠重复 ID 制造含义不清的执行记录。
+
+## 9. 公共修改执行器
+
+流水线不会把多个组件在旧快照上产生的修改集中到最后再应用。每个组件都针对当前快照检查;该组件结束后,
+它的自动修改作为一个批次交给公共执行器。
+
+执行器按以下顺序验证当前组件的整个批次:
+
+1. 问题和编辑的快照哈希都等于当前快照哈希;
+2. 所有范围合法;
+3. `markdown[start:end]` 与 `expected_text` 完全一致;
+4. 不存在重复编辑、范围重叠或同一位置的多个插入;
+5. 所有编辑都会实际改变内容。
+
+相邻但不重叠的范围可以同时修改。验证全部通过后,执行器按位置从后向前应用编辑,避免前面的修改使后面的
+下标失效。任意一项验证失败,当前组件的整个批次都不应用。
+
+这里的“当前组件整个批次”包括该组件本次检查产生的所有 `auto_fix` 候选修改。`suggestion` 和 `detect_only`
+问题永远不进入自动修改批次。
+
+### 9.1 `Change`
+
+每个实际应用的 `TextEdit` 产生一条 `Change`,至少记录:
+
+- 组件标识和版本;
+- 组件在流水线中的执行位置;
+- 修改理由;
+- 修改前范围、`before` 和 `after`;
+- 修改前后的快照哈希;
+- 所属候选修改,使一次多位置动作可以整体追踪。
+
+修改记录描述实际发生的变化,不复制未执行的建议,也不把问题记录冒充改动记录。
+
+## 10. 检查流程
+
+`Pipeline.check(markdown)` 建立一个输入快照。所有组件按照项目给出的顺序检查同一个快照,Markdown 全程不变。
+
+- 一个组件正常完成后,流水线按原顺序收集问题;
+- 一个组件抛出异常或返回违反契约的数据时,流水线记录结构化组件错误,然后继续检查后续组件;
+- 检查结果记录输入哈希、实际组件顺序、版本、参数、问题和错误;
+- 只要存在组件错误,检查结果就不能表示为完整成功,但已经获得的问题仍然保留。
+
+组件异常不能被静默忽略。错误至少记录组件身份、错误阶段、异常类型和安全的错误说明。是否保存 traceback
+留在内存实现中决定,不向未来报告格式作承诺。
+
+## 11. 清洗流程与运行状态
+
+### 11.1 单轮清洗
+
+`Pipeline.transform(markdown)` 按以下步骤运行:
+
+1. 建立输入快照;
+2. 按顺序让当前组件检查当前快照;
+3. 收集该组件的 `auto_fix` 候选修改;
+4. 原子验证并应用当前组件的整个修改批次;
+5. 有修改时建立新快照,下一个组件只能读取新快照;
+6. 所有组件各执行一次后,对最终快照运行最终只读复查。
+
+清洗阶段如果组件异常、返回无效数据或修改批次冲突,流水线立即停止,不继续运行后面的组件,也不进行最终复查。
+之前组件已经完成的内存修改和 `Change` 保留在失败结果中,但不能作为成功输出。
+
+### 11.2 最终只读复查
+
+最终复查让所有已选组件按照原顺序检查最终快照,不应用任何修改。
+
+- 发现仍可由已选组件 `auto_fix` 的问题:状态为 `unstable`;
+- 只剩 `detect_only` 或 `suggestion` 问题:保留为未解决问题,不妨碍核心流水线成为 `success`;
+- 最终复查发生组件错误:状态为 `failed`;复查继续检查其余组件,以汇总只读阶段的错误和问题。
+
+是否因为未解决的 `detect_only` 或 `suggestion` 问题阻止下游使用,由使用项目决定,不写死在共用核心中。
+
+### 11.3 状态与文本字段
+
+清洗状态至少包括:
+
+- `success`:单轮清洗和最终复查完整完成,没有仍可自动修复的问题;
+- `failed`:组件执行、数据契约或修改验证失败;
+- `unstable`:修改阶段没有错误,但最终复查仍发现已选组件可自动修复的问题。
+
+为避免调用方忽略状态并误用半成品:
+
+- `success` 只提供 `output_markdown`,`partial_markdown` 为空;
+- `failed` 和 `unstable` 不提供 `output_markdown`,只提供诊断用的 `partial_markdown`;
+- 三种状态都记录输入哈希、当前内容哈希、组件清单、实际改动、未解决问题和错误。
+
+输入本身从不被原地覆盖。即使输出内容与输入完全相同,只要最终复查通过,也可以是零改动的 `success`。
+
+## 12. 源码与测试结构
+
+批准后创建以下最小结构:
+
+```text
+pyproject.toml
+src/
+└── mdpolish/
+ ├── __init__.py
+ ├── py.typed
+ ├── component.py
+ ├── edits.py
+ ├── models.py
+ └── pipeline.py
+tests/
+├── test_component.py
+├── test_edits.py
+├── test_models.py
+└── test_pipeline.py
+```
+
+- `models.py` 只放不可变的数据模型和状态枚举;
+- `component.py` 放组件基类和组件契约验证;
+- `edits.py` 放纯文本修改验证与应用;
+- `pipeline.py` 放检查、单轮清洗和最终复查编排;
+- `__init__.py` 只导出第一版公共对象;
+- `py.typed` 声明分发包提供类型信息;
+- 测试辅助组件只存在于 `tests/`,不发布成示例清洗能力。
+
+如果实现中发现这些边界导致循环依赖,可以在不改变公共职责的前提下机械拆分模块;新增新的业务层、运行依赖
+或公共入口仍需要重新评审。
+
+## 13. 测试专用组件与验收
+
+第一版不借真实文档验证,也不把测试字符串包装成正式清洗规则。测试中建立最小假组件,分别产生固定问题、
+建议修改、精确替换、组件异常和连锁影响。
+
+至少覆盖:
+
+- 空 Markdown、中文、换行和 Unicode 组合字符;
+- 插入、删除、替换以及一次问题包含多个编辑;
+- 相邻范围可以应用,重叠范围、重复编辑和同点插入明确失败;
+- 哈希过期、范围越界、`expected_text` 不符和无效修改明确失败;
+- 当前组件批次全部成功或全部不应用;
+- 检查流程记录错误后继续其他组件;
+- 清洗流程遇错立即停止,并区分成功输出与部分文本;
+- 后一个组件制造前一个组件的新问题时,最终复查返回 `unstable`,且不自动开始第二轮;
+- `suggestion` 和 `detect_only` 不被自动应用;
+- 组件独立运行与单组件流水线结果一致;
+- 成功流水线再次运行不产生实际改动;
+- 输入字符串不被修改,相同输入、组件和参数产生相同结果。
+
+批准并实现后,基础验证至少包括:
+
+```bash
+ruff check .
+mypy src tests
+pytest
+```
+
+README 届时记录实际可用命令。只有这些命令真实运行成功后,才能报告对应检查通过。
+
+## 14. 风险与代价
+
+- **没有公共 AST:** 结构复杂的组件以后可能需要重复解析;先保持解析细节为组件内部实现,等真实规则证明需要
+ 共享分析后再设计。
+- **Python 字符位置不是跨语言协议:** 第一版只承诺 Python API;以后输出机器可读跨语言格式时,需要单独定义
+ 坐标语义,不能直接假设 JavaScript UTF-16 或 UTF-8 byte offset 与之相同。
+- **最终复查增加检查成本:** 选中的组件最多检查两次,但换来对组合连锁影响的明确判断,第一版接受此代价。
+- **组件级原子批次可能放弃部分正确修改:** 这是有意的保真选择;组件应修复自己的冲突,而不是让流水线猜测。
+- **失败结果仍含部分 Markdown:** 使用独立字段并让成功输出为空,降低误用风险;未来文件适配层不得默认写出
+ `partial_markdown`。
+- **结果可能包含原文片段:** 第一版结果只存在内存,不建设日志和持久化;以后新增 reporter 时必须单独评审
+ 脱敏和保存边界。
+- **组件版本需要维护:** 组件语义、定位或修改行为变化时必须更新版本,不能只改代码而保留相同审计身份。
+
+## 15. 批准后的实施边界
+
+批准本设计只授权:
+
+1. 创建第 12 节列出的工程与测试文件;
+2. 实现第 5 至 11 节描述的内存核心;
+3. 创建测试专用假组件并完成第 13 节验证;
+4. 根据真实实现更新 README 当前阶段和基础检查;
+5. 实现完成后新增 `research-wiki/explanation/` 文档,解释当前实际架构。
+
+批准本设计不授权实现真实清洗规则,不授权读取真实数据,不授权文件覆盖、CLI、发布、提交或推送。
diff --git a/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md b/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md
index 381ab72..e0d4e95 100644
--- a/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md
+++ b/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md
@@ -11,7 +11,7 @@ ClinDB-ReviewBench 是师姐的论文清洗项目。`data/` 下当前 5 份 DOI
(JAMA、EJHF、Statistics in Medicine/arXiv、Springer/arXiv、Disaster Med Public Health Preparedness)
是它的首批输入,未来会继续扩充同源转换产物。
-本仓库(govdoc-md-cleaner)为该项目的数据提供清洗能力;ClinDB-ReviewBench 通过 profile
+本仓库(mdpolish)为该项目的数据提供清洗能力;ClinDB-ReviewBench 通过 profile
表达论文场景的规则组合,不把论文专属规则写进通用核心。
## 2. 第一版清洗目标
diff --git a/research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md b/research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md
new file mode 100644
index 0000000..a54bf7c
--- /dev/null
+++ b/research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md
@@ -0,0 +1,143 @@
+# HTML 表格清洗专题调研:业界工具在 Markdown 清洗中如何处理 HTML 表格
+
+> 状态:调研记录,尚未进入任何 design。
+>
+> 调研日期:2026-08-21。
+>
+> 定位:回答一个具体问题——Markdown 清洗中遇到 HTML 表格,业界工具实际怎么做。
+> 结论用于印证或修正 [`../reference/GOVDOC_SAAS_CLEANING_SCOPE.md`](../reference/GOVDOC_SAAS_CLEANING_SCOPE.md)
+> 中 T001/T002 的方向,不构成对任何方案的批准。
+
+## 1. 结论先行
+
+围绕“Markdown 里的 HTML 表格怎么办”,生态里的工具分成三种流派:
+
+| 流派 | 做法 | 代表 |
+|---|---|---|
+| 生成端保真 | 复杂表格直接输出 HTML,不做管道表格 | MinerU、Docling(HTML/JSON 视图) |
+| 强行归一 | 全部转成管道表格,合并单元格静默损坏或内容重复 | Turndown + gfm 插件、Docling(Markdown 视图) |
+| 保真派 | 容错解析 → 校验网格 → 简单表转 GFM、复杂表保留 HTML/JSON | 本项目审计 T001 方向、Pandoc(grid tables / AST) |
+
+支撑这张表的共同事实是:**GFM 管道表格语法在原理上表达不了合并单元格**。三种流派只是对这条约束的
+不同回答——绕开它、硬转它、或者按能力分流。
+
+## 2. 语法能力边界:GFM 管道表格没有合并单元格写法
+
+Pandoc 手册对各家 Markdown 表格方言的原文描述(2026-08-21 从官方 MANUAL 核实):
+
+| 方言 | 合并单元格 | 单元格内块级元素 |
+|---|---|---|
+| pipe tables(≈GFM 表格) | 不支持(单元格不能跨多行) | 不能包含块级元素 |
+| multiline tables | 明确不支持跨行/跨列单元格 | 可以 |
+| grid tables | **支持**("Cells can span multiple columns or rows") | 可以 |
+| HTML `
` | 原生 `rowspan`/`colspan` | 可以 |
+
+grid tables 是唯一支持合并单元格的 Markdown 表格语法,但它是 Pandoc 扩展,GitHub 不渲染,
+对“清洗后还要在 GFM 渲染器里查看”的场景不可用。所以在 GFM 方言内部,合并单元格没有任何无损写法;
+想保真只能保留 HTML,或存结构化 JSON。
+
+Pandoc 手册同时警告:从表达能力更强的格式转换时"some document elements, such as complex tables,
+may not fit","can be expected to be lossy"。
+
+来源:[Pandoc MANUAL - Tables](https://pandoc.org/MANUAL.html)
+
+## 3. 生成端:PDF→Markdown 转换器为什么输出 HTML 表格
+
+MD 文档里出现 HTML 表格,通常不是 bug,而是转换器面对合并单元格(`rowspan`/`colspan`)、
+多级表头等管道语法表达不了的结构时的标准回退。
+
+| 工具 | 表格输出策略 | 依据 |
+|---|---|--- |
+| [MinerU](https://github.com/opendatalab/MinerU) | 所有表格一律输出 HTML 嵌在 Markdown 中,不做管道表格;支持跨页表格拼接 | 官方 README 功能列表 |
+| [Docling](https://docling.org/) | 内部 TableFormer 模型专门恢复合并单元格;同一文档可导出 HTML / Markdown / JSON 三种视图 | 官网能力页 + docling-core 2.92.0 源码 |
+
+对本项目的含义:HTML 表格是合法的中间形态,不是待清除的垃圾。清洗目标不是“消灭 HTML 表格”,
+而是“识别哪些表格结构正确、哪些在转换中损坏”。
+
+## 4. 反面教材一:Turndown 静默产出错位表格
+
+[Turndown](https://github.com/mixmark-io/turndown) 是最流行的 HTML→Markdown 转换库之一,
+[turndown-plugin-gfm 的 tables.js](https://github.com/mixmark-io/turndown-plugin-gfm/blob/master/src/tables.js)
+(v1.0.2,2018 年发布后基本未改)只做两件事:
+
+1. 首行不是全 ``(无表头行)的表格:保留 HTML 不转;
+2. 其余表格:按 DOM 位置逐格输出管道符。
+
+它**完全没有 `colspan`/`rowspan` 的处理代码**。合并单元格不触发上面的回退,直接按 DOM 位置压扁,
+转出列数不齐的坏表,且**不报任何错**。
+
+对本项目的含义:
+
+- “无表头就不转”是能力判断驱动的回退,这个思想是对的;但它的能力判断漏掉了合并单元格;
+- 连最流行的转换库在这里都会静默弄坏表格——审计要求“先建 DOM、校验网格、禁止正则替换”有真实事故支撑;
+- 选 HTML→Markdown 转换库时,“是否处理 span”必须列入验证项,不能信 README 宣称。
+
+## 5. 反面教材二:Docling 的 Markdown 导出重复合并单元格内容
+
+读了 docling-core 2.92.0 的源码(wheel 解包,2026-08-21):
+
+- 内部 `TableCell` 带 `row_span`/`col_span` 和起止行列偏移;`TableData.grid` 属性把同一个 cell 对象
+ **铺满**它覆盖的每个 (行, 列) 位置;
+- **HTML 序列化器**(`transforms/serializer/html.py`):遍历网格时跳过被覆盖的续位
+ (`rowstart != i` 或 `colstart != j` 时 `continue`),只在起始位置输出,并正确带上
+ `rowspan="N"`/`colspan="N"`——语义保真;
+- **Markdown 序列化器**(`transforms/serializer/markdown.py` 的 `MarkdownTableSerializer`):
+ 直接遍历铺满后的 grid,每个位置都输出 `col.text`——一个 `row_span=3` 的单元格内容在 Markdown
+ 输出里**重复出现 3 次**。转义只处理换行和管道符(`\n`→空格、`|`→`|`),再用
+ tabulate `tablefmt="github"` 输出管道表格。
+
+即 Docling 面对“Markdown 视图必须有合并单元格”的需求,选择了**内容重复**来保住矩形形状。
+这是“强行转管道表格会丢语义”的又一个实例,和 Turndown 的压扁是同一根源的两种表现。
+
+对本项目的含义:
+
+- “转 GFM”不是免费的格式变换,每一家实现都发明了自己的有损映射;
+- 如果未来用 Docling 做回源提取(调研报告第 6.1 节的候选方向),它的 Markdown 导出不能直接当作
+ 保真输出使用,需要用它的 `DoclingDocument` JSON 或 HTML 视图;
+- 审计 T001 说“强行转 GFM 会丢失语义”,这里的机制证据是:跨行列单元格要么被压扁(Turndown)、
+ 要么被重复(Docling)、要么失去合并关系本身(都失去 `rowspan`/`colspan` 语义)。
+
+## 6. 清洗与格式化工具:主流选择是“不动 HTML 块”
+
+| 工具 | 对 Markdown 内 HTML 表格的行为 | 来源 |
+|---|---|---|
+| remark / mdformat | raw HTML 当不透明块原样传递,不重新格式化、不转换 | [mdformat](https://mdformat.readthedocs.io/) 官方文档(核心保证是格式化前后 AST 一致,HTML 块不在处理范围) |
+| rumdl MD033(no-inline-html) | 报告 ``(它有 Markdown 等价物),但 `fix` 只自动转 `em/strong/code/a/img/br/hr` 等行内简单标签,**不含表格**;`allowed-inside = ["table"]` 可整块豁免 | 本地 `reference/rumdl/docs/md033.md` |
+| rumdl MD056(table-column-count) | 校验每行列数与表头一致,自动修复方式是补/删空单元格 | 本地 `reference/rumdl/docs/md056.md` |
+| rumdl MD058(blanks-around-tables) | GFM 表格前后补空行 | 本地 `reference/rumdl/docs/md058.md` |
+
+两点值得注意:
+
+1. **没有主流工具自动把 HTML 表格转成 GFM 表格。** 连以“消灭 HTML”为目标的 MD033 都把表格留在
+ “只报告、不修复”的范围里——因为工具作者知道这个转换会弄坏表格。
+2. **MD056 的自动修复方向与本项目审计相反。** 审计 T002 反对“补空单元格凑齐列数通过语法检查”,
+ 因为这可能掩盖静默丢列;MD056 恰恰把补空作为修复手段。借用这类规则时必须关掉它的自动修复,
+ 只取检测部分。
+
+## 7. 与既有材料的关系
+
+- 审计 T001(`../reference/GOVDOC_SAAS_CLEANING_SCOPE.md` 第 4.4 节)的五步法——容错解析建 DOM、
+ 展开 rowspan/colspan 校验二维网格、简单矩形表转 GFM / 复杂表保留 HTML 或 JSON、拆多行、
+ 回源确认幻觉——与本次调研的所有正面证据一致,未发现需要修正的点;
+- [`markdown-cleaning-ecosystem-research-2026-08-20.md`](markdown-cleaning-ecosystem-research-2026-08-20.md)
+ 第 5 节的推荐流程(html5lib 容错解析、禁正则、按合并单元格分流)同样得到印证;
+- 新增的证据是反面案例的具体机制:Turndown 的压扁路径、Docling Markdown 视图的重复路径、
+ Pandoc 手册的方言能力原文、MD056 修复方向与审计相反。
+
+## 8. 对本项目的待决问题(不是结论)
+
+以下问题在对应 design 时需要回答,本调研只提供背景:
+
+1. 简单/复杂表格的分界线,除了“有无合并单元格”,是否还要看单元格内块级元素、嵌套表格和表头层级;
+2. 复杂表保留的“规范 HTML”具体规范到什么程度(属性白名单?标签重排?缩进策略?);
+3. JSON grid 的格式是否对齐 Docling 的 `TableCell`(row_span/col_span/offset 字段),
+ 还是自定义 schema——涉及与未来回源 adapter 的成本权衡;
+4. 无表头表格(Turndown 的回退条件)按哪种流派处理:补合成表头转 GFM,还是保留 HTML。
+
+## 9. 验证状态
+
+- Pandoc 手册、Turndown 源码、MinerU README、Docling 官网:2026-08-21 通过网络核实;
+- docling-core 2.92.0:下载 wheel 解包读源码核实,涉及
+ `MarkdownTableSerializer.serialize`、`TableData.grid`、HTML 序列化器的 span 处理;
+- rumdl 三条规则:读本地 `reference/rumdl/docs/`(该目录为镜像副本,以 rumdl 上游为准);
+- 未验证:各工具在本项目真实数据上的实际表现——需要等对应组件 design 批准后用受控样本测试。
From 3edeeaf30e2dd242b63d263d75923ce3cb4978a9 Mon Sep 17 00:00:00 2001
From: Bepr4 <63661977@qq.com>
Date: Sat, 22 Aug 2026 01:03:03 +0800
Subject: [PATCH 2/5] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=AC=AC=E4=B8=80?=
=?UTF-8?q?=E7=89=88=E5=86=85=E5=AD=98=E6=B8=85=E6=B4=97=E6=A0=B8=E5=BF=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
落实不可变数据契约、组件基类、原子修改执行器与顺序流水线。补充稳定性复查、审计记录、测试和当前机制文档。
---
README.md | 44 ++-
pyproject.toml | 37 ++
...0003-first-executable-core-architecture.md | 343 +++++++++++-------
research-wiki/explanation/.gitkeep | 0
.../explanation/first-executable-core.md | 149 ++++++++
src/mdpolish/__init__.py | 44 +++
src/mdpolish/component.py | 105 ++++++
src/mdpolish/edits.py | 150 ++++++++
src/mdpolish/models.py | 231 ++++++++++++
src/mdpolish/pipeline.py | 303 ++++++++++++++++
src/mdpolish/py.typed | 1 +
tests/test_component.py | 121 ++++++
tests/test_edits.py | 199 ++++++++++
tests/test_models.py | 148 ++++++++
tests/test_pipeline.py | 293 +++++++++++++++
15 files changed, 2023 insertions(+), 145 deletions(-)
create mode 100644 pyproject.toml
delete mode 100644 research-wiki/explanation/.gitkeep
create mode 100644 research-wiki/explanation/first-executable-core.md
create mode 100644 src/mdpolish/__init__.py
create mode 100644 src/mdpolish/component.py
create mode 100644 src/mdpolish/edits.py
create mode 100644 src/mdpolish/models.py
create mode 100644 src/mdpolish/pipeline.py
create mode 100644 src/mdpolish/py.typed
create mode 100644 tests/test_component.py
create mode 100644 tests/test_edits.py
create mode 100644 tests/test_models.py
create mode 100644 tests/test_pipeline.py
diff --git a/README.md b/README.md
index 4678a05..e482b6b 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,9 @@
格式噪声、结构损坏、内容异常、修改追踪和多用途派生问题。各项目共享通用清洗能力,再通过独立配置或
profile 表达论文、政务文档、RAG、文档对比等不同需求。
-仓库当前仍处于研究和方案设计阶段:用于澄清问题、记录设计选择、积累可复核证据,并在方案获得确认后
-再建立实现。它目前不是可安装的 Python 包,也不提供命令行工具或生产接口。
+仓库当前已从纯文档治理进入第一版核心实现阶段:已经提供可安装的 Python 内存处理包和测试,用于验证
+组件组合、精确修改和审计协议。仓库仍不提供真实清洗规则、命令行工具、文件读写适配器或生产接口,
+因此目前还不是拿来即可清洗文档的成品工具。
## 当前阶段
@@ -25,8 +26,10 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
- 2026-08-21 明确本项目定位为实验室共用库;GovDoc 和论文清洗都是使用场景,不是核心边界。
- 2026-08-21 批准并冻结 `research-wiki/design/0002-composable-cleaning-pipeline.md`,确定只接收 Markdown、
项目显式组装组件、单轮修改加最终只读复查的总体组织方式。
-- 2026-08-21 建立 `research-wiki/design/0003-first-executable-core-architecture.md` 草稿,等待评审第一版
- Python 内存核心、精确修改协议、错误语义和测试边界。
+- 2026-08-22 批准并冻结 `research-wiki/design/0003-first-executable-core-architecture.md`,确定第一版只建设
+ Python 内存自动清洗核心:组件只提出确定可执行的精确修改,不同时建设独立检查、人工建议或真实清洗规则。
+- 2026-08-22 按 `0003` 实现第一版内存核心和测试:包括不可变数据契约、组件基类、原子修改执行器、
+ 顺序流水线和最终稳定性复查;58 项测试以及 Ruff、mypy 检查均通过。
- 2026-08-21 完成 HTML 表格清洗专题调研
(`research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md`):核实 Pandoc 表格
方言能力边界、Turndown 不处理合并单元格、Docling Markdown 导出重复合并单元格内容、MinerU 全
@@ -34,9 +37,10 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
- 2026-08-21 仓库由 `govdoc-md-cleaner` 更名为 `mdpolish`,GitHub 远程仓库与本地目录同步改名;
冻结的 design 记录和带日期的 scratch 笔记保留当时的旧名。
-当前没有清洗算法、可执行命令、运行依赖、测试套件或函数级输入输出契约。`0002` 只批准了总体组织方式;
-调研报告中的解析器、内部 IR、具体 profile 和实施路线仍是候选方案,尚未批准。
-目录存在只代表文档落点已经建立,不代表相应能力已经完成。
+当前已有只处理内存字符串的底层执行机制和函数级契约,运行时只依赖 Python 标准库。组件可以针对当前
+Markdown 快照提出精确修改,流水线负责原子应用、审计记录、失败隔离和最终稳定性复查。仓库尚无任何正式
+清洗组件,因此不能把测试专用假组件或核心执行成功理解为已经具备论文、GovDoc、表格或图片清洗能力。
+调研报告中的解析器、内部 IR、具体 profile 和真实清洗规则仍是候选方案,尚未批准。
## 服务对象与复用目标
@@ -51,10 +55,10 @@ GovDoc 目录、具体客户名称或某一转换器的固定输出路径。
## 面向复用的设计原则
-- **通用核心**:只接收 Markdown,提供结构检查、异常检测和可审计变换,不读取 PDF、图片或转换器 JSON;
+- **通用核心**:只接收 Markdown;第一版只执行确定、可审计的精确修改,不读取 PDF、图片或转换器 JSON;
- **输入边界**:PDF/OCR/DOCX/HTML 转换和外部材料核验由使用项目或上游流程负责,不写入共用组件契约;
- **项目 profile**:论文、GovDoc、对比、RAG、公开脱敏等规则独立组合,不互相污染默认行为;
-- **保真优先**:不确定内容默认保留或进入人工确认,不能为了格式整齐改写业务或学术内容;
+- **保真优先**:不确定内容默认保留;当前核心不猜测修改,也不承担人工确认流程;
- **可复现**:规则、配置、输入哈希、输出和每次变更都可以追踪;
- **可扩展**:新增项目在自身边界处理上游适配,并主要组合或补充组件和 profile,而不是复制一套清洗器。
@@ -82,6 +86,9 @@ mdpolish/
├── AGENTS.md
├── CLAUDE.md
├── README.md
+├── pyproject.toml # Python 包、构建和开发检查的唯一配置
+├── src/mdpolish/ # 第一版内存核心;不含真实清洗组件
+├── tests/ # 核心契约和组合行为测试
├── data/ # 本地项目数据;Git 忽略,未来按项目分区
└── research-wiki/
├── README.md
@@ -101,12 +108,23 @@ mdpolish/
3. `research-wiki/README.md`,确认文档应放在哪里;
4. 与任务直接相关的 `research-wiki/design/` 记录。
-下一项实质工作开始前,应评审并批准 `research-wiki/design/0003-first-executable-core-architecture.md`;
-草稿尚不授权创建源码、测试、依赖或公共接口。
+第一版核心的当前机制见 `research-wiki/explanation/first-executable-core.md`。下一项实质工作应从已经审计的问题中
+选择一个边界明确、能够唯一修复的真实清洗规则,新增 design 说明其语义、适用范围、误改风险和验收样例,
+经批准后再实现。解析器、CLI、文件适配器、profile 格式和独立检查能力仍需分别设计,不能从当前核心存在推导为
+已经获批。
## 当前可用检查
```bash
+# 建立隔离环境并安装包与开发检查工具
+python -m venv .venv
+.venv/bin/python -m pip install -e '.[dev]'
+
+# 第一版核心的基础验收
+.venv/bin/ruff check .
+.venv/bin/mypy src tests
+.venv/bin/pytest
+
# 两份 Agent 入口除标题外必须一致;无输出且退出码为 0 表示通过
diff -u <(tail -n +2 AGENTS.md) <(tail -n +2 CLAUDE.md)
@@ -117,4 +135,6 @@ find research-wiki -maxdepth 2 -type f | sort
git status --short
```
-当前没有测试命令;在真实实现和测试体系获批并落地前,不应声明测试通过。
+上述安装和三项基础验收已于 2026-08-22 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 9 个源码与
+测试文件无问题,pytest 共 58 项测试通过。`requires-python` 仍以 `pyproject.toml` 声明的 Python 3.11 及以上为准;
+本次结果不等于已经在每个受支持版本上完成兼容性验证。
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..198fb91
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,37 @@
+[build-system]
+requires = ["hatchling>=1.27,<2"]
+build-backend = "hatchling.build"
+
+[project]
+name = "mdpolish"
+version = "0.1.0"
+description = "Deterministic in-memory core for composing Markdown cleaning components"
+requires-python = ">=3.11"
+dependencies = []
+
+[project.optional-dependencies]
+dev = [
+ "mypy>=1.15,<2",
+ "pytest>=8.3,<10",
+ "ruff>=0.11,<1",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/mdpolish"]
+
+[tool.pytest.ini_options]
+addopts = "-ra"
+testpaths = ["tests"]
+
+[tool.ruff]
+target-version = "py311"
+line-length = 120
+extend-exclude = ["data", "reference"]
+
+[tool.ruff.lint]
+select = ["B", "E", "F", "I", "RUF", "UP"]
+ignore = ["RUF001"]
+
+[tool.mypy]
+python_version = "3.11"
+strict = true
diff --git a/research-wiki/design/0003-first-executable-core-architecture.md b/research-wiki/design/0003-first-executable-core-architecture.md
index d84add5..a8da29d 100644
--- a/research-wiki/design/0003-first-executable-core-architecture.md
+++ b/research-wiki/design/0003-first-executable-core-architecture.md
@@ -2,41 +2,53 @@
## 状态
-草稿,待批准。本设计细化已冻结的 `0002`,不替代或修改其中的选择。
+已批准并冻结(2026-08-22)。
-本设计批准后,才授权创建这里列出的 Python 包、测试和工程配置,并实现不含真实清洗规则的最小核心。
-在批准前,本仓库仍然没有可运行的清洗工具。
+`supersedes: 0002`(范围有限):本设计只替代 `0002` 中“所有组件必须提供公共 `check`”、
+`detect_only` / `suggestion` / `auto_fix` 三种处理状态,以及第一版同时建设检查流程的选择。
+`0002` 已确定的 Markdown 单一输入、项目显式组装、组件顺序执行、精确修改、单轮清洗、最终只读复查、
+文件适配层与项目 profile 边界继续有效。
+
+本次批准授权创建这里列出的 Python 包、测试和工程配置,并实现不含真实清洗规则的最小核心。
+批准本设计不等于实现已经存在;在对应代码和测试实际落地前,本仓库仍然没有可运行的清洗工具。
## 1. 问题
-`0002` 已经确定:项目显式组合组件,流水线只接收 Markdown,组件先检查再提出精确修改,清洗只执行一轮,
-最后进行只读复查。
+`0002` 已经确定总体组织方式,但把检查和清洗同时放进第一版:组件先返回三类问题,检查流程报告全部问题,
+清洗流程只应用其中的 `auto_fix`。
-这些总体原则还不足以开始实现。目前尚未确定:
+真实材料中确实存在幻觉、截断、损坏表格和断链图片等问题,但第一版核心只有 Markdown,没有原文、图片、
+人工审核或回源能力。此时报告“确认异常但无法处理”的内容不能形成闭环;`suggestion` 也没有批准、拒绝、
+快照复核和安全应用协议。提前把这些能力放入核心,会引入暂时没有消费者的 `Issue`、`CheckResult` 和状态分支。
-- 组件通过什么 Python 接口报告问题;
+第一版应先回答更小的问题:具体组件如何针对当前 Markdown 提出唯一、安全的精确修改,公共执行器如何保证
+这些修改没有过期、冲突或部分执行,流水线又如何确认一次清洗已经稳定。
+
+尚需确定:
+
+- 组件通过什么 Python 接口提出确定修改;
- 中文文本的位置如何表示;
-- 候选修改怎样绑定当前 Markdown,避免旧位置误改新文本;
+- 修改怎样绑定当前 Markdown,避免旧位置误改新文本;
- 多项修改如何保证全部成功或全部不执行;
-- 检查错误、清洗错误和最终未稳定如何返回;
+- 插入与替换在边界接触时如何判定冲突;
+- 组件错误、修改失败和最终未稳定如何返回;
- 第一版源码、测试和依赖边界是什么。
-如果这些问题留给实现时临时决定,组件很容易各自返回不同格式,或者直接生成整篇新 Markdown,最终无法统一
-验证和追踪修改。
-
## 2. 目标与非目标
目标:
-- 建立只处理内存字符串的 Python 核心;
-- 确定快照、问题、候选修改、实际改动和运行结果的职责;
-- 让具体组件只负责定位问题和提出精确修改,不直接改写整篇 Markdown;
-- 让公共修改执行器统一验证范围、冲突、原子性和实际改动记录;
-- 实现组件独立调用、流水线检查、单轮清洗和最终只读复查;
+- 建立只处理内存字符串的 Python 自动清洗核心;
+- 让组件只提出能够立即自动执行的精确修改,不报告无法处理的疑似问题;
+- 确定快照、候选修改、文本编辑、实际改动、错误和运行结果的职责;
+- 让公共修改执行器统一验证范围、原文、冲突、原子性和实际改动记录;
+- 实现项目显式组装、组件顺序执行、单轮清洗和最终稳定性复查;
- 使用测试专用组件验证组合机制,不把真实清洗语义混入架构实现。
非目标:
+- 不实现独立文档检查、`detect_only`、人工建议或审核流程;
+- 不提供公共 `check`、`inspect`、预览或 dry-run 接口;
- 不实现任何面向论文、GovDoc 或其他项目的真实清洗组件;
- 不引入 Markdown parser、AST、HTML parser 或其他运行依赖;
- 不读取或写入 Markdown 文件,不提供 CLI、批处理或服务接口;
@@ -47,7 +59,7 @@
## 3. 名称与技术边界
-- 项目展示名暂定为 `mdpolish`;
+- 项目展示名为 `mdpolish`;
- Python 分发名和导入名使用小写 `mdpolish`;
- 源码包位于 `src/mdpolish/`;
- 第一版支持 Python 3.11 及以上版本;
@@ -60,24 +72,31 @@
## 4. 方案比较与决定
-### 4.1 组件直接返回整篇新 Markdown
+### 4.1 同时建设检查、建议和自动修改
-接口最简单,但流水线无法确认组件实际改了哪里,也无法统一检查过期位置、范围冲突和部分失败。组件的检查逻辑
-还可能与修改逻辑逐渐分离。
+这种方案能够描述长期可能需要的文档体检和人工确认,但第一版没有外部材料、审核入口和建议应用协议。
+不可执行的问题不会参与清洗,候选建议又会在快照变化后过期。
+
+第一版不采用。以后确有独立检查需求时,新增平行的 Inspector 设计,不在本轮预留三段状态。
+
+### 4.2 组件直接返回整篇新 Markdown
+
+接口简单,但流水线无法确认组件实际改了哪里,也无法统一检查过期位置、范围冲突和部分失败。
+组件还可能顺带改写没有被选中的内容。
不采用。
-### 4.2 组件返回 AST,由流水线重新输出全文
+### 4.3 组件返回 AST,由流水线重新输出全文
适合格式化器和结构化编译,但会让没有被组件选中的 Markdown 也发生书写形式变化。第一版还需要先选择 parser、
扩展方言和 renderer,超出了当前最小闭环。
不采用。
-### 4.3 组件报告问题及精确文本修改
+### 4.4 组件只提出能够自动执行的精确修改
-组件只读取当前快照,返回问题和可选候选修改;公共执行器验证并应用修改。这样可以保留原文、统一审计,
-也可以拒绝过期或重叠修改。
+组件读取当前快照,只返回前置条件严格、处理方式唯一的 `ProposedChange`。公共执行器统一验证并应用修改。
+如果某种输入存在歧义,组件直接忽略,不修改,也不在第一版中额外报告。
采用此方案。
@@ -90,24 +109,23 @@ Markdown 字符串
DocumentSnapshot
│
▼
-Component._check_snapshot()
+Component._propose_changes()
│
- ├── Issue
- │ └── 可选 ProposedChange
- │ └── 一个或多个 TextEdit
+ └── ProposedChange
+ └── 一个或多个 TextEdit
│
▼
公共修改执行器
│
├── 验证快照、范围、原文和冲突
- ├── 原子应用当前组件的全部自动修改
+ ├── 原子应用当前组件的全部修改
└── 生成 Change 和新 DocumentSnapshot
│
▼
下一个 Component
│
▼
-最终快照只读复查
+最终快照重新提议但不应用
│
▼
TransformResult
@@ -115,12 +133,12 @@ TransformResult
核心分为四层:
-- **数据模型:** 不可变地表达快照、范围、问题、修改、错误和结果;
-- **组件基类:** 提供统一的 `check` 和 `transform` 行为,只把问题定位留给具体组件;
+- **数据模型:** 不可变地表达快照、范围、候选修改、实际改动、错误和结果;
+- **组件基类:** 只定义组件身份、适用边界和 `_propose_changes()` 扩展点;
- **修改执行器:** 是唯一能够把 `TextEdit` 应用到 Markdown 的位置;
-- **流水线:** 负责组件顺序、检查错误汇总、清洗失败停止和最终只读复查。
+- **流水线:** 负责组件顺序、快照刷新、错误、失败停止和最终稳定性复查。
-文件读写、终端输出和未来配置不进入这四层。
+文件读写、终端输出、未来检查能力和配置不进入这四层。
## 6. 快照与位置
@@ -147,37 +165,25 @@ TransformResult
Python 字符串下标是第一版唯一权威位置。行号和列号由快照与下标计算,只用于显示,不作为修改依据。
这里的位置按 Unicode 码点工作,不按 UTF-8 字节或用户看到的字形数量工作。
-## 7. 问题与候选修改
+## 7. 候选修改与文本编辑
-### 7.1 处理能力
+### 7.1 `ProposedChange`
-问题的处理能力固定为三种:
+一个候选修改表示组件确认能够自动执行的一次完整动作。它至少包含:
-- `detect_only`:只报告,不包含候选修改;
-- `suggestion`:包含候选修改,但自动清洗不应用;
-- `auto_fix`:包含能够自动应用的候选修改。
+- 目标快照哈希;
+- 非空的修改理由;
+- 一个或多个 `TextEdit`。
-`detect_only` 如果携带候选修改,或者 `auto_fix` 没有候选修改,都属于组件契约错误。
+同一候选修改中的编辑必须全部应用或全部不应用。组件必须以确定顺序返回候选修改;通常按首个编辑在原文中的
+位置从前到后排列。流水线根据组件执行位置、目标快照哈希和候选修改序号分配当前结果内的确定性引用,
+组件不生成随机 ID。
-### 7.2 `Issue`
+第一版没有“不自动应用的候选修改”。不能确认唯一正确改法时,组件不返回 `ProposedChange`。
-一条问题至少包含:
+### 7.2 `TextEdit`
-- 产生问题的快照哈希;
-- 组件标识和组件版本;
-- 问题范围;文档级问题可以没有具体范围;
-- 简明说明和可复核证据;
-- 处理能力;
-- 可选的候选修改。
-
-问题只描述某个快照中的事实。快照变化后,它可以继续作为历史记录,但不能直接用于修改新快照。
-
-### 7.3 `ProposedChange` 与 `TextEdit`
-
-一个候选修改表示解决一条问题所需的完整动作,可以包含一个或多个 `TextEdit`。同一候选修改中的编辑必须
-全部应用或全部不应用。
-
-每个 `TextEdit` 至少包含:
+每个文本编辑至少包含:
- 目标快照哈希;
- `TextSpan`;
@@ -187,50 +193,65 @@ Python 字符串下标是第一版唯一权威位置。行号和列号由快照
插入使用 `start == end` 和空 `expected_text`;删除使用空 `replacement`。`replacement` 与
`expected_text` 完全相同的无效修改视为组件契约错误,不生成虚假的改动记录。
-## 8. 组件接口
+## 8. 组件接口与契约
-`Component` 使用抽象基类,而不是仅使用结构化 `Protocol`。公共基类负责保持独立调用与流水线调用的语义一致。
+`Component` 使用抽象基类。每个具体组件只实现以下扩展点:
-每个具体组件只实现以下扩展点:
-
-- 组件标识、组件版本和当前参数;
-- `_check_snapshot(snapshot)`:读取快照并返回问题,不产生副作用。
+- 稳定的组件标识;
+- 组件版本;
+- 当前参数;
+- 非空的适用边界说明;
+- `_propose_changes(snapshot)`:读取快照并返回能够自动执行的候选修改,不产生副作用。
组件标识使用稳定的小写字符串;同一语义不能因为改了 Python 类名就更换标识。组件版本使用
`MAJOR.MINOR.PATCH` 形式。组件参数必须能表示为确定的只读基础数据,流水线将实际参数记录到结果中。
-公共基类提供:
+适用边界至少说明组件处理的结构、严格前置条件和明确排除项。组件只对满足全部前置条件的内容提出修改;
+相似但有歧义的内容直接忽略。
-- `check(markdown)`:建立快照,执行该组件检查并返回检查结果;
-- `transform(markdown)`:等价于只包含该组件的流水线清洗。
+所有组件必须满足以下不变量:
-具体组件不得重写公共 `check`、`transform` 或修改执行器。类型声明使用 `final` 标记这些入口;代码评审和测试
-同时检查组件只实现规定扩展点。
+- 相同 Markdown、组件版本和参数产生相同顺序的候选修改;
+- 成功执行一次后再次执行,不产生新的实际改动;
+- 不读取文件、网络、环境变量、当前时间或随机数;
+- 不修改传入对象或外部状态;
+- 不直接生成或改写整篇 Markdown,只提交精确 `TextEdit`。
-组件必须是确定性的:相同 Markdown、组件版本和参数必须产生相同问题及候选修改。组件不能读取文件、网络、
-环境变量、当前时间或随机数,也不能修改传入对象和外部状态。
+第一版组件没有公共 `check()` 或 `transform()`。单个组件通过只包含它的 `Pipeline` 独立运行:
+
+```python
+result = Pipeline([component]).transform(markdown)
+```
+
+这样避免 `component.py` 反向依赖 `pipeline.py`,也避免组件和流水线维护两套执行逻辑。
第一版流水线禁止出现两个相同组件标识的实例。需要用不同参数运行同一组件两次时,应由项目重新考虑组件边界,
不能依靠重复 ID 制造含义不清的执行记录。
## 9. 公共修改执行器
-流水线不会把多个组件在旧快照上产生的修改集中到最后再应用。每个组件都针对当前快照检查;该组件结束后,
-它的自动修改作为一个批次交给公共执行器。
+每个组件都针对当前快照提出修改;该组件结束后,它的全部候选修改作为一个批次交给公共执行器。
执行器按以下顺序验证当前组件的整个批次:
-1. 问题和编辑的快照哈希都等于当前快照哈希;
-2. 所有范围合法;
-3. `markdown[start:end]` 与 `expected_text` 完全一致;
-4. 不存在重复编辑、范围重叠或同一位置的多个插入;
-5. 所有编辑都会实际改变内容。
+1. 候选修改和编辑的快照哈希都等于当前快照哈希;
+2. 每个候选修改理由非空并至少包含一个编辑;
+3. 所有范围合法;
+4. `markdown[start:end]` 与 `expected_text` 完全一致;
+5. 不存在重复编辑或下述范围冲突;
+6. 所有编辑都会实际改变内容。
-相邻但不重叠的范围可以同时修改。验证全部通过后,执行器按位置从后向前应用编辑,避免前面的修改使后面的
-下标失效。任意一项验证失败,当前组件的整个批次都不应用。
+范围冲突使用以下保守规则:
-这里的“当前组件整个批次”包括该组件本次检查产生的所有 `auto_fix` 候选修改。`suggestion` 和 `detect_only`
-问题永远不进入自动修改批次。
+- 两个非空范围真正重叠时冲突;相邻的 `[a, b)` 与 `[b, c)` 可以同时修改;
+- 两个插入位于同一位置时冲突,不同位置可以同时插入;
+- 插入点位于另一个非空范围内部,或等于该范围的起点、终点时,均视为冲突。
+
+最后一条有意比半开区间的数学重叠更严格,避免相同起点的执行顺序和边界插入语义不明确。组件如果确实需要
+替换一段文字并在边界追加内容,应合并为一个 `TextEdit.replacement`。
+
+验证全部通过后,执行器按位置从后向前应用编辑,避免前面的修改使后面的下标失效。任意一项验证失败,
+当前组件的整个批次都不应用。内部应用顺序不决定报告顺序;结果中的实际改动按原文位置从前到后排列。
### 9.1 `Change`
@@ -238,24 +259,40 @@ Python 字符串下标是第一版唯一权威位置。行号和列号由快照
- 组件标识和版本;
- 组件在流水线中的执行位置;
+- 候选修改在本次组件结果中的引用和编辑序号;
- 修改理由;
- 修改前范围、`before` 和 `after`;
-- 修改前后的快照哈希;
-- 所属候选修改,使一次多位置动作可以整体追踪。
+- 修改前后的快照哈希。
-修改记录描述实际发生的变化,不复制未执行的建议,也不把问题记录冒充改动记录。
+同一个 `ProposedChange` 产生的多条 `Change` 使用相同引用,使一次多位置动作可以整体追踪。同一组件批次中的
+所有 `Change` 共享该批次修改前后的快照哈希,不制造并不存在的中间公开快照。
-## 10. 检查流程
+修改记录只描述实际发生的变化,不把未执行或验证失败的候选修改冒充实际改动。
-`Pipeline.check(markdown)` 建立一个输入快照。所有组件按照项目给出的顺序检查同一个快照,Markdown 全程不变。
+## 10. 模块依赖方向
-- 一个组件正常完成后,流水线按原顺序收集问题;
-- 一个组件抛出异常或返回违反契约的数据时,流水线记录结构化组件错误,然后继续检查后续组件;
-- 检查结果记录输入哈希、实际组件顺序、版本、参数、问题和错误;
-- 只要存在组件错误,检查结果就不能表示为完整成功,但已经获得的问题仍然保留。
+第一版保持以下单向依赖:
-组件异常不能被静默忽略。错误至少记录组件身份、错误阶段、异常类型和安全的错误说明。是否保存 traceback
-留在内存实现中决定,不向未来报告格式作承诺。
+```text
+models.py
+ ▲ ▲
+ │ │
+component.py edits.py
+ ▲ ▲
+ \ /
+ pipeline.py
+```
+
+- `models.py` 只依赖 Python 标准库;
+- `component.py` 只依赖数据模型,不导入流水线或修改执行器;
+- `edits.py` 只依赖数据模型,不调用组件或流水线;
+- `pipeline.py` 可以依赖组件、修改执行器和数据模型;
+- `__init__.py` 只导出批准的公共对象,不实现第二套逻辑。
+
+修改执行器不理解 arXiv、表格、HTML 或其他业务语义,也不决定组件顺序和最终状态。组件不应用编辑,
+不刷新快照,也不知道文件、CLI 或未来报告格式。
+
+如果实现中发现这些边界导致机械性的循环依赖,可以拆分数据模型文件,但不能让底层模块反向导入流水线。
## 11. 清洗流程与运行状态
@@ -264,40 +301,57 @@ Python 字符串下标是第一版唯一权威位置。行号和列号由快照
`Pipeline.transform(markdown)` 按以下步骤运行:
1. 建立输入快照;
-2. 按顺序让当前组件检查当前快照;
-3. 收集该组件的 `auto_fix` 候选修改;
+2. 按项目给出的顺序,让当前组件针对当前快照提出修改;
+3. 验证组件元数据和候选修改契约;
4. 原子验证并应用当前组件的整个修改批次;
5. 有修改时建立新快照,下一个组件只能读取新快照;
-6. 所有组件各执行一次后,对最终快照运行最终只读复查。
+6. 所有组件各执行一次后,对最终快照运行最终稳定性复查。
+
+组件没有提出修改是正常情况,不产生空批次或虚假 `Change`。
+空组件列表也是合法输入:流水线对原输入建立快照后直接完成空的最终复查,返回零改动的 `success`。
清洗阶段如果组件异常、返回无效数据或修改批次冲突,流水线立即停止,不继续运行后面的组件,也不进行最终复查。
之前组件已经完成的内存修改和 `Change` 保留在失败结果中,但不能作为成功输出。
-### 11.2 最终只读复查
+### 11.2 最终稳定性复查
-最终复查让所有已选组件按照原顺序检查最终快照,不应用任何修改。
+最终复查让所有已选组件按照原顺序针对最终快照重新提出修改,但不应用任何修改。
-- 发现仍可由已选组件 `auto_fix` 的问题:状态为 `unstable`;
-- 只剩 `detect_only` 或 `suggestion` 问题:保留为未解决问题,不妨碍核心流水线成为 `success`;
-- 最终复查发生组件错误:状态为 `failed`;复查继续检查其余组件,以汇总只读阶段的错误和问题。
+复查阶段仍然验证组件元数据、候选修改、原文和整个组件批次的冲突;区别只是验证通过后不应用编辑。
+每条有效残留修改连同组件标识、版本、执行位置和确定性引用一起记录,使调用方知道由哪个组件提出。
-是否因为未解决的 `detect_only` 或 `suggestion` 问题阻止下游使用,由使用项目决定,不写死在共用核心中。
+- 所有组件都不再提出修改:状态可以是 `success`;
+- 任一组件仍提出有效修改:状态为 `unstable`,并保留这些 `residual_proposals`;
+- 复查发生组件错误或返回无效数据:状态为 `failed`;复查继续调用其余组件,以汇总只读阶段的错误。
-### 11.3 状态与文本字段
+如果最终复查同时出现错误和其他组件的有效残留修改,最终状态以 `failed` 为准,但已经获得的
+`residual_proposals` 仍然保留,不能因为另一个组件失败而丢失。
+
+最终复查只回答“选中的自动清洗组件是否已经稳定”,不声称 Markdown 没有截断、幻觉、损坏表格或其他
+第一版不处理的问题。
+
+### 11.3 状态与结果字段
清洗状态至少包括:
-- `success`:单轮清洗和最终复查完整完成,没有仍可自动修复的问题;
-- `failed`:组件执行、数据契约或修改验证失败;
-- `unstable`:修改阶段没有错误,但最终复查仍发现已选组件可自动修复的问题。
+- `success`:单轮清洗和最终复查完整完成,选中组件不再提出修改;
+- `failed`:组件执行、组件契约或修改验证失败;
+- `unstable`:修改阶段没有错误,但最终复查仍产生有效候选修改。
为避免调用方忽略状态并误用半成品:
- `success` 只提供 `output_markdown`,`partial_markdown` 为空;
- `failed` 和 `unstable` 不提供 `output_markdown`,只提供诊断用的 `partial_markdown`;
-- 三种状态都记录输入哈希、当前内容哈希、组件清单、实际改动、未解决问题和错误。
+- 三种状态都记录输入哈希、当前内容哈希、组件清单、实际改动和错误;
+- `residual_proposals` 记录最终复查已经验证有效的残留修改;它可以出现在 `unstable` 或最终复查阶段产生的
+ `failed` 结果中,在 `success` 和清洗阶段直接失败的结果中为空。
+
+错误至少记录组件标识和版本、发生阶段(`transform` 或 `final_review`)、异常或契约错误类型,以及不泄露
+额外原文的简明说明。组件异常、契约错误和修改验证错误都不能被静默忽略。
输入本身从不被原地覆盖。即使输出内容与输入完全相同,只要最终复查通过,也可以是零改动的 `success`。
+由于核心只返回内存结果、不写文件,第一版不再提供额外预览接口;调用方可以先审查 `TransformResult`,
+再由未来适配层决定是否保存成功输出。
## 12. 源码与测试结构
@@ -321,35 +375,41 @@ tests/
```
- `models.py` 只放不可变的数据模型和状态枚举;
-- `component.py` 放组件基类和组件契约验证;
+- `component.py` 放组件基类和组件元数据契约;
- `edits.py` 放纯文本修改验证与应用;
-- `pipeline.py` 放检查、单轮清洗和最终复查编排;
+- `pipeline.py` 放单轮清洗和最终稳定性复查编排;
- `__init__.py` 只导出第一版公共对象;
- `py.typed` 声明分发包提供类型信息;
- 测试辅助组件只存在于 `tests/`,不发布成示例清洗能力。
-如果实现中发现这些边界导致循环依赖,可以在不改变公共职责的前提下机械拆分模块;新增新的业务层、运行依赖
-或公共入口仍需要重新评审。
+新增新的业务层、运行依赖或公共入口仍需要重新评审。
## 13. 测试专用组件与验收
-第一版不借真实文档验证,也不把测试字符串包装成正式清洗规则。测试中建立最小假组件,分别产生固定问题、
-建议修改、精确替换、组件异常和连锁影响。
+第一版不借真实文档验证,也不把测试字符串包装成正式清洗规则。测试中建立最小假组件,分别产生零修改、
+固定修改、多位置修改、组件异常、无效候选和连锁影响。
至少覆盖:
- 空 Markdown、中文、换行和 Unicode 组合字符;
-- 插入、删除、替换以及一次问题包含多个编辑;
-- 相邻范围可以应用,重叠范围、重复编辑和同点插入明确失败;
-- 哈希过期、范围越界、`expected_text` 不符和无效修改明确失败;
-- 当前组件批次全部成功或全部不应用;
-- 检查流程记录错误后继续其他组件;
-- 清洗流程遇错立即停止,并区分成功输出与部分文本;
-- 后一个组件制造前一个组件的新问题时,最终复查返回 `unstable`,且不自动开始第二轮;
-- `suggestion` 和 `detect_only` 不被自动应用;
-- 组件独立运行与单组件流水线结果一致;
+- 空组件列表返回原文不变、零改动的 `success`;
+- 插入、删除、替换以及一次候选修改包含多个编辑;
+- 相邻非空范围可以应用,重叠范围和重复编辑明确失败;
+- 不同位置插入可以应用,同点插入明确失败;
+- 插入位于非空范围内部、起点或终点时明确失败;
+- 哈希过期、范围越界、`expected_text` 不符、空候选、空理由和无效修改明确失败;
+- 当前组件批次全部成功或全部不应用,包括不同候选修改之间发生冲突;
+- 组件异常或契约错误使清洗立即停止,并区分成功输出与部分文本;
+- 后一个组件读取前一个组件修改后的新快照;
+- 后一个组件制造前一个组件的新问题时,最终复查返回 `unstable`,保留 `residual_proposals`,且不开始第二轮;
+- 最终复查发生错误时继续调用其余组件并最终返回 `failed`;
+- 最终复查同时出现错误和有效残留修改时,两者都保留,状态为 `failed`;
+- 错误记录能够区分 `transform` 和 `final_review` 阶段;
+- 同一个候选修改的多条 `Change` 共享引用、理由和批次前后哈希;
+- 单个组件通过单组件 `Pipeline` 正常运行;
- 成功流水线再次运行不产生实际改动;
-- 输入字符串不被修改,相同输入、组件和参数产生相同结果。
+- 输入字符串不被修改,相同输入、组件和参数产生相同顺序的结果;
+- 重复组件标识、无效版本、不可表示的参数和空适用边界明确失败。
批准并实现后,基础验证至少包括:
@@ -361,28 +421,45 @@ pytest
README 届时记录实际可用命令。只有这些命令真实运行成功后,才能报告对应检查通过。
-## 14. 风险与代价
+## 14. 未来检查能力如何扩展
-- **没有公共 AST:** 结构复杂的组件以后可能需要重复解析;先保持解析细节为组件内部实现,等真实规则证明需要
- 共享分析后再设计。
+第一版不为未来检查功能预留空枚举或可空候选修改。以后真实项目证明只读检查有独立消费者时,通过新 design
+增加平行接口,例如:
+
+```text
+Inspector.inspect(DocumentSnapshot) -> Finding
+InspectionPipeline.inspect(markdown) -> InspectionResult
+```
+
+未来检查能力可以复用 `DocumentSnapshot`、`TextSpan` 和组件身份规则,但不修改 `TextEdit`、
+`ProposedChange`、公共修改执行器、`Pipeline.transform()` 或 `TransformResult`。某项能力同时需要检查和清洗时,
+对应 Inspector 与 Component 可以在自身实现中复用定位函数,不需要让两个产品流程共享同一种结果模型。
+
+人工建议以后还需要批准、拒绝、快照复核和应用协议,不能只增加一个 `suggestion` 枚举就视为完成。
+
+## 15. 风险与代价
+
+- **第一版不报告不可处理问题:** `success` 只表示选中组件执行稳定,不表示文档整体正确;README 和未来 API
+ 文档必须明确这一点。
+- **没有公共 AST:** 结构复杂的组件以后可能需要重复解析;等真实规则证明需要共享分析后再设计。
- **Python 字符位置不是跨语言协议:** 第一版只承诺 Python API;以后输出机器可读跨语言格式时,需要单独定义
- 坐标语义,不能直接假设 JavaScript UTF-16 或 UTF-8 byte offset 与之相同。
-- **最终复查增加检查成本:** 选中的组件最多检查两次,但换来对组合连锁影响的明确判断,第一版接受此代价。
+ 坐标语义,不能假设 JavaScript UTF-16 或 UTF-8 byte offset 与之相同。
+- **最终复查增加检查成本:** 选中的组件最多提出两次修改,但换来对组合连锁影响的明确判断,第一版接受此代价。
- **组件级原子批次可能放弃部分正确修改:** 这是有意的保真选择;组件应修复自己的冲突,而不是让流水线猜测。
- **失败结果仍含部分 Markdown:** 使用独立字段并让成功输出为空,降低误用风险;未来文件适配层不得默认写出
`partial_markdown`。
- **结果可能包含原文片段:** 第一版结果只存在内存,不建设日志和持久化;以后新增 reporter 时必须单独评审
脱敏和保存边界。
-- **组件版本需要维护:** 组件语义、定位或修改行为变化时必须更新版本,不能只改代码而保留相同审计身份。
+- **组件版本需要维护:** 组件语义、适用边界、定位或修改行为变化时必须更新版本,不能只改代码而保留相同身份。
-## 15. 批准后的实施边界
+## 16. 批准后的实施边界
批准本设计只授权:
1. 创建第 12 节列出的工程与测试文件;
-2. 实现第 5 至 11 节描述的内存核心;
+2. 实现第 5 至 11 节描述的内存自动清洗核心;
3. 创建测试专用假组件并完成第 13 节验证;
4. 根据真实实现更新 README 当前阶段和基础检查;
5. 实现完成后新增 `research-wiki/explanation/` 文档,解释当前实际架构。
-批准本设计不授权实现真实清洗规则,不授权读取真实数据,不授权文件覆盖、CLI、发布、提交或推送。
+批准本设计不授权实现真实清洗规则,不授权读取真实数据,不授权文件覆盖、独立检查能力、CLI、发布、提交或推送。
diff --git a/research-wiki/explanation/.gitkeep b/research-wiki/explanation/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/research-wiki/explanation/first-executable-core.md b/research-wiki/explanation/first-executable-core.md
new file mode 100644
index 0000000..d55caee
--- /dev/null
+++ b/research-wiki/explanation/first-executable-core.md
@@ -0,0 +1,149 @@
+# 第一版内存清洗核心如何工作
+
+## 1. 它解决什么问题
+
+清洗组件如果直接返回一整篇新 Markdown,调用方只能看到修改后的结果,很难确认它实际改了哪里。组件保存的
+旧位置还可能在文本变化后误中另一段内容;同一批修改发生重叠时,按不同顺序执行也可能得到不同结果。
+
+当前核心把“判断应该改什么”和“安全地执行修改”分开:组件只描述绑定当前文本的精确修改,公共执行器统一
+验证并应用。这样可以在不引入真实清洗规则、文件读写或 Markdown parser 的情况下,先让组合与审计协议可运行。
+
+已经实现的范围来自已批准的
+[`0003-first-executable-core-architecture.md`](../design/0003-first-executable-core-architecture.md)。精确类名、字段和
+函数签名以 [`src/mdpolish/`](../../src/mdpolish/) 中的代码和测试为准,本文不维护第二份 API 清单。
+
+## 2. 当前数据流
+
+```text
+输入 Markdown 字符串
+ │
+ ▼
+带内容哈希的当前快照
+ │
+ ▼
+组件提出精确修改 ──► 整批验证 ──► 整批应用 ──► 新快照
+ │ │
+ └──────── 按组件顺序重复 ◄──────────────┘
+ │
+ ▼
+ 最终只重新提议,不再应用
+ │
+ ┌────────────────┼────────────────┐
+ ▼ ▼ ▼
+ success unstable failed
+```
+
+核心只有四层:
+
+| 层次 | 当前职责 | 明确不负责 |
+| --- | --- | --- |
+| 数据模型 | 保存快照、范围、候选修改、实际改动、错误和结果 | 业务规则和文件路径 |
+| 组件基类 | 声明身份、版本、参数、适用边界并提出修改 | 应用修改和组织流水线 |
+| 修改执行器 | 统一验证并原子应用一个组件批次 | 判断 Markdown 业务语义 |
+| 流水线 | 排列组件、刷新快照、处理失败并做最终复查 | 读取文件、选择项目 profile |
+
+依赖保持单向:组件基类和修改执行器只依赖数据模型,流水线可以调用前三者,底层模块不反向调用流水线。
+
+## 3. 为什么修改必须绑定快照
+
+每个 Markdown 快照都带有根据完整字符串计算的 SHA-256。候选修改和其中每条文本编辑都必须指向这个哈希,
+还要同时提供原文范围和该范围预期出现的文字。
+
+执行时会再次检查:
+
+1. 哈希仍然对应当前快照;
+2. 范围没有越过字符串边界;
+3. 当前位置的文字与组件声明的预期原文完全一致;
+4. 替换后确实会改变内容。
+
+任何一项不满足,当前组件的整个批次都不会执行。这使位置只在其产生时的快照内有效,不允许把旧候选修改悄悄
+套到后来变化的 Markdown 上。
+
+范围使用 Python 字符串下标,而不是 UTF-8 字节位置。核心保留输入的换行、Unicode 形式和末尾换行,不做隐式
+规范化。
+
+## 4. 组件为什么只提出自动修改
+
+第一版组件只返回能够立即、唯一执行的候选修改。遇到不知道正确修法的截断、损坏表格、疑似幻觉或无法读取的
+图片时,组件应忽略,不猜测修复,也不额外生成“仅检查”结果。
+
+每个组件必须提供稳定标识、`MAJOR.MINOR.PATCH` 版本、可冻结的参数和非空适用边界。适用边界需要由组件作者说明
+它处理什么结构、依赖哪些严格前置条件、明确排除什么。组件还必须满足确定、无副作用和幂等约束;不能读取文件、
+网络、环境变量、当前时间或随机数。
+
+最终复查能发现多个组件组合后仍会继续提出修改,但不能从有限输入证明一个组件对所有文本都幂等。因此,幂等性
+既由最终复查保护当前运行,也必须由组件自己的针对性测试证明其适用范围内的行为。
+
+独立检查和人工建议目前没有实现。以后只有出现明确消费者和闭环时,才通过新 design 增加平行接口,不在当前
+组件结果中补可空字段或状态枚举。
+
+## 5. 一个组件批次如何保证原子性
+
+一个组件可以提出多个候选修改,每个候选修改又可以包含多个文本编辑。执行器先验证该组件本次提出的全部编辑,
+只有整批通过才从后向前应用;任意一条失败,整批保持原样。这里的原子边界是“当前组件本次执行的全部修改”,
+不是单独一条编辑。
+
+当前冲突规则有意保守:
+
+| 两项编辑的关系 | 结果 |
+| --- | --- |
+| 两个非空范围真正重叠 | 冲突 |
+| 两个非空范围只相邻 | 允许 |
+| 两次插入位于同一点 | 冲突 |
+| 两次插入位于不同点 | 允许 |
+| 插入点位于非空范围内部、起点或终点 | 冲突 |
+
+如果业务动作需要替换一段文字并在边界追加内容,组件应把它表达成同一条替换,而不是依赖编辑执行顺序。
+
+## 6. 流水线状态代表什么
+
+流水线先对所有组件做元数据预检,避免运行到一半才发现重复标识或无效版本。之后每个组件只执行一次,后一个组件
+只能读取前一个组件产生的新快照。清洗阶段出现异常、契约错误或编辑验证错误时会立即停止,且不再进行最终复查。
+
+所有组件完成后,流水线让它们针对最终快照重新提出一次修改,但这一阶段只验证、不应用:
+
+| 状态 | 含义 | Markdown 字段 |
+| --- | --- | --- |
+| `success` | 清洗和最终复查均完成,所选组件不再提出修改 | 只提供成功输出 |
+| `unstable` | 清洗无错误,但最终复查仍有有效候选修改 | 只提供诊断用部分文本和残留候选 |
+| `failed` | 清洗或最终复查发生错误 | 只提供诊断用部分文本和错误 |
+
+最终复查是只读阶段,因此某个组件失败后仍会继续复查其余组件。错误与其他组件的有效残留修改可以同时保留,最终
+状态以 `failed` 为准。流水线不会因为 `unstable` 自动开始第二轮。
+
+`success` 只表示本次选中的自动清洗组件已经稳定,不表示文档没有截断、幻觉、表格损坏、图片断链或其他未实现
+规则能够发现的问题。
+
+## 7. 审计记录能回答什么
+
+每条实际执行的文本编辑都会生成一条改动记录,说明:
+
+- 是哪个组件、哪个版本和流水线位置执行的;
+- 属于哪个候选修改,以及在该候选修改中的编辑序号;
+- 组件给出的修改理由;
+- 修改前范围、原文和替换内容;
+- 当前组件批次修改前后的快照哈希。
+
+同一候选修改中的多条记录共享候选引用,同一组件批次中的所有记录共享批次前后哈希。记录只描述已经发生的修改;
+验证失败或最终复查中没有执行的候选不会冒充实际改动。
+
+这些内容当前只存在于内存返回值中。仓库没有 reporter、审计文件格式或日志持久化,调用方也不能默认把失败结果中
+的部分文本写回原文件。
+
+## 8. 当前验证和剩余边界
+
+核心测试使用短小的假组件,不包含或复制真实文档。测试已经覆盖空文本、中文和组合 Unicode、插入/删除/替换、
+范围冲突、过期哈希、批次原子性、组件连锁影响、错误阶段、审计关联和成功结果再次运行等行为。
+
+实际可用的安装与验收命令、最近一次验证日期和结果只在根目录
+[`README.md`](../../README.md#当前可用检查) 维护。
+
+当前仍然没有:
+
+- 论文、GovDoc、HTML 表格或其他真实清洗组件;
+- 独立文档检查、人工建议或审核流程;
+- Markdown parser、AST 或共享业务中间表示;
+- 文件读写、CLI、批处理、项目 profile 格式和生产集成;
+- 审计结果的长期存储或脱敏输出协议。
+
+这些边界中的任何一项要进入实现,都需要先用新的 design 明确语义、代价和验收方式。
diff --git a/src/mdpolish/__init__.py b/src/mdpolish/__init__.py
new file mode 100644
index 0000000..b3f9730
--- /dev/null
+++ b/src/mdpolish/__init__.py
@@ -0,0 +1,44 @@
+"""Approved public API for the first mdpolish in-memory core."""
+
+from mdpolish.component import Component, ComponentContractError
+from mdpolish.edits import AppliedBatch, EditValidationError, apply_component_batch, validate_component_batch
+from mdpolish.models import (
+ Change,
+ ComponentInfo,
+ DocumentSnapshot,
+ ErrorStage,
+ ProposalReference,
+ ProposedChange,
+ ResidualProposal,
+ RunError,
+ RunStatus,
+ TextEdit,
+ TextSpan,
+ TransformResult,
+ markdown_sha256,
+)
+from mdpolish.pipeline import Pipeline, PipelineContractError
+
+__all__ = [
+ "AppliedBatch",
+ "Change",
+ "Component",
+ "ComponentContractError",
+ "ComponentInfo",
+ "DocumentSnapshot",
+ "EditValidationError",
+ "ErrorStage",
+ "Pipeline",
+ "PipelineContractError",
+ "ProposalReference",
+ "ProposedChange",
+ "ResidualProposal",
+ "RunError",
+ "RunStatus",
+ "TextEdit",
+ "TextSpan",
+ "TransformResult",
+ "apply_component_batch",
+ "markdown_sha256",
+ "validate_component_batch",
+]
diff --git a/src/mdpolish/component.py b/src/mdpolish/component.py
new file mode 100644
index 0000000..cda1e21
--- /dev/null
+++ b/src/mdpolish/component.py
@@ -0,0 +1,105 @@
+"""Component extension contract for the mdpolish core."""
+
+from __future__ import annotations
+
+import math
+import re
+from abc import ABC, abstractmethod
+from collections.abc import Mapping
+from typing import cast, final
+
+from mdpolish.models import ComponentInfo, DocumentSnapshot, Parameters, ParameterValue, ProposedChange
+
+_COMPONENT_ID_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
+_SEMVER_PATTERN = re.compile(r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$")
+
+
+class ComponentContractError(ValueError):
+ """A component does not satisfy the approved extension contract."""
+
+
+def _freeze_parameter(value: object) -> ParameterValue:
+ if value is None or isinstance(value, (str, bool)) or type(value) is int:
+ return value
+ if isinstance(value, float):
+ if not math.isfinite(value):
+ raise ComponentContractError("component parameters cannot contain non-finite floats")
+ return value
+ if isinstance(value, Mapping):
+ pairs: list[tuple[str, ParameterValue]] = []
+ for key, nested_value in value.items():
+ if not isinstance(key, str) or not key:
+ raise ComponentContractError("component parameter mapping keys must be non-empty strings")
+ pairs.append((key, _freeze_parameter(nested_value)))
+ return tuple(sorted(pairs, key=lambda pair: pair[0]))
+ if isinstance(value, (list, tuple)):
+ return tuple(_freeze_parameter(item) for item in value)
+ raise ComponentContractError("component parameters must contain only deterministic basic data")
+
+
+def _freeze_parameters(parameters: object) -> Parameters:
+ if not isinstance(parameters, Mapping):
+ raise ComponentContractError("component parameters must be a mapping")
+ frozen = _freeze_parameter(parameters)
+ if not isinstance(frozen, tuple):
+ raise AssertionError("a mapping must normalize to a tuple")
+ return cast(Parameters, frozen)
+
+
+class Component(ABC):
+ """Base class for deterministic, side-effect-free cleaning components."""
+
+ @property
+ @abstractmethod
+ def component_id(self) -> str:
+ """Return the stable lowercase identity of this component."""
+
+ @property
+ @abstractmethod
+ def version(self) -> str:
+ """Return this component's MAJOR.MINOR.PATCH version."""
+
+ @property
+ @abstractmethod
+ def parameters(self) -> Mapping[str, object]:
+ """Return the current deterministic parameter values."""
+
+ @property
+ @abstractmethod
+ def applicability(self) -> str:
+ """Describe handled structures, strict preconditions, and exclusions."""
+
+ @abstractmethod
+ def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ """Return exact, automatically applicable changes for this snapshot."""
+
+ @final
+ def _component_info(self) -> ComponentInfo:
+ component_id = self.component_id
+ version = self.version
+ applicability = self.applicability
+
+ if not isinstance(component_id, str) or _COMPONENT_ID_PATTERN.fullmatch(component_id) is None:
+ raise ComponentContractError("component_id must be a stable lowercase identifier")
+ if not isinstance(version, str) or _SEMVER_PATTERN.fullmatch(version) is None:
+ raise ComponentContractError("component version must use MAJOR.MINOR.PATCH")
+ if not isinstance(applicability, str) or not applicability.strip():
+ raise ComponentContractError("component applicability must be a non-empty string")
+
+ return ComponentInfo(
+ component_id=component_id,
+ version=version,
+ parameters=_freeze_parameters(self.parameters),
+ applicability=applicability,
+ )
+
+ @final
+ def _collect_proposals(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ if not isinstance(snapshot, DocumentSnapshot):
+ raise ComponentContractError("components require a DocumentSnapshot")
+ proposals = self._propose_changes(snapshot)
+ if not isinstance(proposals, tuple):
+ raise ComponentContractError("_propose_changes must return a tuple")
+ if any(not isinstance(proposal, ProposedChange) for proposal in proposals):
+ raise ComponentContractError("_propose_changes must return only ProposedChange values")
+ return proposals
diff --git a/src/mdpolish/edits.py b/src/mdpolish/edits.py
new file mode 100644
index 0000000..73c922b
--- /dev/null
+++ b/src/mdpolish/edits.py
@@ -0,0 +1,150 @@
+"""Pure validation and atomic application of exact text edits."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from mdpolish.models import (
+ Change,
+ ComponentInfo,
+ DocumentSnapshot,
+ ProposalReference,
+ ProposedChange,
+ TextEdit,
+)
+
+
+class EditValidationError(ValueError):
+ """A component edit batch cannot be applied safely."""
+
+
+@dataclass(frozen=True, slots=True)
+class AppliedBatch:
+ """The new snapshot and audit entries from one atomic component batch."""
+
+ snapshot: DocumentSnapshot
+ changes: tuple[Change, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class _IndexedEdit:
+ proposal_index: int
+ edit_index: int
+ reason: str
+ edit: TextEdit
+
+
+def _edits_conflict(left: TextEdit, right: TextEdit) -> bool:
+ left_span = left.span
+ right_span = right.span
+
+ if left_span.is_empty and right_span.is_empty:
+ return left_span.start == right_span.start
+ if left_span.is_empty:
+ return right_span.start <= left_span.start <= right_span.end
+ if right_span.is_empty:
+ return left_span.start <= right_span.start <= left_span.end
+ return max(left_span.start, right_span.start) < min(left_span.end, right_span.end)
+
+
+def validate_component_batch(
+ snapshot: DocumentSnapshot,
+ proposals: tuple[ProposedChange, ...],
+) -> tuple[_IndexedEdit, ...]:
+ """Validate a complete component batch without changing the snapshot."""
+ if not isinstance(snapshot, DocumentSnapshot):
+ raise TypeError("snapshot must be a DocumentSnapshot")
+ if not isinstance(proposals, tuple):
+ raise EditValidationError("component proposals must be a tuple")
+
+ indexed_edits: list[_IndexedEdit] = []
+ seen_edits: set[TextEdit] = set()
+ for proposal_index, proposal in enumerate(proposals):
+ if not isinstance(proposal, ProposedChange):
+ raise EditValidationError("a component batch must contain only ProposedChange values")
+ if proposal.snapshot_sha256 != snapshot.sha256:
+ raise EditValidationError("a proposal targets a stale document snapshot")
+ for edit_index, edit in enumerate(proposal.edits):
+ if edit.snapshot_sha256 != snapshot.sha256:
+ raise EditValidationError("an edit targets a stale document snapshot")
+ if edit.span.end > len(snapshot.markdown):
+ raise EditValidationError("an edit span is outside the document snapshot")
+ if snapshot.markdown[edit.span.start : edit.span.end] != edit.expected_text:
+ raise EditValidationError("an edit's expected_text does not match the document snapshot")
+ if edit in seen_edits:
+ raise EditValidationError("a component batch contains a duplicate edit")
+ seen_edits.add(edit)
+ indexed_edits.append(
+ _IndexedEdit(
+ proposal_index=proposal_index,
+ edit_index=edit_index,
+ reason=proposal.reason,
+ edit=edit,
+ )
+ )
+
+ for left_index, left in enumerate(indexed_edits):
+ for right in indexed_edits[left_index + 1 :]:
+ if _edits_conflict(left.edit, right.edit):
+ raise EditValidationError("a component batch contains conflicting edit ranges")
+
+ return tuple(indexed_edits)
+
+
+def apply_component_batch(
+ snapshot: DocumentSnapshot,
+ proposals: tuple[ProposedChange, ...],
+ component: ComponentInfo,
+ component_position: int,
+) -> AppliedBatch:
+ """Atomically apply one fully validated component batch."""
+ indexed_edits = validate_component_batch(snapshot, proposals)
+ if not indexed_edits:
+ return AppliedBatch(snapshot=snapshot, changes=())
+
+ markdown = snapshot.markdown
+ application_order = sorted(
+ indexed_edits,
+ key=lambda item: (
+ item.edit.span.start,
+ item.edit.span.end,
+ item.proposal_index,
+ item.edit_index,
+ ),
+ reverse=True,
+ )
+ for item in application_order:
+ edit = item.edit
+ markdown = markdown[: edit.span.start] + edit.replacement + markdown[edit.span.end :]
+
+ updated_snapshot = DocumentSnapshot(markdown)
+ report_order = sorted(
+ indexed_edits,
+ key=lambda item: (
+ item.edit.span.start,
+ item.edit.span.end,
+ item.proposal_index,
+ item.edit_index,
+ ),
+ )
+ changes = tuple(
+ Change(
+ component_id=component.component_id,
+ component_version=component.version,
+ component_position=component_position,
+ proposal_ref=ProposalReference(
+ component_position=component_position,
+ snapshot_sha256=snapshot.sha256,
+ proposal_index=item.proposal_index,
+ ),
+ edit_index=item.edit_index,
+ reason=item.reason,
+ span=item.edit.span,
+ before=item.edit.expected_text,
+ after=item.edit.replacement,
+ before_sha256=snapshot.sha256,
+ after_sha256=updated_snapshot.sha256,
+ )
+ for item in report_order
+ )
+ return AppliedBatch(snapshot=updated_snapshot, changes=changes)
diff --git a/src/mdpolish/models.py b/src/mdpolish/models.py
new file mode 100644
index 0000000..a7baf40
--- /dev/null
+++ b/src/mdpolish/models.py
@@ -0,0 +1,231 @@
+"""Immutable values shared by the mdpolish core."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import StrEnum
+from hashlib import sha256
+from string import hexdigits
+from typing import TypeAlias
+
+ParameterValue: TypeAlias = (
+ str
+ | int
+ | float
+ | bool
+ | tuple["ParameterValue", ...]
+ | tuple[tuple[str, "ParameterValue"], ...]
+ | None
+)
+Parameters: TypeAlias = tuple[tuple[str, ParameterValue], ...]
+
+
+def markdown_sha256(markdown: str) -> str:
+ """Return the authoritative digest for a Markdown string."""
+ if not isinstance(markdown, str):
+ raise TypeError("markdown must be a string")
+ return sha256(markdown.encode("utf-8")).hexdigest()
+
+
+def _require_sha256(value: str, field_name: str) -> None:
+ if (
+ not isinstance(value, str)
+ or len(value) != 64
+ or any(character not in hexdigits for character in value)
+ or value.lower() != value
+ ):
+ raise ValueError(f"{field_name} must be a lowercase SHA-256 digest")
+
+
+def _require_nonempty(value: str, field_name: str) -> None:
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"{field_name} must be a non-empty string")
+
+
+@dataclass(frozen=True, slots=True)
+class DocumentSnapshot:
+ """An exact Markdown string and its library-computed digest."""
+
+ markdown: str
+ sha256: str = field(init=False)
+
+ def __post_init__(self) -> None:
+ object.__setattr__(self, "sha256", markdown_sha256(self.markdown))
+
+
+@dataclass(frozen=True, slots=True, order=True)
+class TextSpan:
+ """A half-open range using Python string indexes."""
+
+ start: int
+ end: int
+
+ def __post_init__(self) -> None:
+ if type(self.start) is not int or type(self.end) is not int:
+ raise TypeError("span indexes must be integers")
+ if self.start < 0 or self.end < self.start:
+ raise ValueError("span must satisfy 0 <= start <= end")
+
+ @property
+ def is_empty(self) -> bool:
+ return self.start == self.end
+
+
+@dataclass(frozen=True, slots=True)
+class TextEdit:
+ """An exact replacement bound to one document snapshot."""
+
+ snapshot_sha256: str
+ span: TextSpan
+ expected_text: str
+ replacement: str
+
+ def __post_init__(self) -> None:
+ _require_sha256(self.snapshot_sha256, "snapshot_sha256")
+ if not isinstance(self.span, TextSpan):
+ raise TypeError("span must be a TextSpan")
+ if not isinstance(self.expected_text, str) or not isinstance(self.replacement, str):
+ raise TypeError("expected_text and replacement must be strings")
+ if len(self.expected_text) != self.span.end - self.span.start:
+ raise ValueError("expected_text length must equal the span length")
+ if self.expected_text == self.replacement:
+ raise ValueError("a text edit must change the content")
+
+
+@dataclass(frozen=True, slots=True)
+class ProposedChange:
+ """One atomic, automatically applicable action proposed by a component."""
+
+ snapshot_sha256: str
+ reason: str
+ edits: tuple[TextEdit, ...]
+
+ def __post_init__(self) -> None:
+ _require_sha256(self.snapshot_sha256, "snapshot_sha256")
+ _require_nonempty(self.reason, "reason")
+ if not isinstance(self.edits, tuple) or not self.edits:
+ raise ValueError("edits must be a non-empty tuple")
+ for edit in self.edits:
+ if not isinstance(edit, TextEdit):
+ raise TypeError("edits must contain only TextEdit values")
+ if edit.snapshot_sha256 != self.snapshot_sha256:
+ raise ValueError("an edit digest must match its proposal digest")
+
+
+@dataclass(frozen=True, slots=True)
+class ComponentInfo:
+ """Validated, immutable component metadata recorded in a run."""
+
+ component_id: str
+ version: str
+ parameters: Parameters
+ applicability: str
+
+
+@dataclass(frozen=True, slots=True)
+class ProposalReference:
+ """A deterministic reference scoped to one transform result."""
+
+ component_position: int
+ snapshot_sha256: str
+ proposal_index: int
+
+ def __post_init__(self) -> None:
+ if type(self.component_position) is not int or self.component_position < 0:
+ raise ValueError("component_position must be a non-negative integer")
+ if type(self.proposal_index) is not int or self.proposal_index < 0:
+ raise ValueError("proposal_index must be a non-negative integer")
+ _require_sha256(self.snapshot_sha256, "snapshot_sha256")
+
+
+@dataclass(frozen=True, slots=True)
+class Change:
+ """One text edit that was actually applied."""
+
+ component_id: str
+ component_version: str
+ component_position: int
+ proposal_ref: ProposalReference
+ edit_index: int
+ reason: str
+ span: TextSpan
+ before: str
+ after: str
+ before_sha256: str
+ after_sha256: str
+
+
+@dataclass(frozen=True, slots=True)
+class ResidualProposal:
+ """A valid proposal found by the final read-only stability review."""
+
+ component_id: str
+ component_version: str
+ component_position: int
+ proposal_ref: ProposalReference
+ proposal: ProposedChange
+
+
+class RunStatus(StrEnum):
+ """The terminal status of a pipeline transform."""
+
+ SUCCESS = "success"
+ FAILED = "failed"
+ UNSTABLE = "unstable"
+
+
+class ErrorStage(StrEnum):
+ """The pipeline phase in which an error occurred."""
+
+ TRANSFORM = "transform"
+ FINAL_REVIEW = "final_review"
+
+
+@dataclass(frozen=True, slots=True)
+class RunError:
+ """A source-safe component, contract, or edit error."""
+
+ component_id: str
+ component_version: str
+ component_position: int
+ stage: ErrorStage
+ error_type: str
+ message: str
+
+
+@dataclass(frozen=True, slots=True)
+class TransformResult:
+ """The immutable result of one transform and its final review."""
+
+ status: RunStatus
+ input_sha256: str
+ current_sha256: str
+ components: tuple[ComponentInfo, ...] = ()
+ changes: tuple[Change, ...] = ()
+ errors: tuple[RunError, ...] = ()
+ residual_proposals: tuple[ResidualProposal, ...] = ()
+ output_markdown: str | None = None
+ partial_markdown: str | None = None
+
+ def __post_init__(self) -> None:
+ _require_sha256(self.input_sha256, "input_sha256")
+ _require_sha256(self.current_sha256, "current_sha256")
+ if not isinstance(self.status, RunStatus):
+ raise TypeError("status must be a RunStatus")
+ for field_name in ("components", "changes", "errors", "residual_proposals"):
+ if not isinstance(getattr(self, field_name), tuple):
+ raise TypeError(f"{field_name} must be a tuple")
+
+ current_markdown = self.output_markdown if self.status is RunStatus.SUCCESS else self.partial_markdown
+ if current_markdown is None or markdown_sha256(current_markdown) != self.current_sha256:
+ raise ValueError("the current Markdown must match current_sha256")
+
+ if self.status is RunStatus.SUCCESS:
+ if self.partial_markdown is not None or self.errors or self.residual_proposals:
+ raise ValueError("a successful result cannot contain partial output, errors, or residual proposals")
+ elif self.status is RunStatus.FAILED:
+ if self.output_markdown is not None or not self.errors:
+ raise ValueError("a failed result requires errors and cannot contain successful output")
+ elif self.status is RunStatus.UNSTABLE:
+ if self.output_markdown is not None or self.errors or not self.residual_proposals:
+ raise ValueError("an unstable result requires residual proposals and cannot contain output or errors")
diff --git a/src/mdpolish/pipeline.py b/src/mdpolish/pipeline.py
new file mode 100644
index 0000000..2c25103
--- /dev/null
+++ b/src/mdpolish/pipeline.py
@@ -0,0 +1,303 @@
+"""Sequential orchestration and final stability review."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from mdpolish.component import Component, ComponentContractError
+from mdpolish.edits import apply_component_batch, validate_component_batch
+from mdpolish.models import (
+ Change,
+ ComponentInfo,
+ DocumentSnapshot,
+ ErrorStage,
+ ProposalReference,
+ ProposedChange,
+ ResidualProposal,
+ RunError,
+ RunStatus,
+ TransformResult,
+)
+
+
+class PipelineContractError(ValueError):
+ """A pipeline composition does not satisfy the approved contract."""
+
+
+class Pipeline:
+ """Run selected components once, then review the final snapshot for stability."""
+
+ def __init__(self, components: Iterable[Component]) -> None:
+ self._components = tuple(components)
+
+ @property
+ def components(self) -> tuple[Component, ...]:
+ return self._components
+
+ def transform(self, markdown: str) -> TransformResult:
+ input_snapshot = DocumentSnapshot(markdown)
+ component_infos, preflight_error = self._preflight_components()
+ if preflight_error is not None:
+ return self._failed_result(
+ input_snapshot=input_snapshot,
+ current_snapshot=input_snapshot,
+ component_infos=component_infos,
+ changes=(),
+ errors=(preflight_error,),
+ )
+
+ current_snapshot = input_snapshot
+ changes: list[Change] = []
+ for position, (component, expected_info) in enumerate(zip(self._components, component_infos, strict=True)):
+ current_info, metadata_error = self._current_component_info(
+ component=component,
+ expected_info=expected_info,
+ position=position,
+ stage=ErrorStage.TRANSFORM,
+ )
+ if metadata_error is not None:
+ return self._failed_result(
+ input_snapshot=input_snapshot,
+ current_snapshot=current_snapshot,
+ component_infos=component_infos,
+ changes=tuple(changes),
+ errors=(metadata_error,),
+ )
+
+ proposals, proposal_error = self._proposals(
+ component=component,
+ component_info=current_info,
+ position=position,
+ stage=ErrorStage.TRANSFORM,
+ snapshot=current_snapshot,
+ )
+ if proposal_error is not None:
+ return self._failed_result(
+ input_snapshot=input_snapshot,
+ current_snapshot=current_snapshot,
+ component_infos=component_infos,
+ changes=tuple(changes),
+ errors=(proposal_error,),
+ )
+
+ try:
+ applied_batch = apply_component_batch(
+ snapshot=current_snapshot,
+ proposals=proposals,
+ component=current_info,
+ component_position=position,
+ )
+ except Exception as error:
+ return self._failed_result(
+ input_snapshot=input_snapshot,
+ current_snapshot=current_snapshot,
+ component_infos=component_infos,
+ changes=tuple(changes),
+ errors=(
+ self._run_error(
+ component_info=current_info,
+ position=position,
+ stage=ErrorStage.TRANSFORM,
+ error=error,
+ unexpected_message="component edit batch does not satisfy the edit contract",
+ ),
+ ),
+ )
+
+ current_snapshot = applied_batch.snapshot
+ changes.extend(applied_batch.changes)
+
+ review_errors: list[RunError] = []
+ residual_proposals: list[ResidualProposal] = []
+ for position, (component, expected_info) in enumerate(zip(self._components, component_infos, strict=True)):
+ current_info, metadata_error = self._current_component_info(
+ component=component,
+ expected_info=expected_info,
+ position=position,
+ stage=ErrorStage.FINAL_REVIEW,
+ )
+ if metadata_error is not None:
+ review_errors.append(metadata_error)
+ continue
+
+ proposals, proposal_error = self._proposals(
+ component=component,
+ component_info=current_info,
+ position=position,
+ stage=ErrorStage.FINAL_REVIEW,
+ snapshot=current_snapshot,
+ )
+ if proposal_error is not None:
+ review_errors.append(proposal_error)
+ continue
+
+ try:
+ validate_component_batch(current_snapshot, proposals)
+ except Exception as error:
+ review_errors.append(
+ self._run_error(
+ component_info=current_info,
+ position=position,
+ stage=ErrorStage.FINAL_REVIEW,
+ error=error,
+ unexpected_message="component edit batch does not satisfy the edit contract",
+ )
+ )
+ continue
+
+ residual_proposals.extend(
+ ResidualProposal(
+ component_id=current_info.component_id,
+ component_version=current_info.version,
+ component_position=position,
+ proposal_ref=ProposalReference(
+ component_position=position,
+ snapshot_sha256=current_snapshot.sha256,
+ proposal_index=proposal_index,
+ ),
+ proposal=proposal,
+ )
+ for proposal_index, proposal in enumerate(proposals)
+ )
+
+ if review_errors:
+ return self._failed_result(
+ input_snapshot=input_snapshot,
+ current_snapshot=current_snapshot,
+ component_infos=component_infos,
+ changes=tuple(changes),
+ errors=tuple(review_errors),
+ residual_proposals=tuple(residual_proposals),
+ )
+ if residual_proposals:
+ return TransformResult(
+ status=RunStatus.UNSTABLE,
+ input_sha256=input_snapshot.sha256,
+ current_sha256=current_snapshot.sha256,
+ components=component_infos,
+ changes=tuple(changes),
+ residual_proposals=tuple(residual_proposals),
+ partial_markdown=current_snapshot.markdown,
+ )
+ return TransformResult(
+ status=RunStatus.SUCCESS,
+ input_sha256=input_snapshot.sha256,
+ current_sha256=current_snapshot.sha256,
+ components=component_infos,
+ changes=tuple(changes),
+ output_markdown=current_snapshot.markdown,
+ )
+
+ def _preflight_components(self) -> tuple[tuple[ComponentInfo, ...], RunError | None]:
+ component_infos: list[ComponentInfo] = []
+ seen_ids: set[str] = set()
+ for position, component in enumerate(self._components):
+ if not isinstance(component, Component):
+ error = ComponentContractError("pipeline entries must be Component instances")
+ return tuple(component_infos), self._run_error(
+ component_info=None,
+ position=position,
+ stage=ErrorStage.TRANSFORM,
+ error=error,
+ unexpected_message="a pipeline entry is not a Component instance",
+ )
+ try:
+ component_info = component._component_info()
+ except Exception as error:
+ return tuple(component_infos), self._run_error(
+ component_info=None,
+ position=position,
+ stage=ErrorStage.TRANSFORM,
+ error=error,
+ unexpected_message="component metadata does not satisfy the component contract",
+ )
+ component_infos.append(component_info)
+ if component_info.component_id in seen_ids:
+ duplicate_error = PipelineContractError("pipeline component_id values must be unique")
+ return tuple(component_infos), self._run_error(
+ component_info=component_info,
+ position=position,
+ stage=ErrorStage.TRANSFORM,
+ error=duplicate_error,
+ unexpected_message="pipeline component_id values must be unique",
+ )
+ seen_ids.add(component_info.component_id)
+ return tuple(component_infos), None
+
+ def _current_component_info(
+ self,
+ component: Component,
+ expected_info: ComponentInfo,
+ position: int,
+ stage: ErrorStage,
+ ) -> tuple[ComponentInfo, RunError | None]:
+ try:
+ current_info = component._component_info()
+ if current_info != expected_info:
+ raise ComponentContractError("component metadata changed during pipeline execution")
+ except Exception as error:
+ return expected_info, self._run_error(
+ component_info=expected_info,
+ position=position,
+ stage=stage,
+ error=error,
+ unexpected_message="component metadata does not satisfy the component contract",
+ )
+ return current_info, None
+
+ def _proposals(
+ self,
+ component: Component,
+ component_info: ComponentInfo,
+ position: int,
+ stage: ErrorStage,
+ snapshot: DocumentSnapshot,
+ ) -> tuple[tuple[ProposedChange, ...], RunError | None]:
+ try:
+ proposals = component._collect_proposals(snapshot)
+ except Exception as error:
+ return (), self._run_error(
+ component_info=component_info,
+ position=position,
+ stage=stage,
+ error=error,
+ unexpected_message="component could not produce contract-valid proposals",
+ )
+ return proposals, None
+
+ @staticmethod
+ def _run_error(
+ component_info: ComponentInfo | None,
+ position: int,
+ stage: ErrorStage,
+ error: Exception,
+ unexpected_message: str,
+ ) -> RunError:
+ return RunError(
+ component_id=component_info.component_id if component_info is not None else "",
+ component_version=component_info.version if component_info is not None else "",
+ component_position=position,
+ stage=stage,
+ error_type=type(error).__name__,
+ message=unexpected_message,
+ )
+
+ @staticmethod
+ def _failed_result(
+ input_snapshot: DocumentSnapshot,
+ current_snapshot: DocumentSnapshot,
+ component_infos: tuple[ComponentInfo, ...],
+ changes: tuple[Change, ...],
+ errors: tuple[RunError, ...],
+ residual_proposals: tuple[ResidualProposal, ...] = (),
+ ) -> TransformResult:
+ return TransformResult(
+ status=RunStatus.FAILED,
+ input_sha256=input_snapshot.sha256,
+ current_sha256=current_snapshot.sha256,
+ components=component_infos,
+ changes=changes,
+ errors=errors,
+ residual_proposals=residual_proposals,
+ partial_markdown=current_snapshot.markdown,
+ )
diff --git a/src/mdpolish/py.typed b/src/mdpolish/py.typed
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/mdpolish/py.typed
@@ -0,0 +1 @@
+
diff --git a/tests/test_component.py b/tests/test_component.py
new file mode 100644
index 0000000..35ac51d
--- /dev/null
+++ b/tests/test_component.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import cast
+
+import pytest
+
+from mdpolish import Component, ComponentContractError, DocumentSnapshot, ProposedChange, TextEdit, TextSpan
+
+
+class ExampleComponent(Component):
+ def __init__(
+ self,
+ *,
+ component_id: object = "test.example",
+ version: object = "1.2.3",
+ parameters: object = None,
+ applicability: object = "处理测试标记,要求精确匹配,排除所有其他内容。",
+ ) -> None:
+ self._component_id = component_id
+ self._version = version
+ self._parameters = {} if parameters is None else parameters
+ self._applicability = applicability
+
+ @property
+ def component_id(self) -> str:
+ return cast(str, self._component_id)
+
+ @property
+ def version(self) -> str:
+ return cast(str, self._version)
+
+ @property
+ def parameters(self) -> Mapping[str, object]:
+ return cast(Mapping[str, object], self._parameters)
+
+ @property
+ def applicability(self) -> str:
+ return cast(str, self._applicability)
+
+ def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ if not snapshot.markdown:
+ return ()
+ edit = TextEdit(snapshot.sha256, TextSpan(0, 1), snapshot.markdown[0], "X")
+ return (ProposedChange(snapshot.sha256, "replace first character", (edit,)),)
+
+
+class ListReturningComponent(ExampleComponent):
+ def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ return cast(tuple[ProposedChange, ...], [])
+
+
+class WrongValueComponent(ExampleComponent):
+ def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ return cast(tuple[ProposedChange, ...], ("wrong",))
+
+
+def test_component_metadata_is_validated_and_parameters_are_frozen_deterministically() -> None:
+ component = ExampleComponent(
+ parameters={
+ "z": [1, {"b": False, "a": None}],
+ "a": "value",
+ }
+ )
+
+ info = component._component_info()
+
+ assert info.component_id == "test.example"
+ assert info.version == "1.2.3"
+ assert info.parameters == (
+ ("a", "value"),
+ ("z", (1, (("a", None), ("b", False)))),
+ )
+
+
+@pytest.mark.parametrize("component_id", ["", "Uppercase", "has space", "two..dots", "_leading"])
+def test_invalid_component_id_is_a_contract_error(component_id: str) -> None:
+ with pytest.raises(ComponentContractError, match="component_id"):
+ ExampleComponent(component_id=component_id)._component_info()
+
+
+@pytest.mark.parametrize("version", ["1", "1.2", "v1.2.3", "01.2.3", "1.2.3-alpha"])
+def test_invalid_version_is_a_contract_error(version: str) -> None:
+ with pytest.raises(ComponentContractError, match=r"MAJOR.MINOR.PATCH"):
+ ExampleComponent(version=version)._component_info()
+
+
+def test_empty_applicability_is_a_contract_error() -> None:
+ with pytest.raises(ComponentContractError, match="applicability"):
+ ExampleComponent(applicability=" \n")._component_info()
+
+
+@pytest.mark.parametrize(
+ "parameters",
+ [
+ {"bad": {1, 2}},
+ {"bad": float("inf")},
+ {"bad": float("nan")},
+ {1: "non-string key"},
+ ["not", "a", "mapping"],
+ ],
+)
+def test_unrepresentable_parameters_are_contract_errors(parameters: object) -> None:
+ with pytest.raises(ComponentContractError, match="parameter"):
+ ExampleComponent(parameters=parameters)._component_info()
+
+
+def test_collect_proposals_requires_a_tuple_of_proposed_changes() -> None:
+ snapshot = DocumentSnapshot("abc")
+
+ with pytest.raises(ComponentContractError, match="return a tuple"):
+ ListReturningComponent()._collect_proposals(snapshot)
+ with pytest.raises(ComponentContractError, match="only ProposedChange"):
+ WrongValueComponent()._collect_proposals(snapshot)
+
+
+def test_component_exposes_no_public_check_or_transform_shortcut() -> None:
+ component = ExampleComponent()
+
+ assert not hasattr(component, "check")
+ assert not hasattr(component, "transform")
diff --git a/tests/test_edits.py b/tests/test_edits.py
new file mode 100644
index 0000000..9f2558a
--- /dev/null
+++ b/tests/test_edits.py
@@ -0,0 +1,199 @@
+from __future__ import annotations
+
+import pytest
+
+from mdpolish import (
+ ComponentInfo,
+ DocumentSnapshot,
+ EditValidationError,
+ ProposedChange,
+ TextEdit,
+ TextSpan,
+ apply_component_batch,
+)
+
+COMPONENT = ComponentInfo(
+ component_id="test.component",
+ version="1.2.3",
+ parameters=(),
+ applicability="测试精确文本编辑,只处理测试字符串,排除其他输入。",
+)
+
+
+def make_edit(snapshot: DocumentSnapshot, start: int, end: int, replacement: str) -> TextEdit:
+ return TextEdit(
+ snapshot_sha256=snapshot.sha256,
+ span=TextSpan(start, end),
+ expected_text=snapshot.markdown[start:end],
+ replacement=replacement,
+ )
+
+
+def make_proposal(snapshot: DocumentSnapshot, *edits: TextEdit, reason: str = "test reason") -> ProposedChange:
+ return ProposedChange(snapshot_sha256=snapshot.sha256, reason=reason, edits=edits)
+
+
+def test_applies_insert_delete_and_replace() -> None:
+ insert_snapshot = DocumentSnapshot("ab")
+ delete_snapshot = DocumentSnapshot("abc")
+ replace_snapshot = DocumentSnapshot("abc")
+
+ inserted = apply_component_batch(
+ insert_snapshot,
+ (make_proposal(insert_snapshot, make_edit(insert_snapshot, 1, 1, "X")),),
+ COMPONENT,
+ 0,
+ )
+ deleted = apply_component_batch(
+ delete_snapshot,
+ (make_proposal(delete_snapshot, make_edit(delete_snapshot, 1, 2, "")),),
+ COMPONENT,
+ 0,
+ )
+ replaced = apply_component_batch(
+ replace_snapshot,
+ (make_proposal(replace_snapshot, make_edit(replace_snapshot, 1, 2, "X")),),
+ COMPONENT,
+ 0,
+ )
+
+ assert inserted.snapshot.markdown == "aXb"
+ assert deleted.snapshot.markdown == "ac"
+ assert replaced.snapshot.markdown == "aXc"
+
+
+def test_multiple_edits_apply_backwards_but_report_in_source_order() -> None:
+ snapshot = DocumentSnapshot("abcdef")
+ proposal = make_proposal(
+ snapshot,
+ make_edit(snapshot, 4, 6, "F"),
+ make_edit(snapshot, 0, 1, "A"),
+ reason="normalize two locations",
+ )
+
+ applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 3)
+
+ assert applied.snapshot.markdown == "AbcdF"
+ assert [change.span.start for change in applied.changes] == [0, 4]
+ assert {change.before_sha256 for change in applied.changes} == {snapshot.sha256}
+ assert {change.after_sha256 for change in applied.changes} == {applied.snapshot.sha256}
+ assert {change.proposal_ref for change in applied.changes} == {
+ applied.changes[0].proposal_ref,
+ }
+ assert {change.reason for change in applied.changes} == {"normalize two locations"}
+ assert [change.edit_index for change in applied.changes] == [1, 0]
+ assert all(change.component_position == 3 for change in applied.changes)
+
+
+def test_adjacent_nonempty_ranges_are_allowed() -> None:
+ snapshot = DocumentSnapshot("abcd")
+ proposal = make_proposal(
+ snapshot,
+ make_edit(snapshot, 0, 2, "A"),
+ make_edit(snapshot, 2, 4, "D"),
+ )
+
+ applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
+
+ assert applied.snapshot.markdown == "AD"
+
+
+def test_overlapping_ranges_fail_without_changing_snapshot() -> None:
+ snapshot = DocumentSnapshot("abcdef")
+ proposal = make_proposal(
+ snapshot,
+ make_edit(snapshot, 1, 4, "X"),
+ make_edit(snapshot, 3, 5, "Y"),
+ )
+
+ with pytest.raises(EditValidationError, match="conflicting"):
+ apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
+
+ assert snapshot.markdown == "abcdef"
+ assert snapshot.sha256 == DocumentSnapshot("abcdef").sha256
+
+
+def test_duplicate_edits_fail_explicitly() -> None:
+ snapshot = DocumentSnapshot("abc")
+ edit = make_edit(snapshot, 0, 1, "A")
+ proposal = make_proposal(snapshot, edit, edit)
+
+ with pytest.raises(EditValidationError, match="duplicate"):
+ apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
+
+
+def test_distinct_insert_points_are_allowed() -> None:
+ snapshot = DocumentSnapshot("abcd")
+ proposal = make_proposal(
+ snapshot,
+ make_edit(snapshot, 1, 1, "X"),
+ make_edit(snapshot, 3, 3, "Y"),
+ )
+
+ applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
+
+ assert applied.snapshot.markdown == "aXbcYd"
+
+
+def test_same_insert_point_conflicts() -> None:
+ snapshot = DocumentSnapshot("abc")
+ proposal = make_proposal(
+ snapshot,
+ make_edit(snapshot, 1, 1, "X"),
+ make_edit(snapshot, 1, 1, "Y"),
+ )
+
+ with pytest.raises(EditValidationError, match="conflicting"):
+ apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
+
+
+@pytest.mark.parametrize("insert_position", [1, 2, 3])
+def test_insert_at_start_inside_or_end_of_nonempty_range_conflicts(insert_position: int) -> None:
+ snapshot = DocumentSnapshot("abcd")
+ proposal = make_proposal(
+ snapshot,
+ make_edit(snapshot, 1, 3, "X"),
+ make_edit(snapshot, insert_position, insert_position, "Y"),
+ )
+
+ with pytest.raises(EditValidationError, match="conflicting"):
+ apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
+
+
+def test_stale_hash_out_of_range_and_expected_text_mismatch_fail() -> None:
+ original = DocumentSnapshot("abc")
+ current = DocumentSnapshot("abd")
+ stale = make_proposal(original, make_edit(original, 0, 1, "A"))
+
+ with pytest.raises(EditValidationError, match="stale"):
+ apply_component_batch(current, (stale,), COMPONENT, 0)
+
+ out_of_range_edit = TextEdit(current.sha256, TextSpan(2, 5), "dxx", "D")
+ out_of_range = make_proposal(current, out_of_range_edit)
+ with pytest.raises(EditValidationError, match="outside"):
+ apply_component_batch(current, (out_of_range,), COMPONENT, 0)
+
+ mismatch_edit = TextEdit(current.sha256, TextSpan(0, 1), "z", "A")
+ mismatch = make_proposal(current, mismatch_edit)
+ with pytest.raises(EditValidationError, match="expected_text"):
+ apply_component_batch(current, (mismatch,), COMPONENT, 0)
+
+
+def test_conflict_across_proposals_rejects_whole_component_batch() -> None:
+ snapshot = DocumentSnapshot("abcdef")
+ first = make_proposal(snapshot, make_edit(snapshot, 0, 3, "X"), reason="first")
+ second = make_proposal(snapshot, make_edit(snapshot, 2, 4, "Y"), reason="second")
+
+ with pytest.raises(EditValidationError, match="conflicting"):
+ apply_component_batch(snapshot, (first, second), COMPONENT, 0)
+
+ assert snapshot.markdown == "abcdef"
+
+
+def test_empty_component_batch_keeps_same_snapshot_and_records_nothing() -> None:
+ snapshot = DocumentSnapshot("abc")
+
+ applied = apply_component_batch(snapshot, (), COMPONENT, 0)
+
+ assert applied.snapshot is snapshot
+ assert applied.changes == ()
diff --git a/tests/test_models.py b/tests/test_models.py
new file mode 100644
index 0000000..d63b23d
--- /dev/null
+++ b/tests/test_models.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from dataclasses import FrozenInstanceError
+from hashlib import sha256
+
+import pytest
+
+from mdpolish import (
+ DocumentSnapshot,
+ ErrorStage,
+ ProposedChange,
+ ResidualProposal,
+ RunError,
+ RunStatus,
+ TextEdit,
+ TextSpan,
+ TransformResult,
+)
+from mdpolish.models import ProposalReference
+
+
+def test_snapshot_preserves_exact_markdown_and_computes_hash() -> None:
+ markdown = "标题\r\nCafe\u0301\n🙂\n"
+
+ snapshot = DocumentSnapshot(markdown)
+
+ assert snapshot.markdown == markdown
+ assert snapshot.sha256 == sha256(markdown.encode("utf-8")).hexdigest()
+
+
+def test_empty_snapshot_is_valid_and_hash_cannot_be_supplied() -> None:
+ snapshot = DocumentSnapshot("")
+
+ assert snapshot.sha256 == sha256(b"").hexdigest()
+ with pytest.raises(TypeError):
+ DocumentSnapshot("", sha256="0" * 64) # type: ignore[call-arg]
+
+
+def test_snapshot_is_frozen() -> None:
+ snapshot = DocumentSnapshot("original")
+
+ with pytest.raises(FrozenInstanceError):
+ snapshot.markdown = "changed" # type: ignore[misc]
+
+
+@pytest.mark.parametrize(
+ ("start", "end", "error_type"),
+ [
+ (-1, 0, ValueError),
+ (2, 1, ValueError),
+ (True, 1, TypeError),
+ ],
+)
+def test_span_rejects_invalid_indexes(start: int, end: int, error_type: type[Exception]) -> None:
+ with pytest.raises(error_type):
+ TextSpan(start, end)
+
+
+def test_text_edit_supports_insert_delete_and_replace() -> None:
+ snapshot = DocumentSnapshot("中文abc")
+
+ insertion = TextEdit(snapshot.sha256, TextSpan(2, 2), "", "!")
+ deletion = TextEdit(snapshot.sha256, TextSpan(2, 3), "a", "")
+ replacement = TextEdit(snapshot.sha256, TextSpan(3, 5), "bc", "BC")
+
+ assert insertion.span.is_empty
+ assert deletion.replacement == ""
+ assert replacement.expected_text == "bc"
+
+
+def test_text_edit_rejects_bad_digest_length_mismatch_and_no_op() -> None:
+ digest = DocumentSnapshot("abc").sha256
+
+ with pytest.raises(ValueError, match="SHA-256"):
+ TextEdit("bad", TextSpan(0, 1), "a", "b")
+ with pytest.raises(ValueError, match="length"):
+ TextEdit(digest, TextSpan(0, 2), "a", "b")
+ with pytest.raises(ValueError, match="must change"):
+ TextEdit(digest, TextSpan(0, 1), "a", "a")
+
+
+def test_proposal_requires_reason_edits_and_one_matching_digest() -> None:
+ snapshot = DocumentSnapshot("abc")
+ other = DocumentSnapshot("xyz")
+ edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A")
+ other_edit = TextEdit(other.sha256, TextSpan(0, 1), "x", "X")
+
+ with pytest.raises(ValueError, match="reason"):
+ ProposedChange(snapshot.sha256, " ", (edit,))
+ with pytest.raises(ValueError, match="non-empty tuple"):
+ ProposedChange(snapshot.sha256, "reason", ())
+ with pytest.raises(ValueError, match="proposal digest"):
+ ProposedChange(snapshot.sha256, "reason", (other_edit,))
+
+
+def test_transform_result_enforces_status_specific_output_fields() -> None:
+ snapshot = DocumentSnapshot("abc")
+ error = RunError("component", "1.0.0", 0, ErrorStage.TRANSFORM, "ExampleError", "safe")
+ edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A")
+ proposal = ProposedChange(snapshot.sha256, "reason", (edit,))
+ residual = ResidualProposal(
+ component_id="component",
+ component_version="1.0.0",
+ component_position=0,
+ proposal_ref=ProposalReference(0, snapshot.sha256, 0),
+ proposal=proposal,
+ )
+
+ success = TransformResult(
+ status=RunStatus.SUCCESS,
+ input_sha256=snapshot.sha256,
+ current_sha256=snapshot.sha256,
+ output_markdown=snapshot.markdown,
+ )
+ failed = TransformResult(
+ status=RunStatus.FAILED,
+ input_sha256=snapshot.sha256,
+ current_sha256=snapshot.sha256,
+ errors=(error,),
+ partial_markdown=snapshot.markdown,
+ )
+ unstable = TransformResult(
+ status=RunStatus.UNSTABLE,
+ input_sha256=snapshot.sha256,
+ current_sha256=snapshot.sha256,
+ residual_proposals=(residual,),
+ partial_markdown=snapshot.markdown,
+ )
+
+ assert success.output_markdown == "abc"
+ assert failed.partial_markdown == "abc"
+ assert unstable.residual_proposals == (residual,)
+
+ with pytest.raises(ValueError, match="successful result"):
+ TransformResult(
+ status=RunStatus.SUCCESS,
+ input_sha256=snapshot.sha256,
+ current_sha256=snapshot.sha256,
+ errors=(error,),
+ output_markdown=snapshot.markdown,
+ )
+ with pytest.raises(ValueError, match="failed result"):
+ TransformResult(
+ status=RunStatus.FAILED,
+ input_sha256=snapshot.sha256,
+ current_sha256=snapshot.sha256,
+ partial_markdown=snapshot.markdown,
+ )
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
new file mode 100644
index 0000000..8919ef3
--- /dev/null
+++ b/tests/test_pipeline.py
@@ -0,0 +1,293 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import cast
+
+import pytest
+
+from mdpolish import (
+ Component,
+ DocumentSnapshot,
+ ErrorStage,
+ Pipeline,
+ ProposedChange,
+ RunStatus,
+ TextEdit,
+ TextSpan,
+)
+
+
+class ReplaceComponent(Component):
+ def __init__(
+ self,
+ needle: str,
+ replacement: str,
+ *,
+ component_id: str,
+ version: str = "1.0.0",
+ parameters: object = None,
+ applicability: str = "处理精确测试字符串,要求完整匹配,排除其他内容。",
+ ) -> None:
+ self.needle = needle
+ self.replacement = replacement
+ self._component_id = component_id
+ self._version = version
+ self._parameters = {"needle": needle, "replacement": replacement} if parameters is None else parameters
+ self._applicability = applicability
+
+ @property
+ def component_id(self) -> str:
+ return self._component_id
+
+ @property
+ def version(self) -> str:
+ return self._version
+
+ @property
+ def parameters(self) -> Mapping[str, object]:
+ return cast(Mapping[str, object], self._parameters)
+
+ @property
+ def applicability(self) -> str:
+ return self._applicability
+
+ def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ position = snapshot.markdown.find(self.needle)
+ if position < 0:
+ return ()
+ edit = TextEdit(
+ snapshot_sha256=snapshot.sha256,
+ span=TextSpan(position, position + len(self.needle)),
+ expected_text=self.needle,
+ replacement=self.replacement,
+ )
+ return (
+ ProposedChange(
+ snapshot_sha256=snapshot.sha256,
+ reason=f"replace test token for {self.component_id}",
+ edits=(edit,),
+ ),
+ )
+
+
+class ExplodingComponent(ReplaceComponent):
+ def __init__(self, *, trigger: str | None = None, component_id: str = "test.exploding") -> None:
+ super().__init__("unused", "unused-replacement", component_id=component_id)
+ self.trigger = trigger
+
+ def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ if self.trigger is None or snapshot.markdown == self.trigger:
+ raise RuntimeError(f"SECRET source: {snapshot.markdown}")
+ return ()
+
+
+class InvalidReturnComponent(ReplaceComponent):
+ def __init__(self) -> None:
+ super().__init__("a", "A", component_id="test.invalid-return")
+
+ def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ return cast(tuple[ProposedChange, ...], [])
+
+
+class StaleProposalComponent(ReplaceComponent):
+ def __init__(self) -> None:
+ super().__init__("a", "A", component_id="test.stale")
+
+ def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ stale_snapshot = DocumentSnapshot(snapshot.markdown + "!")
+ edit = TextEdit(stale_snapshot.sha256, TextSpan(0, 1), stale_snapshot.markdown[0], "X")
+ return (ProposedChange(stale_snapshot.sha256, "stale test proposal", (edit,)),)
+
+
+class OverlapComponent(ReplaceComponent):
+ def __init__(self) -> None:
+ super().__init__("a", "A", component_id="test.overlap")
+
+ def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ first = TextEdit(snapshot.sha256, TextSpan(0, 3), snapshot.markdown[0:3], "X")
+ second = TextEdit(snapshot.sha256, TextSpan(2, 4), snapshot.markdown[2:4], "Y")
+ return (ProposedChange(snapshot.sha256, "overlapping test proposal", (first, second)),)
+
+
+def test_empty_pipeline_returns_unchanged_success_for_empty_unicode_text() -> None:
+ for markdown in ("", "中文\nCafe\u0301\n🙂"):
+ result = Pipeline([]).transform(markdown)
+
+ assert result.status is RunStatus.SUCCESS
+ assert result.output_markdown == markdown
+ assert result.partial_markdown is None
+ assert result.changes == ()
+ assert result.components == ()
+
+
+def test_later_component_reads_snapshot_produced_by_earlier_component() -> None:
+ pipeline = Pipeline(
+ [
+ ReplaceComponent("初", "中", component_id="test.first"),
+ ReplaceComponent("中", "终", component_id="test.second"),
+ ]
+ )
+
+ result = pipeline.transform("初")
+
+ assert result.status is RunStatus.SUCCESS
+ assert result.output_markdown == "终"
+ assert [change.component_id for change in result.changes] == ["test.first", "test.second"]
+ assert result.changes[1].before_sha256 == result.changes[0].after_sha256
+
+
+def test_same_input_components_and_parameters_produce_same_ordered_result() -> None:
+ pipeline = Pipeline(
+ [
+ ReplaceComponent("a", "b", component_id="test.first"),
+ ReplaceComponent("b", "c", component_id="test.second"),
+ ]
+ )
+
+ assert pipeline.transform("a") == pipeline.transform("a")
+
+
+def test_successful_pipeline_is_idempotent_on_its_output() -> None:
+ pipeline = Pipeline([ReplaceComponent("old", "new", component_id="test.replace")])
+
+ first = pipeline.transform("old value")
+ assert first.status is RunStatus.SUCCESS
+ assert first.output_markdown is not None
+
+ second = pipeline.transform(first.output_markdown)
+
+ assert second.status is RunStatus.SUCCESS
+ assert second.output_markdown == "new value"
+ assert second.changes == ()
+
+
+def test_single_component_runs_through_pipeline_without_shortcut() -> None:
+ component = ReplaceComponent("a", "A", component_id="test.single")
+
+ result = Pipeline([component]).transform("a")
+
+ assert result.status is RunStatus.SUCCESS
+ assert result.output_markdown == "A"
+ assert len(result.changes) == 1
+
+
+def test_transform_error_stops_later_components_and_keeps_only_partial_text() -> None:
+ pipeline = Pipeline(
+ [
+ ReplaceComponent("a", "b", component_id="test.first"),
+ ExplodingComponent(),
+ ReplaceComponent("b", "c", component_id="test.never-runs"),
+ ]
+ )
+
+ result = pipeline.transform("a")
+
+ assert result.status is RunStatus.FAILED
+ assert result.output_markdown is None
+ assert result.partial_markdown == "b"
+ assert [change.component_id for change in result.changes] == ["test.first"]
+ assert len(result.errors) == 1
+ assert result.errors[0].stage is ErrorStage.TRANSFORM
+ assert result.residual_proposals == ()
+
+
+def test_unexpected_component_error_does_not_leak_source_or_exception_message() -> None:
+ result = Pipeline([ExplodingComponent()]).transform("private markdown")
+
+ assert result.status is RunStatus.FAILED
+ assert result.errors[0].error_type == "RuntimeError"
+ assert "SECRET" not in result.errors[0].message
+ assert "private markdown" not in result.errors[0].message
+
+
+def test_invalid_proposal_return_is_a_transform_contract_failure() -> None:
+ result = Pipeline([InvalidReturnComponent()]).transform("abc")
+
+ assert result.status is RunStatus.FAILED
+ assert result.partial_markdown == "abc"
+ assert result.errors[0].error_type == "ComponentContractError"
+ assert result.errors[0].stage is ErrorStage.TRANSFORM
+
+
+@pytest.mark.parametrize("component", [StaleProposalComponent(), OverlapComponent()])
+def test_invalid_edit_batch_fails_atomically(component: Component) -> None:
+ result = Pipeline([component]).transform("abcd")
+
+ assert result.status is RunStatus.FAILED
+ assert result.partial_markdown == "abcd"
+ assert result.changes == ()
+ assert result.errors[0].error_type == "EditValidationError"
+
+
+def test_duplicate_component_ids_fail_during_preflight_before_modification() -> None:
+ pipeline = Pipeline(
+ [
+ ReplaceComponent("a", "b", component_id="test.duplicate"),
+ ReplaceComponent("b", "c", component_id="test.duplicate"),
+ ]
+ )
+
+ result = pipeline.transform("a")
+
+ assert result.status is RunStatus.FAILED
+ assert result.partial_markdown == "a"
+ assert result.changes == ()
+ assert result.errors[0].error_type == "PipelineContractError"
+
+
+@pytest.mark.parametrize(
+ "component",
+ [
+ ReplaceComponent("a", "b", component_id="test.bad-version", version="1.0"),
+ ReplaceComponent("a", "b", component_id="test.bad-parameters", parameters={"bad": {1}}),
+ ReplaceComponent("a", "b", component_id="test.bad-applicability", applicability=""),
+ ],
+)
+def test_invalid_component_metadata_fails_before_modification(component: Component) -> None:
+ result = Pipeline([component]).transform("a")
+
+ assert result.status is RunStatus.FAILED
+ assert result.partial_markdown == "a"
+ assert result.changes == ()
+ assert result.errors[0].stage is ErrorStage.TRANSFORM
+
+
+def test_cross_component_chain_is_reported_unstable_without_a_second_round() -> None:
+ pipeline = Pipeline(
+ [
+ ReplaceComponent("bad", "good", component_id="test.to-good"),
+ ReplaceComponent("good", "bad", component_id="test.to-bad"),
+ ]
+ )
+
+ result = pipeline.transform("bad")
+
+ assert result.status is RunStatus.UNSTABLE
+ assert result.output_markdown is None
+ assert result.partial_markdown == "bad"
+ assert len(result.changes) == 2
+ assert len(result.residual_proposals) == 1
+ assert result.residual_proposals[0].component_id == "test.to-good"
+ assert result.errors == ()
+
+
+def test_final_review_continues_after_error_and_keeps_valid_residual_proposal() -> None:
+ pipeline = Pipeline(
+ [
+ ExplodingComponent(trigger="done", component_id="test.review-error"),
+ ReplaceComponent("done", "clean", component_id="test.residual"),
+ ReplaceComponent("start", "done", component_id="test.producer"),
+ ]
+ )
+
+ result = pipeline.transform("start")
+
+ assert result.status is RunStatus.FAILED
+ assert result.output_markdown is None
+ assert result.partial_markdown == "done"
+ assert len(result.errors) == 1
+ assert result.errors[0].stage is ErrorStage.FINAL_REVIEW
+ assert result.errors[0].component_id == "test.review-error"
+ assert len(result.residual_proposals) == 1
+ assert result.residual_proposals[0].component_id == "test.residual"
+ assert result.residual_proposals[0].proposal_ref.snapshot_sha256 == result.current_sha256
From 8c23ac55213fd9ae063580522c33ab9251b0ab02 Mon Sep 17 00:00:00 2001
From: Bepr4 <63661977@qq.com>
Date: Sat, 22 Aug 2026 15:02:21 +0800
Subject: [PATCH 3/5] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20arXiv=20=E6=8F=90?=
=?UTF-8?q?=E4=BA=A4=E8=BE=B9=E6=A0=8F=E6=88=B3=E6=B8=85=E6=B4=97=E7=BB=84?=
=?UTF-8?q?=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
冻结 0004,新增严格整行删除组件和测试,并记录 5 份论文的只读验证结果。同步 ClinDB 清洗范围,并忽略本地 reference 调研副本。
---
.gitignore | 3 +
README.md | 29 +--
.../0004-arxiv-submission-stamp-component.md | 219 ++++++++++++++++++
.../explanation/arxiv-submission-stamp.md | 76 ++++++
.../explanation/first-executable-core.md | 11 +-
.../CLINDB_REVIEWBENCH_CLEANING_SCOPE.md | 34 +--
src/mdpolish/components/__init__.py | 5 +
.../components/arxiv_submission_stamp.py | 81 +++++++
tests/test_arxiv_submission_stamp.py | 155 +++++++++++++
9 files changed, 580 insertions(+), 33 deletions(-)
create mode 100644 research-wiki/design/0004-arxiv-submission-stamp-component.md
create mode 100644 research-wiki/explanation/arxiv-submission-stamp.md
create mode 100644 src/mdpolish/components/__init__.py
create mode 100644 src/mdpolish/components/arxiv_submission_stamp.py
create mode 100644 tests/test_arxiv_submission_stamp.py
diff --git a/.gitignore b/.gitignore
index 84059b9..a6b02ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,9 @@ experiments/
*.cleaned.md
report.json
+# Local third-party research checkouts
+/reference/
+
# Editor and operating-system files
.DS_Store
.idea/
diff --git a/README.md b/README.md
index e482b6b..b9ec0cb 100644
--- a/README.md
+++ b/README.md
@@ -7,8 +7,8 @@
profile 表达论文、政务文档、RAG、文档对比等不同需求。
仓库当前已从纯文档治理进入第一版核心实现阶段:已经提供可安装的 Python 内存处理包和测试,用于验证
-组件组合、精确修改和审计协议。仓库仍不提供真实清洗规则、命令行工具、文件读写适配器或生产接口,
-因此目前还不是拿来即可清洗文档的成品工具。
+组件组合、精确修改和审计协议,并已有一个严格整行匹配的论文清洗组件。仓库仍不提供完整规则集、命令行工具、
+文件读写适配器或生产接口,因此目前还不是拿来即可完成整篇文档清洗的成品工具。
## 当前阶段
@@ -22,7 +22,7 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
- `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 类确定性规则)。
+ (`research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md`,8 类自动清洗候选)。
- 2026-08-21 明确本项目定位为实验室共用库;GovDoc 和论文清洗都是使用场景,不是核心边界。
- 2026-08-21 批准并冻结 `research-wiki/design/0002-composable-cleaning-pipeline.md`,确定只接收 Markdown、
项目显式组装组件、单轮修改加最终只读复查的总体组织方式。
@@ -30,6 +30,9 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
Python 内存自动清洗核心:组件只提出确定可执行的精确修改,不同时建设独立检查、人工建议或真实清洗规则。
- 2026-08-22 按 `0003` 实现第一版内存核心和测试:包括不可变数据契约、组件基类、原子修改执行器、
顺序流水线和最终稳定性复查;58 项测试以及 Ruff、mypy 检查均通过。
+- 2026-08-22 批准并实现 `research-wiki/design/0004-arxiv-submission-stamp-component.md`:新增严格整行匹配的
+ arXiv 提交边栏戳删除组件;5 份论文只读复核只命中 sim 和 springer 各一处,两处合法参考文献保持不变,
+ 第二次运行零修改,源文件没有变化。
- 2026-08-21 完成 HTML 表格清洗专题调研
(`research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md`):核实 Pandoc 表格
方言能力边界、Turndown 不处理合并单元格、Docling Markdown 导出重复合并单元格内容、MinerU 全
@@ -38,9 +41,9 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
冻结的 design 记录和带日期的 scratch 笔记保留当时的旧名。
当前已有只处理内存字符串的底层执行机制和函数级契约,运行时只依赖 Python 标准库。组件可以针对当前
-Markdown 快照提出精确修改,流水线负责原子应用、审计记录、失败隔离和最终稳定性复查。仓库尚无任何正式
-清洗组件,因此不能把测试专用假组件或核心执行成功理解为已经具备论文、GovDoc、表格或图片清洗能力。
-调研报告中的解析器、内部 IR、具体 profile 和真实清洗规则仍是候选方案,尚未批准。
+Markdown 快照提出精确修改,流水线负责原子应用、审计记录、失败隔离和最终稳定性复查。当前唯一正式组件只删除
+完整匹配的 arXiv 提交边栏戳,不能把这一项能力理解为已经具备完整论文、GovDoc、表格或图片清洗能力。
+调研报告中的解析器、内部 IR、具体 profile 和其他真实清洗规则仍是候选方案,尚未批准。
## 服务对象与复用目标
@@ -87,7 +90,7 @@ mdpolish/
├── CLAUDE.md
├── README.md
├── pyproject.toml # Python 包、构建和开发检查的唯一配置
-├── src/mdpolish/ # 第一版内存核心;不含真实清洗组件
+├── src/mdpolish/ # 第一版内存核心和已批准的业务组件
├── tests/ # 核心契约和组合行为测试
├── data/ # 本地项目数据;Git 忽略,未来按项目分区
└── research-wiki/
@@ -108,10 +111,10 @@ mdpolish/
3. `research-wiki/README.md`,确认文档应放在哪里;
4. 与任务直接相关的 `research-wiki/design/` 记录。
-第一版核心的当前机制见 `research-wiki/explanation/first-executable-core.md`。下一项实质工作应从已经审计的问题中
-选择一个边界明确、能够唯一修复的真实清洗规则,新增 design 说明其语义、适用范围、误改风险和验收样例,
-经批准后再实现。解析器、CLI、文件适配器、profile 格式和独立检查能力仍需分别设计,不能从当前核心存在推导为
-已经获批。
+第一版核心的当前机制见 `research-wiki/explanation/first-executable-core.md`,首个真实组件见
+`research-wiki/explanation/arxiv-submission-stamp.md`。下一项候选是 ClinDB 范围中的 HTML 实体双重转义,
+但必须先用新 design 确定只在哪些 HTML 范围替换、如何避开字面示例以及实体替换边界。解析器、CLI、文件适配器、
+profile 格式和独立检查能力仍需分别设计,不能从当前核心或单个组件存在推导为已经获批。
## 当前可用检查
@@ -135,6 +138,6 @@ find research-wiki -maxdepth 2 -type f | sort
git status --short
```
-上述安装和三项基础验收已于 2026-08-22 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 9 个源码与
-测试文件无问题,pytest 共 58 项测试通过。`requires-python` 仍以 `pyproject.toml` 声明的 Python 3.11 及以上为准;
+上述安装和三项基础验收已于 2026-08-22 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 12 个源码与
+测试文件无问题,pytest 共 87 项测试通过。`requires-python` 仍以 `pyproject.toml` 声明的 Python 3.11 及以上为准;
本次结果不等于已经在每个受支持版本上完成兼容性验证。
diff --git a/research-wiki/design/0004-arxiv-submission-stamp-component.md b/research-wiki/design/0004-arxiv-submission-stamp-component.md
new file mode 100644
index 0000000..8f28e65
--- /dev/null
+++ b/research-wiki/design/0004-arxiv-submission-stamp-component.md
@@ -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<数字版本> [] <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、独立检查或人工建议;不授权保存真实清洗
+输出、修改真实数据、提交、推送或发布。
diff --git a/research-wiki/explanation/arxiv-submission-stamp.md b/research-wiki/explanation/arxiv-submission-stamp.md
new file mode 100644
index 0000000..2435797
--- /dev/null
+++ b/research-wiki/explanation/arxiv-submission-stamp.md
@@ -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:<新版数字编号和版本> [] <日> <英文月份缩写> <四位年份>
+```
+
+首尾空格、列表或引用前缀、缺少版本、旧式编号、错误月份以及句子中的 `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,再决定是否放宽模式,不能为了
+提高命中数量直接修改正则表达式。
diff --git a/research-wiki/explanation/first-executable-core.md b/research-wiki/explanation/first-executable-core.md
index d55caee..ae06d5f 100644
--- a/research-wiki/explanation/first-executable-core.md
+++ b/research-wiki/explanation/first-executable-core.md
@@ -6,7 +6,8 @@
旧位置还可能在文本变化后误中另一段内容;同一批修改发生重叠时,按不同顺序执行也可能得到不同结果。
当前核心把“判断应该改什么”和“安全地执行修改”分开:组件只描述绑定当前文本的精确修改,公共执行器统一
-验证并应用。这样可以在不引入真实清洗规则、文件读写或 Markdown parser 的情况下,先让组合与审计协议可运行。
+验证并应用。核心建立时先不引入真实清洗规则、文件读写或 Markdown parser,让组合与审计协议独立可运行;
+现在第一个真实组件已经在这套协议上完成验证,没有改变核心接口。
已经实现的范围来自已批准的
[`0003-first-executable-core-architecture.md`](../design/0003-first-executable-core-architecture.md)。精确类名、字段和
@@ -132,15 +133,17 @@
## 8. 当前验证和剩余边界
-核心测试使用短小的假组件,不包含或复制真实文档。测试已经覆盖空文本、中文和组合 Unicode、插入/删除/替换、
-范围冲突、过期哈希、批次原子性、组件连锁影响、错误阶段、审计关联和成功结果再次运行等行为。
+核心测试继续使用短小的假组件,不包含或复制真实文档。它们覆盖空文本、中文和组合 Unicode、插入/删除/替换、
+范围冲突、过期哈希、批次原子性、组件连锁影响、错误阶段、审计关联和成功结果再次运行等行为。首个真实组件另用
+合成样例测试,并在本地真实材料上只读复核;机制与结果见
+[`arxiv-submission-stamp.md`](arxiv-submission-stamp.md)。
实际可用的安装与验收命令、最近一次验证日期和结果只在根目录
[`README.md`](../../README.md#当前可用检查) 维护。
当前仍然没有:
-- 论文、GovDoc、HTML 表格或其他真实清洗组件;
+- 除严格删除 arXiv 提交边栏戳外的其他论文、GovDoc 或 HTML 表格清洗组件;
- 独立文档检查、人工建议或审核流程;
- Markdown parser、AST 或共享业务中间表示;
- 文件读写、CLI、批处理、项目 profile 格式和生产集成;
diff --git a/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md b/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md
index e0d4e95..e99b8c3 100644
--- a/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md
+++ b/research-wiki/reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md
@@ -1,8 +1,8 @@
# ClinDB-ReviewBench 清洗目标(第一版)
> 性质:reference——本项目清洗范围的权威查询事实。
-> 权威关系:本文只定义 ClinDB-ReviewBench 第一版"洗什么、不洗什么";清洗语义的方案比较与批准记录
-> 属于 `research-wiki/design/`(尚未建立),实现后的运行方式属于 `explanation/` 与 `guides/`。
+> 权威关系:本文只定义 ClinDB-ReviewBench 第一批自动清洗"洗什么、不洗什么";清洗语义的方案比较与批准记录
+> 属于 `research-wiki/design/`,实现后的运行方式属于 `explanation/` 与 `guides/`。未来只读检查不属于当前批次。
> 依据:`research-wiki/scratch/data-5papers-cleaning-audit-2026-08-21.md`(问题编号 A–H 沿用该审计)。
## 1. 项目定位
@@ -16,22 +16,22 @@ ClinDB-ReviewBench 是师姐的论文清洗项目。`data/` 下当前 5 份 DOI
## 2. 第一版清洗目标
-第一版只做"全自动、规则确定、可安全执行"的问题(审计第一档,共 9 类)。
+第一版只做"全自动、规则确定、可安全执行"的问题(审计第一档中的 8 类自动修改)。
判定标准是三条同时满足:模式可用确定规则描述;不依赖对正文语义的理解;改错可以从 diff 直接看出。
| # | 问题(审计编号) | 规则要点 | 触发范围(本轮实测) |
|---|---|---|---|
| 1 | HTML 实体双重转义(D2) | `>`→`>`、`<`→`<`、`&`→`&`,还原一层;幂等 | 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 是合法参考文献,必须不误删 |
+| 2 | arXiv 边栏戳(H1) | 只有整行满足 `design/0004-arxiv-submission-stamp-component.md` 第 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) |
+| 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) |
+| 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 项是只读校验:它修复不了任何东西,输出的是"哪些文档的图片资产已损坏"清单。
+本表只包含当前自动清洗核心能够承载的修改。原审计中的图片断链校验修复不了 Markdown,已移到第 3 节等待
+未来独立 Inspector 设计,不计入这 8 类自动清洗目标。
## 3. 明确不洗(第一版非目标)
@@ -48,18 +48,20 @@ ClinDB-ReviewBench 是师姐的论文清洗项目。`data/` 下当前 5 份 DOI
"自动检测+人工确认",等第一档验证后再立项。
- **封面页(H2)**:ejhf L1–11 的仓库封面区第一版不删——它是整块连续的正文区,删除逻辑
与页眉类噪声不同,归入后续批次。
+- **图片断链校验(H3)**:全部 8 处图片引用是否存在属于未来只读检查;当前核心不读取图片资产、不输出断链
+ 报告,也不移动、复制或改写图片。需要该结果时先新增平行 Inspector 设计。
- **一切内容改写**:原文写作瑕疵、欧式千分位、拼写(含 "Conounder")不属于转换噪声,永不由清洗工具修改。
## 4. 输入输出边界
-- 输入:`data/<转换结果目录>/markdowns/*.md`,原文件只读;
-- 输出:清洗结果写入独立输出位置(具体目录待 design 批准后确定),不回写、不覆盖原文件;
-- 图片资产只校验存在性,不复制进仓库、不修改路径(路径改写是后续批次的独立决策);
-- 每处修改必须可追踪(规则编号 + 原文/改后对照),保证审计和回滚。
+- 项目输入位于 `data/<转换结果目录>/markdowns/*.md`,原文件只读;
+- 当前核心只接收内存 Markdown 字符串并返回内存结果;文件输出位置和保存流程尚未设计,不回写、不覆盖原文件;
+- 当前批次不读取图片资产;未来 Inspector 和路径改写分别设计;
+- 每处实际修改必须在内存结果中记录组件、理由、原文、改后内容和批次哈希,保证可追踪。
## 5. 验收口径(第一版)
-- 上述 9 类问题在本轮 5 份文件上的触发处全部按规则处理,处理数与审计报告的实测数字一致;
+- 上述 8 类问题在本轮 5 份文件上的触发处全部按规则处理,处理数与审计报告的实测数字一致;
- 5 份文件中未被任何规则命中的正文零变更——除表中列出的触发处外不得有任何其他 diff;
- 幂等性:同一输入清洗两次,第二次产出与第一次完全一致;
- 规则 2(arXiv 戳)在 springer 上的验收必须包含反向用例:L143/L152 参考文献原文保留;
@@ -68,6 +70,6 @@ ClinDB-ReviewBench 是师姐的论文清洗项目。`data/` 下当前 5 份 DOI
## 6. 与审计报告的编号对应
-本文的 9 类规则对应 `scratch/data-5papers-cleaning-audit-2026-08-21.md` 的决策清单行:
-D2、H1、B2、B1、C4、D1、E2、G3、H3。该审计是 scratch 材料,本文引用其编号仅为便于追溯,
-权威以本文为准。
+本文的 8 类自动清洗规则对应 `scratch/data-5papers-cleaning-audit-2026-08-21.md` 的决策清单行:
+D2、H1、B2、B1、C4、D1、E2、G3。H3 保留为未来只读检查候选。该审计是 scratch 材料,本文引用其编号
+仅为便于追溯,权威以本文为准。
diff --git a/src/mdpolish/components/__init__.py b/src/mdpolish/components/__init__.py
new file mode 100644
index 0000000..2d8a08d
--- /dev/null
+++ b/src/mdpolish/components/__init__.py
@@ -0,0 +1,5 @@
+"""Approved business cleaning components."""
+
+from mdpolish.components.arxiv_submission_stamp import ArxivSubmissionStampComponent
+
+__all__ = ["ArxivSubmissionStampComponent"]
diff --git a/src/mdpolish/components/arxiv_submission_stamp.py b/src/mdpolish/components/arxiv_submission_stamp.py
new file mode 100644
index 0000000..8490762
--- /dev/null
+++ b/src/mdpolish/components/arxiv_submission_stamp.py
@@ -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)
diff --git a/tests/test_arxiv_submission_stamp.py b/tests/test_arxiv_submission_stamp.py
new file mode 100644
index 0000000..ddea098
--- /dev/null
+++ b/tests/test_arxiv_submission_stamp.py
@@ -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)
From 48dd02ce1d0c3fd8207522669c980a4f249a00b3 Mon Sep 17 00:00:00 2001
From: Bepr4 <63661977@qq.com>
Date: Sat, 22 Aug 2026 15:05:35 +0800
Subject: [PATCH 4/5] =?UTF-8?q?=E8=AE=B0=E5=BD=95=2045=20=E4=BB=BD=20GovDo?=
=?UTF-8?q?c=20=E6=B5=8B=E8=AF=95=20Markdown=20=E7=9A=84=20HTML=20?=
=?UTF-8?q?=E8=A1=A8=E6=A0=BC=E5=8F=AA=E8=AF=BB=E7=BB=93=E6=9E=84=E5=88=86?=
=?UTF-8?q?=E6=9E=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增 research-wiki/scratch/html-table-real-data-analysis-2026-08-21.md:
按展开 span 后行列一致性给 2474+356+1879 个顶层表格分类(剔除空 tr 后
简单表 53% / 合并合并表 8% / 损坏表 40%),定位标签不闭合、丢失
colspan 标注、无表头三类主因。README 当前阶段补对应条目。
---
README.md | 3 +
...tml-table-real-data-analysis-2026-08-21.md | 170 ++++++++++++++++++
2 files changed, 173 insertions(+)
create mode 100644 research-wiki/scratch/html-table-real-data-analysis-2026-08-21.md
diff --git a/README.md b/README.md
index b9ec0cb..b9af324 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,9 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
(`research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md`):核实 Pandoc 表格
方言能力边界、Turndown 不处理合并单元格、Docling Markdown 导出重复合并单元格内容、MinerU 全
HTML 输出,印证审计 T001 的分流方向。
+- 2026-08-21 完成 45 份测试 Markdown 中 HTML 表格的只读结构分析
+ (`research-wiki/scratch/html-table-real-data-analysis-2026-08-21.md`):剔除空 ` ` 后
+ 简单表 53% / 合法合并表 8% / 损坏表 40%,并定位标签不闭合、丢失 colspan 标注、无表头三类主因。
- 2026-08-21 仓库由 `govdoc-md-cleaner` 更名为 `mdpolish`,GitHub 远程仓库与本地目录同步改名;
冻结的 design 记录和带日期的 scratch 笔记保留当时的旧名。
diff --git a/research-wiki/scratch/html-table-real-data-analysis-2026-08-21.md b/research-wiki/scratch/html-table-real-data-analysis-2026-08-21.md
new file mode 100644
index 0000000..ff24e08
--- /dev/null
+++ b/research-wiki/scratch/html-table-real-data-analysis-2026-08-21.md
@@ -0,0 +1,170 @@
+# HTML 表格真实数据结构分析:45 份 GovDoc 测试 Markdown
+
+> 状态:只读分析记录,尚未进入任何 design。文中"建议"部分是候选方向,不是批准的方案。
+>
+> 分析日期:2026-08-21。
+>
+> 定位:用真实数据回答"我们的 HTML 表格到底长什么样、损坏在哪",为未来表格清洗 design 提供测量依据,
+> 并修正 [`html-table-cleaning-ecosystem-research-2026-08-21.md`](html-table-cleaning-ecosystem-research-2026-08-21.md)
+> 第 8 节中可以用数据回答的待决问题。
+
+## 1. 结论先行
+
+按"展开 rowspan/colspan 后每行列数是否一致"给全部顶层表格分类,**剔除空 ` ` 之后**的分布:
+
+| 分类 | 定义 | 数量 | 占比 |
+|---|---|---|---|
+| A 简单矩形表 | 无合并单元格、每行列数一致、无嵌套、无游离内容 | 2474 | 53% |
+| B 合法合并表 | 有 rowspan/colspan,展开后仍是完整矩形 | 356 | 8% |
+| C 损坏表 | 参差网格、标签截断、占位冲突等 | 1879 | 40% |
+
+四个改变预期的发现:
+
+1. **一半的"损坏"是空 ` ` 造成的假象**——剔除后 C 类从 54% 降到 40%;
+2. **标签不闭合会吞掉半篇文档**——最极端的一个未闭合表格吞了 2.79MB、1294 个表格;
+3. **参差网格与合并单元格强相关**——六成以上参差表带 span 属性,指向转换器丢失 colspan 标注;
+4. **无表头是常态**(A 类中 96%),**表格内图片为零**,单元格内管道符为零。
+
+## 2. 数据范围与方法
+
+- 输入:`/home/lihaoze/gov_test_data/compare/*/uploads/*.md`,45 份,全程只读;
+ 本文只含聚合数字和结构事实,不含任何原文片段;
+- 分析脚本在会话级临时目录 `/tmp/table-analysis/analyze.py`,未入库(仓库处于文档治理阶段,无源码目录);
+ 本文第 2.1 节的规则描述是复现依据;
+- 解析器:lxml `HTMLParser(recover=True)`。本地未装 html5lib。
+
+### 2.1 测量规则
+
+1. **代码围栏遮蔽**:先标记 ```` ``` ````/`~~~` 围栏内的位置,围栏里的 `` 不计入;
+2. **片段切分**:对围栏外的 ``
+ (无对应开启)单独计数并忽略;到文件尾仍未闭合的片段标记 `truncated`;
+3. **结构分析**:每个片段单独喂给 lxml 容错解析;对每个 `` 展开 rowspan/colspan 建二维占位网格,
+ 检测占位冲突,计算每行展开后的列数,收集表头、嵌套、游离文本、块级子标签、空单元格、超长单元格、
+ 退化重复等特征;
+4. **分类谓词**(`truncated` 为片段级标记,其余为表格级):
+
+ ```text
+ A_simple : 非 truncated 且 span_cells=0 且无嵌套 且每行展开列数一致
+ 且无占位冲突 且无游离文本 且块级子标签 ⊆ {br}
+ B_span_ok: 非 truncated 且无占位冲突 且每行展开列数一致 且无游离文本
+ C_broken : 其余全部
+ ```
+
+5. **空行修复复核**:把没有任何 `td`/`th` 子元素的 `` 整行剔除后重新跑同一分类,观察迁移。
+
+## 3. 总量与原始分类
+
+- 45 份文档共 7550 个 `` 259(5.5%)、
+单行表 157、截断片段 67、超长单元格(>500 字符)197、巨型片段(>10KB)70、退化重复内容 33、
+游离文本 27、占位冲突 14、嵌套表格 5、含块级标签 5、**含图片 0**。
+
+## 4. 发现一:空 ` ` 是最大的单一"假损坏"来源
+
+完全没有 `td`/`th` 子元素的空行标签在参差表里出现 3000+ 行次。它们把"列数一致的好表"撑成
+"某些行 0 列"的参差表。
+
+剔除空行后重新分类:
+
+| 迁移路径 | 数量 |
+|---|---|
+| C_broken → A_simple | 553 |
+| C_broken → B_span_ok | 94 |
+
+即分类变为 **A 2474(53%)/ B 356(8%)/ C 1879(40%)**,一条零风险修复救回 13% 的表。
+空行不含任何内容,剔除是无损的。
+
+空行出现的位置:表中间 1151 处、表尾 385 处(对修复后仍为 C 的表统计),**没有出现在表头位置**——
+符合"转换器输出残留"而非"表头占位"的形态。
+
+## 5. 发现二:标签不闭合会吞掉后续正文
+
+最极端案例 `001-2/uploads/file_0_PDF.md`:全文件 1315 开 / 1124 闭;从第 1315 行开始的一个未闭合
+表格把后续 **2,789,092 字符、内部含 1294 个表格**的内容全部吞进一个"顶层片段"。
+
+全库 395 个闭合缺口意味着:**清洗的第一步不是处理表格,而是安全切分片段**。深度配对在标签缺失时
+会把正文和后续完整表格归并进一个巨型片段,后续所有基于片段的统计和修改都会失真。
+
+切分策略的可用锚点:相邻顶层表格之间,2943 对隔着真实文本、1661 对只隔空行——块边界
+(空行 + 后续正文)在实际数据中是可识别的。
+
+## 6. 发现三:参差网格与 span 强相关,指向"丢 colspan 标注"
+
+2506 个参差表中 **1624 个(65%)带 span 属性**——格子内容在、宽度信息没了,是转换器丢失
+colspan 标注的形态,不全是真缺内容。
+
+剔除空行后仍参差的 1851 个表,参差发生位置:
+
+| 位置 | 数量 | 推断成因 |
+|---|---|---|
+| 中间各行乱 | 780 | 真·结构损坏或逐行丢标注 |
+| 表头行比正文长 | 619 | 多级表头被拍平成一行、丢层级 |
+| 首行短 | 239 | 表头/首行缺格 |
+| 仅尾部截短 | 213 | 跨页截断尾巴 |
+
+四类成因不同,修复策略应当不同,但**都不能靠补空单元格自动修**——那正是
+[`../reference/GOVDOC_SAAS_CLEANING_SCOPE.md`](../reference/GOVDOC_SAAS_CLEANING_SCOPE.md) T002
+明确反对、且 rumdl MD056 的 auto-fix 方向被本仓库否决的做法。
+
+## 7. 发现四:无表头是常态,转换障碍集中在 ` `
+
+对修复后仍是 A 类的 2474 个表,检查转 GFM 管道表格的内容障碍:
+
+| 障碍 | 数量 | 占 A 类 |
+|---|---|---|
+| 无 `` 表头 | 2393 | 96% |
+| 单元格含 ` ` | 501 | 20% |
+| 单行表 | 176 | 7% |
+| 超长单元格(>500 字符) | 54 | 2% |
+| 单元格文本含 `\|` | 0 | 0% |
+| 表格内 ` ` | 0 | 0% |
+
+转义压力比预期小(管道符、图片都是零),压力集中在**合成表头**和 ` ` 处理上。
+
+## 8. 建议的处理策略(候选,未批准)
+
+三步走,对齐审计 T001 的分流方向,用真实数据修正边界:
+
+1. **切分**:容错定位 `` 片段;未闭合的在块边界截断并标记 truncated,
+ 绝不让深度配对吞正文;
+2. **无损修复**:只做剔除空 `` 这一级别的零风险修复(实测救回 13% 的表);
+3. **分流**:
+ - A 类(53%)→ 转 GFM:合成表头、` ` 转空格、管道符转义兜底;
+ - B 类(8%)→ 保留 HTML,规范化输出;
+ - C 类(40%)→ 不自动修,保留原样 + 按第 6 节四类成因报告分类原因,交人工确认或回源。
+
+对生态调研第 8 节待决问题的数据回答:
+
+- 问题 4(无表头表格怎么处理):数据表明无表头占绝对多数(96%),"补合成表头转 GFM"
+ 是主流路径;空表头还是首行充当表头仍是 design 待决;
+- 问题 1(简单/复杂分界线):分界线除了合并单元格,必须加"展开后网格是否矩形"——
+ 本数据中它是比 span 更强的损坏信号(53% 对 40%);
+- 问题 2、3(规范 HTML 程度、JSON schema)本次数据没有新增证据,维持开放。
+
+## 9. 局限
+
+- 全部数字只来自这 45 份文档,不得外推为一般结论(CLAUDE.md 第 5 节约束);
+- 53/8/40 依赖"剔除空 ` `"这条规则被采纳;不采纳则为 41/5/54;
+- lxml recover 解析可能自行重排损坏片段,个别表格的行列统计是解析结果而非字节事实;
+- 巨型片段内部的表格统计(如 001-2/file_0 被吞的 1294 个)已计入总数,但它们在原文中的
+ 真实边界未经人工核对;
+- 参差位置的四分类用的是简单规则(与列数众数比较),是启发式归类,不是语义判断。
+
+## 10. 验证状态
+
+- 第 3 至 7 节所有数字:2026-08-21 由只读脚本在真实数据上实际运行得出,脚本未入库;
+- 分类谓词与第 2.1 节规则描述和脚本逻辑一致,可据此重建等价测量;
+- 未验证:任何修复或转换策略的实际效果——A/B/C 分流、空行剔除、块边界截断都还没有实现,
+ 需要等对应 design 批准后用受控样本测试。
From 6548f0f4868f4ffd185848f107bd143a59d3582c Mon Sep 17 00:00:00 2001
From: Bepr4 <63661977@qq.com>
Date: Sat, 22 Aug 2026 15:59:30 +0800
Subject: [PATCH 5/5] =?UTF-8?q?=E7=B2=BE=E7=AE=80=20README=20=E5=BD=93?=
=?UTF-8?q?=E5=89=8D=E9=98=B6=E6=AE=B5=E4=B8=BA=E8=83=BD=E5=8A=9B=E6=B8=85?=
=?UTF-8?q?=E5=8D=95=E5=B9=B6=E5=90=8C=E6=AD=A5=E5=8D=8F=E4=BD=9C=E8=A7=84?=
=?UTF-8?q?=E5=88=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
README 把逐条流水账式进度改为当前能力与边界概述:已实现内存核心、
唯一组件 paper.arxiv_submission_stamp 和 87 项测试;进度细节交由
design 与带日期的 scratch 记录。补充测试数据来源说明和展开的目录
结构。AGENTS/CLAUDE 同步:阶段描述改为指向 README 唯一权威,定位
改为实验室共用库;正文镜像保持一致。gitignore 忽略 .claude/scratch。
---
.gitignore | 1 +
AGENTS.md | 14 +++----
CLAUDE.md | 14 +++----
README.md | 109 ++++++++++++++++++++++++-----------------------------
4 files changed, 64 insertions(+), 74 deletions(-)
diff --git a/.gitignore b/.gitignore
index a6b02ba..f9819bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ __pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
+.claude/scratch/
dist/
build/
*.egg-info/
diff --git a/AGENTS.md b/AGENTS.md
index 87cc33e..d16471f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,9 +1,9 @@
# AGENTS.md
> [!IMPORTANT]
-> **当前是文档治理基础阶段,不是已实现的清洗工具。**
+> **当前阶段与已实现范围只以根目录 `README.md` 为准。**
>
-> 1. 本仓库研究政务文档 PDF→Markdown 清洗问题;当前没有可运行实现。
+> 1. 本仓库是实验室共用的 Markdown 清洗研究与基础工具库;已有实现不等于完整清洗工具或生产能力。
> 2. 真实文档和外部数据默认只读,不修改、不复制、不提交。
> 3. 面向用户的说明使用简体中文;代码、命令、路径和标识符使用英文。
> 4. `AGENTS.md` 与 `CLAUDE.md` 是同步镜像,除第一行标题外正文必须一致。
@@ -28,11 +28,12 @@
## 1. 项目定位与当前阶段
-`mdpolish` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、
-比较候选方案并积累可复核证据。
+`mdpolish` 是实验室共用的 Markdown 清洗研究与基础工具库。它用于理解 PDF、DOCX、OCR、网页等上游管线
+生成的 Markdown 噪音,定义清洗边界,比较候选方案并积累可复核证据。
-当前只建立文档治理基础。源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系均未获批准、
-也未实现。不得因为 README 中描述了目标,就把目标写成已经存在的能力。
+当前阶段、已经完成的工作和实际能力边界只查阅根目录 `README.md`,不在本文件维护第二份进度清单。
+源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系都必须先经对应 design 批准后才能建立或改变;
+已经存在某个核心接口或单项组件,不表示完整规则集、文件适配、CLI、profile 或生产接口已经实现。
本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。
@@ -127,4 +128,3 @@ design 草稿可以在评审中修改;批准后冻结。决策发生变化时
- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户;
- 进度和最终报告必须对应真实工具输出,不把计划描述成结果;
- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。
-
diff --git a/CLAUDE.md b/CLAUDE.md
index b1a69d4..49d5e3c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,9 +1,9 @@
# CLAUDE.md
> [!IMPORTANT]
-> **当前是文档治理基础阶段,不是已实现的清洗工具。**
+> **当前阶段与已实现范围只以根目录 `README.md` 为准。**
>
-> 1. 本仓库研究政务文档 PDF→Markdown 清洗问题;当前没有可运行实现。
+> 1. 本仓库是实验室共用的 Markdown 清洗研究与基础工具库;已有实现不等于完整清洗工具或生产能力。
> 2. 真实文档和外部数据默认只读,不修改、不复制、不提交。
> 3. 面向用户的说明使用简体中文;代码、命令、路径和标识符使用英文。
> 4. `AGENTS.md` 与 `CLAUDE.md` 是同步镜像,除第一行标题外正文必须一致。
@@ -28,11 +28,12 @@
## 1. 项目定位与当前阶段
-`mdpolish` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、
-比较候选方案并积累可复核证据。
+`mdpolish` 是实验室共用的 Markdown 清洗研究与基础工具库。它用于理解 PDF、DOCX、OCR、网页等上游管线
+生成的 Markdown 噪音,定义清洗边界,比较候选方案并积累可复核证据。
-当前只建立文档治理基础。源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系均未获批准、
-也未实现。不得因为 README 中描述了目标,就把目标写成已经存在的能力。
+当前阶段、已经完成的工作和实际能力边界只查阅根目录 `README.md`,不在本文件维护第二份进度清单。
+源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系都必须先经对应 design 批准后才能建立或改变;
+已经存在某个核心接口或单项组件,不表示完整规则集、文件适配、CLI、profile 或生产接口已经实现。
本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。
@@ -127,4 +128,3 @@ design 草稿可以在评审中修改;批准后冻结。决策发生变化时
- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户;
- 进度和最终报告必须对应真实工具输出,不把计划描述成结果;
- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。
-
diff --git a/README.md b/README.md
index b9af324..e337ecf 100644
--- a/README.md
+++ b/README.md
@@ -12,41 +12,16 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
## 当前阶段
-2026-08-20 已完成仓库重置与最小文档治理骨架:
+项目当前已经进入第一版可执行核心和真实组件验证阶段:
-- 原有 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`,8 类自动清洗候选)。
-- 2026-08-21 明确本项目定位为实验室共用库;GovDoc 和论文清洗都是使用场景,不是核心边界。
-- 2026-08-21 批准并冻结 `research-wiki/design/0002-composable-cleaning-pipeline.md`,确定只接收 Markdown、
- 项目显式组装组件、单轮修改加最终只读复查的总体组织方式。
-- 2026-08-22 批准并冻结 `research-wiki/design/0003-first-executable-core-architecture.md`,确定第一版只建设
- Python 内存自动清洗核心:组件只提出确定可执行的精确修改,不同时建设独立检查、人工建议或真实清洗规则。
-- 2026-08-22 按 `0003` 实现第一版内存核心和测试:包括不可变数据契约、组件基类、原子修改执行器、
- 顺序流水线和最终稳定性复查;58 项测试以及 Ruff、mypy 检查均通过。
-- 2026-08-22 批准并实现 `research-wiki/design/0004-arxiv-submission-stamp-component.md`:新增严格整行匹配的
- arXiv 提交边栏戳删除组件;5 份论文只读复核只命中 sim 和 springer 各一处,两处合法参考文献保持不变,
- 第二次运行零修改,源文件没有变化。
-- 2026-08-21 完成 HTML 表格清洗专题调研
- (`research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md`):核实 Pandoc 表格
- 方言能力边界、Turndown 不处理合并单元格、Docling Markdown 导出重复合并单元格内容、MinerU 全
- HTML 输出,印证审计 T001 的分流方向。
-- 2026-08-21 完成 45 份测试 Markdown 中 HTML 表格的只读结构分析
- (`research-wiki/scratch/html-table-real-data-analysis-2026-08-21.md`):剔除空 ` ` 后
- 简单表 53% / 合法合并表 8% / 损坏表 40%,并定位标签不闭合、丢失 colspan 标注、无表头三类主因。
-- 2026-08-21 仓库由 `govdoc-md-cleaner` 更名为 `mdpolish`,GitHub 远程仓库与本地目录同步改名;
- 冻结的 design 记录和带日期的 scratch 笔记保留当时的旧名。
+- 提供可安装的 Python 3.11+ 内存处理包,运行时只依赖标准库;
+- 已实现不可变数据契约、组件基类、原子修改执行器、顺序流水线、审计记录和最终稳定性复查;
+- 当前唯一正式组件是 `paper.arxiv_submission_stamp`,只删除严格整行匹配的 arXiv 提交边栏戳;
+- 该组件已在 5 份论文 Markdown 上只读验证,只命中 sim 和 springer 各一处,合法参考文献保持不变;
+- 当前基础检查为 Ruff、mypy 和 87 项 pytest 测试,实际命令见本文“当前可用检查”。
-当前已有只处理内存字符串的底层执行机制和函数级契约,运行时只依赖 Python 标准库。组件可以针对当前
-Markdown 快照提出精确修改,流水线负责原子应用、审计记录、失败隔离和最终稳定性复查。当前唯一正式组件只删除
-完整匹配的 arXiv 提交边栏戳,不能把这一项能力理解为已经具备完整论文、GovDoc、表格或图片清洗能力。
-调研报告中的解析器、内部 IR、具体 profile 和其他真实清洗规则仍是候选方案,尚未批准。
+项目还没有完整清洗规则集、Markdown/HTML parser、profile 格式、文件读写、CLI、批处理或生产接口。当前组件
+只能证明第一条严格规则已经闭环,不能据此认为论文、GovDoc、表格或图片已经具备完整清洗能力。
## 服务对象与复用目标
@@ -59,6 +34,15 @@ Markdown 快照提出精确修改,流水线负责原子应用、审计记录
当前用例只用于发现真实问题和验证通用能力,不能反过来限定库的设计。核心代码不得依赖论文 DOI、
GovDoc 目录、具体客户名称或某一转换器的固定输出路径。
+## 测试数据
+
+- **论文 Markdown**:`data/md/`,共 5 份,按 ClinDB-ReviewBench 中使用的论文缩写命名,
+ 供本地查看和组件只读验证;
+- **GovDoc Markdown**:`/home/lihaoze/gov_test_data/compare`,共 7 组、45 份,输入位于各组 `uploads/` 下,
+ 保持仓库外只读,不复制到本项目。
+
+两组数据都不是可提交的自动测试 fixture。`data/` 已被 Git 忽略,清洗实验不得覆盖这些输入。
+
## 面向复用的设计原则
- **通用核心**:只接收 Markdown;第一版只执行确定、可审计的精确修改,不读取 PDF、图片或转换器 JSON;
@@ -68,41 +52,46 @@ GovDoc 目录、具体客户名称或某一转换器的固定输出路径。
- **可复现**:规则、配置、输入哈希、输出和每次变更都可以追踪;
- **可扩展**:新增项目在自身边界处理上游适配,并主要组合或补充组件和 profile,而不是复制一套清洗器。
-## `data/` 的职责
-
-`data/` 是实验室项目的本地数据工作区。当前存放师姐论文清洗项目的输入和转换产物,未来可能继续加入
-其他项目的数据。新增数据时应逐步按项目命名空间组织,例如 `data//...`,避免不同项目的
-输入、产物和评测结果混在一起。
-
-数据目录与通用库保持以下边界:
-
-- `data/` 已被 Git 忽略,不作为库源码、公开 fixture 或发布包的一部分;
-- 默认把项目数据视为只读输入,清洗结果写到独立输出位置,不覆盖原文件;
-- 数据可以推动通用规则设计,但项目专属规则必须进入对应 profile;
-- 测试需要的公开样例应单独制作脱敏、最小化 fixture,不能直接复制真实项目文档;
-- `/home/lihaoze/gov_test_data` 等仓库外真实材料同样保持只读,不复制、不修改、不提交。
-
-任何清洗语义、规则格式、评估指标、代码目录或运行依赖,都应先形成设计记录并获得确认。研究结果进入
-具体项目或生产系统前,还需要在使用方范围内独立验证。
-
## 目录结构
```text
mdpolish/
+├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── README.md
-├── pyproject.toml # Python 包、构建和开发检查的唯一配置
-├── src/mdpolish/ # 第一版内存核心和已批准的业务组件
-├── tests/ # 核心契约和组合行为测试
-├── data/ # 本地项目数据;Git 忽略,未来按项目分区
+├── pyproject.toml # Python 包、构建和开发检查配置
+├── src/
+│ └── mdpolish/
+│ ├── __init__.py # 第一版核心公共导出
+│ ├── component.py # 组件基类和元数据契约
+│ ├── edits.py # 文本编辑验证与原子应用
+│ ├── models.py # 不可变数据模型和运行状态
+│ ├── pipeline.py # 顺序执行和最终稳定性复查
+│ ├── py.typed # 类型信息声明
+│ └── components/
+│ ├── __init__.py
+│ └── arxiv_submission_stamp.py
+├── tests/
+│ ├── test_arxiv_submission_stamp.py
+│ ├── test_component.py
+│ ├── test_edits.py
+│ ├── test_models.py
+│ └── test_pipeline.py
+├── data/ # 本地测试数据;Git 忽略;此处只展开常用入口
+│ └── md/
+│ ├── dmp.md
+│ ├── ejhf.md
+│ ├── jama.md
+│ ├── sim.md
+│ └── springer.md
└── research-wiki/
- ├── README.md
- ├── design/ # 方案选择与冻结决策
- ├── explanation/ # 当前有效机制及原因
- ├── reference/ # 需要准确查询的稳定事实
- ├── guides/ # 已实际验证的操作步骤
- └── scratch/ # 调研笔记和未收敛草稿
+ ├── README.md # Wiki 分类与维护规则
+ ├── design/ # 批准前的选择;批准后冻结
+ ├── explanation/ # 当前有效机制及原因
+ ├── reference/ # 稳定查询事实
+ ├── guides/ # 已验证操作步骤
+ └── scratch/ # 调研和未收敛材料
```
## 开始工作
| |