实现本地 Markdown 清洗评审器

This commit is contained in:
2026-08-24 01:02:16 +08:00
parent 80001a8ab9
commit ac798a2610
39 changed files with 9357 additions and 119 deletions
+691
View File
@@ -0,0 +1,691 @@
"""Strict adapters from published mdpolish artifacts to the reviewer API."""
from __future__ import annotations
import json
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from typing import NoReturn, TypeAlias, cast
from mdpolish._artifact_replay import (
LocatedChange,
ReplayChange,
ReplayComponent,
ReplayError,
ReplayResult,
replay_change_chain,
)
JsonValue: TypeAlias = bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] | None
JsonObject: TypeAlias = dict[str, JsonValue]
_STATUSES = {"success", "failed", "unstable"}
_ERROR_STAGES = {"transform", "final_review"}
class ReviewArtifactError(ValueError):
"""Published artifacts cannot be exposed as a trustworthy review response."""
def __init__(self, code: str, message: str, http_status: int = 422) -> None:
super().__init__(message)
self.code = code
self.http_status = http_status
@dataclass(frozen=True, slots=True)
class ComponentRecord:
component_id: str
version: str
parameters: JsonValue
applicability: str
@dataclass(frozen=True, slots=True)
class ManifestDocument:
document_id: str
source_label: str
status: str
input_sha256: str
current_sha256: str
change_count: int
result_path: str
cleaned_path: str | None
diff_path: str | None
@dataclass(frozen=True, slots=True)
class LocatorDocument:
document_id: str
source_path: Path
input_sha256: str
@dataclass(frozen=True, slots=True)
class ChangeRecord:
replay: ReplayChange
reason: str
recorded_line: int
recorded_column: int
@dataclass(frozen=True, slots=True)
class ResultRecord:
status: str
input_sha256: str
current_sha256: str
changes: tuple[ChangeRecord, ...]
errors: tuple[JsonObject, ...]
residual_proposals: tuple[JsonObject, ...]
@dataclass(frozen=True, slots=True)
class DocumentRecord:
manifest: ManifestDocument
result: ResultRecord
locator: LocatorDocument | None
def _fail(code: str, message: str, http_status: int = 422) -> NoReturn:
raise ReviewArtifactError(code, message, http_status)
def _object(value: object, label: str) -> dict[str, object]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
_fail("invalid_artifact", f"{label} 必须是 JSON 对象。")
return cast(dict[str, object], value)
def _array(value: object, label: str) -> list[object]:
if not isinstance(value, list):
_fail("invalid_artifact", f"{label} 必须是数组。")
return cast(list[object], value)
def _string(value: object, label: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str) or "\0" in value or (not allow_empty and not value):
_fail("invalid_artifact", f"{label} 必须是字符串。")
return value
def _integer(value: object, label: str, *, minimum: int = 0) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
_fail("invalid_artifact", f"{label} 必须是不小于 {minimum} 的整数。")
return value
def _hash(value: object, label: str) -> str:
digest = _string(value, label)
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
_fail("invalid_artifact", f"{label} 必须是小写 SHA-256 摘要。")
return digest
def _status(value: object, label: str) -> str:
status = _string(value, label)
if status not in _STATUSES:
_fail("invalid_artifact", f"{label} 不是已知状态。")
return status
def _json_value(value: object, label: str) -> JsonValue:
if value is None or isinstance(value, str | int | float | bool):
return value
if isinstance(value, list):
return [_json_value(item, label) for item in value]
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
return {cast(str, key): _json_value(item, label) for key, item in value.items()}
_fail("invalid_artifact", f"{label} 包含不支持的 JSON 值。")
raise AssertionError("unreachable")
def _read_json(path: Path, label: str) -> dict[str, object]:
try:
content = path.read_bytes()
except OSError:
_fail("missing_artifact", f"无法读取{label}")
if content.startswith(b"\xef\xbb\xbf"):
_fail("invalid_artifact", f"{label} 不能包含 UTF-8 BOM。")
if not content.endswith(b"\n"):
_fail("invalid_artifact", f"{label} 必须以换行结尾。")
try:
text = content.decode("utf-8", errors="strict")
except UnicodeDecodeError:
_fail("invalid_utf8", f"{label} 不是严格 UTF-8。")
try:
payload = cast(
object,
json.loads(
text,
parse_constant=lambda _value: _fail(
"invalid_artifact", f"{label} 不能包含非有限数值。"
),
),
)
except json.JSONDecodeError:
_fail("invalid_artifact", f"{label} 不是合法 JSON。")
return _object(payload, label)
def _read_markdown(path: Path, expected_hash: str, label: str, error_code: str) -> str:
try:
if path.is_symlink() or not path.is_file():
_fail(error_code, f"{label}不是普通文件。", 409)
content = path.read_bytes()
except OSError:
_fail(error_code, f"无法读取{label}", 409)
if sha256(content).hexdigest() != expected_hash:
_fail("hash_mismatch", f"{label}的内容哈希已经变化。", 409)
try:
return content.decode("utf-8", errors="strict")
except UnicodeDecodeError:
_fail("invalid_utf8", f"{label}不是严格 UTF-8。", 409)
raise AssertionError("unreachable")
def _run_path(run_directory: Path, relative_path: str, label: str) -> Path:
relative = Path(relative_path)
if relative.is_absolute() or "\0" in relative_path:
_fail("unsafe_path", f"{label}必须是运行目录内的相对路径。")
unresolved = run_directory / relative
try:
if unresolved.is_symlink() or not unresolved.is_file():
_fail("missing_artifact", f"无法读取{label}")
resolved = unresolved.resolve(strict=True)
except OSError:
_fail("missing_artifact", f"无法读取{label}")
if not resolved.is_relative_to(run_directory):
_fail("unsafe_path", f"{label}越过了运行目录边界。")
return resolved
def _component(value: object, position: int) -> ComponentRecord:
item = _object(value, f"pipeline.components[{position}]")
return ComponentRecord(
component_id=_string(item.get("component_id"), "component_id"),
version=_string(item.get("version"), "component version"),
parameters=_json_value(item.get("parameters"), "component parameters"),
applicability=_string(item.get("applicability"), "component applicability"),
)
def _manifest_document(value: object, position: int) -> ManifestDocument:
item = _object(value, f"manifest.documents[{position}]")
document_id = _string(item.get("document_id"), "document_id")
status = _status(item.get("status"), "document status")
result_path = _string(item.get("result_path"), "result_path")
cleaned_value = item.get("cleaned_path")
diff_value = item.get("diff_path")
cleaned_path = None if cleaned_value is None else _string(cleaned_value, "cleaned_path")
diff_path = None if diff_value is None else _string(diff_value, "diff_path")
base = f"documents/{document_id}"
if (
result_path != f"{base}/result.json"
or cleaned_path != (f"{base}/cleaned.md" if status == "success" else None)
or diff_path != (f"{base}/changes.diff" if status == "success" else None)
):
_fail("invalid_artifact", "manifest 文档产物路径不符合 schema 1。")
return ManifestDocument(
document_id=document_id,
source_label=_string(item.get("source_label"), "source_label"),
status=status,
input_sha256=_hash(item.get("input_sha256"), "input_sha256"),
current_sha256=_hash(item.get("current_sha256"), "current_sha256"),
change_count=_integer(item.get("change_count"), "change_count"),
result_path=result_path,
cleaned_path=cleaned_path,
diff_path=diff_path,
)
def _parse_change(value: object, position: int) -> ChangeRecord:
item = _object(value, f"changes[{position}]")
proposal = _object(item.get("proposal_ref"), "proposal_ref")
span = _object(item.get("span"), "span")
location = _object(item.get("location"), "location")
start = _integer(span.get("start"), "span.start")
end = _integer(span.get("end"), "span.end")
if end < start:
_fail("invalid_artifact", "change span 必须满足 start <= end。")
replay = ReplayChange(
component_id=_string(item.get("component_id"), "change component_id"),
component_version=_string(item.get("component_version"), "change component_version"),
component_position=_integer(item.get("component_position"), "change component_position"),
proposal_component_position=_integer(
proposal.get("component_position"), "proposal component_position"
),
proposal_snapshot_sha256=_hash(proposal.get("snapshot_sha256"), "proposal snapshot_sha256"),
proposal_index=_integer(proposal.get("proposal_index"), "proposal_index"),
edit_index=_integer(item.get("edit_index"), "edit_index"),
start=start,
end=end,
before=_string(item.get("before"), "before", allow_empty=True),
after=_string(item.get("after"), "after", allow_empty=True),
before_sha256=_hash(item.get("before_sha256"), "before_sha256"),
after_sha256=_hash(item.get("after_sha256"), "after_sha256"),
)
return ChangeRecord(
replay=replay,
reason=_string(item.get("reason"), "change reason"),
recorded_line=_integer(location.get("line"), "location.line", minimum=1),
recorded_column=_integer(location.get("column"), "location.column", minimum=1),
)
def _parse_error(value: object, position: int) -> JsonObject:
item = _object(value, f"errors[{position}]")
stage = _string(item.get("stage"), "error stage")
if stage not in _ERROR_STAGES:
_fail("invalid_artifact", "error stage 不是已知阶段。")
return {
"component_id": _string(item.get("component_id"), "error component_id"),
"component_version": _string(item.get("component_version"), "error component_version"),
"component_position": _integer(item.get("component_position"), "error component_position"),
"stage": stage,
"error_type": _string(item.get("error_type"), "error_type"),
"message": _string(item.get("message"), "error message"),
}
def _parse_residual(value: object, position: int) -> JsonObject:
item = _object(value, f"residual_proposals[{position}]")
reference = _object(item.get("proposal_ref"), "residual proposal_ref")
proposal = _object(item.get("proposal"), "residual proposal")
component_position = _integer(item.get("component_position"), "residual component_position")
proposal_snapshot = _hash(proposal.get("snapshot_sha256"), "residual proposal snapshot_sha256")
if (
_integer(reference.get("component_position"), "residual reference component_position")
!= component_position
or _hash(reference.get("snapshot_sha256"), "residual reference snapshot_sha256")
!= proposal_snapshot
):
_fail("invalid_artifact", "residual proposal_ref 与候选身份不一致。")
_integer(reference.get("proposal_index"), "residual proposal_index")
edits = _array(proposal.get("edits"), "residual proposal edits")
if not edits:
_fail("invalid_artifact", "residual proposal edits 不能为空。")
for edit_position, edit_value in enumerate(edits):
edit = _object(edit_value, f"residual edit[{edit_position}]")
span = _object(edit.get("span"), "residual edit span")
if _hash(edit.get("snapshot_sha256"), "residual edit snapshot_sha256") != proposal_snapshot:
_fail("invalid_artifact", "residual edit 与候选快照不一致。")
start = _integer(span.get("start"), "residual span.start")
end = _integer(span.get("end"), "residual span.end")
expected = _string(edit.get("expected_text"), "residual expected_text", allow_empty=True)
_string(edit.get("replacement"), "residual replacement", allow_empty=True)
if end < start or len(expected) != end - start:
_fail("invalid_artifact", "residual edit 范围与 expected_text 不一致。")
return {
"component_id": _string(item.get("component_id"), "residual component_id"),
"component_version": _string(item.get("component_version"), "residual component_version"),
"component_position": component_position,
"reason": _string(proposal.get("reason"), "residual reason"),
"edit_count": len(edits),
}
def _component_json(component: ComponentRecord, position: int, change_count: int) -> JsonObject:
return {
"component_position": position,
"component_id": component.component_id,
"version": component.version,
"parameters": component.parameters,
"applicability": component.applicability,
"change_count": change_count,
}
class ReviewArtifacts:
"""Validated, read-only view over one explicitly selected run directory."""
def __init__(self, run_directory: str | Path) -> None:
requested = Path(run_directory)
try:
if requested.is_symlink() or not requested.is_dir():
_fail("missing_run", "指定的运行目录不存在或不是普通目录。", 400)
self.run_directory = requested.resolve(strict=True)
except OSError:
_fail("missing_run", "无法读取指定的运行目录。", 400)
manifest = _read_json(_run_path(self.run_directory, "manifest.json", "manifest.json"), "manifest.json")
if manifest.get("schema_version") != 1:
_fail("unsupported_schema", "只支持 manifest.json schema 1。", 409)
run = _object(manifest.get("run"), "manifest.run")
pipeline = _object(manifest.get("pipeline"), "manifest.pipeline")
summary = _object(manifest.get("summary"), "manifest.summary")
self.run_id = _string(run.get("run_id"), "run_id")
self.run_json: JsonObject = {
"run_id": self.run_id,
"run_date": _string(run.get("run_date"), "run_date"),
"status": _status(run.get("status"), "run status"),
"started_at_utc": _string(run.get("started_at_utc"), "started_at_utc"),
"completed_at_utc": _string(run.get("completed_at_utc"), "completed_at_utc"),
"retention_until": _string(run.get("retention_until"), "retention_until"),
}
self.components = tuple(
_component(value, position)
for position, value in enumerate(_array(pipeline.get("components"), "pipeline.components"))
)
manifest_documents = tuple(
_manifest_document(value, position)
for position, value in enumerate(_array(manifest.get("documents"), "manifest.documents"))
)
if len({item.document_id for item in manifest_documents}) != len(manifest_documents):
_fail("invalid_artifact", "manifest 中的 document_id 必须唯一。")
self.summary_json: JsonObject = {
"document_count": _integer(summary.get("document_count"), "summary.document_count"),
"success_count": _integer(summary.get("success_count"), "summary.success_count"),
"failed_count": _integer(summary.get("failed_count"), "summary.failed_count"),
"unstable_count": _integer(summary.get("unstable_count"), "summary.unstable_count"),
"change_count": _integer(summary.get("change_count"), "summary.change_count"),
}
expected_summary: JsonObject = {
"document_count": len(manifest_documents),
"success_count": sum(item.status == "success" for item in manifest_documents),
"failed_count": sum(item.status == "failed" for item in manifest_documents),
"unstable_count": sum(item.status == "unstable" for item in manifest_documents),
"change_count": sum(item.change_count for item in manifest_documents),
}
if self.summary_json != expected_summary:
_fail("invalid_artifact", "manifest 汇总与文档索引不一致。")
expected_status = (
"failed"
if expected_summary["failed_count"]
else "unstable"
if expected_summary["unstable_count"]
else "success"
)
if self.run_json["status"] != expected_status:
_fail("invalid_artifact", "manifest 运行状态与文档状态不一致。")
locator_path = self.run_directory / "review-locator.json"
locators: tuple[LocatorDocument, ...] | None = None
self.original_run_location_changed = False
if locator_path.exists():
locator = _read_json(
_run_path(self.run_directory, "review-locator.json", "review-locator.json"),
"review-locator.json",
)
if locator.get("schema_version") != 1:
_fail("unsupported_schema", "只支持 review-locator.json schema 1。", 409)
locator_run = _object(locator.get("run"), "review locator run")
if (
_string(locator_run.get("run_id"), "locator run_id") != self.run_id
or locator_run.get("manifest_path") != "manifest.json"
):
_fail("invalid_artifact", "review locator 与 manifest 运行身份不一致。")
recorded_directory_text = _string(locator_run.get("run_directory"), "locator run_directory")
recorded_directory = Path(recorded_directory_text)
if not recorded_directory.is_absolute() or str(recorded_directory) != str(recorded_directory.resolve()):
_fail("invalid_artifact", "locator run_directory 必须是绝对解析路径。")
self.original_run_location_changed = recorded_directory != self.run_directory
parsed_locators: list[LocatorDocument] = []
for position, value in enumerate(_array(locator.get("documents"), "locator documents")):
item = _object(value, f"locator.documents[{position}]")
source_text = _string(item.get("source_path"), "source_path")
source_path = Path(source_text)
if not source_path.is_absolute() or str(source_path) != str(source_path.resolve()):
_fail("invalid_artifact", "source_path 必须是绝对解析路径。")
parsed_locators.append(
LocatorDocument(
document_id=_string(item.get("document_id"), "locator document_id"),
source_path=source_path,
input_sha256=_hash(item.get("input_sha256"), "locator input_sha256"),
)
)
locators = tuple(parsed_locators)
if len(locators) != len(manifest_documents):
_fail("invalid_artifact", "review locator 文档数量与 manifest 不一致。")
for indexed, located in zip(manifest_documents, locators, strict=True):
if indexed.document_id != located.document_id or indexed.input_sha256 != located.input_sha256:
_fail("invalid_artifact", "review locator 文档身份与 manifest 不一致。")
records: list[DocumentRecord] = []
for position, manifest_document in enumerate(manifest_documents):
result = self._parse_result(manifest_document)
records.append(
DocumentRecord(
manifest=manifest_document,
result=result,
locator=None if locators is None else locators[position],
)
)
self.documents = tuple(records)
self.documents_by_id = {item.manifest.document_id: item for item in self.documents}
def _parse_result(self, manifest: ManifestDocument) -> ResultRecord:
payload = _read_json(_run_path(self.run_directory, manifest.result_path, "result.json"), "result.json")
if payload.get("schema_version") != 1:
_fail("unsupported_schema", "只支持 result.json schema 1。", 409)
document = _object(payload.get("document"), "result.document")
if (
_string(document.get("document_id"), "result document_id") != manifest.document_id
or _string(document.get("source_label"), "result source_label") != manifest.source_label
):
_fail("invalid_artifact", "result 与 manifest 文档身份不一致。")
status = _status(payload.get("status"), "result status")
input_hash = _hash(payload.get("input_sha256"), "result input_sha256")
current_hash = _hash(payload.get("current_sha256"), "result current_sha256")
changes = tuple(
_parse_change(value, position)
for position, value in enumerate(_array(payload.get("changes"), "result changes"))
)
errors = tuple(
_parse_error(value, position)
for position, value in enumerate(_array(payload.get("errors"), "result errors"))
)
residuals = tuple(
_parse_residual(value, position)
for position, value in enumerate(_array(payload.get("residual_proposals"), "result residual_proposals"))
)
output = _object(payload.get("output"), "result output")
cleaned = output.get("cleaned_path")
diff = output.get("diff_path")
expected_cleaned: object = "cleaned.md" if status == "success" else None
expected_diff: object = "changes.diff" if status == "success" else None
if cleaned != expected_cleaned or diff != expected_diff:
_fail("invalid_artifact", "result 状态与输出路径不一致。")
if (
status != manifest.status
or input_hash != manifest.input_sha256
or current_hash != manifest.current_sha256
or len(changes) != manifest.change_count
):
_fail("invalid_artifact", "result 与 manifest 文档索引不一致。")
if status == "success" and (errors or residuals):
_fail("invalid_artifact", "success 文档不能包含错误或残留候选。")
if status == "failed" and (not errors or residuals):
_fail("invalid_artifact", "failed 文档的错误或残留状态不合法。")
if status == "unstable" and (errors or not residuals):
_fail("invalid_artifact", "unstable 文档的错误或残留状态不合法。")
for change in changes:
position = change.replay.component_position
if position >= len(self.components):
_fail("invalid_artifact", "change 没有对应的流水线组件。")
component = self.components[position]
if (
change.replay.component_id != component.component_id
or change.replay.component_version != component.version
):
_fail("invalid_artifact", "change 身份与流水线组件不一致。")
for evidence in (*errors, *residuals):
position_value = evidence["component_position"]
component_id = evidence["component_id"]
component_version = evidence["component_version"]
if not isinstance(position_value, int) or position_value >= len(self.components):
_fail("invalid_artifact", "审计证据没有对应的流水线组件。")
component = self.components[position_value]
if component_id != component.component_id or component_version != component.version:
_fail("invalid_artifact", "审计证据身份与流水线组件不一致。")
if manifest.diff_path is not None:
_run_path(self.run_directory, manifest.diff_path, "changes.diff")
return ResultRecord(status, input_hash, current_hash, changes, errors, residuals)
def _source(self, record: DocumentRecord) -> tuple[str | None, str | None]:
if record.locator is None:
return None, "这次历史运行没有 review-locator.json,无法定位完整原文。"
try:
return (
_read_markdown(
record.locator.source_path,
record.manifest.input_sha256,
"原文",
"unavailable_source",
),
None,
)
except ReviewArtifactError as error:
return None, str(error)
def _cleaned(self, record: DocumentRecord) -> tuple[str | None, str | None]:
if record.manifest.status != "success" or record.manifest.cleaned_path is None:
return None, None
try:
path = _run_path(self.run_directory, record.manifest.cleaned_path, "cleaned.md")
return (
_read_markdown(path, record.manifest.current_sha256, "清洗结果", "missing_artifact"),
None,
)
except ReviewArtifactError as error:
return None, str(error)
def _replay(self, record: DocumentRecord, original: str, cleaned: str | None) -> ReplayResult:
try:
replayed = replay_change_chain(
input_markdown=original,
input_sha256=record.result.input_sha256,
components=tuple(ReplayComponent(item.component_id, item.version) for item in self.components),
changes=tuple(item.replay for item in record.result.changes),
current_sha256=record.result.current_sha256,
current_markdown=cleaned,
include_zero_change_stages=record.result.status == "success",
)
except ReplayError as error:
raise ReviewArtifactError("untrusted_replay", f"产物无法可信重放:{error}", 409) from error
for recorded, located in zip(record.result.changes, replayed.changes, strict=True):
if recorded.recorded_line != located.line or recorded.recorded_column != located.column:
_fail("untrusted_replay", "产物记录的位置与重放快照不一致。", 409)
return replayed
@staticmethod
def _summary_json(
record: DocumentRecord,
original: str | None,
source_error: str | None,
cleaned: str | None,
output_error: str | None,
) -> JsonObject:
return {
"document_id": record.manifest.document_id,
"source_label": record.manifest.source_label,
"status": record.manifest.status,
"input_sha256": record.manifest.input_sha256,
"current_sha256": record.manifest.current_sha256,
"change_count": record.manifest.change_count,
"source_available": original is not None,
"output_available": cleaned is not None,
"availability_error": source_error or output_error,
}
def _summary(self, record: DocumentRecord) -> JsonObject:
original, source_error = self._source(record)
cleaned, output_error = self._cleaned(record)
return self._summary_json(record, original, source_error, cleaned, output_error)
def _component_summaries(self, changes: tuple[ChangeRecord, ...]) -> list[JsonValue]:
counts = [0] * len(self.components)
for change in changes:
if 0 <= change.replay.component_position < len(counts):
counts[change.replay.component_position] += 1
return [_component_json(item, position, counts[position]) for position, item in enumerate(self.components)]
@staticmethod
def _change_json(recorded: ChangeRecord, located: LocatedChange | None) -> JsonObject:
change = recorded.replay
return {
"component_id": change.component_id,
"component_version": change.component_version,
"component_position": change.component_position,
"proposal_ref": {
"component_position": change.proposal_component_position,
"snapshot_sha256": change.proposal_snapshot_sha256,
"proposal_index": change.proposal_index,
},
"edit_index": change.edit_index,
"reason": recorded.reason,
"location": {
"line": recorded.recorded_line if located is None else located.line,
"column": recorded.recorded_column if located is None else located.column,
},
"editor_range": (
None if located is None else {"start": located.editor_start, "end": located.editor_end}
),
"before": change.before,
"after": change.after,
}
def run_summary(self) -> JsonObject:
all_changes = tuple(change for document in self.documents for change in document.result.changes)
return {
"schema_version": 1,
"run": self.run_json,
"components": self._component_summaries(all_changes),
"documents": [self._summary(item) for item in self.documents],
"summary": self.summary_json,
"original_run_location_changed": self.original_run_location_changed,
}
def _document(self, document_id: str) -> DocumentRecord:
record = self.documents_by_id.get(document_id)
if record is None:
_fail("unknown_document", "文档不存在。", 404)
return record
def document_comparison(self, document_id: str) -> JsonObject:
record = self._document(document_id)
original, source_error = self._source(record)
cleaned, output_error = self._cleaned(record)
replayed = None if original is None else self._replay(record, original, cleaned)
located = () if replayed is None else replayed.changes
return {
"schema_version": 1,
"document": self._summary_json(record, original, source_error, cleaned, output_error),
"components": self._component_summaries(record.result.changes),
"original_markdown": original,
"cleaned_markdown": cleaned,
"changes": [
self._change_json(change, located[position] if position < len(located) else None)
for position, change in enumerate(record.result.changes)
],
"errors": list(record.result.errors),
"residual_proposals": list(record.result.residual_proposals),
}
def component_stage(self, document_id: str, component_position: int) -> JsonObject:
record = self._document(document_id)
if record.manifest.status != "success":
_fail("stage_unavailable", "只有 success 文档具有完整组件阶段。", 409)
if component_position < 0 or component_position >= len(self.components):
_fail("unknown_component", "组件位置不存在。", 404)
original, source_error = self._source(record)
cleaned, output_error = self._cleaned(record)
if original is None or cleaned is None:
_fail("comparison_unavailable", source_error or output_error or "组件阶段不可用。", 409)
replayed = self._replay(record, original, cleaned)
stage = replayed.stages[component_position]
component = self.components[component_position]
recorded_changes = tuple(
item for item in record.result.changes if item.replay.component_position == component_position
)
return {
"schema_version": 1,
"document_id": record.manifest.document_id,
"component": _component_json(component, component_position, len(stage.changes)),
"before_sha256": stage.before_sha256,
"after_sha256": stage.after_sha256,
"before_markdown": stage.before_markdown,
"after_markdown": stage.after_markdown,
"changes": [
self._change_json(change, located)
for change, located in zip(recorded_changes, stage.changes, strict=True)
],
}