Files
mdpolish/research-wiki/design/0003-first-executable-core-architecture.md
T
Bepr4 8ac4f1dd12 更名 mdpolish 并补充清洗流水线设计与调研记录
- 仓库由 govdoc-md-cleaner 更名为 mdpolish,更新 README、AGENTS、CLAUDE
  及 reference 中的仓库名;冻结的 design 与带日期 scratch 保留旧名
- 冻结 0002:可组合清洗组件与流水线(check/transform、单轮修改加最终复查)
- 新增 0003 草稿:第一版可执行核心架构,待评审
- 新增 HTML 表格清洗专题调研(2026-08-21)
2026-08-21 22:49:03 +08:00

389 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 0003Mdpolish 第一版可执行核心架构
## 状态
草稿,待批准。本设计细化已冻结的 `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、发布、提交或推送。