实现第一版内存清洗核心
落实不可变数据契约、组件基类、原子修改执行器与顺序流水线。补充稳定性复查、审计记录、测试和当前机制文档。
This commit is contained in:
@@ -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 及以上为准;
|
||||
本次结果不等于已经在每个受支持版本上完成兼容性验证。
|
||||
|
||||
@@ -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
|
||||
@@ -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、发布、提交或推送。
|
||||
|
||||
@@ -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 明确语义、代价和验收方式。
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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 "<invalid>",
|
||||
component_version=component_info.version if component_info is not None else "<invalid>",
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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")
|
||||
@@ -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 == ()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user