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

17 KiB
Raw Blame History

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. 总体结构

Markdown 字符串
      │
      ▼
DocumentSnapshot
      │
      ▼
Component._check_snapshot()
      │
      ├── Issue
      │     └── 可选 ProposedChange
      │               └── 一个或多个 TextEdit
      │
      ▼
公共修改执行器
      │
      ├── 验证快照、范围、原文和冲突
      ├── 原子应用当前组件的全部自动修改
      └── 生成 Change 和新 DocumentSnapshot
      │
      ▼
下一个 Component
      │
      ▼
最终快照只读复查
      │
      ▼
TransformResult

核心分为四层:

  • 数据模型: 不可变地表达快照、范围、问题、修改、错误和结果;
  • 组件基类: 提供统一的 checktransform 行为,只把问题定位留给具体组件;
  • 修改执行器: 是唯一能够把 TextEdit 应用到 Markdown 的位置;
  • 流水线: 负责组件顺序、检查错误汇总、清洗失败停止和最终只读复查。

文件读写、终端输出和未来配置不进入这四层。

6. 快照与位置

6.1 DocumentSnapshot

快照至少包含:

  • markdown:当前完整 Markdown 字符串;
  • sha256markdown.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 ProposedChangeTextEdit

一个候选修改表示解决一条问题所需的完整动作,可以包含一个或多个 TextEdit。同一候选修改中的编辑必须 全部应用或全部不应用。

每个 TextEdit 至少包含:

  • 目标快照哈希;
  • TextSpan
  • expected_text:修改前该范围必须准确等于的原文;
  • replacement:替换内容。

插入使用 start == end 和空 expected_text;删除使用空 replacementreplacementexpected_text 完全相同的无效修改视为组件契约错误,不生成虚假的改动记录。

8. 组件接口

Component 使用抽象基类,而不是仅使用结构化 Protocol。公共基类负责保持独立调用与流水线调用的语义一致。

每个具体组件只实现以下扩展点:

  • 组件标识、组件版本和当前参数;
  • _check_snapshot(snapshot):读取快照并返回问题,不产生副作用。

组件标识使用稳定的小写字符串;同一语义不能因为改了 Python 类名就更换标识。组件版本使用 MAJOR.MINOR.PATCH 形式。组件参数必须能表示为确定的只读基础数据,流水线将实际参数记录到结果中。

公共基类提供:

  • check(markdown):建立快照,执行该组件检查并返回检查结果;
  • transform(markdown):等价于只包含该组件的流水线清洗。

具体组件不得重写公共 checktransform 或修改执行器。类型声明使用 final 标记这些入口;代码评审和测试 同时检查组件只实现规定扩展点。

组件必须是确定性的:相同 Markdown、组件版本和参数必须产生相同问题及候选修改。组件不能读取文件、网络、 环境变量、当前时间或随机数,也不能修改传入对象和外部状态。

第一版流水线禁止出现两个相同组件标识的实例。需要用不同参数运行同一组件两次时,应由项目重新考虑组件边界, 不能依靠重复 ID 制造含义不清的执行记录。

9. 公共修改执行器

流水线不会把多个组件在旧快照上产生的修改集中到最后再应用。每个组件都针对当前快照检查;该组件结束后, 它的自动修改作为一个批次交给公共执行器。

执行器按以下顺序验证当前组件的整个批次:

  1. 问题和编辑的快照哈希都等于当前快照哈希;
  2. 所有范围合法;
  3. markdown[start:end]expected_text 完全一致;
  4. 不存在重复编辑、范围重叠或同一位置的多个插入;
  5. 所有编辑都会实际改变内容。

相邻但不重叠的范围可以同时修改。验证全部通过后,执行器按位置从后向前应用编辑,避免前面的修改使后面的 下标失效。任意一项验证失败,当前组件的整个批次都不应用。

这里的“当前组件整个批次”包括该组件本次检查产生的所有 auto_fix 候选修改。suggestiondetect_only 问题永远不进入自动修改批次。

9.1 Change

每个实际应用的 TextEdit 产生一条 Change,至少记录:

  • 组件标识和版本;
  • 组件在流水线中的执行位置;
  • 修改理由;
  • 修改前范围、beforeafter
  • 修改前后的快照哈希;
  • 所属候选修改,使一次多位置动作可以整体追踪。

修改记录描述实际发生的变化,不复制未执行的建议,也不把问题记录冒充改动记录。

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_onlysuggestion 问题:保留为未解决问题,不妨碍核心流水线成为 success
  • 最终复查发生组件错误:状态为 failed;复查继续检查其余组件,以汇总只读阶段的错误和问题。

是否因为未解决的 detect_onlysuggestion 问题阻止下游使用,由使用项目决定,不写死在共用核心中。

11.3 状态与文本字段

清洗状态至少包括:

  • success:单轮清洗和最终复查完整完成,没有仍可自动修复的问题;
  • failed:组件执行、数据契约或修改验证失败;
  • unstable:修改阶段没有错误,但最终复查仍发现已选组件可自动修复的问题。

为避免调用方忽略状态并误用半成品:

  • success 只提供 output_markdownpartial_markdown 为空;
  • failedunstable 不提供 output_markdown,只提供诊断用的 partial_markdown
  • 三种状态都记录输入哈希、当前内容哈希、组件清单、实际改动、未解决问题和错误。

输入本身从不被原地覆盖。即使输出内容与输入完全相同,只要最终复查通过,也可以是零改动的 success

12. 源码与测试结构

批准后创建以下最小结构:

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,且不自动开始第二轮;
  • suggestiondetect_only 不被自动应用;
  • 组件独立运行与单组件流水线结果一致;
  • 成功流水线再次运行不产生实际改动;
  • 输入字符串不被修改,相同输入、组件和参数产生相同结果。

批准并实现后,基础验证至少包括:

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、发布、提交或推送。