feat: 增加评审文档机器投影与 JSON 报告

This commit is contained in:
2026-08-28 14:23:30 +08:00
parent dd012f4905
commit 843952b194
7 changed files with 2096 additions and 16 deletions
+44 -9
View File
@@ -3,8 +3,9 @@
`mdpolish` 是实验室共用的、项目无关的 Python Markdown 修改库。它提供函数式 `Modifier`、精确文本编辑执行器、 `mdpolish` 是实验室共用的、项目无关的 Python Markdown 修改库。它提供函数式 `Modifier`、精确文本编辑执行器、
有序 `Pipeline`、正则修改器工厂,以及少量可以用合成样例完整说明的通用修改器。 有序 `Pipeline`、正则修改器工厂,以及少量可以用合成样例完整说明的通用修改器。
当前发布版本是 [`v0.4.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.4.0)。库只处理内存中的 Markdown 字符串, 当前发布版本是 [`v0.4.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.4.0),当前源码树的下一候选版本是
不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、数据清单、实验脚本或评审界面。 `0.5.0`。库只处理内存中的 Markdown 字符串,不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、
数据清单、实验脚本或评审界面。
## 当前能力 ## 当前能力
@@ -15,6 +16,8 @@
| `Pipeline` | 按调用方顺序运行修改器,并对最终快照做只读稳定性复查 | 不自动选规则、不重排、不循环执行 | | `Pipeline` | 按调用方顺序运行修改器,并对最终快照做只读稳定性复查 | 不自动选规则、不重排、不循环执行 |
| `build_review_document()` | 验证并重放已有结果,提供可信阶段、位置、全文和错误/残留证据 | 不重新运行修改器,不猜测损坏或不完整的结果 | | `build_review_document()` | 验证并重放已有结果,提供可信阶段、位置、全文和错误/残留证据 | 不重新运行修改器,不猜测损坏或不完整的结果 |
| `render_markdown_report()` | 把评审视图编排成完整 Markdown 源码报告字符串 | 只返回内存字符串,不创建文件或业务页面 | | `render_markdown_report()` | 把评审视图编排成完整 Markdown 源码报告字符串 | 只返回内存字符串,不创建文件或业务页面 |
| `review_document_to_dict()` | 按 schema `1.0` 把评审视图投影成普通 JSON 基本值 | 单向投影,不反序列化或重新应用修改 |
| `render_json_report()` | 复用正式 dict 投影生成确定的内存 JSON 字符串 | 不创建文件;默认摘要不等于公开安全日志 |
| `regex_replace()` | 把非空正则匹配转换为精确编辑 | 不提供规则注册表、配置加载或默认模式 | | `regex_replace()` | 把非空正则匹配转换为精确编辑 | 不提供规则注册表、配置加载或默认模式 |
| `mapped_line_join()` | 用精确、正则或可选本地词典规则合并跨行片段 | 无默认规则;代码、表格、未知结构和歧义失败关闭 | | `mapped_line_join()` | 用精确、正则或可选本地词典规则合并跨行片段 | 无默认规则;代码、表格、未知结构和歧义失败关闭 |
| HTML 表格修改器 | 处理严格表格子集的实体和单行布局 | 不是完整 HTML parser,也不是 HTML→GFM 转换器 | | HTML 表格修改器 | 处理严格表格子集的实体和单行布局 | 不是完整 HTML parser,也不是 HTML→GFM 转换器 |
@@ -45,7 +48,8 @@ python -m pip install 'mdpolish[lexical] @ https://github.com/Bepr4/mdpolish/rel
``` ```
Release 页面同时提供 wheel 的 SHA-256 校验值。仓库或 Release 如果是私有的,调用方需要自行配置 GitHub 访问权限; Release 页面同时提供 wheel 的 SHA-256 校验值。仓库或 Release 如果是私有的,调用方需要自行配置 GitHub 访问权限;
库不会保存凭据。开发环境仍从本地工作树安装: 库不会保存凭据。以上命令当前安装的是已发布的 `v0.4.0`,不包含本源码树尚未发布的 `0.5.0` 机器投影。开发环境仍从
本地工作树安装:
```bash ```bash
python -m venv .venv python -m venv .venv
@@ -158,23 +162,33 @@ if result.status is RunStatus.SUCCESS and result.output_markdown is not None:
output_path.write_text(result.output_markdown, encoding="utf-8") output_path.write_text(result.output_markdown, encoding="utf-8")
``` ```
文件读取、输出命名、覆盖策略、批处理和 CLI 都属于调用项目。`mdpolish` 可以生成通用的内存评审视图与 Markdown 报告 文件读取、输出命名、覆盖策略、批处理和 CLI 都属于调用项目。`mdpolish` 可以生成通用的内存评审视图、机器投影、JSON
字符串,但不会自动保存它们,也不知道报告来自哪个文件。 字符串与 Markdown 报告字符串,但不会自动保存它们,也不知道报告来自哪个文件。
## 构建内存评审视图和报告 ## 构建内存评审视图和报告
调用方保留原始 Markdown,并把它与 `TransformResult` 一起传给评审构建函数: 调用方保留原始 Markdown,并把它与 `TransformResult` 一起传给评审构建函数:
```python ```python
from mdpolish.review import build_review_document, render_markdown_report from mdpolish.review import (
build_review_document,
render_json_report,
render_markdown_report,
review_document_to_dict,
)
input_markdown = "an exam-\nple text" input_markdown = "an exam-\nple text"
result = pipeline.transform(input_markdown) result = pipeline.transform(input_markdown)
review = build_review_document(input_markdown, result) review = build_review_document(input_markdown, result)
review_summary = review_document_to_dict(review)
report_json = render_json_report(review, detail="changes")
report_markdown = render_markdown_report(review) report_markdown = render_markdown_report(review)
assert review.current_markdown == "an example text" assert review.current_markdown == "an example text"
assert review_summary["schema_version"] == "1.0"
assert review_summary["detail"] == "summary"
assert report_json.startswith('{\n "schema_name": "mdpolish.review"')
assert report_markdown.startswith("# mdpolish review report\n") assert report_markdown.startswith("# mdpolish review report\n")
``` ```
@@ -188,6 +202,16 @@ Markdown reporter 会包含完整输入、当前全文、统一 diff 以及实
前 20 条残留候选,并且不重复输出残留候选正文;调用项目如果保存报告,仍需负责路径、权限、脱敏和保留周期。每个完整 前 20 条残留候选,并且不重复输出残留候选正文;调用项目如果保存报告,仍需负责路径、权限、脱敏和保留周期。每个完整
阶段都会保留前后快照,当前版本没有承诺无限文档长度或修改器数量下的内存上限。 阶段都会保留前后快照,当前版本没有承诺无限文档长度或修改器数量下的内存上限。
机器投影使用独立于包版本的 schema `1.0`,并提供三个显式 detail
- `summary`:默认值;只含状态、哈希、计数、modifier 身份、阶段摘要和稳定错误代码,不含正文承载字段;
- `changes`:再加入 modifier 参数、适用说明、实际修改片段、错误消息和未应用残留候选;
- `full`:再加入完整输入、当前全文和每个阶段的完整前后全文。
高 detail 可能还原敏感内容。即使 `summary` 不含正文,它仍然携带项目元数据和哈希,不能自动视为匿名或适合公开传播。
dict 和 JSON 都是单向派生视图,不用于恢复 `ReviewDocument` 或重新应用修改。完整 schema、坐标、哈希和兼容口径见
[`review-projection-schema-v1.md`](research-wiki/reference/review-projection-schema-v1.md)。
## 编写项目自己的修改器 ## 编写项目自己的修改器
复杂规则使用普通函数返回精确候选修改,不需要继承库基类: 复杂规则使用普通函数返回精确候选修改,不需要继承库基类:
@@ -262,7 +286,7 @@ src/mdpolish/
├── modifier.py # 函数式 Modifier 契约 ├── modifier.py # 函数式 Modifier 契约
├── edits.py # 批次验证与原子应用 ├── edits.py # 批次验证与原子应用
├── pipeline.py # 有序执行与最终稳定性复查 ├── pipeline.py # 有序执行与最终稳定性复查
├── review.py # 可信评审投影内存 Markdown reporter ├── review.py # 可信评审视图、机器投影内存 JSON/Markdown reporter
├── regex.py # 正则修改器工厂 ├── regex.py # 正则修改器工厂
└── modifiers/ # 少量项目无关的通用修改器 └── modifiers/ # 少量项目无关的通用修改器
tests/ # 只使用虚构文本的核心与通用修改器测试 tests/ # 只使用虚构文本的核心与通用修改器测试
@@ -279,14 +303,17 @@ research-wiki/
- 文件适配器、公共 CLI、配置文件、profile 或批处理协议; - 文件适配器、公共 CLI、配置文件、profile 或批处理协议;
- 自动规则发现、注册表或默认流水线; - 自动规则发现、注册表或默认流水线;
- Markdown AST、完整 HTML parser 或必装的第三方运行依赖; - Markdown AST、完整 HTML parser 或必装的第三方运行依赖;
- artifact、报告文件、JSON/HTML reporter、Web/桌面评审器或项目审核流程; - artifact、自动保存的报告文件、正式 JSON Schema 文件、HTML reporter、Web/桌面评审器或项目审核流程;
- 任何业务项目的规则、固定参数、文档 ID、数据或验收统计。 - 任何业务项目的规则、固定参数、文档 ID、数据或验收统计。
公共边界与原因见 公共边界与原因见
[`0008-generic-functional-library-boundary.md`](research-wiki/design/0008-generic-functional-library-boundary.md),当前机制见 [`0008-generic-functional-library-boundary.md`](research-wiki/design/0008-generic-functional-library-boundary.md),当前机制见
[`functional-modifier-core.md`](research-wiki/explanation/functional-modifier-core.md)。通用内存评审能力的批准边界见 [`functional-modifier-core.md`](research-wiki/explanation/functional-modifier-core.md)。通用内存评审能力的批准边界见
[`0011-generic-review-projection-and-reporting.md`](research-wiki/design/0011-generic-review-projection-and-reporting.md),当前机制见 [`0011-generic-review-projection-and-reporting.md`](research-wiki/design/0011-generic-review-projection-and-reporting.md),当前机制见
[`review-projection.md`](research-wiki/explanation/review-projection.md)。旧 design 只保存历史决策,不代表当前交付能力。 [`review-projection.md`](research-wiki/explanation/review-projection.md)。正式机器投影的批准边界见
[`0012-review-document-machine-projection.md`](research-wiki/design/0012-review-document-machine-projection.md)schema `1.0` 的稳定
查询口径见 [`review-projection-schema-v1.md`](research-wiki/reference/review-projection-schema-v1.md)。旧 design 只保存历史
决策,不代表当前交付能力。
## 当前可用检查 ## 当前可用检查
@@ -316,3 +343,11 @@ Python 标准库。Release wheel 的 SHA-256 是
`12e24863314958130ed082f78e89ab8bc0dad39a3848f2ae42e273d19a409693` `12e24863314958130ed082f78e89ab8bc0dad39a3848f2ae42e273d19a409693`
上述结果证明当前版本可安装并按合成契约运行,不代表任意词典阈值已经在真实业务语料上达到生产准确率。 上述结果证明当前版本可安装并按合成契约运行,不代表任意词典阈值已经在真实业务语料上达到生产准确率。
`0.5.0` 候选于 2026-08-28 在 Python 3.13.11 开发环境中实际得到:mypy 通过,pytest 为
`228 passed, 3 skipped`;三个 skip 仍是没有安装的 optional backend。除工作区已有的 `src/mdpolish/regex.py` 中文注释
改动外,Ruff 全部通过;未排除该文件的全仓 Ruff 因其中 30 个 `RUF002` / `RUF003` 失败,本轮没有擅自修改该用户改动。
候选 `mdpolish-0.5.0-py3-none-any.whl` 构建成功,共 17 个文件,包含更新后的 `review.py``py.typed`,不包含 tests、Wiki、
报告或真实数据;在仓库外全新虚拟环境中无依赖安装后,dict 投影与 JSON reporter smoke test 通过。该临时 wheel 不是 Release
资产,其哈希不构成发布身份;完成全仓 Ruff、提交、合并、tag 和 Release 仍需要分别确认。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mdpolish" name = "mdpolish"
version = "0.4.0" version = "0.5.0"
description = "Deterministic functional core for composing exact Markdown modifiers" description = "Deterministic functional core for composing exact Markdown modifiers"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [] dependencies = []
@@ -0,0 +1,703 @@
# 0012ReviewDocument 的正式机器投影
## 状态
已于 2026-08-28 获用户明确批准,按本文第 16 节实施。本文自批准起冻结;后续改变决策需新增 design 并使用
`supersedes` 指向本文。
`supersedes: 0011`(范围有限):本文只替代 `0011` 第 8 节“第一版不提供 JSON reporter”和其中把稳定序列化继续留给
调用项目的决定。`0011` 已批准并实现的可信重放、`ReviewDocument`、阶段坐标、Markdown reporter、无文件 I/O 和项目拥有
持久化决定权继续有效。
本文不改变 `Pipeline``TransformResult`、清洗语义或评审重放结果。它只定义怎样把已经建立并验证的
`ReviewDocument` 转成稳定的普通 Python 数据,再按同一结构编码为 JSON。
## 1. 问题与可观察现象
`v0.4.0` 已经可以在同一个 Python 进程中这样消费评审结果:
```python
review = build_review_document(input_markdown, result)
if review.status is RunStatus.SUCCESS:
print(review.current_sha256)
```
这时调用方直接使用不可变的 `ReviewDocument``ReviewStage``ReviewChange`,不需要序列化。
一旦评审结果需要经过 Web、数据库、消息队列、JSON 文件或其他语言,内部 Python 对象就不能直接作为契约。当前项目端只能
自己决定怎样处理 dataclass、`StrEnum`、tuple、半开范围、阶段哈希和部分输出。最短的做法看似是:
```python
payload = dataclasses.asdict(review)
```
但这会产生四类问题:
1. 内部 dataclass 字段会在未经评审的情况下变成外部协议;以后正常的 Python 重构也会破坏消费者;
2. enum、tuple、递归参数值和位置单位没有正式 JSON 表达,消费者容易形成不同解释;
3. `ReviewDocument` 包含完整输入、当前全文、每阶段全文和修改片段,机械展开会默认暴露全部正文;
4. 当前 `RunError.error_type` 是诊断用 Python 异常类名,不能被项目端误当成稳定错误代码。
因此,需要由 `mdpolish` 自己提供一个经过选择和转换的对外视图,而不是让每个项目根据内部字段猜一个版本。
本文把这个视图称为“机器投影”:它是 `ReviewDocument` 的有损、单向、稳定表示,不是内部对象的镜像,也不是可用于重新
应用修改的序列化快照。
## 2. 外部规范带来的约束
2026-08-28 查阅的官方规范给出以下直接约束:
- [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259.html) 把 JSON object 定义为无序名称/值集合,把 array 定义为有序序列;
对外语义不能依赖 object 成员顺序,但修改器、阶段、修改和残留候选的顺序必须用 array 保存;
- RFC 8259 要求开放系统中的 JSON 文本使用 UTF-8,成员名应唯一,并指出超出 IEEE 754 binary64 精确整数范围的数字会降低
互操作性;本投影只发出唯一键、可 UTF-8 编码的字符串和安全范围整数;
- [Python `json` 文档](https://docs.python.org/3/library/json.html) 显示 `allow_nan` 默认允许非标准的 `NaN` / `Infinity`
`ensure_ascii` 默认转义非 ASCII;官方 reporter 必须显式使用 `allow_nan=False``ensure_ascii=False`
- [JSON Schema 2020-12](https://json-schema.org/draft/2020-12/json-schema-core.html) 区分 schema 版本和实例内容,并允许通过
schema 约束对象、数组和 enum;本轮先固定实例 schema 和兼容策略,不引入运行时 validator 或第三方 schema 依赖;
- [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html) 区分稳定标识符与给人看的消息,并明确
不应从没有稳定标识的来源中猜造精细规则 ID;本投影只为现有 `ErrorStage` 提供粗粒度稳定代码,不从异常类名或文案
推断更细错误原因。
这些参考不表示 `mdpolish` 要实现 SARIF 或 JSON Schema validator。它们只帮助确定 JSON 互操作、顺序、版本和错误身份的
边界。
## 3. 目标与非目标
### 3.1 目标
- 提供官方 `ReviewDocument -> dict` 投影,返回只含 JSON 基本值的全新普通数据结构;
- 提供使用同一投影的确定性 JSON reporter,不建立第二套字段;
- 每个结果都携带独立于包版本的 `schema_version`
- 固定 enum、array、哈希和位置的外部表达,避免调用方解释 Python 内部对象;
- 提供 `summary``changes``full` 三个单调增加的正文暴露等级,默认不暴露正文;
- 给当前三个错误阶段提供粗粒度、稳定、可供程序判断的代码,同时保留诊断字段的非稳定身份;
- 明确 schema 的兼容升级规则,并让旧消费者可以安全忽略同一主版本新增的可选字段;
- 保持纯函数、无文件 I/O、无网络、零第三方核心运行依赖;
- 只用合成内容验证 Unicode、换行、部分输出、残留候选和内容泄漏边界。
### 3.2 非目标
- 不提供 `dict` / JSON 到 `ReviewDocument` 的反序列化或 round-trip
- 不把 JSON 投影作为重新应用 `Change`、恢复 Pipeline 或验证原始结果的权威输入;
- 不生成或保存 `.json` 文件,不接收路径,不决定目录、权限、覆盖或保留周期;
- 不提供 CLI、Web API、数据库模型、消息队列协议、OpenAPI、HTML 或项目页面;
- 不发布 SARIF、JSON Lines、JSON Patch、JSON-LD 或项目 artifact 格式;
- 不改变 `ReviewDocument``ReviewStage``Change``RunError``TransformResult` 的字段;
- 不增加细粒度的 Pipeline 错误原因。当前结果没有保存足够的稳定原因身份,本轮不解析异常消息来猜;
- 不把 Python 码点坐标转换成 UTF-16、UTF-8 byte offset、LSP 位置或终端显示宽度;
- 不承诺 `summary` 是可以公开传播的“安全日志”。它不含正文,但 modifier id、版本、计数和哈希仍可能属于项目元数据;
- 不读取、复制或修改项目端测试仓、外部真实文档、历史报告或数据库。
## 4. 方案比较
| 方案 | 优点 | 问题 | 选择 |
| --- | --- | --- | --- |
| 各项目继续 `dataclasses.asdict()` | 上游零工作 | 内部结构意外变成协议;enum、tuple、正文和兼容策略失控 | 不采用 |
| 使用 Pydantic / Marshmallow 建模 | schema 和校验工具成熟 | 给零依赖核心增加运行依赖,并形成第二套评审模型 | 不采用 |
| 只提供 JSON reporter | 调用入口短 | Web 或数据库仍要解析 JSON 才能得到 Python 基本值 | 不采用 |
| 只提供 dict,不提供 reporter | 表面积最小 | 各项目会重复 JSON 编码选项,可能生成 NaN、ASCII 转义或不同格式 | 不采用 |
| 手写窄投影,JSON reporter 复用它 | 字段和暴露级别可审计;零依赖;dict 与 JSON 只有一个语义来源 | 需要长期维护 schema 兼容 | 采用 |
| 第一版同时发布 JSON Schema 文件 | 其他语言可直接验证 | 增加一份必须与代码同步的公共文件和打包契约;当前尚无独立 validator 需求 | 本轮不采用 |
如果真实消费者以后需要脱离 Python wheel 独立验证 payload,再新增 design 决定是否把 JSON Schema 文件作为 Release 资产或
包资源发布,不能根据本文自动补一个未维护的 schema 文件。
## 5. 职责与数据流
```text
Pipeline.transform()
TransformResult + 原始 Markdown
build_review_document() 0011:验证和可信重放
ReviewDocument
├── render_markdown_report() 人工完整评审
└── review_document_to_dict(detail=...)
├── 项目 Web / 数据库 / 其他语言
└── render_json_report() 内存 JSON 字符串
```
`build_review_document()` 仍是建立可信评审对象的唯一入口。机器投影不重新运行 modifier,也不重新实现阶段重放、冲突检查或
哈希证明。
投影函数负责:
- 检查输入确实是 `ReviewDocument`
- 把已知 enum 转成规定字符串;
- 把 tuple 和内部值转成规定 array/object
- 选择当前 detail 允许的字段;
- 拒绝不能安全进入标准 JSON 的值;
- 返回一个与原对象不共享 dict/list 容器的全新结果。
投影函数不负责:
- 修复手工伪造或语义矛盾的 `ReviewDocument`
- 再次应用修改或重新计算阶段;
- 对正文脱敏、截断或摘要生成;
- 保存或发送结果。
调用方应把 `build_review_document()` 返回的对象传给投影。手工构造的对象即使恰好通过结构检查,也不获得可信重放保证。
## 6. 第一版公共接口
公共名称继续位于 `mdpolish.review`,不在包根 `mdpolish.__init__` 重新导出:
```python
from enum import StrEnum
from typing import TypeAlias
JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
ReviewProjection: TypeAlias = dict[str, JsonValue]
class ReviewDetail(StrEnum):
SUMMARY = "summary"
CHANGES = "changes"
FULL = "full"
class ReviewProjectionError(ValueError):
"""ReviewDocument 不能按正式机器契约投影。"""
def review_document_to_dict(
review: ReviewDocument,
*,
detail: ReviewDetail | str = ReviewDetail.SUMMARY,
) -> ReviewProjection:
...
def render_json_report(
review: ReviewDocument,
*,
detail: ReviewDetail | str = ReviewDetail.SUMMARY,
) -> str:
...
```
允许字符串形式是为了让项目配置和 Web 层直接传入 `"summary"``"changes"``"full"`。其他字符串、非字符串且非
`ReviewDetail` 的值直接抛出 `ReviewProjectionError`,不能回退到默认值。
默认 `summary` 是有意的隐私边界:调用方必须显式选择 `changes``full` 才能得到 Markdown 正文或修改片段。
返回类型是普通可变 dict/list,因为目标就是 Python 和 JSON 生态通用的数据载体;权威 `ReviewDocument` 仍然不可变。
每次调用都返回新的递归容器,修改返回值不能反向改变 review,也不能影响下一次投影。
## 7. Schema 身份与顶层结构
第一版 schema 名称和版本固定为:
```json
{
"schema_name": "mdpolish.review",
"schema_version": "1.0"
}
```
`schema_version` 是数据格式版本,不是 `mdpolish` 包版本,也不是 modifier 版本。第一版完整顶层结构为:
```json
{
"schema_name": "mdpolish.review",
"schema_version": "1.0",
"detail": "summary",
"status": "success",
"current_kind": "success_output",
"stages_complete": true,
"hash_contract": {
"algorithm": "sha256",
"encoding": "utf-8",
"normalization": "none"
},
"coordinate_contract": {
"offset_unit": "unicode_code_point",
"span_index_base": 0,
"span_end": "exclusive",
"location_index_base": 1,
"physical_line_endings": ["lf", "crlf", "cr"]
},
"input": {},
"current": {},
"counts": {},
"modifiers": [],
"stages": [],
"errors": []
}
```
`residual_proposals` 只在 `changes``full` 中出现;`summary` 通过 `counts.residual_proposal_count` 报告总数。
所有 object 键必须唯一。上面的成员排列是官方 renderer 的可读输出顺序,但 JSON object 本身无序,消费者不得根据键顺序
解释语义。所有 array 顺序都有意义,必须保持 `ReviewDocument` 的权威顺序,不按 ID、哈希或文本重新排序。
### 7.1 enum 表达
enum 一律投影为已批准的 `.value` 小写字符串,不输出 Python 类名、`repr()` 或整数序号:
| Python enum | 第一版允许值 |
| --- | --- |
| `RunStatus` | `success``failed``unstable` |
| `ReviewCurrentKind` | `success_output``partial_output` |
| `ErrorStage` | `preflight``transform``final_review` |
| `ReviewDetail` | `summary``changes``full` |
投影遇到未知 enum 实例或未知值时失败,不把 `str(value)` 当作向前兼容。
### 7.2 哈希口径
所有 `sha256` 字段都是对对应精确 Markdown 字符串执行:
```python
sha256(markdown.encode("utf-8")).hexdigest()
```
结果为 64 位小写十六进制字符串。不规范化 Unicode,不统一 CR/LF/CRLF,不添加或删除 BOM、空白或末尾换行。
`Change.before_sha256` / `after_sha256` 和阶段哈希指向完整阶段快照,不是片段哈希。
### 7.3 位置口径
- `span.start` / `span.end` 是所属 `ReviewStage.before_markdown` 中的 0-based Unicode code point 半开范围;
- `location.line` / `location.column` 指向 `span.start`,是同一阶段修改前全文中的 1-based 人类位置;
- LF、CRLF、CR 都形成一个物理换行;CRLF 在 offset 中仍占两个 code point
- 不提供最终全文坐标、UTF-16、byte offset 或显示列宽。
所有计数、位置和长度必须是 `0..2**53-1` 范围内的 JSON integer;超出时投影失败,避免其他语言使用 binary64 数字时静默
丢失整数精度。现实内存文档远小于该上限,因此这不是实际文档规模承诺。
## 8. 三个正文暴露等级
三个等级必须满足单调关系:
```text
summary 的字段 ⊂ changes 的字段 ⊂ full 的字段
```
高等级只能增加正文相关字段,不能改变低等级已有字段的值、顺序或语义。
| 内容 | `summary` | `changes` | `full` |
| --- | ---: | ---: | ---: |
| 状态、哈希、坐标契约、计数 | 是 | 是 | 是 |
| modifier id / version / position | 是 | 是 | 是 |
| modifier parameters / applicability | 否 | 是 | 是 |
| 阶段前后哈希和修改数 | 是 | 是 | 是 |
| 实际修改位置、理由、`before` / `after` | 否 | 是 | 是 |
| 稳定错误代码和错误位置 | 是 | 是 | 是 |
| Python 诊断类型和错误消息 | 否 | 是 | 是 |
| 残留候选理由、范围、`expected_text` / `replacement` | 否 | 是 | 是 |
| 完整输入和当前 Markdown | 否 | 否 | 是 |
| 每个阶段的完整 before / after Markdown | 否 | 否 | 是 |
低等级不允许用 `null` 或空字符串代替被隐藏的正文,而是完全省略对应键。这样消费者能够区分“字段因 detail 未暴露”和“原文
本来就是空字符串”。`detail` 顶层字段说明当前投影使用的等级。
`summary` 只承诺不包含以下正文承载字段:`markdown``before``after``expected_text``replacement``reason`
`message``parameters``applicability`。它仍含 modifier identity、哈希、位置和计数,不能在不了解项目数据政策的情况下
称为匿名、脱敏或可公开日志。
`changes` 会暴露实际修改和未应用残留候选中的片段,也会暴露项目 modifier 配置和诊断消息。它可能足以还原敏感局部内容。
`full` 还会重复保存输入、当前全文及每个完整阶段的前后全文,内存和 JSON 大小可能随修改器数量线性增长。调用方必须显式
选择,库不截断、不脱敏,也不自动落盘。
## 9. 各对象的正式投影
以下字段名称、类型和层级属于 schema `1.0`。示例中的省略号只为文档可读,正式输出不得包含省略号。
### 9.1 输入和当前文本
三个 detail 都输出:
```json
"input": {
"sha256": "...",
"code_point_length": 123
},
"current": {
"sha256": "...",
"code_point_length": 120
}
```
`full` 分别增加:
```json
"markdown": "完整文本"
```
`current.markdown` 的性质必须结合根字段 `current_kind` 判断。`partial_output` 永远不能因为进入 JSON 而改名为 cleaned、final
或 successful。
### 9.2 计数
```json
"counts": {
"modifier_count": 3,
"completed_stage_count": 2,
"change_count": 5,
"error_count": 1,
"residual_proposal_count": 0
}
```
这些值是投影时从权威 array 计算的派生摘要,必须与 `ReviewDocument` 一致。`change_count` 是所有完整阶段实际
`ReviewChange` 的总数,不包含 residual proposal edit;零修改阶段仍计入 `completed_stage_count`
### 9.3 Modifier
所有 detail 都输出所有 modifier 的稳定身份:
```json
"modifiers": [
{
"position": 0,
"modifier_id": "example.normalize",
"version": "1.0.0"
}
]
```
`changes``full` 增加:
```json
"parameters": [
["pattern", " {2,}"],
["replacement", " "]
],
"applicability": "调用方声明的适用范围"
```
`parameters` 不投影成 JSON object。当前内部 `ParameterValue` 会把 mapping 和 sequence 都冻结成 tuple;某些嵌套值在运行时
无法可靠区分原来是 mapping 还是二元组 sequence。第一版忠实投影冻结后的结构:顶层及所有 tuple 都变成有序 array
标量保持 `str``int`、有限 `float``bool``null`。投影不得根据“看起来像键值对”猜成 object。
### 9.4 完整阶段
所有 detail 都输出:
```json
"stages": [
{
"modifier_position": 0,
"before": {
"sha256": "...",
"code_point_length": 123
},
"after": {
"sha256": "...",
"code_point_length": 120
},
"change_count": 2
}
]
```
`changes``full` 增加 `changes` array`full` 再给 `before``after` 增加 `markdown`。阶段通过
`modifier_position` 引用根 `modifiers`,不复制第二份 modifier 身份。
阶段顺序与 `ReviewDocument.stages` 相同。不得从 `change_count` 猜测阶段是否完整;完整性继续由根字段
`stages_complete` 和已有阶段边界表达。
### 9.5 已应用修改
只在 `changes``full` 中出现:
```json
"changes": [
{
"proposal_index": 0,
"edit_index": 0,
"reason": "应用调用方声明的替换",
"location": {
"line": 3,
"column": 7
},
"span": {
"start": 24,
"end": 31
},
"before": "exam-\nple",
"after": "example",
"before_sha256": "...",
"after_sha256": "..."
}
]
```
修改所在的 modifier 由外层 stage 唯一确定,因此不重复输出 `modifier_id`、版本和位置。`proposal_index` / `edit_index` 保留
原权威引用顺序;`before_sha256` / `after_sha256` 是完整阶段快照哈希。
### 9.6 错误
所有 detail 都输出稳定身份和位置:
```json
"errors": [
{
"code": "run.transform_failed",
"stage": "transform",
"modifier_position": 1,
"modifier_id": "example.normalize",
"modifier_version": "1.0.0"
}
]
```
`changes``full` 增加:
```json
"diagnostic_type": "ModifierContractError",
"message": "modifier proposal failed"
```
稳定代码只按已经存在的 `ErrorStage` 映射:
| `ErrorStage` | `code` |
| --- | --- |
| `preflight` | `run.preflight_failed` |
| `transform` | `run.transform_failed` |
| `final_review` | `run.final_review_failed` |
`diagnostic_type` 是当前 Python 异常类名,`message` 是给人排障的消息;两者都不属于稳定程序分支条件。消费者只能使用
`code``stage` 做稳定判断。
这些代码有意保持粗粒度。当前 `RunError` 没有保存“元数据变化”“propose 失败”“批次验证失败”等稳定原因,投影不得解析
`error_type``message` 猜出更细代码。未来要增加精细代码,必须先用另一份 design 改变 Pipeline 的错误事实来源。
### 9.7 残留候选
`summary` 只输出总数。`changes``full` 输出完整 residual proposal
```json
"residual_proposals": [
{
"modifier_position": 0,
"proposal_index": 0,
"snapshot_sha256": "...",
"reason": "仍可应用的候选",
"edits": [
{
"edit_index": 0,
"span": {
"start": 10,
"end": 15
},
"expected_text": "exam-",
"replacement": "example"
}
]
}
]
```
modifier id 和版本通过 `modifier_position` 引用根 `modifiers`。array 顺序严格保持 `ReviewDocument.residual_proposals`
`ProposedChange.edits` 的顺序。残留候选仍未应用;投影不能把它放进 stages 或 change count。
## 10. JSON 基本值和失败关闭
`review_document_to_dict()` 只能发出:
```text
object / array / string / integer / finite number / boolean / null
```
它不能依赖 `json.dumps(default=...)` 临时处理未知对象。每种公共模型和 enum 都要显式转换;遇到未知类型立即抛出
`ReviewProjectionError`
投影时至少检查:
- `review``ReviewDocument`
- detail 类型和值有效;
- enum 是 schema `1.0` 明确支持的成员;
- array 中的公共模型类型符合预期;
- 字符串可以无损 UTF-8 编码,不含孤立 UTF-16 surrogate
- 整数不是 `bool` 且在安全范围内;
- float 有限,不含 NaN 或正负 Infinity
- `modifier_position` 能引用根 `modifiers`
- summary 中没有任何正文承载键;
- changes/full 的附加字段符合第 8、9 节。
这组检查保证序列化结构,不复制 `build_review_document()` 的哈希重放和修改契约。如果调用方绕过 builder 手工构造了
语义矛盾但结构合法的 review,投影不声称能恢复可信性。
`ReviewProjectionError` 的消息只说明字段路径和契约类别,不拼入具体正文、modifier 参数、reason、error message 或周边
文本。底层异常可以作为 `__cause__` 保留,但顶层消息不能泄漏被拒绝值。
## 11. JSON reporter
`render_json_report()` 必须只做两步:
1. 调用 `review_document_to_dict(review, detail=detail)`
2. 使用标准库 `json.dumps()` 编码这个返回值。
固定编码行为:
```python
dumps(
projection,
ensure_ascii=False,
allow_nan=False,
indent=2,
)
```
第一版不开放 `indent``sort_keys`、encoder、`default` 或文件对象参数,避免把 JSON 编码器的全部表面积变成库契约。需要紧凑
JSON 的项目可以对官方 dict 投影自行调用 `json.dumps()`,但不能改变字段语义。
reporter 返回 Python `str`,不写文件、不添加 UTF-8 BOM,也不在末尾额外添加换行。调用方通过文件、HTTP 或数据库发送时
负责按 UTF-8 编码并设置正确媒体类型。
相同值的 `ReviewDocument` 和相同 detail 必须得到相等 dict 和完全相同的 JSON 字符串。官方实现会使用固定插入顺序方便
diff 和测试,但消费者仍不得把 object 键顺序当成语义。
## 12. Schema 兼容策略
`schema_version` 使用 `MAJOR.MINOR`
- `MAJOR` 改变表示现有消费者可能误读或无法读取;
- `MINOR` 只允许旧消费者在忽略未知字段时仍能正确理解的加法变化;
- 文案修正、实现重构和使输出重新符合既有契约的 bug fix 不改变 schema 版本;
- schema 版本与 Python 包版本分别管理。`mdpolish 0.6.0` 可以继续输出 schema `1.0`
下列变化必须提升 schema major
- 删除或改名已有字段;
- 改变字段类型、坐标、哈希或 array 顺序语义;
- 改变已有 enum 或稳定错误代码的含义;
- 删除 enum 值,或让生产者在既有字段中自动输出消费者不认识的新 enum 值;
- 在相同 detail 下新增正文承载字段,导致原暴露等级泄漏更多内容;
- 把可选字段改为必需,或改变字段缺失与空值的区别。
下列变化可以提升 schema minor
- 增加不改变现有字段含义的非正文可选字段;
- 增加只有调用方显式请求才会返回的新 detail;
- 增加一个新的可选顶层摘要对象,同时保留既有对象。
同一 major 的消费者必须忽略未知 object 字段,但必须保留 array 顺序;不得接受未知 major。消费者应对自己依赖的 enum 值
显式处理未知情况,不能把未知状态当成 `success`
正文暴露是安全边界:即使新增字段通常属于 minor,在 `summary``changes` 中新增更高等级正文也必须升 major,或新增一个
需要调用方显式选择的 detail。
第一版只提供生产,不提供兼容读取器。历史 JSON 的迁移、数据库 schema 和多版本读取由实际跨进程需求触发下一份 design。
## 13. 源码、文档与版本边界
批准后计划修改:
```text
src/mdpolish/review.py
tests/test_review_projection.py
README.md
research-wiki/explanation/review-projection.md
research-wiki/reference/review-projection-schema-v1.md
pyproject.toml
```
- `review.py` 增加第 6 节的公共类型、投影和 JSON reporter,并更新模块 `__all__`
- 独立测试文件固定 schema 和内容暴露边界,避免继续扩大现有 700 行的 `test_review.py`
- explanation 只在实现完成后更新当前机制;
- reference 记录代码难以完整表达的 schema `1.0`、兼容和正文暴露契约,不复制 README 的当前进度;
- README 在实现完成并验证后才增加能力、示例和真实检查结果;
- 不把新接口导出到包根,不新增依赖或源码包目录。
这是新的公共接口和跨进程数据契约,计划包版本为 `0.5.0`。批准 design 不自动改变当前 `v0.4.0` 事实,也不授权创建 tag、
GitHub Release 或发布 wheel。
当前工作区已有不属于本文的 `AGENTS.md``CLAUDE.md``src/mdpolish/regex.py` 修改。后续实施必须继续保留并隔离这些改动,
不能把它们混入本功能的 diff 或提交。
## 14. 测试与验收
### 14.1 Schema 和 detail
合成测试至少覆盖:
- 空文档、空流水线和零修改结果;
- `success``failed``unstable`,以及完整和不完整 stages
- 三个 detail 的精确顶层键、嵌套键、enum 字符串和 array 顺序;
- `summary` 的递归结果中不存在第 8 节列出的任何正文承载键,也找不到专门放入原文、reason、message 和参数的哨兵字符串;
- `changes` 包含实际及残留修改片段,但不包含输入、当前和阶段完整 `markdown`
- `full` 包含完整输入、当前文本、阶段全文、修改片段和残留候选;
- 空字符串正文通过 `markdown: ""` 与字段未暴露清楚区分;
- detail 之间共同字段的值和 array 顺序完全一致;
- 返回 dict/list 是新容器,修改一次投影不影响 review 或下一次投影。
### 14.2 坐标、哈希和参数
- 中文、补充平面字符、组合字符、BOM、LF、CRLF、CR 和无末尾换行;
- span 的 0-based 半开码点范围和 location 的 1-based 码点行列保持现有口径;
- 输入、当前、阶段和 change 哈希字段指向正确的精确全文;
- tuple、顶层参数对、嵌套二元组、空 tuple、bool、null、int 和有限 float 都按第 9.3 节投影;
- mapping 形状的 tuple 不被启发式改成 JSON object
- 计数与权威 array 一致,残留 edit 不计入实际 change count。
### 14.3 错误与失败关闭
- 三种 `ErrorStage` 分别得到固定稳定代码;
- summary 不含 `diagnostic_type``message`changes/full 原样包含;
- 未知 detail、错误 review 类型、未知 enum、错误嵌套模型、孤立 surrogate、非有限 float、越界整数和无效引用都失败;
- 失败异常为 `ReviewProjectionError`,消息不包含测试正文、参数、reason 或 error message 哨兵;
- 投影不调用 modifier、不读文件、不访问网络、不修复非法值、不静默省略错误字段。
### 14.4 JSON reporter
- `json.loads(render_json_report(...))` 与同 detail 的官方 dict 投影值相等;
- 相同输入重复调用得到逐字符相同的 JSON;
- Unicode 正文不被强制写成 `\uXXXX`,控制字符仍由标准 JSON 正确转义;
- 输出没有 BOM、没有尾随换行、没有 NaN / Infinity,也不依赖 object 键顺序解释;
- summary JSON 中不存在正文哨兵,changes/full 的暴露边界与 dict 完全一致;
- reporter 不接受自定义 encoder 或文件对象,不写入磁盘。
### 14.5 回归和交付检查
实施完成后实际运行根 README 当时列出的全部检查,并确认:
- 现有 `Pipeline`、编辑执行器、modifier、`ReviewDocument` 和 Markdown reporter 行为不变;
- mypy strict、Ruff 和全部 pytest 通过;
- 核心安装仍然没有第三方运行依赖;
- wheel 包含更新后的 `review.py``py.typed`,不包含 tests、Wiki、JSON 报告、真实数据或项目文件;
- README 示例只处理内存对象和字符串,不暗示 JSON 已经保存;
- `AGENTS.md``CLAUDE.md` 除标题外正文一致;
- Git diff 不混入用户现有改动、真实文本、大文件或生成产物。
## 15. 风险与代价
- **公共 schema 需要长期维护:** 内部模型以后可以重构,但 schema `1.x` 不能跟着任意改变;这是正式跨进程接口的必要成本。
- **三个 detail 增加测试矩阵:** 每个字段都要证明在哪些等级出现;换来的是正文暴露由调用方显式决定。
- **`changes` 仍可能泄漏大量内容:** 多条修改和 residual proposal 能覆盖文档大部分区域;它不是脱敏模式。
- **`full` 重复全文:** `ReviewDocument` 已持有阶段快照,投影和 JSON 会再次分配;本轮不做流式或惰性序列化。
- **错误代码较粗:** 它只能稳定表达失败阶段,不能区分具体原因;精细化必须先改善 `RunError` 的事实来源。
- **参数 tuple 表达不够自然:** array-of-pairs 比 JSON object 更啰嗦,但不会猜错已经丢失的 mapping/sequence 身份。
- **没有正式 JSON Schema 文件:** 第一版依靠代码、严格测试和 reference 契约;真正出现独立 validator 需求后再增加发布资产。
- **summary 可能被误称为安全日志:** 它只排除正文承载字段,不替代项目的数据分类、访问控制和哈希治理。
## 16. 批准后的实施边界
用户明确批准本文后,只授权:
1.`mdpolish.review` 实现第 6 至 12 节的公共类型、dict 投影和 JSON reporter
2. 新增合成测试并按第 14 节验证,不接触真实文档;
3. 更新第 13 节列出的 README、explanation、reference 和包版本;
4. 在功能 diff 中隔离并保留工作区已有的其他修改;
5. 报告实际测试、wheel 内容和 Git diff,不把设计批准描述成已经发布。
批准本文不授权:
- 提交、push、创建 PR、tag、GitHub Release 或上传 wheel
- 修改 `Pipeline`、清洗规则、错误捕获语义、`ReviewDocument` 字段或其他仓库;
- 创建 CLI、文件适配器、Web 服务、数据库表、JSON Schema 发布资产或反序列化器;
- 读取、复制、修改或公开真实文档和外部数据。
+32 -4
View File
@@ -16,10 +16,11 @@
│ 验证并重放,不运行 Modifier │ 验证并重放,不运行 Modifier
ReviewDocument ReviewDocument
│ │ ├── render_markdown_report() ─────────────► 内存 Markdown 字符串
└── render_markdown_report() ──► 内存 Markdown 字符串
└── review_document_to_dict(detail=...)
项目自己的界面或转换层 ├───────────────────────────► 项目自己的界面或转换层
└── render_json_report() ──► 内存 JSON 字符串
``` ```
文件读取、保存位置、HTML 页面、权限和审核流程仍由调用项目决定。 文件读取、保存位置、HTML 页面、权限和审核流程仍由调用项目决定。
@@ -85,3 +86,30 @@ span 和修改前后长度,不重复输出 `expected_text`、`replacement` 或
报告有意包含完整文档、实际修改的 `before` / `after` 和 diff,可能还原敏感内容。库不会自动打印、保存、上传或缓存 报告有意包含完整文档、实际修改的 `before` / `after` 和 diff,可能还原敏感内容。库不会自动打印、保存、上传或缓存
报告;持久化后的路径、访问权限、脱敏和保留周期属于调用项目。每个阶段保存完整前后快照,当前实现优先保证可复核性, 报告;持久化后的路径、访问权限、脱敏和保留周期属于调用项目。每个阶段保存完整前后快照,当前实现优先保证可复核性,
没有声称适合无限长度文档或无限修改器链。 没有声称适合无限长度文档或无限修改器链。
## 6. 机器投影为什么不是 `dataclasses.asdict()`
`review_document_to_dict()``ReviewDocument` 的单向公共视图,不是内部 dataclass 的机械展开。它显式转换 enum、tuple、
modifier 参数、位置和哈希,并在根对象写入 `schema_name=mdpolish.review` 与独立的 `schema_version=1.0`。这样内部 Python
结构可以在不改变 schema 的前提下重构,调用项目也不需要猜测 enum、数组和阶段坐标。
投影有三个内容等级:
| detail | 增加的内容 |
| --- | --- |
| `summary` | 状态、哈希、计数、modifier 身份、阶段摘要和稳定错误代码;默认不含正文承载字段 |
| `changes` | modifier 参数和适用说明、实际修改片段、错误诊断、残留候选片段 |
| `full` | 完整输入、当前全文和每个完整阶段的前后全文 |
高等级只增加字段,不改变低等级已有字段的值和顺序。被 detail 隐藏的字段直接不存在,不使用 `null` 或空字符串假装隐藏,
因此空文档在 `full` 中仍能明确表示为 `markdown: ""`
`summary` 不含正文,但仍包含 modifier identity、哈希和计数,只能称为“无正文投影”,不能称为脱敏或公开安全日志。
`changes``full` 都可能还原敏感内容;库不会自动打印、保存或发送任何投影。
`render_json_report()` 只把同 detail 的正式 dict 投影用标准 JSON 编码,不维护第二套字段。它返回内存字符串,使用 Unicode、
拒绝 NaN / Infinity,不写文件或添加 BOM。Markdown reporter 继续直接读取 `ReviewDocument`:它面向人类排版并包含 diff、
动态围栏和 residual 展示限额,不依赖机器 schema。
机器投影只生产,不提供 JSON 到 `ReviewDocument` 的反序列化,也不能用于重新应用修改。完整字段、坐标、错误代码和兼容规则
见 [`review-projection-schema-v1.md`](../reference/review-projection-schema-v1.md)。
@@ -0,0 +1,250 @@
# ReviewDocument 机器投影 schema 1.0
本文记录 `mdpolish.review.review_document_to_dict()``render_json_report()` 当前稳定的跨进程查询口径。公共 Python 类型和
运行校验以 `src/mdpolish/review.py` 与测试为准;设计理由和批准边界见
[`0012-review-document-machine-projection.md`](../design/0012-review-document-machine-projection.md)。
## 1. Schema 身份与入口
每个投影都包含:
```json
{
"schema_name": "mdpolish.review",
"schema_version": "1.0"
}
```
schema 版本独立于 `mdpolish` 包版本和 modifier 版本。公共入口是:
```python
from mdpolish.review import render_json_report, review_document_to_dict
payload = review_document_to_dict(review, detail="summary")
json_text = render_json_report(review, detail="full")
```
两者只接受内存中的 `ReviewDocument`。JSON reporter 编码同 detail 的正式 dict,不定义另一套字段,也不读写文件。
## 2. 顶层字段
| 字段 | JSON 类型 | 口径 |
| --- | --- | --- |
| `schema_name` | string | 固定为 `mdpolish.review` |
| `schema_version` | string | 当前固定为 `1.0` |
| `detail` | string | `summary``changes``full` |
| `status` | string | `success``failed``unstable` |
| `current_kind` | string | `success_output``partial_output` |
| `stages_complete` | boolean | 是否能证明全部 modifier transform 阶段完成 |
| `hash_contract` | object | 所有 Markdown 哈希的算法、编码和规范化口径 |
| `coordinate_contract` | object | span、行列和物理换行口径 |
| `input` | object | 输入文本摘要;`full` 增加正文 |
| `current` | object | 当前文本摘要;`full` 增加正文 |
| `counts` | object | modifier、完成阶段、实际修改、错误和残留候选数量 |
| `modifiers` | array | 全部 modifier 的权威顺序 |
| `stages` | array | 已完成 transform 阶段的权威顺序 |
| `errors` | array | Pipeline 记录的错误顺序 |
| `residual_proposals` | array | 只在 `changes` / `full` 出现;候选未应用 |
JSON object 成员顺序不构成语义。所有 array 顺序构成语义,消费者不得重新按 ID、哈希或文本排序后再解释位置。
## 3. 内容暴露等级
| 内容 | `summary` | `changes` | `full` |
| --- | ---: | ---: | ---: |
| 状态、哈希、位置口径和计数 | 是 | 是 | 是 |
| modifier id、版本和位置 | 是 | 是 | 是 |
| modifier 参数和 applicability | 否 | 是 | 是 |
| 阶段前后哈希、长度和修改数 | 是 | 是 | 是 |
| 实际修改的 reason、位置、`before` / `after` | 否 | 是 | 是 |
| 稳定错误代码和错误位置 | 是 | 是 | 是 |
| Python 诊断类型和错误消息 | 否 | 是 | 是 |
| 残留候选的 reason、`expected_text` / `replacement` | 否 | 是 | 是 |
| 输入、当前和阶段完整 Markdown | 否 | 否 | 是 |
默认是 `summary`。被隐藏的内容键不存在;`null` 和空字符串都不是“已隐藏”的替代值。`summary` 不含直接正文,但仍含
modifier identity、哈希和计数,不自动等于匿名、脱敏或适合公开传播。
## 4. 固定口径
### 4.1 哈希
```json
"hash_contract": {
"algorithm": "sha256",
"encoding": "utf-8",
"normalization": "none"
}
```
哈希是精确 Markdown 的 `sha256(markdown.encode("utf-8")).hexdigest()`。不规范化 Unicode、BOM、空白、末尾换行或
CR/LF/CRLF。`Change.before_sha256` / `after_sha256` 是完整阶段快照哈希,不是修改片段哈希。
### 4.2 坐标
```json
"coordinate_contract": {
"offset_unit": "unicode_code_point",
"span_index_base": 0,
"span_end": "exclusive",
"location_index_base": 1,
"physical_line_endings": ["lf", "crlf", "cr"]
}
```
- `span` 相对于所属 `ReviewStage.before_markdown`,使用 0-based Unicode code point 半开范围;
- `location.line` / `column` 指向 `span.start`,使用同一阶段修改前文本中的 1-based Unicode code point 位置;
- CRLF 形成一个物理换行,但在 offset 中占两个 code point
- 不提供最终全文坐标、UTF-16、byte offset 或终端显示宽度。
所有整数都在 `0..2**53-1`;参数中的有符号整数在 `-(2**53-1)..2**53-1`。所有 float 必须有限。
## 5. 嵌套对象
### 5.1 文本摘要
`input``current` 以及 stage 的 `before` / `after` 都至少包含:
```json
{
"sha256": "64 位小写十六进制",
"code_point_length": 123
}
```
`full` 增加 `markdown``current.markdown` 是否为正式输出必须看根字段 `current_kind``partial_output` 不能当作成功结果。
### 5.2 Counts
```json
"counts": {
"modifier_count": 3,
"completed_stage_count": 2,
"change_count": 5,
"error_count": 1,
"residual_proposal_count": 0
}
```
`change_count` 只统计已完成 stage 中的实际 `ReviewChange`,不含 residual proposal edit。零修改的完整 stage 仍计入
`completed_stage_count`
### 5.3 Modifier
所有 detail
```json
{
"position": 0,
"modifier_id": "example.normalize",
"version": "1.0.0"
}
```
`changes` / `full` 增加 `parameters``applicability``parameters` 是有序 array-of-pairs;所有内部 tuple 继续投影成
array,不根据二元组外形猜成 JSON object
```json
"parameters": [
["pattern", " {2,}"],
["replacement", " "]
]
```
### 5.4 Stage
所有 detail
```json
{
"modifier_position": 0,
"before": {"sha256": "...", "code_point_length": 20},
"after": {"sha256": "...", "code_point_length": 18},
"change_count": 1
}
```
`changes` / `full` 增加 `changes` array`full` 还给两个文本摘要增加 `markdown``modifier_position` 引用根
`modifiers[position]`,不复制 modifier 元数据。
### 5.5 实际 Change
只在 `changes` / `full` 出现:
```json
{
"proposal_index": 0,
"edit_index": 0,
"reason": "修改原因",
"location": {"line": 3, "column": 7},
"span": {"start": 24, "end": 31},
"before": "原片段",
"after": "新片段",
"before_sha256": "修改前完整阶段哈希",
"after_sha256": "修改后完整阶段哈希"
}
```
### 5.6 Error
所有 detail 都含 `code``stage`、modifier 位置与身份。`changes` / `full` 再增加诊断用的 `diagnostic_type`
`message`
| `stage` | 稳定 `code` |
| --- | --- |
| `preflight` | `run.preflight_failed` |
| `transform` | `run.transform_failed` |
| `final_review` | `run.final_review_failed` |
代码只稳定表达失败阶段。`diagnostic_type` 是 Python 异常类名,`message` 是人类消息,不能作为稳定程序分支条件。当前
`RunError` 没有保存更细的稳定原因;不能从类名或消息猜造更细代码。
### 5.7 Residual proposal
只在 `changes` / `full` 出现:
```json
{
"modifier_position": 0,
"proposal_index": 0,
"snapshot_sha256": "当前完整快照哈希",
"reason": "候选原因",
"edits": [
{
"edit_index": 0,
"span": {"start": 10, "end": 15},
"expected_text": "原片段",
"replacement": "候选片段"
}
]
}
```
它是 final review 证据,没有应用,不进入 stage 或实际 change count。
## 6. JSON 编码
`render_json_report()` 固定使用标准库 JSON 的以下语义:
- `ensure_ascii=False`
- `allow_nan=False`
- `indent=2`
- 无 UTF-8 BOM
- 返回字符串末尾不额外添加换行。
返回值是 Python `str`。调用方保存或发送时负责 UTF-8 编码、媒体类型、权限和保留周期。库不接收路径或文件对象。
## 7. 兼容策略
`schema_version` 使用 `MAJOR.MINOR`
- 删除、改名、改类型、改变位置/哈希/顺序语义、改变已有 enum 或错误代码含义,需要提升 major;
- 在相同 detail 中新增更高敏感度的正文承载字段,需要提升 major,或增加必须显式请求的新 detail;
- 不改变旧字段解释的非正文可选字段,可以提升 minor;
- 实现修正为重新符合已有契约,不改变 schema 版本;
- schema 版本不跟随 Python 包版本自动变化。
同一 major 的消费者必须忽略未知 object 字段,但必须保持 array 顺序;不得把未知状态当成 `success`。消费者应拒绝自己不
支持的 schema major。
schema `1.0` 是单向生产契约。当前没有官方反序列化器、JSON Schema 文件、历史迁移器或数据库 schema。
+554 -2
View File
@@ -1,4 +1,4 @@
"""可信解释 TransformResult 的内存评审视图与 Markdown 报告。""" """可信解释 TransformResult 的评审视图、机器投影与 Markdown 报告。"""
from __future__ import annotations from __future__ import annotations
@@ -7,7 +7,8 @@ from difflib import unified_diff
from enum import StrEnum from enum import StrEnum
from itertools import pairwise from itertools import pairwise
from json import dumps from json import dumps
from typing import Never from math import isfinite
from typing import Never, TypeAlias
from mdpolish.edits import _apply_validated_edits, _ordered_indexed_edits, validate_modifier_batch from mdpolish.edits import _apply_validated_edits, _ordered_indexed_edits, validate_modifier_batch
from mdpolish.models import ( from mdpolish.models import (
@@ -15,11 +16,13 @@ from mdpolish.models import (
DocumentSnapshot, DocumentSnapshot,
ErrorStage, ErrorStage,
ModifierInfo, ModifierInfo,
ProposalReference,
ProposedChange, ProposedChange,
ResidualProposal, ResidualProposal,
RunError, RunError,
RunStatus, RunStatus,
TextEdit, TextEdit,
TextSpan,
TransformResult, TransformResult,
markdown_sha256, markdown_sha256,
) )
@@ -29,6 +32,10 @@ class ReviewBuildError(ValueError):
"""TransformResult 不能被安全地解释为评审视图。""" """TransformResult 不能被安全地解释为评审视图。"""
class ReviewProjectionError(ValueError):
"""ReviewDocument 不能按正式机器契约投影。"""
class ReviewCurrentKind(StrEnum): class ReviewCurrentKind(StrEnum):
"""评审视图中当前全文的结果性质。""" """评审视图中当前全文的结果性质。"""
@@ -36,6 +43,19 @@ class ReviewCurrentKind(StrEnum):
PARTIAL_OUTPUT = "partial_output" PARTIAL_OUTPUT = "partial_output"
class ReviewDetail(StrEnum):
"""机器投影允许暴露的正文详细程度。"""
SUMMARY = "summary"
CHANGES = "changes"
FULL = "full"
JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
ReviewProjection: TypeAlias = dict[str, JsonValue]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ReviewLocation: class ReviewLocation:
"""修改前快照中的 1-based Python 码点位置。""" """修改前快照中的 1-based Python 码点位置。"""
@@ -485,6 +505,531 @@ def build_review_document(input_markdown: str, result: TransformResult) -> Revie
raise ReviewBuildError("review build failed: result structure is malformed") from None raise ReviewBuildError("review build failed: result structure is malformed") from None
_SCHEMA_NAME = "mdpolish.review"
_SCHEMA_VERSION = "1.0"
_MAX_SAFE_JSON_INTEGER = 2**53 - 1
_BODY_FIELD_NAMES = frozenset(
{
"applicability",
"expected_text",
"markdown",
"message",
"parameters",
"reason",
"replacement",
}
)
_ERROR_CODES = {
ErrorStage.PREFLIGHT: "run.preflight_failed",
ErrorStage.TRANSFORM: "run.transform_failed",
ErrorStage.FINAL_REVIEW: "run.final_review_failed",
}
def _projection_fail(path: str, contract: str) -> Never:
raise ReviewProjectionError(f"review projection failed at {path}: {contract}")
def _projection_string(value: object, path: str) -> str:
if not isinstance(value, str):
_projection_fail(path, "value must be a string")
try:
encoded = value.encode("utf-8")
except UnicodeEncodeError:
_projection_fail(path, "string must be valid Unicode encodable as UTF-8")
return encoded.decode("utf-8")
def _projection_nonempty_string(value: object, path: str) -> str:
text = _projection_string(value, path)
if not text:
_projection_fail(path, "string must not be empty")
return text
def _projection_integer(value: object, path: str, *, minimum: int = 0) -> int:
if type(value) is not int:
_projection_fail(path, "value must be an integer")
if value < minimum or value > _MAX_SAFE_JSON_INTEGER:
_projection_fail(path, "integer is outside the interoperable JSON range")
return value
def _projection_signed_integer(value: object, path: str) -> int:
if type(value) is not int:
_projection_fail(path, "value must be an integer")
if value < -_MAX_SAFE_JSON_INTEGER or value > _MAX_SAFE_JSON_INTEGER:
_projection_fail(path, "integer is outside the interoperable JSON range")
return value
def _projection_sha256(value: object, path: str) -> str:
digest = _projection_string(value, path)
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
_projection_fail(path, "value must be a lowercase SHA-256 digest")
return digest
def _projection_detail(value: object) -> ReviewDetail:
if not isinstance(value, (ReviewDetail, str)):
_projection_fail("detail", "value must be summary, changes, or full")
try:
return ReviewDetail(value)
except ValueError:
_projection_fail("detail", "value must be summary, changes, or full")
def _projection_parameter(value: object, path: str) -> JsonValue:
if value is None or isinstance(value, bool):
return value
if isinstance(value, str):
return _projection_string(value, path)
if type(value) is int:
return _projection_signed_integer(value, path)
if isinstance(value, float):
if not isfinite(value):
_projection_fail(path, "float must be finite")
return float(value)
if isinstance(value, tuple):
return [_projection_parameter(item, f"{path}[{index}]") for index, item in enumerate(value)]
_projection_fail(path, "parameter contains an unsupported value")
def _projection_parameters(value: object, path: str) -> list[JsonValue]:
if not isinstance(value, tuple):
_projection_fail(path, "parameters must be a tuple")
projected: list[JsonValue] = []
seen_keys: set[str] = set()
for index, pair in enumerate(value):
pair_path = f"{path}[{index}]"
if not isinstance(pair, tuple) or len(pair) != 2:
_projection_fail(pair_path, "parameter entry must be a key-value tuple")
key = _projection_nonempty_string(pair[0], f"{pair_path}[0]")
if key in seen_keys:
_projection_fail(pair_path, "parameter keys must be unique")
seen_keys.add(key)
projected.append([key, _projection_parameter(pair[1], f"{pair_path}[1]")])
return projected
def _projection_text(
markdown: object,
digest: object,
*,
include_markdown: bool,
path: str,
) -> ReviewProjection:
text = _projection_string(markdown, f"{path}.markdown")
projection: ReviewProjection = {
"sha256": _projection_sha256(digest, f"{path}.sha256"),
"code_point_length": _projection_integer(len(text), f"{path}.code_point_length"),
}
if include_markdown:
projection["markdown"] = text
return projection
def _projection_modifier(value: object, position: int, detail: ReviewDetail) -> ReviewProjection:
path = f"modifiers[{position}]"
if not isinstance(value, ModifierInfo):
_projection_fail(path, "value must be ModifierInfo")
modifier_id = _projection_nonempty_string(value.modifier_id, f"{path}.modifier_id")
version = _projection_nonempty_string(value.version, f"{path}.version")
parameters = _projection_parameters(value.parameters, f"{path}.parameters")
applicability = _projection_nonempty_string(value.applicability, f"{path}.applicability")
projection: ReviewProjection = {
"position": _projection_integer(position, f"{path}.position"),
"modifier_id": modifier_id,
"version": version,
}
if detail is not ReviewDetail.SUMMARY:
projection["parameters"] = parameters
projection["applicability"] = applicability
return projection
def _projection_span(value: object, path: str) -> ReviewProjection:
if not isinstance(value, TextSpan):
_projection_fail(path, "value must be TextSpan")
start = _projection_integer(value.start, f"{path}.start")
end = _projection_integer(value.end, f"{path}.end")
if end < start:
_projection_fail(path, "span end must not precede start")
return {"start": start, "end": end}
def _projection_location(value: object, path: str) -> ReviewProjection:
if not isinstance(value, ReviewLocation):
_projection_fail(path, "value must be ReviewLocation")
return {
"line": _projection_integer(value.line, f"{path}.line", minimum=1),
"column": _projection_integer(value.column, f"{path}.column", minimum=1),
}
def _projection_change(
value: object,
*,
stage: ReviewStage,
stage_modifier: ModifierInfo,
path: str,
) -> ReviewProjection:
if not isinstance(value, ReviewChange):
_projection_fail(path, "value must be ReviewChange")
change = value.change
if not isinstance(change, Change):
_projection_fail(f"{path}.change", "value must be Change")
if (
change.modifier_position != stage.modifier_position
or change.modifier_id != stage_modifier.modifier_id
or change.modifier_version != stage_modifier.version
):
_projection_fail(path, "change identity does not match its stage")
proposal_ref = change.proposal_ref
if not isinstance(proposal_ref, ProposalReference):
_projection_fail(f"{path}.proposal_ref", "value must be ProposalReference")
proposal_index = _projection_integer(
proposal_ref.proposal_index,
f"{path}.proposal_ref.proposal_index",
)
if (
proposal_ref.modifier_position != stage.modifier_position
or proposal_ref.snapshot_sha256 != stage.before_sha256
):
_projection_fail(f"{path}.proposal_ref", "reference does not match its stage")
edit_index = _projection_integer(change.edit_index, f"{path}.edit_index")
reason = _projection_nonempty_string(change.reason, f"{path}.reason")
span = _projection_span(change.span, f"{path}.span")
before = _projection_string(change.before, f"{path}.before")
after = _projection_string(change.after, f"{path}.after")
if len(before) != change.span.end - change.span.start or before == after:
_projection_fail(path, "change text does not satisfy its span contract")
before_sha256 = _projection_sha256(change.before_sha256, f"{path}.before_sha256")
after_sha256 = _projection_sha256(change.after_sha256, f"{path}.after_sha256")
if before_sha256 != stage.before_sha256 or after_sha256 != stage.after_sha256:
_projection_fail(path, "change hashes do not match its stage")
return {
"proposal_index": proposal_index,
"edit_index": edit_index,
"reason": reason,
"location": _projection_location(value.location, f"{path}.location"),
"span": span,
"before": before,
"after": after,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
def _projection_stage(
value: object,
*,
expected_position: int,
modifiers: tuple[ModifierInfo, ...],
detail: ReviewDetail,
) -> tuple[ReviewProjection, int]:
path = f"stages[{expected_position}]"
if not isinstance(value, ReviewStage):
_projection_fail(path, "value must be ReviewStage")
position = _projection_integer(value.modifier_position, f"{path}.modifier_position")
if position != expected_position or position >= len(modifiers):
_projection_fail(path, "stage position does not reference the expected modifier")
modifier = modifiers[position]
if value.modifier != modifier:
_projection_fail(path, "stage modifier does not match root modifier metadata")
if not isinstance(value.changes, tuple):
_projection_fail(f"{path}.changes", "changes must be a tuple")
before = _projection_text(
value.before_markdown,
value.before_sha256,
include_markdown=detail is ReviewDetail.FULL,
path=f"{path}.before",
)
after = _projection_text(
value.after_markdown,
value.after_sha256,
include_markdown=detail is ReviewDetail.FULL,
path=f"{path}.after",
)
projected_changes: list[JsonValue] = [
_projection_change(
review_change,
stage=value,
stage_modifier=modifier,
path=f"{path}.changes[{index}]",
)
for index, review_change in enumerate(value.changes)
]
projection: ReviewProjection = {
"modifier_position": position,
"before": before,
"after": after,
"change_count": _projection_integer(len(value.changes), f"{path}.change_count"),
}
if detail is not ReviewDetail.SUMMARY:
projection["changes"] = projected_changes
return projection, len(value.changes)
def _projection_error(
value: object,
*,
error_index: int,
modifiers: tuple[ModifierInfo, ...],
detail: ReviewDetail,
) -> ReviewProjection:
path = f"errors[{error_index}]"
if not isinstance(value, RunError):
_projection_fail(path, "value must be RunError")
if not isinstance(value.stage, ErrorStage) or value.stage not in _ERROR_CODES:
_projection_fail(f"{path}.stage", "error stage is not supported by schema 1.0")
position = _projection_integer(value.modifier_position, f"{path}.modifier_position")
modifier_id = _projection_nonempty_string(value.modifier_id, f"{path}.modifier_id")
modifier_version = _projection_nonempty_string(value.modifier_version, f"{path}.modifier_version")
if position < len(modifiers):
modifier = modifiers[position]
if modifier_id != modifier.modifier_id or modifier_version != modifier.version:
_projection_fail(path, "error identity does not match root modifier metadata")
elif position != len(modifiers) or value.stage is not ErrorStage.PREFLIGHT:
_projection_fail(path, "error position is outside root modifier metadata")
diagnostic_type = _projection_nonempty_string(value.error_type, f"{path}.diagnostic_type")
message = _projection_nonempty_string(value.message, f"{path}.message")
projection: ReviewProjection = {
"code": _ERROR_CODES[value.stage],
"stage": value.stage.value,
"modifier_position": position,
"modifier_id": modifier_id,
"modifier_version": modifier_version,
}
if detail is not ReviewDetail.SUMMARY:
projection["diagnostic_type"] = diagnostic_type
projection["message"] = message
return projection
def _projection_residual_edit(value: object, *, edit_index: int, path: str) -> ReviewProjection:
if not isinstance(value, TextEdit):
_projection_fail(path, "value must be TextEdit")
span = _projection_span(value.span, f"{path}.span")
expected_text = _projection_string(value.expected_text, f"{path}.expected_text")
replacement = _projection_string(value.replacement, f"{path}.replacement")
if len(expected_text) != value.span.end - value.span.start or expected_text == replacement:
_projection_fail(path, "residual edit text does not satisfy its span contract")
return {
"edit_index": _projection_integer(edit_index, f"{path}.edit_index"),
"span": span,
"expected_text": expected_text,
"replacement": replacement,
}
def _projection_residual(
value: object,
*,
residual_index: int,
modifiers: tuple[ModifierInfo, ...],
current_sha256: str,
) -> ReviewProjection:
path = f"residual_proposals[{residual_index}]"
if not isinstance(value, ResidualProposal):
_projection_fail(path, "value must be ResidualProposal")
position = _projection_integer(value.modifier_position, f"{path}.modifier_position")
if position >= len(modifiers):
_projection_fail(path, "residual position is outside root modifier metadata")
modifier = modifiers[position]
if value.modifier_id != modifier.modifier_id or value.modifier_version != modifier.version:
_projection_fail(path, "residual identity does not match root modifier metadata")
proposal_ref = value.proposal_ref
if not isinstance(proposal_ref, ProposalReference):
_projection_fail(f"{path}.proposal_ref", "value must be ProposalReference")
proposal_index = _projection_integer(
proposal_ref.proposal_index,
f"{path}.proposal_ref.proposal_index",
)
if proposal_ref.modifier_position != position or proposal_ref.snapshot_sha256 != current_sha256:
_projection_fail(f"{path}.proposal_ref", "reference does not target the current snapshot")
proposal = value.proposal
if not isinstance(proposal, ProposedChange):
_projection_fail(f"{path}.proposal", "value must be ProposedChange")
snapshot_sha256 = _projection_sha256(proposal.snapshot_sha256, f"{path}.snapshot_sha256")
if snapshot_sha256 != current_sha256:
_projection_fail(path, "residual proposal does not target the current snapshot")
reason = _projection_nonempty_string(proposal.reason, f"{path}.reason")
if not isinstance(proposal.edits, tuple) or not proposal.edits:
_projection_fail(f"{path}.edits", "edits must be a non-empty tuple")
edits: list[JsonValue] = [
_projection_residual_edit(edit, edit_index=index, path=f"{path}.edits[{index}]")
for index, edit in enumerate(proposal.edits)
]
for index, edit in enumerate(proposal.edits):
if edit.snapshot_sha256 != current_sha256:
_projection_fail(f"{path}.edits[{index}]", "edit does not target the current snapshot")
return {
"modifier_position": position,
"proposal_index": proposal_index,
"snapshot_sha256": snapshot_sha256,
"reason": reason,
"edits": edits,
}
def _require_no_body_fields(value: JsonValue, path: str = "root") -> None:
if isinstance(value, dict):
for key, nested in value.items():
if key in _BODY_FIELD_NAMES:
_projection_fail(path, "summary contains a body-bearing field")
_require_no_body_fields(nested, f"{path}.{key}")
elif isinstance(value, list):
for index, nested in enumerate(value):
_require_no_body_fields(nested, f"{path}[{index}]")
def review_document_to_dict(
review: ReviewDocument,
*,
detail: ReviewDetail | str = ReviewDetail.SUMMARY,
) -> ReviewProjection:
"""把可信评审对象投影为 schema 1.0 的普通 JSON 基本值。"""
if not isinstance(review, ReviewDocument):
_projection_fail("review", "value must be a ReviewDocument")
resolved_detail = _projection_detail(detail)
if not isinstance(review.status, RunStatus):
_projection_fail("review.status", "status is not supported by schema 1.0")
if not isinstance(review.current_kind, ReviewCurrentKind):
_projection_fail("review.current_kind", "current kind is not supported by schema 1.0")
if (review.status is RunStatus.SUCCESS) != (
review.current_kind is ReviewCurrentKind.SUCCESS_OUTPUT
):
_projection_fail("review.current_kind", "current kind does not match run status")
if type(review.stages_complete) is not bool:
_projection_fail("review.stages_complete", "value must be a boolean")
if not isinstance(review.modifiers, tuple):
_projection_fail("review.modifiers", "modifiers must be a tuple")
if not isinstance(review.stages, tuple):
_projection_fail("review.stages", "stages must be a tuple")
if not isinstance(review.errors, tuple):
_projection_fail("review.errors", "errors must be a tuple")
if not isinstance(review.residual_proposals, tuple):
_projection_fail("review.residual_proposals", "residual proposals must be a tuple")
input_projection = _projection_text(
review.input_markdown,
review.input_sha256,
include_markdown=resolved_detail is ReviewDetail.FULL,
path="input",
)
current_sha256 = _projection_sha256(review.current_sha256, "current.sha256")
current_projection = _projection_text(
review.current_markdown,
current_sha256,
include_markdown=resolved_detail is ReviewDetail.FULL,
path="current",
)
modifier_projections: list[JsonValue] = [
_projection_modifier(modifier, position, resolved_detail)
for position, modifier in enumerate(review.modifiers)
]
stage_projections: list[JsonValue] = []
total_changes = 0
for expected_position, stage in enumerate(review.stages):
stage_projection, stage_change_count = _projection_stage(
stage,
expected_position=expected_position,
modifiers=review.modifiers,
detail=resolved_detail,
)
stage_projections.append(stage_projection)
total_changes += stage_change_count
error_projections: list[JsonValue] = [
_projection_error(
error,
error_index=index,
modifiers=review.modifiers,
detail=resolved_detail,
)
for index, error in enumerate(review.errors)
]
residual_projections: list[JsonValue] = [
_projection_residual(
residual,
residual_index=index,
modifiers=review.modifiers,
current_sha256=current_sha256,
)
for index, residual in enumerate(review.residual_proposals)
]
hash_contract: ReviewProjection = {
"algorithm": "sha256",
"encoding": "utf-8",
"normalization": "none",
}
coordinate_contract: ReviewProjection = {
"offset_unit": "unicode_code_point",
"span_index_base": 0,
"span_end": "exclusive",
"location_index_base": 1,
"physical_line_endings": ["lf", "crlf", "cr"],
}
counts: ReviewProjection = {
"modifier_count": _projection_integer(len(review.modifiers), "counts.modifier_count"),
"completed_stage_count": _projection_integer(
len(review.stages),
"counts.completed_stage_count",
),
"change_count": _projection_integer(total_changes, "counts.change_count"),
"error_count": _projection_integer(len(review.errors), "counts.error_count"),
"residual_proposal_count": _projection_integer(
len(review.residual_proposals),
"counts.residual_proposal_count",
),
}
projection: ReviewProjection = {
"schema_name": _SCHEMA_NAME,
"schema_version": _SCHEMA_VERSION,
"detail": resolved_detail.value,
"status": review.status.value,
"current_kind": review.current_kind.value,
"stages_complete": review.stages_complete,
"hash_contract": hash_contract,
"coordinate_contract": coordinate_contract,
"input": input_projection,
"current": current_projection,
"counts": counts,
"modifiers": modifier_projections,
"stages": stage_projections,
"errors": error_projections,
}
if resolved_detail is not ReviewDetail.SUMMARY:
projection["residual_proposals"] = residual_projections
else:
_require_no_body_fields(projection)
return projection
def render_json_report(
review: ReviewDocument,
*,
detail: ReviewDetail | str = ReviewDetail.SUMMARY,
) -> str:
"""把正式机器投影确定地编码为内存 JSON 字符串。"""
return dumps(
review_document_to_dict(review, detail=detail),
ensure_ascii=False,
allow_nan=False,
indent=2,
)
def _maximum_run(text: str, character: str) -> int: def _maximum_run(text: str, character: str) -> int:
longest = 0 longest = 0
current = 0 current = 0
@@ -714,12 +1259,19 @@ def render_markdown_report(review: ReviewDocument, *, residual_limit: int = 20)
__all__ = [ __all__ = [
"JsonScalar",
"JsonValue",
"ReviewBuildError", "ReviewBuildError",
"ReviewChange", "ReviewChange",
"ReviewCurrentKind", "ReviewCurrentKind",
"ReviewDetail",
"ReviewDocument", "ReviewDocument",
"ReviewLocation", "ReviewLocation",
"ReviewProjection",
"ReviewProjectionError",
"ReviewStage", "ReviewStage",
"build_review_document", "build_review_document",
"render_json_report",
"render_markdown_report", "render_markdown_report",
"review_document_to_dict",
] ]
+512
View File
@@ -0,0 +1,512 @@
from __future__ import annotations
from dataclasses import replace
from json import loads
from typing import cast
import pytest
from mdpolish import (
DocumentSnapshot,
Modifier,
ModifierInfo,
Pipeline,
ProposedChange,
RunStatus,
TextEdit,
TextSpan,
)
from mdpolish.review import (
JsonValue,
ReviewCurrentKind,
ReviewDetail,
ReviewDocument,
ReviewLocation,
ReviewProjection,
ReviewProjectionError,
build_review_document,
render_json_report,
review_document_to_dict,
)
_MAX_SAFE_JSON_INTEGER = 2**53 - 1
def _replace_modifier(
needle: str,
replacement: str,
*,
modifier_id: str,
reason: str,
parameters: dict[str, object] | None = None,
applicability: str = "只处理虚构投影测试标记。",
) -> Modifier:
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
position = snapshot.markdown.find(needle)
if position < 0:
return ()
edit = TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(position, position + len(needle)),
expected_text=needle,
replacement=replacement,
)
return (
ProposedChange(
snapshot_sha256=snapshot.sha256,
reason=reason,
edits=(edit,),
),
)
return Modifier(
modifier_id=modifier_id,
version="1.2.3",
parameters=parameters or {"needle": needle, "replacement": replacement},
applicability=applicability,
propose=propose,
)
def _rich_success_review() -> ReviewDocument:
modifier = _replace_modifier(
"BEFORE_SECRET",
"AFTER_SECRET",
modifier_id="test.machine-projection",
reason="REASON_SECRET",
parameters={
"enabled": True,
"finite": 1.25,
"mapping": {"alpha": 1},
"none": None,
"sequence": (("alpha", 1),),
},
applicability="APPLICABILITY_SECRET",
)
input_markdown = "中文_SOURCE_ONLY\r\nBEFORE_SECRET\tTAIL"
result = Pipeline((modifier,)).transform(input_markdown)
assert result.status is RunStatus.SUCCESS
return build_review_document(input_markdown, result)
def _unstable_review() -> ReviewDocument:
def propose_residual(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
needle = "RESIDUAL_EXPECTED_SECRET"
position = snapshot.markdown.find(needle)
if position < 0:
return ()
edit = TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(position, position + len(needle)),
expected_text=needle,
replacement="RESIDUAL_REPLACEMENT_SECRET",
)
return (
ProposedChange(
snapshot_sha256=snapshot.sha256,
reason="RESIDUAL_REASON_SECRET",
edits=(edit,),
),
)
residual = Modifier(
modifier_id="test.residual",
version="1.0.0",
parameters=(),
applicability="只在最终复查中产生虚构候选。",
propose=propose_residual,
)
producer = _replace_modifier(
"start",
"RESIDUAL_EXPECTED_SECRET",
modifier_id="test.producer",
reason="produce residual marker",
)
result = Pipeline((residual, producer)).transform("start")
assert result.status is RunStatus.UNSTABLE
return build_review_document("start", result)
def _exploding_modifier(modifier_id: str, *, trigger: str | None = None) -> Modifier:
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
if trigger is None or snapshot.markdown == trigger:
raise RuntimeError(f"ERROR_MESSAGE_SECRET: {snapshot.markdown}")
return ()
return Modifier(
modifier_id=modifier_id,
version="1.0.0",
parameters=(),
applicability="只测试机器错误投影。",
propose=propose,
)
def _object(value: JsonValue) -> ReviewProjection:
assert isinstance(value, dict)
return value
def _array(value: JsonValue) -> list[JsonValue]:
assert isinstance(value, list)
return value
def _contains_key(value: JsonValue, key: str) -> bool:
if isinstance(value, dict):
return key in value or any(_contains_key(nested, key) for nested in value.values())
if isinstance(value, list):
return any(_contains_key(nested, key) for nested in value)
return False
def test_summary_projection_has_stable_schema_without_body_content() -> None:
review = _rich_success_review()
projection = review_document_to_dict(review)
assert set(projection) == {
"schema_name",
"schema_version",
"detail",
"status",
"current_kind",
"stages_complete",
"hash_contract",
"coordinate_contract",
"input",
"current",
"counts",
"modifiers",
"stages",
"errors",
}
assert projection["schema_name"] == "mdpolish.review"
assert projection["schema_version"] == "1.0"
assert projection["detail"] == "summary"
assert projection["status"] == "success"
assert projection["current_kind"] == "success_output"
assert projection["stages_complete"] is True
assert projection["hash_contract"] == {
"algorithm": "sha256",
"encoding": "utf-8",
"normalization": "none",
}
assert projection["coordinate_contract"] == {
"offset_unit": "unicode_code_point",
"span_index_base": 0,
"span_end": "exclusive",
"location_index_base": 1,
"physical_line_endings": ["lf", "crlf", "cr"],
}
assert projection["counts"] == {
"modifier_count": 1,
"completed_stage_count": 1,
"change_count": 1,
"error_count": 0,
"residual_proposal_count": 0,
}
input_projection = _object(projection["input"])
current_projection = _object(projection["current"])
assert input_projection == {
"sha256": review.input_sha256,
"code_point_length": len(review.input_markdown),
}
assert current_projection == {
"sha256": review.current_sha256,
"code_point_length": len(review.current_markdown),
}
modifiers = _array(projection["modifiers"])
assert modifiers == [
{
"position": 0,
"modifier_id": "test.machine-projection",
"version": "1.2.3",
}
]
stages = _array(projection["stages"])
stage = _object(stages[0])
assert set(stage) == {"modifier_position", "before", "after", "change_count"}
assert stage["modifier_position"] == 0
assert stage["change_count"] == 1
assert stage["before"] == {
"sha256": review.stages[0].before_sha256,
"code_point_length": len(review.stages[0].before_markdown),
}
assert stage["after"] == {
"sha256": review.stages[0].after_sha256,
"code_point_length": len(review.stages[0].after_markdown),
}
rendered = render_json_report(review)
for secret in (
"中文_SOURCE_ONLY",
"BEFORE_SECRET",
"AFTER_SECRET",
"REASON_SECRET",
"APPLICABILITY_SECRET",
"ERROR_MESSAGE_SECRET",
):
assert secret not in rendered
for body_key in (
"markdown",
"expected_text",
"replacement",
"reason",
"message",
"parameters",
"applicability",
):
assert not _contains_key(projection, body_key)
def test_changes_and_full_details_add_content_monotonically() -> None:
review = _rich_success_review()
summary = review_document_to_dict(review, detail=ReviewDetail.SUMMARY)
changes = review_document_to_dict(review, detail="changes")
full = review_document_to_dict(review, detail=ReviewDetail.FULL)
assert set(summary) < set(changes)
assert set(changes) == set(full)
assert changes["detail"] == "changes"
assert full["detail"] == "full"
for key in set(summary) - {"detail", "input", "current", "modifiers", "stages"}:
assert summary[key] == changes[key] == full[key]
changes_modifier = _object(_array(changes["modifiers"])[0])
full_modifier = _object(_array(full["modifiers"])[0])
assert changes_modifier == full_modifier
assert changes_modifier["applicability"] == "APPLICABILITY_SECRET"
assert changes_modifier["parameters"] == [
["enabled", True],
["finite", 1.25],
["mapping", [["alpha", 1]]],
["none", None],
["sequence", [["alpha", 1]]],
]
changes_stage = _object(_array(changes["stages"])[0])
full_stage = _object(_array(full["stages"])[0])
assert "markdown" not in _object(changes_stage["before"])
assert "markdown" not in _object(changes_stage["after"])
assert not _contains_key(changes, "markdown")
assert _object(full_stage["before"])["markdown"] == review.input_markdown
assert _object(full_stage["after"])["markdown"] == review.current_markdown
assert _object(full["input"])["markdown"] == review.input_markdown
assert _object(full["current"])["markdown"] == review.current_markdown
change = _object(_array(changes_stage["changes"])[0])
assert change == {
"proposal_index": 0,
"edit_index": 0,
"reason": "REASON_SECRET",
"location": {"line": 2, "column": 1},
"span": {
"start": review.input_markdown.index("BEFORE_SECRET"),
"end": review.input_markdown.index("BEFORE_SECRET") + len("BEFORE_SECRET"),
},
"before": "BEFORE_SECRET",
"after": "AFTER_SECRET",
"before_sha256": review.stages[0].before_sha256,
"after_sha256": review.stages[0].after_sha256,
}
assert "中文_SOURCE_ONLY" not in render_json_report(review, detail="changes")
def test_residual_content_requires_changes_or_full_detail() -> None:
review = _unstable_review()
summary = review_document_to_dict(review, detail="summary")
changes = review_document_to_dict(review, detail="changes")
assert summary["status"] == "unstable"
assert summary["current_kind"] == "partial_output"
assert _object(summary["counts"])["residual_proposal_count"] == 1
assert "residual_proposals" not in summary
assert "RESIDUAL_EXPECTED_SECRET" not in render_json_report(review, detail="summary")
residuals = _array(changes["residual_proposals"])
assert len(residuals) == 1
residual = _object(residuals[0])
assert residual["modifier_position"] == 0
assert residual["proposal_index"] == 0
assert residual["snapshot_sha256"] == review.current_sha256
assert residual["reason"] == "RESIDUAL_REASON_SECRET"
edit = _object(_array(residual["edits"])[0])
assert edit["edit_index"] == 0
assert edit["expected_text"] == "RESIDUAL_EXPECTED_SECRET"
assert edit["replacement"] == "RESIDUAL_REPLACEMENT_SECRET"
def _error_reviews() -> tuple[tuple[ReviewDocument, str], ...]:
invalid_pipeline = Pipeline(cast(tuple[Modifier, ...], (object(),)))
preflight_result = invalid_pipeline.transform("PREFLIGHT_SOURCE_SECRET")
preflight = build_review_document("PREFLIGHT_SOURCE_SECRET", preflight_result)
transform_result = Pipeline((_exploding_modifier("test.transform-error"),)).transform(
"TRANSFORM_SOURCE_SECRET"
)
transform = build_review_document("TRANSFORM_SOURCE_SECRET", transform_result)
review_error = _exploding_modifier("test.final-review-error", trigger="done")
producer = _replace_modifier(
"start",
"done",
modifier_id="test.final-producer",
reason="produce final review trigger",
)
final_result = Pipeline((review_error, producer)).transform("start")
final_review = build_review_document("start", final_result)
return (
(preflight, "run.preflight_failed"),
(transform, "run.transform_failed"),
(final_review, "run.final_review_failed"),
)
def test_error_projection_uses_stable_codes_and_hides_diagnostics_in_summary() -> None:
for review, expected_code in _error_reviews():
summary = review_document_to_dict(review, detail="summary")
changes = review_document_to_dict(review, detail="changes")
summary_error = _object(_array(summary["errors"])[0])
changes_error = _object(_array(changes["errors"])[0])
assert summary_error["code"] == expected_code
assert summary_error["stage"] == review.errors[0].stage.value
assert "diagnostic_type" not in summary_error
assert "message" not in summary_error
assert "ERROR_MESSAGE_SECRET" not in render_json_report(review, detail="summary")
assert changes_error["diagnostic_type"] == review.errors[0].error_type
assert changes_error["message"] == review.errors[0].message
def test_projection_preserves_unicode_line_endings_and_exact_coordinates() -> None:
modifier = _replace_modifier(
"🙂Cafe\u0301",
"完成",
modifier_id="test.unicode",
reason="Unicode coordinate test",
)
input_markdown = "\ufeff首行\r\n🙂Cafe\u0301\r尾行\n"
result = Pipeline((modifier,)).transform(input_markdown)
review = build_review_document(input_markdown, result)
projection = review_document_to_dict(review, detail="full")
stage = _object(_array(projection["stages"])[0])
change = _object(_array(stage["changes"])[0])
assert _object(projection["input"])["code_point_length"] == len(input_markdown)
assert _object(projection["input"])["markdown"] == input_markdown
assert change["location"] == {"line": 2, "column": 1}
assert change["span"] == {
"start": input_markdown.index("🙂"),
"end": input_markdown.index("🙂") + len("🙂Cafe\u0301"),
}
def test_json_report_is_deterministic_unicode_json_without_bom_or_final_newline() -> None:
review = _rich_success_review()
first = render_json_report(review, detail="full")
second = render_json_report(review, detail=ReviewDetail.FULL)
assert first == second
assert loads(first) == review_document_to_dict(review, detail="full")
assert not first.startswith("\ufeff")
assert not first.endswith("\n")
assert "中文_SOURCE_ONLY" in first
assert "\\u4e2d" not in first.lower()
assert "\\tTAIL" in first
assert "NaN" not in first
assert "Infinity" not in first
def test_projection_returns_fresh_containers_without_mutating_review() -> None:
review = _rich_success_review()
first = review_document_to_dict(review, detail="full")
second = review_document_to_dict(review, detail="full")
first["schema_name"] = "changed"
_array(first["modifiers"]).clear()
assert second["schema_name"] == "mdpolish.review"
assert len(_array(second["modifiers"])) == 1
assert review.modifiers[0].modifier_id == "test.machine-projection"
assert review_document_to_dict(review, detail="full") == second
@pytest.mark.parametrize("detail", ["", "SUMMARY", "unknown", 1, True, None])
def test_projection_rejects_unknown_detail(detail: object) -> None:
review = _rich_success_review()
with pytest.raises(ReviewProjectionError, match="review projection failed at detail"):
review_document_to_dict(review, detail=detail) # type: ignore[arg-type]
def test_projection_rejects_wrong_review_and_nested_model_types() -> None:
with pytest.raises(ReviewProjectionError, match="ReviewDocument"):
review_document_to_dict(object()) # type: ignore[arg-type]
review = _rich_success_review()
malformed = replace(review, modifiers=cast(tuple[ModifierInfo, ...], (object(),)))
with pytest.raises(ReviewProjectionError, match=r"modifiers\[0\]"):
review_document_to_dict(malformed)
def test_projection_rejects_unknown_enum_invalid_reference_and_large_integer() -> None:
review = _rich_success_review()
unknown_status = replace(review, status=cast(RunStatus, "future"))
bad_stage = replace(review.stages[0], modifier_position=1)
bad_reference = replace(review, stages=(bad_stage,))
review_change = review.stages[0].changes[0]
large_location = replace(
review_change,
location=ReviewLocation(line=_MAX_SAFE_JSON_INTEGER + 1, column=1),
)
large_stage = replace(review.stages[0], changes=(large_location,))
large_integer = replace(review, stages=(large_stage,))
for malformed in (unknown_status, bad_reference, large_integer):
with pytest.raises(ReviewProjectionError):
review_document_to_dict(malformed)
def test_projection_rejects_nonfinite_parameter_and_invalid_unicode_without_leaking_values() -> None:
review = _rich_success_review()
modifier = review.modifiers[0]
nonfinite_modifier = replace(modifier, parameters=(("NONFINITE_SECRET", float("nan")),))
nonfinite_stage = replace(review.stages[0], modifier=nonfinite_modifier)
nonfinite_review = replace(
review,
modifiers=(nonfinite_modifier,),
stages=(nonfinite_stage,),
)
invalid_unicode = replace(review, input_markdown="UNICODE_SECRET\ud800")
with pytest.raises(ReviewProjectionError) as nonfinite_error:
review_document_to_dict(nonfinite_review)
assert "NONFINITE_SECRET" not in str(nonfinite_error.value)
with pytest.raises(ReviewProjectionError) as unicode_error:
review_document_to_dict(invalid_unicode)
assert "UNICODE_SECRET" not in str(unicode_error.value)
def test_empty_document_projection_distinguishes_hidden_and_empty_markdown() -> None:
result = Pipeline(()).transform("")
review = build_review_document("", result)
summary = review_document_to_dict(review, detail="summary")
full = review_document_to_dict(review, detail="full")
assert "markdown" not in _object(summary["input"])
assert _object(full["input"])["markdown"] == ""
assert _object(full["current"])["markdown"] == ""
assert full["modifiers"] == []
assert full["stages"] == []
assert full["errors"] == []
assert full["residual_proposals"] == []
assert review.current_kind is ReviewCurrentKind.SUCCESS_OUTPUT