重构为函数式通用 Markdown 修改库

This commit is contained in:
2026-08-26 15:33:37 +08:00
parent 1abf72ccb1
commit bb0507db30
89 changed files with 1589 additions and 14850 deletions
@@ -1,83 +0,0 @@
# arXiv 提交边栏戳为什么能自动删除
## 1. 可观察的问题
部分 arXiv 论文转换为 Markdown 后,会把提交页边栏中的编号、分类和日期留下来,形成一整行独立文字。它不是
论文正文,却会进入后续分块、检索和对比。与此同时,论文参考文献也可能包含 `arXiv:`;只要见到这个子串就删行,
会损坏合法引用。
当前组件只处理前一种格式固定的独立行。它的决策来自已批准的
[`0004-arxiv-submission-stamp-component.md`](../design/0004-arxiv-submission-stamp-component.md),项目范围编号为 H1。
精确模式、类名和返回对象以
[`arxiv_submission_stamp.py`](../../src/mdpolish/components/arxiv_submission_stamp.py) 及其
[`测试`](../../tests/test_arxiv_submission_stamp.py) 为准。
## 2. 当前识别边界
组件逐个读取物理行,只在整行同时具有以下结构时提出删除:
```text
arXiv:<新版数字编号和版本> [<ASCII 分类>] <日> <英文月份缩写> <四位年份>
```
首尾空格、列表或引用前缀、缺少版本、旧式编号、错误月份以及句子中的 `arXiv:` 都不会命中。组件没有参数,
调用方不能传入更宽松的正则表达式改变同一版本的语义。
当前版本有意不解析 Markdown 块结构。围栏代码、HTML 注释或其他块中如果存在一行完整目标文字,同样会被删除。
这是 `0004` 明确接受的代价,不是实现遗漏。以后出现必须保留的真实反例时,需要重新评审识别边界并更新组件版本。
## 3. 删除如何保持原文边界
每个命中行产生一个候选修改和一个删除型文本编辑。删除范围包含该行自己的 `\n``\r\n` 或单独 `\r`
没有行尾的末行只删除文字,不拿走前一行已有的行尾。
| 输入位置 | 当前行为 |
| --- | --- |
| 首行且有行尾 | 连同行尾删除,后续正文成为首行 |
| 文档中间 | 连同目标行自己的行尾删除,前后内容保持两行 |
| 末行且没有行尾 | 只删除目标文字,保留前一行原有行尾 |
| 多个目标行 | 每行一个候选,按原文顺序记录,作为一个组件批次原子应用 |
组件不整理空行、不统一换行符,也不改变未命中的字符。候选修改绑定当前快照哈希和准确原文,仍由公共修改执行器
验证和应用;组件本身没有文件读写或独立 `transform()`
## 4. 审计与稳定性
组件标识为 `paper.arxiv_submission_stamp`,版本为 `1.0.0`,参数为空。每条实际删除记录固定理由,并保留删除原文、
原始范围、组件位置以及批次修改前后的哈希。
删除完成后目标行已经不存在。流水线最终复查不应再得到候选修改;把成功输出再次交给同一组件,也应保持原文不变
且产生零条实际改动。
## 5. 已完成验证
合成测试覆盖严格匹配、反向引用、首行/中间/末行、三种行尾、多个命中、围栏中仍删除、审计字段、确定性和
第二次运行零修改。测试只使用短小的虚构字符串,不含真实论文片段。
2026-08-22 先对本地 5 份 ClinDB-ReviewBench Markdown 做了只读、纯内存复核:
| 复核项 | 结果 |
| --- | --- |
| 输入范围 | dmp、jama、ejhf、sim、springer 各 1 份,共 5 份 |
| 实际删除 | sim 1 行、springer 1 行,其余 0 行,共 2 行 |
| 合法反向样例 | springer 的 2 处 `arXiv preprint arXiv:` 修改前后均保留 |
| 第二次运行 | 5 份合计 0 条修改 |
| 源文件复读 | 5/5 与处理前内存内容一致,没有回写 |
这次早期复核没有保存清洗后 Markdown,也没有把真实原文复制进测试、日志或仓库。它是 `0004` 当时验证边界的
历史事实。
`0005` 批准本地产物机制后,同日又完成一次保存型实验:5 份文档全部为 `success`,仍然只修改 sim 和 springer
各一处;输出哈希全部匹配,输入运行前后字节不变,Springer 两处合法引用仍保留。产物位于
`artifacts/2026-08-22/runs/clindb-arxiv-stamp-artifacts-v1/`,只在本机保留并受 Git 忽略。具体产物结构和验证结果见
[`local-experiment-artifacts.md`](local-experiment-artifacts.md)。安装、静态检查和完整测试命令仍只在根目录
[`README.md`](../../README.md#当前可用检查) 维护。
## 6. 剩余边界
这个组件最初证明了第一条严格删除规则能够在公共核心上闭环。此后 ClinDB 第一批另外 7 个组件已经按 `0006` 实现,
当前完整组合见 [`clindb-first-batch-components.md`](clindb-first-batch-components.md)。这仍不表示论文内容问题全部解决;
通用文件接口、profile、公共 CLI、图片资产和通用批处理仍不存在。
如果出现新的提交戳格式,默认行为是保留。必须先补充真实证据、反向样例和 design,再决定是否放宽模式,不能为了
提高命中数量直接修改正则表达式。
@@ -1,116 +0,0 @@
# ClinDB 第一批组件如何在不猜正文的前提下完成清洗
## 1. 它解决的实际问题
5 份 ClinDB 论文 Markdown 同时包含编辑痕迹、转换噪声和排版噪声。它们看起来都像“删掉几行或整理一下格式”,
实际误删边界不同:行首数字可能是手稿行号,也可能是作者单位;`arXiv:` 可能是边栏戳,也可能是合法参考文献;
编号列表可能属于 References,也可能是正文方法步骤。
当前实现没有建立一个能随意改全文的“大清洗器”,而是把第一批确定问题拆成 8 个组件。每个组件只识别一种证据,
返回快照绑定的精确 `TextEdit`,由公共流水线统一验证、应用和记录。
清洗语义来自已批准的
[`0006-clindb-first-batch-cleaning-components.md`](../design/0006-clindb-first-batch-cleaning-components.md)
输入范围和稳定计数见
[`CLINDB_REVIEWBENCH_CLEANING_SCOPE.md`](../reference/CLINDB_REVIEWBENCH_CLEANING_SCOPE.md)。
## 2. 当前组件和顺序
```text
Word 批注 ─┐
手稿行号 ──┼──► arXiv 戳 ──► 重复页眉 ──► 映射断词
│ │
│ └────────────► 参考文献空行
└────► HTML 双重实体 ──► HTML 表格布局
```
实验脚本固定按以下顺序组装:
| 顺序 | 组件 | 当前作用 |
| ---: | --- | --- |
| 1 | `paper.word_review_comment` | 删除完整单行 Word 批注及其后一个空行 |
| 2 | `paper.manuscript_line_number` | 删除由长递增序列确认的手稿行号 |
| 3 | `paper.arxiv_submission_stamp` | 删除严格整行提交戳 |
| 4 | `paper.repeated_running_header` | 删除重复页眉,并接回有明确续句证据的段落 |
| 5 | `paper.page_break_word_join` | 只应用本次运行参数中记录的词片段映射 |
| 6 | `markdown.html_table_double_escape` | 只在严格表格单元格文本中解除一层实体转义 |
| 7 | `markdown.html_table_layout` | 保留 HTML 内容,把单行表格展开成一行一个 `<tr>` |
| 8 | `paper.reference_spacing` | 只在完整连续的 References 章节中统一条目空行 |
顺序不是为了让结果“看起来整齐”。dmp 的一个重复页眉正好位于第 18、19 条参考文献之间;如果不先删除页眉,
参考文献组件就不能确认这是完整连续序列。HTML 实体先修改小范围 token,表格布局再基于新快照替换整张表,
两类修改仍能在审计中分别追踪。
## 3. 为什么行号不能使用全局正则
JAMA 文档中一共有 134 个看似 `数字 + 空格` 的行首前缀。前 59 个位于 Abstract 之前,是作者单位编号;真正的手稿
行号只有 Abstract 之后的 75 个。
当前组件要求:
- 文档中恰好有一个严格 `## Abstract`
- 只看它之后的候选;
- 候选数字全部严格递增;
- 至少有 20 个候选,并至少有 2 个数字标题。
任一条件失败就整篇不改。这样会漏掉较短的带行号手稿,但不会为了提高命中率删除作者单位或零散数字段落。
## 4. 为什么断词使用显式映射
“行尾连字符加下一行小写字母”无法决定连字符应删还是保留:`possi-` + `bly` 应成为 `possibly`,而
`SOFA-` + `based` 应保留为 `SOFA-based`。dmp 还存在 `threshold.` + `olds`,它同时包含多余句点和重复片段,
普通词典也无法解释。
因此组件的实际参数记录三项:左片段、右片段和结果词。只有相邻物理行或中间恰好一个空行、词边界完整且映射唯一时
才修改。增加新词不是自动学习行为,需要先批准新的项目映射;组件算法版本不变时,运行清单仍能通过参数区分实际语义。
## 5. 两个表格组件怎样共享范围
`_html_table.py` 只识别当前转换器输出的严格子集:`<tr>` 直接位于 `<table>` 下,`<td>` / `<th>` 直接位于
`<tr>` 下,标签完整闭合,单元格中没有嵌套标签。它返回原字符串下标,不生成 DOM,也不重新渲染全文。
实体组件只处理单元格文本中的三个精确 token:
```text
&amp;lt; → &lt;
&amp;gt; → &gt;
&amp;amp; → &amp;
```
结果仍是合法 HTML 源码中的单层实体。标签、属性、表格外文本和其他实体不受影响。
布局组件只处理整个片段没有换行的严格表格。它原样复用 `<table>` 起始标签、每个完整 `<tr>...</tr>` 和结束标签,
只增加外层换行与两个空格缩进。当前 9 张真实表格都有 `colspan`,所以全部保留 HTML;实现没有猜测表头,也没有转 GFM。
遇到未闭合、嵌套、额外结构标签或混合换行时,扫描失败关闭,保持原文。
## 6. 共享代码为什么仍然很小
- `_text_ranges.py` 只提供 Python 字符下标下的物理行、行尾和空行关系;
- `_html_table.py` 只提供严格 HTML 表格、行和单元格范围;
- 业务组件依赖这两个私有模块,但辅助模块不依赖组件、流水线或文件层;
- 文件实验层只接收已经组装的 `Pipeline`,不知道任何识别规则。
因此以后放宽某个业务规则通常只改一个组件及其测试;替换 HTML 识别方式不会改变 `DocumentSnapshot`
`ProposedChange``Change` 或 JSON 产物;未来引入 Profile 时,也只接管当前脚本里的组件组装。
## 7. 当前验证结果和边界
2026-08-22 在 Python 3.13.11 环境完成:
- Ruff 通过;
- mypy 检查 37 个文件无问题;
- pytest 204 项通过;
- 5 份本地论文全部 `success`,合计 155 条 `Change`
- 对 5 份成功输出再次运行,全部 `success` 且零修改;
- 输入运行前后哈希不变;
- JAMA Abstract 前内容不变,Springer 两条合法 arXiv 参考文献保留;
- 图片引用文字不变,但实验产物没有复制图片资产。
保存型实验位于本机 Git 忽略的
`artifacts/2026-08-22/runs/clindb-first-batch-v1/`。运行和复核方法见
[`run-local-clindb-first-batch-experiment.md`](../guides/run-local-clindb-first-batch-experiment.md)。
这次成功只证明批准的 8 类规则在当前 5 份输入上闭环。截断、缺表、乱码、OCR 语义错误、图片资产、修订词选择和一般
段落重排仍不在自动清洗范围内。
@@ -1,155 +0,0 @@
# 第一版内存清洗核心如何工作
## 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,可以校验修改链并把审计、
成功 Markdown 和 diff 保存到私有产物目录;机制见
[`local-experiment-artifacts.md`](local-experiment-artifacts.md)。这没有改变核心接口,也不允许把失败结果中的部分文本
写成正式输出或写回原文件。
## 8. 当前验证和剩余边界
核心测试继续使用短小的假组件,不包含或复制真实文档。它们覆盖空文本、中文和组合 Unicode、插入/删除/替换、
范围冲突、过期哈希、批次原子性、组件连锁影响、错误阶段、审计关联和成功结果再次运行等行为。首个真实组件另用
合成样例测试,并在本地真实材料上只读复核;机制与结果见
[`arxiv-submission-stamp.md`](arxiv-submission-stamp.md)。
实际可用的安装与验收命令、最近一次验证日期和结果只在根目录
[`README.md`](../../README.md#当前可用检查) 维护。
当前仍然没有:
- 除严格删除 arXiv 提交边栏戳外的其他论文、GovDoc 或 HTML 表格清洗组件;
- 独立文档检查、人工建议或审核流程;
- Markdown parser、AST 或共享业务中间表示;
- 通用文件输入、公共 CLI、通用批处理、项目 profile 格式和生产集成;
- 审计结果的长期存储、自动清理或脱敏输出协议。
当前只有一个固定数据和组件组合的本地实验脚本,不构成上述公共能力。这些边界中的任何一项要进入实现,都需要先用
新的 design 明确语义、代价和验收方式。
@@ -0,0 +1,97 @@
# 函数式修改核心如何工作
## 1. 它解决什么问题
项目规则如果直接返回一整篇新 Markdown,库无法确认它改了哪里,也无法在文本已经变化时阻止旧位置继续执行。
`mdpolish` 把三个职责分开:项目规则提出修改,库验证并在内存中执行,调用项目决定是否写回文件。
当前机制来自已批准的
[`0008-generic-functional-library-boundary.md`](../design/0008-generic-functional-library-boundary.md)。公共类名、字段和
函数签名以 [`src/mdpolish/`](../../src/mdpolish/) 和测试为准,本文只解释不变量与边界。
## 2. 当前数据流
```text
项目选择 Modifier、参数和顺序
当前 Markdown 快照
Modifier 提出精确修改 ──► 整批验证 ──► 内存中整批应用 ──► 新快照
│ │
└────────── 按项目给定顺序重复 ◄──────────┘
最终只复查,不再应用
┌────────────────┼────────────────┐
▼ ▼ ▼
success unstable failed
```
| 层次 | 负责 | 不负责 |
| --- | --- | --- |
| 不可变数据模型 | 保存快照、范围、候选修改、实际改动、错误和结果 | 项目业务判断 |
| `Modifier` | 冻结身份、版本、参数、适用边界和提议函数 | 直接改变字符串或文件 |
| 修改执行器 | 校验并原子应用一个修改器批次 | 判断规则是否符合某个项目 |
| `Pipeline` | 按显式顺序运行修改器并做最终稳定性复查 | 自动选规则、文件读写和默认组合 |
`mdpolish` 不导入使用项目。项目可以使用正则工厂、通用内置修改器,也可以用普通函数建立自己的 `Modifier`
## 3. 修改器拥有什么权限
修改函数接收不可变 `DocumentSnapshot`,返回一个 `ProposedChange` 元组。每项候选修改说明原因,并包含一条或多条
精确 `TextEdit`。修改器拥有规则判断权,可以提议插入、删除或替换,但不能通过公共契约原地改变快照,也不能用
整篇新文本绕过执行器。
`Modifier` 还保存稳定 ID、语义版本、冻结后的参数和适用边界。普通函数无需继承基类。相同输入、身份、版本和参数
应产生相同顺序的候选修改;修改函数不得读取文件、网络、环境变量、当前时间或随机数。
Python 不能沙箱隔离任意调用方函数。外部函数若私下写文件,属于绕过库契约的副作用,不在 `mdpolish` 的验证、
审计和回滚保证内。
## 4. 为什么修改必须绑定快照
每个快照都带有完整 Markdown 的 SHA-256。候选修改及其中每条编辑必须绑定这个哈希,并提供半开字符串范围、该范围
应有的原文和替换文本。执行器再次确认:
1. 哈希对应当前快照;
2. 范围没有越界;
3. 当前位置与预期原文完全一致;
4. 编辑不是无变化操作;
5. 当前修改器批次没有重复或冲突范围。
任意检查失败,当前修改器的整批候选都不执行。范围使用 Python 字符串索引,不是 UTF-8 字节位置;核心不会隐式
改变换行、Unicode 形式或末尾换行。
执行器从文本后方向前应用编辑,避免前面的修改使后面的下标失效;审计记录仍按原文位置排列。
## 5. `Pipeline` 的状态
`Pipeline` 先预检全部修改器及重复 ID,再按调用方顺序各运行一次。后一个修改器读取前一个修改器生成的新快照。
全部执行完成后,每个修改器对最终快照再提议一次,但复查阶段只验证,不应用,也不会自动开始第二轮。
| 状态 | 含义 | 文本字段 |
| --- | --- | --- |
| `success` | 运行和复查均完成,所选修改器不再提出修改 | `output_markdown` |
| `unstable` | 运行无错误,但最终快照仍有有效候选修改 | `partial_markdown` |
| `failed` | 提议、契约或执行验证发生错误 | `partial_markdown` |
`success` 只说明调用方选择的这组修改器在这次输入上已经稳定,不说明文档不存在其他质量问题。库只返回内存结果;
调用项目检查状态后,自己决定是否保存。
## 6. 当前通用能力与边界
除了核心,发布包只提供:
- `regex_replace()`:把非空正则匹配转换为精确编辑;
- `mapped_line_join()`:按调用方映射合并跨行片段,库不附带词表;
- `html_table_entity_unescape()`:在严格表格单元格文本中解除一层受支持的实体转义;
- `html_table_layout()`:把严格单行 HTML 表格展开为每行一个表格行。
HTML 能力使用失败关闭的词法子集,不是完整 HTML parser,也不识别 Markdown 围栏。正则工厂只保证定位和执行契约,
不保证调用方正则的业务语义正确。
当前没有默认流水线、文件适配器、CLI、profile、配置加载、批处理、artifact、评审器或项目规则集。安装或导入库不会
自动修改任何文本。实际安装、示例和当前检查命令只以根目录 [`README.md`](../../README.md) 为准。
@@ -1,195 +0,0 @@
# 本地清洗实验如何保存 Markdown、审计和 diff
## 1. 它解决什么问题
内存流水线可以安全地产生 `TransformResult`,但进程结束后,评审者仍需要打开清洗后的完整 Markdown、查看总 diff
并追溯每条修改属于哪个组件、为什么修改、修改前后是什么。
当前本地实验层把这些结果保存到独立目录,同时继续保持三个边界:
- 组件和 `Pipeline` 仍然不读写文件;
- 输入文件永远不被覆盖;
- 只有 `success` 文档才产生正式的清洗后 Markdown。
已经实现的范围来自已批准的
[`0005-local-experiment-runner-and-artifacts.md`](../design/0005-local-experiment-runner-and-artifacts.md) 和
[`0007-local-markdown-reviewer.md`](../design/0007-local-markdown-reviewer.md)。
精确字段、校验和函数签名以 `src/mdpolish/` 中的代码与测试为准。
## 2. 保存与评审怎样解耦
```text
experiment.py
├── pipeline.py 只负责内存清洗
├── reporting.py 只负责 JSON、行列和 unified diff
└── artifact_store.py 只负责日期目录、权限和原子发布
已发布运行目录
reviewer/ 只通过发布后的文件做本地只读评审
```
- `experiment.py` 严格读取调用方显式列出的 UTF-8 Markdown,逐份调用同一个 `Pipeline`
- `reporting.py` 重放并校验 `Change` 的快照链,再生成机器可读审计和人可读 diff;
- `artifact_store.py` 不理解清洗规则,只把已经生成的字节写入私有临时目录,校验后一次性发布。
- 同仓库 `reviewer/server/` 只共用无文件 I/O 的 Python 快照重放模块,不导入组件或流水线;它读取 manifest、result、
成功输出和本机定位文件。
因此,新增组件不会改变文件层;调整目录布局不会影响清洗和报告;修改 JSON 或 diff 时也不需要碰流水线。
ClinDB 的 5 份论文、历史 arXiv 单组件组合和当前 first-batch 组合只存在于两个仓库内实验脚本,通用模块没有硬编码
论文名或业务组件。
## 3. 输入怎样保持原样
实验层先完成整批预检:
1. 验证日期、运行 ID、文档 ID 和来源标签;
2. 确认所有路径存在、是普通文件且没有重复;
3. 以二进制读取全部输入;
4. 使用严格 UTF-8 解码;
5. 比较原始字节 SHA-256 与核心 Markdown SHA-256。
读取过程不剔除 BOM,不规范化 Unicode,不转换 `\n``\r\n` 或文件末尾换行。全部文档运行结束后,
实验层再次读取每份输入并比较原始字节和哈希;任何变化都会阻止产物发布。
输入缺失、不是普通文件、不是有效 UTF-8 或清单冲突属于整批预检失败。这时不调用流水线,也不创建最终运行目录。
## 4. 状态怎样决定产物
每份文档独立运行,某一份发生核心错误不会阻止其他已经预检的文档继续产生结果。
| 文档状态 | `result.json` | `cleaned.md` | `changes.diff` |
| --- | --- | --- | --- |
| `success` | 有 | 有 | 有;零修改时为空文件 |
| `failed` | 有 | 无 | 无 |
| `unstable` | 有 | 无 | 无 |
`failed``unstable` 的审计仍保留已经实际发生的 `Change`、错误或残留候选,但不持久化
`partial_markdown`,避免半成品看起来像正式结果。
整批状态按 `failed``unstable``success` 的优先级汇总。即使其他文档有成功产物,只要一份失败,
`manifest.json` 就会把整批标为 `failed`
## 5. 产物怎样组织
```text
artifacts/
└── <YYYY-MM-DD>/
└── runs/
└── <run_id>/
├── manifest.json
├── review-locator.json
└── documents/
└── <document_id>/
├── result.json
├── cleaned.md
└── changes.diff
```
日期取实验启动时本机时区中的日历日期。运行 ID 由调用方显式提供;同一日期下已经存在同名目录时拒绝覆盖。
`manifest.json` 是整次运行的索引,记录:
- 开始、完成和到期时间;
- 本机日期及 UTC 偏移;
- mdpolish、Python、平台和 Git 状态;
- 实际组件顺序、版本和参数;
- 每份输入的来源标签、前后哈希、状态、修改数量和产物相对路径;
- 整批成功、失败、不稳定和修改数量。
每份 `result.json` 保存实际修改、错误与残留候选。每条修改包含组件、候选引用、理由、Python 字符范围、
1-based 行列、`before``after` 和批次前后哈希。完整成功文本只存在于 `cleaned.md`
`changes.diff` 是原始输入到最终成功输出的 unified diff,只用于人工查看。它不包含绝对路径或时间戳,
也不是修改重放的权威;机器审计仍以 `result.json` 为准。
`review-locator.json` 只记录本次运行目录、每份输入的绝对解析路径和输入哈希,供本地评审器重新找到完整原文。它不复制
原文,不替代 manifest 或 result,也不作为可移植运行身份。绝对路径可能泄露本机目录结构,因此该文件同样是本地敏感数据。
## 6. 为什么 reporter 要重放修改
第二个组件看到的是第一个组件修改后的快照,因此后续 `Change.span` 不一定对应最初输入。为了生成准确行列,
reporter 从输入开始,按组件批次重放修改:
1. 当前文本哈希必须等于该批次 `before_sha256`
2. 每条范围内的原文必须等于 `before`
3. 同一批次按核心相同的从后向前顺序应用;
4. 结果哈希必须等于 `after_sha256`
5. 全部批次完成后必须等于 `TransformResult.current_sha256`
任何一步不一致都说明内存结果、reporter 或调用方式违反契约,整次运行不会发布最终目录。行列只是方便人查看的
派生信息,修改权威仍是快照绑定的 Python 字符范围。
## 7. 文件怎样安全发布
artifact store 先在同一日期的 `runs/` 下建立本次专用临时目录。所有文件写入后都会重新读取校验,
成功 Markdown 还要再次核对输出 SHA-256,manifest 的身份、状态、计数和路径也必须与各文档审计及实际文件一致。
只有全部文件、清单和权限都通过,临时目录才会在 `runs/` 目录协作锁内重新检查目标,并原子重命名为最终运行 ID。
当前 Linux 本地实现使用:
- 目录权限 `0700`
- 文件权限 `0600`
- 已存在的目标目录拒绝覆盖;
- 写入中途失败时不发布最终目录。
这保证不会发布已知不完整的结果,但不承诺跨平台断电耐久性或网络文件系统语义。
## 8. 隐私和保留边界
`cleaned.md`、diff 和 JSON 审计都可能包含真实原文,因此整个 `artifacts/` 都是本地敏感数据:
- 受 Git 忽略;
- 不进入 Wiki、提交、推送或外部系统;
- 终端只显示状态、计数和目录;
- 默认保留 30 个日历日;
- manifest 记录 `retention_until`
- 第一版不自动删除,到期后仍需用户确认具体目录再清理。
当前只批准对 `data/md/` 中 5 份论文副本保存产物。仓库外 GovDoc 和其他真实数据没有因此获得输出授权。
同仓库只读页面的路径验证、组件快照重放和使用边界见
[`local-markdown-reviewer.md`](local-markdown-reviewer.md)。
## 9. 已完成的真实验证
2026-08-22 使用 `paper.arxiv_submission_stamp` `1.0.0` 对 5 份本地论文副本完成一次保存型实验:
| 项目 | 结果 |
| --- | --- |
| 运行 ID | `clindb-arxiv-stamp-artifacts-v1` |
| 输出位置 | `artifacts/2026-08-22/runs/clindb-arxiv-stamp-artifacts-v1/` |
| 文档状态 | 5/5 `success` |
| 实际修改 | `sim` 1 条、`springer` 1 条,其余 0 条 |
| 修改位置 | `sim` 1:1、`springer` 18:1 |
| 合法反向样例 | Springer 两处 `arXiv preprint arXiv:` 均保留 |
| 输出校验 | 5/5 `cleaned.md``current_sha256` 一致 |
| 输入只读 | 5/5 运行前后字节和哈希不变 |
| 权限 | 全部运行目录 `0700`,产物文件 `0600` |
实际运行方法见
[`run-local-clindb-arxiv-experiment.md`](../guides/run-local-clindb-arxiv-experiment.md)。
同日又使用 `design/0006` 的 8 组件流水线完成 ClinDB 第一批保存型实验:
| 项目 | 结果 |
| --- | --- |
| 运行 ID | `clindb-first-batch-v1` |
| 输出位置 | `artifacts/2026-08-22/runs/clindb-first-batch-v1/` |
| 文档状态 | 5/5 `success` |
| 实际修改 | dmp 47、ejhf 9、jama 79、sim 3、springer 17,共 155 条 |
| 第二次运行 | 5/5 `success`,合计 0 条修改 |
| 输出校验 | 5/5 `cleaned.md``current_sha256` 一致 |
| 输入只读 | 5/5 运行前后字节和哈希不变 |
| 内容反例 | JAMA Abstract 前内容不变;Springer 两条合法 arXiv 引用保留;图片引用文字不变 |
| 权限 | 运行目录 `0700`,产物文件 `0600` |
当前完整运行方法见
[`run-local-clindb-first-batch-experiment.md`](../guides/run-local-clindb-first-batch-experiment.md)。实验层的文件、
JSON、diff 和权限契约没有因组件增多而改变。
2026-08-23 又产生运行 `clindb-first-batch-reviewer-v1`,用于验证新定位文件和本地页面:5/5 文档为 `success`,合计
155 条修改,输入运行前后哈希不变。评审器 API 校验了 5 份原文、成功输出以及 8 个组件形成的 40 个阶段,所有文本哈希
均与审计一致。实际页面启动步骤见 [`review-local-cleaning-run.md`](../guides/review-local-cleaning-run.md)。
@@ -1,109 +0,0 @@
# 本地 Markdown 清洗评审器如何保持只读和可追踪
## 1. 它解决什么问题
本地清洗实验已经保存最终 Markdown、逐条审计和 unified diff,但人工评审仍需要在多个文件之间切换,也看不到某个组件
执行前后的完整文本。当前评审器把一次已发布运行变成只读页面:主视图比较原文和最终成功输出,组件时间线则比较每个
组件实际收到的快照和它产生的新快照。
实现范围来自已批准的
[`0007-local-markdown-reviewer.md`](../design/0007-local-markdown-reviewer.md)。精确 API、字段和运行行为以
[`reviewer/`](../../reviewer/) 中的代码、类型和测试为准。
## 2. 同仓库怎样保持解耦
```text
mdpolish 本地实验层
manifest / result / cleaned / review-locator
Python 产物适配器与共享重放 ──► 本地只读 API ──► React 页面
```
- `src/mdpolish/` 不导入 `reviewer/`
- `reviewer/server/` 只从 `mdpolish` 导入无文件 I/O 的 `_artifact_replay.py`,不导入组件、流水线或实验入口;
- 浏览器只读取 `/api/v1/`,不解析磁盘 JSON,也不知道绝对路径;
- Python wheel 不包含前端代码或 Node.js 依赖;
- 产物 schema 以后改变时,差异集中在服务端版本适配器,不扩散到页面组件。
前端与核心位于同一 Git 仓库,方便开发和评审,但仍是独立的 Node.js package。依赖版本只在
[`reviewer/package.json`](../../reviewer/package.json) 和锁文件维护。
## 3. 运行定位文件保存什么
新实验会在运行目录根部原子保存 `review-locator.json`。它记录:
- 运行 ID 和发布时的绝对运行目录;
- `manifest.json` 的固定相对位置;
- 每份文档的 ID、实际读取的绝对源路径和输入 SHA-256。
定位文件不复制原文,也不替代 manifest 或 result。评审器由用户显式指定当前运行目录;记录的旧运行目录只用于判断目录
是否被移动。服务只根据定位文件读取对应原文,并在每次展示前重新计算哈希。源文件不存在或内容变化时,页面明确报告
不可用,不按名称搜索替代文件。
绝对路径会暴露本机目录结构,所以定位文件与其他 artifact 一样使用 `0600` 权限并按本地敏感数据处理。历史运行没有该
文件时仍可查看清单和局部审计,但不能自动展示完整原文;评审器不会回写历史目录。
## 4. 服务为什么只读取一次运行
启动时必须传入一个具体运行目录。服务不会扫描 `artifacts/`,也没有让浏览器传入任意文件路径的 API。它先校验:
1. manifest、result 和存在的 locator 都是支持的 schema、严格 UTF-8 JSON
2. 文档、组件、状态、路径和计数彼此一致;
3. 所有 artifact 路径都留在所选运行目录;
4. 原文和成功输出的字节哈希与审计一致;
5. 成功文档确实同时具有 `cleaned.md``changes.diff`
HTTP 只监听 `127.0.0.1` 的随机空闲端口,只接受 `GET``HEAD`。服务拒绝非本机 Host、跨域 Origin、路径穿越和
写请求,不提供删除、移动、重新清洗或 shell 执行能力。响应禁止缓存,不开放 CORS,也不向浏览器返回绝对源路径。
## 5. 组件阶段怎样准确重放
`Change.span` 使用 Python Unicode 码点位置,而 JavaScript 编辑器使用 UTF-16 code unit。共享 Python 重放模块直接按
原生码点范围逐组件处理,不让浏览器应用修改:
1. 当前完整文本哈希必须等于组件批次的 `before_sha256`
2. 每条范围内文本必须等于 `before`
3. 同一批次不得有冲突范围,并按位置从后向前应用;
4. 应用后完整文本哈希必须等于 `after_sha256`
5. 全部组件结束后必须逐字等于 `cleaned.md`
6. 服务端另外从已验证的组件前快照派生 UTF-16 `editor_range`,只供 CodeMirror 跳转。
组件没有修改时,阶段前后文本和哈希相同,但该组件仍显示在时间线中。包含中文、emoji、组合字符、BOM、CRLF 和无末尾
换行的合成测试用于保护跨语言坐标。任一重放校验失败时,页面拒绝显示组件阶段,不通过搜索或 diff 猜测位置。
中间快照只在服务内存中按需生成,不保存新的 Markdown 文件。`failed``unstable` 文档只显示错误、残留候选和已有的
局部审计,不重建一份看似正式的部分输出。
## 6. 页面当前能看什么
页面当前提供:
- 运行状态、文档状态、哈希和实际 `Change` 数量;
- 完整原文与最终成功 Markdown 的只读双栏源码比较;
- 8 个组件的实际顺序、版本和每份文档修改数量;
- 任一组件执行前后的完整文本比较;
- 修改理由、派生行列、`before` / `after` 和同候选修改关联;
- `failed``unstable`、路径失效、哈希变化和未知 schema 的独立错误状态。
Markdown 只作为文本交给 CodeMirror,不进入 `innerHTML`。第一版不渲染 Markdown、HTML 或图片,不加载 CDN、远程字体、
遥测和其他外部资源,也不提供编辑、审核或回写。
## 7. 当前验证结果和边界
2026-08-24 使用 Python 3.13.11 和当前用户 nvm 中的 Node.js 24.19.0 完成:
- Python Ruff、mypy 和 229 项 pytest 通过;
- reviewer ESLint、TypeScript、9 项 Vitest 和生产构建通过;
- 新运行 `clindb-first-batch-reviewer-v1` 的 5 份论文全部 `success`,共 155 条实际修改;
- 5 份原文运行前后哈希不变;
- 本地 API 成功校验 5 份文档、8 个组件和 40 个组件阶段;
- 所有原文、成功输出和阶段前后文本的 SHA-256 与运行审计一致;
- 生产页面和全部本地构建资源可以通过只读服务读取,响应没有 CORS 并包含禁止缓存和内容类型保护头。
当前环境没有可用于自动视觉检查的本地浏览器,因此布局的真实浏览器视觉效果尚未验证。当前结果证明构建、服务、数据
重放和主要 React 状态可以运行,不等于已经完成跨浏览器、极端长度、渲染预览或生产部署验证。
实际启动与评审步骤见 [`review-local-cleaning-run.md`](../guides/review-local-cleaning-run.md)。