692 lines
32 KiB
Python
692 lines
32 KiB
Python
"""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)
|
|
],
|
|
}
|