实现本地 Markdown 清洗评审器
This commit is contained in:
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -0,0 +1 @@
|
||||
"""Repository-local Markdown cleaning reviewer."""
|
||||
@@ -0,0 +1,40 @@
|
||||
import eslint from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "coverage", "node_modules"] },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.flat.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
|
||||
"@typescript-eslint/no-confusing-void-expression": "off",
|
||||
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>mdpolish 评审器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4599
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "mdpolish-reviewer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"lint": "eslint src tests vite.config.ts",
|
||||
"test": "vitest run",
|
||||
"check": "npm run lint && npm run test && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/merge": "6.12.2",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@vitejs/plugin-react": "6.1.0",
|
||||
"eslint": "10.9.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.4",
|
||||
"globals": "17.11.0",
|
||||
"jsdom": "30.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Read-only artifact adapter and local HTTP service."""
|
||||
|
||||
from reviewer.server.artifacts import ReviewArtifactError, ReviewArtifacts
|
||||
|
||||
__all__ = ["ReviewArtifactError", "ReviewArtifacts"]
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Serve one local review run through a loopback-only read-only HTTP API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from reviewer.server.artifacts import JsonObject, ReviewArtifactError, ReviewArtifacts
|
||||
|
||||
_SECURITY_HEADERS = {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Security-Policy": (
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'none'; "
|
||||
"font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
|
||||
),
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
}
|
||||
|
||||
|
||||
class ReviewerHttpServer(ThreadingHTTPServer):
|
||||
"""Threaded local server whose workers never keep process shutdown alive."""
|
||||
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
def _valid_local_request(handler: BaseHTTPRequestHandler) -> bool:
|
||||
host = handler.headers.get("Host")
|
||||
if host is None:
|
||||
return False
|
||||
try:
|
||||
parsed_host = urlsplit(f"//{host}")
|
||||
if parsed_host.username is not None or parsed_host.password is not None:
|
||||
return False
|
||||
if parsed_host.hostname not in {"127.0.0.1", "localhost"}:
|
||||
return False
|
||||
if parsed_host.port is not None and not 0 < parsed_host.port < 65536:
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
origin = handler.headers.get("Origin")
|
||||
if origin is None:
|
||||
return True
|
||||
try:
|
||||
parsed_origin = urlsplit(origin)
|
||||
return parsed_origin.scheme == "http" and parsed_origin.netloc == host
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _api_response(repository: ReviewArtifacts, path: str) -> JsonObject:
|
||||
if path == "/api/v1/run":
|
||||
return repository.run_summary()
|
||||
parts = [part for part in path.split("/") if part]
|
||||
try:
|
||||
if len(parts) == 4 and parts[:3] == ["api", "v1", "documents"]:
|
||||
return repository.document_comparison(unquote(parts[3], encoding="utf-8", errors="strict"))
|
||||
if len(parts) == 6 and parts[:3] == ["api", "v1", "documents"] and parts[4] == "components":
|
||||
document_id = unquote(parts[3], encoding="utf-8", errors="strict")
|
||||
try:
|
||||
component_position = int(parts[5])
|
||||
except ValueError:
|
||||
raise ReviewArtifactError("unknown_component", "组件位置不存在。", 404) from None
|
||||
return repository.component_stage(document_id, component_position)
|
||||
except UnicodeDecodeError:
|
||||
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404) from None
|
||||
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404)
|
||||
|
||||
|
||||
def _handler_factory(repository: ReviewArtifacts, static_root: Path) -> type[BaseHTTPRequestHandler]:
|
||||
class ReviewRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "mdpolish-reviewer"
|
||||
sys_version = ""
|
||||
|
||||
def log_message(self, format_: str, *args: Any) -> None:
|
||||
del format_, args
|
||||
|
||||
def _headers(self, status: int, content_type: str, content_length: int) -> None:
|
||||
self.send_response(status)
|
||||
for name, value in _SECURITY_HEADERS.items():
|
||||
self.send_header(name, value)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(content_length))
|
||||
self.end_headers()
|
||||
|
||||
def _json(self, status: int, payload: JsonObject, *, head_only: bool) -> None:
|
||||
content = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
|
||||
self._headers(status, "application/json; charset=utf-8", len(content))
|
||||
if not head_only:
|
||||
self.wfile.write(content)
|
||||
|
||||
def _error(self, error: Exception, *, head_only: bool) -> None:
|
||||
if isinstance(error, ReviewArtifactError):
|
||||
status = error.http_status
|
||||
code = error.code
|
||||
message = str(error)
|
||||
else:
|
||||
status = 500
|
||||
code = "internal_error"
|
||||
message = "评审器无法完成该请求。"
|
||||
self._json(status, {"error": {"code": code, "message": message}}, head_only=head_only)
|
||||
|
||||
def _static(self, path: str, *, head_only: bool) -> None:
|
||||
requested = "index.html" if path == "/" else unquote(path[1:], encoding="utf-8", errors="strict")
|
||||
if "\0" in requested:
|
||||
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404)
|
||||
candidate = static_root / requested
|
||||
try:
|
||||
if candidate.is_symlink():
|
||||
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404)
|
||||
resolved = candidate.resolve(strict=True)
|
||||
if not resolved.is_relative_to(static_root) or not resolved.is_file():
|
||||
raise FileNotFoundError
|
||||
except (FileNotFoundError, OSError):
|
||||
if Path(requested).suffix:
|
||||
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404) from None
|
||||
resolved = (static_root / "index.html").resolve(strict=True)
|
||||
content = resolved.read_bytes()
|
||||
content_type = mimetypes.guess_type(resolved.name)[0] or "application/octet-stream"
|
||||
if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
|
||||
content_type += "; charset=utf-8"
|
||||
self._headers(200, content_type, len(content))
|
||||
if not head_only:
|
||||
self.wfile.write(content)
|
||||
|
||||
def _handle(self, method: str) -> None:
|
||||
head_only = method == "HEAD"
|
||||
if method not in {"GET", "HEAD"}:
|
||||
self.send_response(405)
|
||||
for name, value in _SECURITY_HEADERS.items():
|
||||
self.send_header(name, value)
|
||||
self.send_header("Allow", "GET, HEAD")
|
||||
payload: JsonObject = {
|
||||
"error": {"code": "method_not_allowed", "message": "只允许 GET 和 HEAD。"}
|
||||
}
|
||||
content = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
return
|
||||
try:
|
||||
if not _valid_local_request(self):
|
||||
raise ReviewArtifactError("invalid_origin", "只接受本机同源请求。", 403)
|
||||
request_path = urlsplit(self.path).path
|
||||
if request_path.startswith("/api/"):
|
||||
self._json(200, _api_response(repository, request_path), head_only=head_only)
|
||||
else:
|
||||
self._static(request_path, head_only=head_only)
|
||||
except Exception as error: # the response intentionally hides unexpected implementation details
|
||||
self._error(error, head_only=head_only)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._handle("GET")
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
self._handle("HEAD")
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._handle("POST")
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
self._handle("PUT")
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
self._handle("PATCH")
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
self._handle("DELETE")
|
||||
|
||||
def do_OPTIONS(self) -> None:
|
||||
self._handle("OPTIONS")
|
||||
|
||||
return ReviewRequestHandler
|
||||
|
||||
|
||||
def create_server(
|
||||
repository: ReviewArtifacts,
|
||||
static_root: Path,
|
||||
*,
|
||||
port: int = 0,
|
||||
) -> ReviewerHttpServer:
|
||||
"""Create, but do not start, the loopback reviewer server."""
|
||||
index = static_root / "index.html"
|
||||
if not index.is_file():
|
||||
raise ReviewArtifactError("missing_build", "未找到前端构建结果,请先运行 npm run build。", 400)
|
||||
return ReviewerHttpServer(("127.0.0.1", port), _handler_factory(repository, static_root.resolve()))
|
||||
|
||||
|
||||
def _arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="只读查看一次 mdpolish 本地清洗运行。")
|
||||
parser.add_argument("--run-dir", required=True, help="一次已发布运行目录的绝对或相对路径")
|
||||
parser.add_argument("--port", type=int, default=0, help="本机端口;默认 0 表示自动选择")
|
||||
arguments = parser.parse_args(argv)
|
||||
if arguments.port < 0 or arguments.port > 65535:
|
||||
parser.error("--port 必须在 0 到 65535 之间")
|
||||
return arguments
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
arguments = _arguments(argv)
|
||||
repository = ReviewArtifacts(arguments.run_dir)
|
||||
static_root = Path(__file__).resolve().parents[1] / "dist"
|
||||
try:
|
||||
server = create_server(repository, static_root, port=arguments.port)
|
||||
except OSError as error:
|
||||
raise ReviewArtifactError("server_error", "无法启动本地评审服务。", 500) from error
|
||||
port = server.server_address[1]
|
||||
print(
|
||||
f"mdpolish 评审器已启动:http://127.0.0.1:{port}({repository.run_id},"
|
||||
f"{len(repository.documents)} 份文档)",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except ReviewArtifactError as error:
|
||||
raise SystemExit(f"评审器启动失败:{error}") from None
|
||||
@@ -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)
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
ChangeDetail,
|
||||
ComponentStageResponse,
|
||||
DocumentComparisonResponse,
|
||||
RunStatus,
|
||||
RunSummaryResponse,
|
||||
} from "../shared/api.js";
|
||||
import { fetchComponentStage, fetchDocument, fetchRun } from "./api-client.js";
|
||||
|
||||
const DiffView = lazy(async () => {
|
||||
const module = await import("./DiffView.js");
|
||||
return { default: module.DiffView };
|
||||
});
|
||||
|
||||
interface AsyncState<T> {
|
||||
loading: boolean;
|
||||
value: T | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const emptyState = <T,>(): AsyncState<T> => ({ loading: true, value: null, error: null });
|
||||
|
||||
function statusLabel(status: RunStatus): string {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "成功";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "unstable":
|
||||
return "不稳定";
|
||||
}
|
||||
}
|
||||
|
||||
function shortHash(hash: string): string {
|
||||
return `${hash.slice(0, 8)}…${hash.slice(-6)}`;
|
||||
}
|
||||
|
||||
function ErrorPanel({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="state-panel state-panel--error" role="alert">
|
||||
<span className="eyebrow">无法显示</span>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingPanel() {
|
||||
return (
|
||||
<div className="state-panel" role="status">
|
||||
<span className="loading-dot" />
|
||||
<p>正在校验本地运行产物…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeList({
|
||||
changes,
|
||||
onSelect,
|
||||
canJump,
|
||||
}: {
|
||||
changes: ChangeDetail[];
|
||||
onSelect: (change: ChangeDetail) => void;
|
||||
canJump: boolean;
|
||||
}) {
|
||||
if (changes.length === 0) {
|
||||
return <p className="quiet-message">这个组件运行过,但没有修改当前文档。</p>;
|
||||
}
|
||||
return (
|
||||
<ol className="change-list">
|
||||
{changes.map((change) => (
|
||||
<li key={`${change.proposal_ref.snapshot_sha256}-${change.proposal_ref.proposal_index}-${change.edit_index}`}>
|
||||
<button type="button" onClick={() => onSelect(change)} disabled={!canJump}>
|
||||
<span className="change-location">
|
||||
第 {change.location.line} 行,第 {change.location.column} 列 · 候选
|
||||
{change.proposal_ref.proposal_index + 1} / 编辑 {change.edit_index + 1}
|
||||
</span>
|
||||
<strong>{change.reason}</strong>
|
||||
<span className="change-sample">
|
||||
<del>{change.before || "∅"}</del>
|
||||
<span aria-hidden="true">→</span>
|
||||
<ins>{change.after || "∅"}</ins>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [runState, setRunState] = useState<AsyncState<RunSummaryResponse>>(emptyState);
|
||||
const [selectedDocument, setSelectedDocument] = useState<string | null>(null);
|
||||
const [documentState, setDocumentState] = useState<AsyncState<DocumentComparisonResponse>>({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: null,
|
||||
});
|
||||
const [selectedComponent, setSelectedComponent] = useState<number | null>(null);
|
||||
const [stageState, setStageState] = useState<AsyncState<ComponentStageResponse>>({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: null,
|
||||
});
|
||||
const [focusRange, setFocusRange] = useState<{ start: number; end: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchRun(controller.signal)
|
||||
.then((run) => {
|
||||
setRunState({ loading: false, value: run, error: null });
|
||||
setSelectedDocument(run.documents[0]?.document_id ?? null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setRunState({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : "无法读取运行摘要。",
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedComponent(null);
|
||||
setFocusRange(null);
|
||||
if (selectedDocument === null) {
|
||||
setDocumentState({ loading: false, value: null, error: null });
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setDocumentState(emptyState());
|
||||
fetchDocument(selectedDocument, controller.signal)
|
||||
.then((document) => setDocumentState({ loading: false, value: document, error: null }))
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setDocumentState({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : "无法读取文档。",
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [selectedDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDocument === null || selectedComponent === null) {
|
||||
setStageState({ loading: false, value: null, error: null });
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setStageState(emptyState());
|
||||
fetchComponentStage(selectedDocument, selectedComponent, controller.signal)
|
||||
.then((stage) => setStageState({ loading: false, value: stage, error: null }))
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setStageState({
|
||||
loading: false,
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : "无法读取组件阶段。",
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [selectedDocument, selectedComponent]);
|
||||
|
||||
const visibleChanges = useMemo(() => {
|
||||
const document = documentState.value;
|
||||
if (document === null) {
|
||||
return [];
|
||||
}
|
||||
if (selectedComponent === null) {
|
||||
return document.changes;
|
||||
}
|
||||
return document.changes.filter((change) => change.component_position === selectedComponent);
|
||||
}, [documentState.value, selectedComponent]);
|
||||
|
||||
const selectChange = (change: ChangeDetail): void => {
|
||||
setSelectedComponent(change.component_position);
|
||||
setFocusRange(change.editor_range);
|
||||
};
|
||||
|
||||
if (runState.loading) {
|
||||
return <LoadingPanel />;
|
||||
}
|
||||
if (runState.error !== null || runState.value === null) {
|
||||
return <ErrorPanel message={runState.error ?? "运行摘要为空。"} />;
|
||||
}
|
||||
|
||||
const run = runState.value;
|
||||
const document = documentState.value;
|
||||
const selectedSummary = run.documents.find((item) => item.document_id === selectedDocument);
|
||||
const selectedStage = stageState.value;
|
||||
const canCompare =
|
||||
document?.document.status === "success" &&
|
||||
document.document.source_available &&
|
||||
document.document.output_available &&
|
||||
document.original_markdown !== null &&
|
||||
document.cleaned_markdown !== null;
|
||||
const beforeText = selectedStage?.before_markdown ?? document?.original_markdown ?? "";
|
||||
const afterText = selectedStage?.after_markdown ?? document?.cleaned_markdown ?? "";
|
||||
const beforeLabel = selectedStage === null ? "清洗前" : `组件 ${selectedStage.component.component_position + 1} 执行前`;
|
||||
const afterLabel = selectedStage === null ? "清洗后" : `组件 ${selectedStage.component.component_position + 1} 执行后`;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<span className="brand-mark">md</span>
|
||||
<div>
|
||||
<p className="eyebrow">本地清洗评审器</p>
|
||||
<h1>{run.run.run_id}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="run-facts">
|
||||
<span className={`status status--${run.run.status}`}>{statusLabel(run.run.status)}</span>
|
||||
<span>{run.summary.document_count} 份文档</span>
|
||||
<span>{run.summary.change_count} 条修改</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{run.original_run_location_changed ? (
|
||||
<div className="notice" role="status">
|
||||
这次运行目录已被移动;评审器使用你本次指定的目录读取产物。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="layout">
|
||||
<aside className="sidebar" aria-label="运行导航">
|
||||
<section>
|
||||
<div className="section-heading">
|
||||
<h2>文档</h2>
|
||||
<span>{run.documents.length}</span>
|
||||
</div>
|
||||
<nav className="document-list" aria-label="文档列表">
|
||||
{run.documents.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.document_id}
|
||||
className={item.document_id === selectedDocument ? "is-active" : ""}
|
||||
onClick={() => setSelectedDocument(item.document_id)}
|
||||
aria-current={item.document_id === selectedDocument ? "page" : undefined}
|
||||
>
|
||||
<span className={`status-dot status-dot--${item.status}`} />
|
||||
<span>
|
||||
<strong>{item.source_label}</strong>
|
||||
<small>{item.change_count} 条修改</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section className="component-section">
|
||||
<div className="section-heading">
|
||||
<h2>组件时间线</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => {
|
||||
setSelectedComponent(null);
|
||||
setFocusRange(null);
|
||||
}}
|
||||
disabled={selectedComponent === null}
|
||||
>
|
||||
查看总结果
|
||||
</button>
|
||||
</div>
|
||||
<ol className="component-list">
|
||||
{(document?.components ?? run.components).map((component) => (
|
||||
<li key={component.component_id}>
|
||||
<button
|
||||
type="button"
|
||||
className={component.component_position === selectedComponent ? "is-active" : ""}
|
||||
onClick={() => {
|
||||
setSelectedComponent(component.component_position);
|
||||
setFocusRange(null);
|
||||
}}
|
||||
disabled={!canCompare}
|
||||
>
|
||||
<span className="component-index">{component.component_position + 1}</span>
|
||||
<span>
|
||||
<strong>{component.component_id}</strong>
|
||||
<small>
|
||||
v{component.version} · {component.change_count} 条
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main className="workspace">
|
||||
<section className="document-header">
|
||||
<div>
|
||||
<p className="eyebrow">当前文档</p>
|
||||
<h2>{selectedSummary?.source_label ?? "未选择"}</h2>
|
||||
</div>
|
||||
{selectedSummary === undefined ? null : (
|
||||
<div className="document-meta">
|
||||
<span className={`status status--${selectedSummary.status}`}>
|
||||
{statusLabel(selectedSummary.status)}
|
||||
</span>
|
||||
<span title={selectedSummary.input_sha256}>输入 {shortHash(selectedSummary.input_sha256)}</span>
|
||||
<span title={selectedSummary.current_sha256}>当前 {shortHash(selectedSummary.current_sha256)}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{documentState.loading || stageState.loading ? <LoadingPanel /> : null}
|
||||
{documentState.error !== null ? <ErrorPanel message={documentState.error} /> : null}
|
||||
{stageState.error !== null ? <ErrorPanel message={stageState.error} /> : null}
|
||||
|
||||
{!documentState.loading && document !== null && document.document.status === "success" && !canCompare ? (
|
||||
<ErrorPanel message={document.document.availability_error ?? "完整原文或清洗结果不可用。"} />
|
||||
) : null}
|
||||
|
||||
{!documentState.loading && document !== null && document.document.status !== "success" ? (
|
||||
<div className="diagnostic-panel">
|
||||
<p className="eyebrow">没有正式清洗结果</p>
|
||||
<h3>{statusLabel(document.document.status)}文档只展示审计证据</h3>
|
||||
<p>
|
||||
该状态不会生成 <code>cleaned.md</code>,因此这里不构造完整部分输出。
|
||||
</p>
|
||||
{document.errors.map((error) => (
|
||||
<article key={`${error.component_position}-${error.stage}-${error.error_type}`}>
|
||||
<strong>{error.error_type}</strong>
|
||||
<span>{error.message}</span>
|
||||
</article>
|
||||
))}
|
||||
{document.residual_proposals.map((proposal) => (
|
||||
<article key={`${proposal.component_position}-${proposal.reason}`}>
|
||||
<strong>最终复查仍有 {proposal.edit_count} 项候选</strong>
|
||||
<span>{proposal.reason}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!documentState.loading && !stageState.loading && canCompare && stageState.error === null ? (
|
||||
<Suspense fallback={<LoadingPanel />}>
|
||||
<DiffView
|
||||
before={beforeText}
|
||||
after={afterText}
|
||||
beforeLabel={beforeLabel}
|
||||
afterLabel={afterLabel}
|
||||
focusRange={focusRange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{document !== null ? (
|
||||
<section className="changes-panel" aria-label="修改详情">
|
||||
<div className="changes-heading">
|
||||
<div>
|
||||
<p className="eyebrow">实际修改</p>
|
||||
<h3>
|
||||
{selectedComponent === null
|
||||
? `全部组件 · ${visibleChanges.length} 条`
|
||||
: `${document.components[selectedComponent]?.component_id ?? "组件"} · ${visibleChanges.length} 条`}
|
||||
</h3>
|
||||
</div>
|
||||
{selectedStage === null ? null : <p>{selectedStage.component.applicability}</p>}
|
||||
</div>
|
||||
<ChangeList changes={visibleChanges} onSelect={selectChange} canJump={canCompare} />
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { MergeView } from "@codemirror/merge";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView, lineNumbers } from "@codemirror/view";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface DiffViewProps {
|
||||
before: string;
|
||||
after: string;
|
||||
beforeLabel: string;
|
||||
afterLabel: string;
|
||||
focusRange?: { start: number; end: number } | null;
|
||||
}
|
||||
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
backgroundColor: "#fbfaf7",
|
||||
color: "#262822",
|
||||
fontSize: "13px",
|
||||
},
|
||||
".cm-scroller": {
|
||||
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
|
||||
lineHeight: "1.68",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#f2f0ea",
|
||||
color: "#8a877e",
|
||||
border: "none",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "18px 0 36px",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0 14px",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "2px solid #a7b9ac",
|
||||
outlineOffset: "-2px",
|
||||
},
|
||||
});
|
||||
|
||||
const readOnlyExtensions = [
|
||||
lineNumbers(),
|
||||
markdown(),
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.editable.of(false),
|
||||
EditorView.lineWrapping,
|
||||
editorTheme,
|
||||
];
|
||||
|
||||
export function DiffView({ before, after, beforeLabel, afterLabel, focusRange }: DiffViewProps) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const merge = useRef<MergeView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (host.current === null) {
|
||||
return undefined;
|
||||
}
|
||||
const view = new MergeView({
|
||||
parent: host.current,
|
||||
a: { doc: before, extensions: readOnlyExtensions },
|
||||
b: { doc: after, extensions: readOnlyExtensions },
|
||||
orientation: "a-b",
|
||||
gutter: true,
|
||||
highlightChanges: true,
|
||||
collapseUnchanged: { margin: 4, minSize: 8 },
|
||||
});
|
||||
merge.current = view;
|
||||
return () => {
|
||||
view.destroy();
|
||||
merge.current = null;
|
||||
};
|
||||
}, [before, after]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = merge.current;
|
||||
if (view === null || focusRange === null || focusRange === undefined) {
|
||||
return;
|
||||
}
|
||||
const anchor = Math.min(Math.max(focusRange.start, 0), view.a.state.doc.length);
|
||||
const head = Math.min(Math.max(focusRange.end, anchor), view.a.state.doc.length);
|
||||
view.a.dispatch({
|
||||
selection: { anchor, head },
|
||||
effects: EditorView.scrollIntoView(anchor, { y: "center" }),
|
||||
});
|
||||
view.a.focus();
|
||||
}, [focusRange]);
|
||||
|
||||
return (
|
||||
<section className="diff-shell" aria-label={`${beforeLabel}与${afterLabel}对比`}>
|
||||
<div className="diff-labels" aria-hidden="true">
|
||||
<span>{beforeLabel}</span>
|
||||
<span>{afterLabel}</span>
|
||||
</div>
|
||||
<div className="diff-host" ref={host} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
ComponentStageResponse,
|
||||
DocumentComparisonResponse,
|
||||
RunSummaryResponse,
|
||||
} from "../shared/api.js";
|
||||
|
||||
export class ReviewerApiError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "ReviewerApiError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function invalid(label: string): never {
|
||||
throw new ReviewerApiError("invalid_response", `本地服务返回的 ${label} 格式不正确。`);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): JsonRecord {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return invalid(label);
|
||||
}
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return invalid(label);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string") {
|
||||
return invalid(label);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, minimum = 0): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
||||
return invalid(label);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
return invalid(label);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : string(value, label);
|
||||
}
|
||||
|
||||
function hash(value: unknown, label: string): string {
|
||||
const digest = string(value, label);
|
||||
if (!/^[0-9a-f]{64}$/.test(digest)) {
|
||||
return invalid(label);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function status(value: unknown): "success" | "failed" | "unstable" {
|
||||
if (value !== "success" && value !== "failed" && value !== "unstable") {
|
||||
return invalid("status");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function component(value: unknown): RunSummaryResponse["components"][number] {
|
||||
const item = record(value, "component");
|
||||
return {
|
||||
component_position: integer(item.component_position, "component_position"),
|
||||
component_id: string(item.component_id, "component_id"),
|
||||
version: string(item.version, "component version"),
|
||||
parameters: item.parameters,
|
||||
applicability: string(item.applicability, "component applicability"),
|
||||
change_count: integer(item.change_count, "component change_count"),
|
||||
};
|
||||
}
|
||||
|
||||
function documentSummary(value: unknown): RunSummaryResponse["documents"][number] {
|
||||
const item = record(value, "document summary");
|
||||
return {
|
||||
document_id: string(item.document_id, "document_id"),
|
||||
source_label: string(item.source_label, "source_label"),
|
||||
status: status(item.status),
|
||||
input_sha256: hash(item.input_sha256, "input_sha256"),
|
||||
current_sha256: hash(item.current_sha256, "current_sha256"),
|
||||
change_count: integer(item.change_count, "document change_count"),
|
||||
source_available: boolean(item.source_available, "source_available"),
|
||||
output_available: boolean(item.output_available, "output_available"),
|
||||
availability_error: nullableString(item.availability_error, "availability_error"),
|
||||
};
|
||||
}
|
||||
|
||||
function summary(value: unknown): RunSummaryResponse["summary"] {
|
||||
const item = record(value, "summary");
|
||||
return {
|
||||
document_count: integer(item.document_count, "document_count"),
|
||||
success_count: integer(item.success_count, "success_count"),
|
||||
failed_count: integer(item.failed_count, "failed_count"),
|
||||
unstable_count: integer(item.unstable_count, "unstable_count"),
|
||||
change_count: integer(item.change_count, "change_count"),
|
||||
};
|
||||
}
|
||||
|
||||
function change(value: unknown): DocumentComparisonResponse["changes"][number] {
|
||||
const item = record(value, "change");
|
||||
const proposal = record(item.proposal_ref, "proposal_ref");
|
||||
const location = record(item.location, "location");
|
||||
const editorValue = item.editor_range;
|
||||
const editorRange =
|
||||
editorValue === null
|
||||
? null
|
||||
: (() => {
|
||||
const editor = record(editorValue, "editor_range");
|
||||
const start = integer(editor.start, "editor_range.start");
|
||||
const end = integer(editor.end, "editor_range.end");
|
||||
if (end < start) {
|
||||
return invalid("editor_range");
|
||||
}
|
||||
return { start, end };
|
||||
})();
|
||||
return {
|
||||
component_id: string(item.component_id, "change component_id"),
|
||||
component_version: string(item.component_version, "change component_version"),
|
||||
component_position: integer(item.component_position, "change component_position"),
|
||||
proposal_ref: {
|
||||
component_position: integer(proposal.component_position, "proposal component_position"),
|
||||
snapshot_sha256: hash(proposal.snapshot_sha256, "proposal snapshot_sha256"),
|
||||
proposal_index: integer(proposal.proposal_index, "proposal_index"),
|
||||
},
|
||||
edit_index: integer(item.edit_index, "edit_index"),
|
||||
reason: string(item.reason, "reason"),
|
||||
location: {
|
||||
line: integer(location.line, "location.line", 1),
|
||||
column: integer(location.column, "location.column", 1),
|
||||
},
|
||||
editor_range: editorRange,
|
||||
before: string(item.before, "before"),
|
||||
after: string(item.after, "after"),
|
||||
};
|
||||
}
|
||||
|
||||
function runError(value: unknown): DocumentComparisonResponse["errors"][number] {
|
||||
const item = record(value, "run error");
|
||||
const stage = item.stage;
|
||||
if (stage !== "transform" && stage !== "final_review") {
|
||||
return invalid("error stage");
|
||||
}
|
||||
return {
|
||||
component_id: string(item.component_id, "error component_id"),
|
||||
component_version: string(item.component_version, "error component_version"),
|
||||
component_position: integer(item.component_position, "error component_position"),
|
||||
stage,
|
||||
error_type: string(item.error_type, "error_type"),
|
||||
message: string(item.message, "error message"),
|
||||
};
|
||||
}
|
||||
|
||||
function residual(value: unknown): DocumentComparisonResponse["residual_proposals"][number] {
|
||||
const item = record(value, "residual proposal");
|
||||
return {
|
||||
component_id: string(item.component_id, "residual component_id"),
|
||||
component_version: string(item.component_version, "residual component_version"),
|
||||
component_position: integer(item.component_position, "residual component_position"),
|
||||
reason: string(item.reason, "residual reason"),
|
||||
edit_count: integer(item.edit_count, "residual edit_count", 1),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRun(value: unknown): RunSummaryResponse {
|
||||
const payload = record(value, "run response");
|
||||
if (payload.schema_version !== 1) {
|
||||
return invalid("run schema_version");
|
||||
}
|
||||
const run = record(payload.run, "run");
|
||||
return {
|
||||
schema_version: 1,
|
||||
run: {
|
||||
run_id: string(run.run_id, "run_id"),
|
||||
run_date: string(run.run_date, "run_date"),
|
||||
status: status(run.status),
|
||||
started_at_utc: string(run.started_at_utc, "started_at_utc"),
|
||||
completed_at_utc: string(run.completed_at_utc, "completed_at_utc"),
|
||||
retention_until: string(run.retention_until, "retention_until"),
|
||||
},
|
||||
components: array(payload.components, "components").map(component),
|
||||
documents: array(payload.documents, "documents").map(documentSummary),
|
||||
summary: summary(payload.summary),
|
||||
original_run_location_changed: boolean(
|
||||
payload.original_run_location_changed,
|
||||
"original_run_location_changed",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDocument(value: unknown): DocumentComparisonResponse {
|
||||
const payload = record(value, "document response");
|
||||
if (payload.schema_version !== 1) {
|
||||
return invalid("document schema_version");
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
document: documentSummary(payload.document),
|
||||
components: array(payload.components, "components").map(component),
|
||||
original_markdown: nullableString(payload.original_markdown, "original_markdown"),
|
||||
cleaned_markdown: nullableString(payload.cleaned_markdown, "cleaned_markdown"),
|
||||
changes: array(payload.changes, "changes").map(change),
|
||||
errors: array(payload.errors, "errors").map(runError),
|
||||
residual_proposals: array(payload.residual_proposals, "residual_proposals").map(residual),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStage(value: unknown): ComponentStageResponse {
|
||||
const payload = record(value, "component stage response");
|
||||
if (payload.schema_version !== 1) {
|
||||
return invalid("component stage schema_version");
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
document_id: string(payload.document_id, "document_id"),
|
||||
component: component(payload.component),
|
||||
before_sha256: hash(payload.before_sha256, "before_sha256"),
|
||||
after_sha256: hash(payload.after_sha256, "after_sha256"),
|
||||
before_markdown: string(payload.before_markdown, "before_markdown"),
|
||||
after_markdown: string(payload.after_markdown, "after_markdown"),
|
||||
changes: array(payload.changes, "changes").map(change),
|
||||
};
|
||||
}
|
||||
|
||||
async function getJson<T>(
|
||||
pathname: string,
|
||||
parse: (payload: unknown) => T,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const response = await fetch(pathname, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal,
|
||||
});
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorPayload = payload as Partial<ApiErrorResponse>;
|
||||
throw new ReviewerApiError(
|
||||
errorPayload.error?.code ?? "request_failed",
|
||||
errorPayload.error?.message ?? `请求失败(HTTP ${response.status})。`,
|
||||
);
|
||||
}
|
||||
return parse(payload);
|
||||
}
|
||||
|
||||
export function fetchRun(signal?: AbortSignal): Promise<RunSummaryResponse> {
|
||||
return getJson("/api/v1/run", parseRun, signal);
|
||||
}
|
||||
|
||||
export function fetchDocument(
|
||||
documentId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DocumentComparisonResponse> {
|
||||
return getJson(
|
||||
`/api/v1/documents/${encodeURIComponent(documentId)}`,
|
||||
parseDocument,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchComponentStage(
|
||||
documentId: string,
|
||||
componentPosition: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ComponentStageResponse> {
|
||||
return getJson(
|
||||
`/api/v1/documents/${encodeURIComponent(documentId)}/components/${componentPosition}`,
|
||||
parseStage,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App.js";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) {
|
||||
throw new Error("missing #root element");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,554 @@
|
||||
:root {
|
||||
color: #252720;
|
||||
background: #ecebe5;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-width: 1180px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid #315f4b;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, rgb(255 255 255 / 72%), transparent 34%),
|
||||
#ecebe5;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
display: flex;
|
||||
min-height: 76px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid #d7d5cd;
|
||||
background: rgb(248 247 242 / 94%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.topbar > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: #284d3d;
|
||||
color: #f3f4ed;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 3px;
|
||||
color: #78796f;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-family: Georgia, "Songti SC", serif;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.run-facts,
|
||||
.document-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #66685f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.run-facts > span:not(.status),
|
||||
.document-meta > span:not(.status) {
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid #d5d2c9;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status--success {
|
||||
color: #277052;
|
||||
background: #edf6ef;
|
||||
}
|
||||
|
||||
.status--failed {
|
||||
color: #a04338;
|
||||
background: #fff0ed;
|
||||
}
|
||||
|
||||
.status--unstable {
|
||||
color: #986617;
|
||||
background: #fff7df;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 9px 24px;
|
||||
border-bottom: 1px solid #e6d09c;
|
||||
background: #fff7de;
|
||||
color: #77561d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
min-height: calc(100vh - 76px);
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 76px);
|
||||
border-right: 1px solid #d7d5cd;
|
||||
background: #f7f6f1;
|
||||
}
|
||||
|
||||
.sidebar section {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.sidebar section + section {
|
||||
border-top: 1px solid #dfddd5;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-heading > span {
|
||||
color: #888980;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.document-list,
|
||||
.component-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.document-list button,
|
||||
.component-list button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.document-list button {
|
||||
grid-template-columns: 9px 1fr;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.document-list button:hover,
|
||||
.component-list button:hover:not(:disabled) {
|
||||
background: #eceae2;
|
||||
}
|
||||
|
||||
.document-list button.is-active,
|
||||
.component-list button.is-active {
|
||||
background: #e0e8e0;
|
||||
color: #234b39;
|
||||
}
|
||||
|
||||
.document-list strong,
|
||||
.component-list strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-list small,
|
||||
.component-list small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #7d7e75;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #999;
|
||||
}
|
||||
|
||||
.status-dot--success {
|
||||
background: #348361;
|
||||
}
|
||||
|
||||
.status-dot--failed {
|
||||
background: #b64b3f;
|
||||
}
|
||||
|
||||
.status-dot--unstable {
|
||||
background: #bd831c;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #315f4b;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.text-button:disabled {
|
||||
color: #aaa99f;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.component-list {
|
||||
counter-reset: components;
|
||||
}
|
||||
|
||||
.component-list button {
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.component-list button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.component-index {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border: 1px solid #d3d1c8;
|
||||
border-radius: 50%;
|
||||
color: #74766e;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
padding: 18px 20px 30px;
|
||||
}
|
||||
|
||||
.document-header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.document-header h2 {
|
||||
margin: 0;
|
||||
font-family: Georgia, "Songti SC", serif;
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diff-shell,
|
||||
.changes-panel,
|
||||
.diagnostic-panel,
|
||||
.state-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid #d5d3ca;
|
||||
border-radius: 13px;
|
||||
background: #fbfaf7;
|
||||
box-shadow: 0 12px 36px rgb(55 57 48 / 7%);
|
||||
}
|
||||
|
||||
.diff-shell {
|
||||
min-height: 510px;
|
||||
}
|
||||
|
||||
.diff-labels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-bottom: 1px solid #dcdbd3;
|
||||
background: #f4f2ec;
|
||||
color: #6f7168;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.diff-labels span {
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.diff-labels span + span {
|
||||
border-left: 1px solid #dcdbd3;
|
||||
}
|
||||
|
||||
.diff-host,
|
||||
.diff-host > .cm-mergeView {
|
||||
height: 510px;
|
||||
}
|
||||
|
||||
.diff-host .cm-mergeViewEditors {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.diff-host .cm-editor {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.changes-panel {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.changes-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 15px 18px;
|
||||
border-bottom: 1px solid #e0ded6;
|
||||
}
|
||||
|
||||
.changes-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.changes-heading > p {
|
||||
max-width: 58%;
|
||||
margin: 0;
|
||||
color: #77786f;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.change-list {
|
||||
display: grid;
|
||||
max-height: 310px;
|
||||
gap: 1px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #e4e2da;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.change-list button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 145px minmax(240px, 1fr) minmax(260px, 0.9fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border: 0;
|
||||
padding: 11px 18px;
|
||||
background: #fbfaf7;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.change-list button:hover {
|
||||
background: #f4f5ef;
|
||||
}
|
||||
|
||||
.change-list button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.change-location {
|
||||
color: #73756c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.change-list strong {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.change-sample {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.change-sample del,
|
||||
.change-sample ins {
|
||||
overflow: hidden;
|
||||
max-width: 46%;
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.change-sample del {
|
||||
background: #f9ded9;
|
||||
color: #913e34;
|
||||
}
|
||||
|
||||
.change-sample ins {
|
||||
background: #dcecdf;
|
||||
color: #276348;
|
||||
}
|
||||
|
||||
.quiet-message {
|
||||
margin: 0;
|
||||
padding: 22px 18px;
|
||||
color: #77786f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.state-panel,
|
||||
.diagnostic-panel {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
display: grid;
|
||||
min-height: 180px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
color: #66685f;
|
||||
}
|
||||
|
||||
.state-panel p {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.state-panel--error {
|
||||
border-color: #e2b6ae;
|
||||
color: #8f3e34;
|
||||
}
|
||||
|
||||
.loading-dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: #3d735b;
|
||||
box-shadow: 0 0 0 7px #dce9df;
|
||||
animation: pulse 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.diagnostic-panel h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.diagnostic-panel > p:not(.eyebrow) {
|
||||
color: #686a61;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.diagnostic-panel article {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #e0ded6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
background: #eceae3;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.layout {
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.change-list button {
|
||||
grid-template-columns: 125px minmax(180px, 1fr) minmax(220px, 0.8fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
export type RunStatus = "success" | "failed" | "unstable";
|
||||
|
||||
export interface ComponentSummary {
|
||||
component_position: number;
|
||||
component_id: string;
|
||||
version: string;
|
||||
parameters: unknown;
|
||||
applicability: string;
|
||||
change_count: number;
|
||||
}
|
||||
|
||||
export interface DocumentSummary {
|
||||
document_id: string;
|
||||
source_label: string;
|
||||
status: RunStatus;
|
||||
input_sha256: string;
|
||||
current_sha256: string;
|
||||
change_count: number;
|
||||
source_available: boolean;
|
||||
output_available: boolean;
|
||||
availability_error: string | null;
|
||||
}
|
||||
|
||||
export interface RunSummaryResponse {
|
||||
schema_version: 1;
|
||||
run: {
|
||||
run_id: string;
|
||||
run_date: string;
|
||||
status: RunStatus;
|
||||
started_at_utc: string;
|
||||
completed_at_utc: string;
|
||||
retention_until: string;
|
||||
};
|
||||
components: ComponentSummary[];
|
||||
documents: DocumentSummary[];
|
||||
summary: {
|
||||
document_count: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
unstable_count: number;
|
||||
change_count: number;
|
||||
};
|
||||
original_run_location_changed: boolean;
|
||||
}
|
||||
|
||||
export interface ChangeDetail {
|
||||
component_id: string;
|
||||
component_version: string;
|
||||
component_position: number;
|
||||
proposal_ref: {
|
||||
component_position: number;
|
||||
snapshot_sha256: string;
|
||||
proposal_index: number;
|
||||
};
|
||||
edit_index: number;
|
||||
reason: string;
|
||||
location: {
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
editor_range: {
|
||||
start: number;
|
||||
end: number;
|
||||
} | null;
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
|
||||
export interface RunErrorDetail {
|
||||
component_id: string;
|
||||
component_version: string;
|
||||
component_position: number;
|
||||
stage: "transform" | "final_review";
|
||||
error_type: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ResidualProposalDetail {
|
||||
component_id: string;
|
||||
component_version: string;
|
||||
component_position: number;
|
||||
reason: string;
|
||||
edit_count: number;
|
||||
}
|
||||
|
||||
export interface DocumentComparisonResponse {
|
||||
schema_version: 1;
|
||||
document: DocumentSummary;
|
||||
components: ComponentSummary[];
|
||||
original_markdown: string | null;
|
||||
cleaned_markdown: string | null;
|
||||
changes: ChangeDetail[];
|
||||
errors: RunErrorDetail[];
|
||||
residual_proposals: ResidualProposalDetail[];
|
||||
}
|
||||
|
||||
export interface ComponentStageResponse {
|
||||
schema_version: 1;
|
||||
document_id: string;
|
||||
component: ComponentSummary;
|
||||
before_sha256: string;
|
||||
after_sha256: string;
|
||||
before_markdown: string;
|
||||
after_markdown: string;
|
||||
changes: ChangeDetail[];
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { App } from "../src/client/App.js";
|
||||
import type {
|
||||
ComponentStageResponse,
|
||||
DocumentComparisonResponse,
|
||||
RunSummaryResponse,
|
||||
} from "../src/shared/api.js";
|
||||
|
||||
vi.mock("../src/client/DiffView.js", () => ({
|
||||
DiffView: ({ beforeLabel, afterLabel }: { beforeLabel: string; afterLabel: string }) => (
|
||||
<div data-testid="diff-view">
|
||||
{beforeLabel} / {afterLabel}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const components = [
|
||||
{
|
||||
component_position: 0,
|
||||
component_id: "paper.rule",
|
||||
version: "1.0.0",
|
||||
parameters: [],
|
||||
applicability: "替换测试单词。",
|
||||
change_count: 1,
|
||||
},
|
||||
{
|
||||
component_position: 1,
|
||||
component_id: "paper.zero",
|
||||
version: "1.0.0",
|
||||
parameters: [],
|
||||
applicability: "不修改当前测试文档。",
|
||||
change_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const documentSummary = {
|
||||
document_id: "paper",
|
||||
source_label: "inputs/paper.md",
|
||||
status: "success" as const,
|
||||
input_sha256: "1".repeat(64),
|
||||
current_sha256: "2".repeat(64),
|
||||
change_count: 1,
|
||||
source_available: true,
|
||||
output_available: true,
|
||||
availability_error: null,
|
||||
};
|
||||
|
||||
const runResponse: RunSummaryResponse = {
|
||||
schema_version: 1,
|
||||
run: {
|
||||
run_id: "review-run",
|
||||
run_date: "2026-08-23",
|
||||
status: "success",
|
||||
started_at_utc: "2026-08-23T01:00:00Z",
|
||||
completed_at_utc: "2026-08-23T01:01:00Z",
|
||||
retention_until: "2026-09-22T01:01:00Z",
|
||||
},
|
||||
components,
|
||||
documents: [documentSummary],
|
||||
summary: {
|
||||
document_count: 1,
|
||||
success_count: 1,
|
||||
failed_count: 0,
|
||||
unstable_count: 0,
|
||||
change_count: 1,
|
||||
},
|
||||
original_run_location_changed: false,
|
||||
};
|
||||
|
||||
const change = {
|
||||
component_id: "paper.rule",
|
||||
component_version: "1.0.0",
|
||||
component_position: 0,
|
||||
proposal_ref: {
|
||||
component_position: 0,
|
||||
snapshot_sha256: "1".repeat(64),
|
||||
proposal_index: 0,
|
||||
},
|
||||
edit_index: 0,
|
||||
reason: "替换测试单词",
|
||||
location: { line: 1, column: 3 },
|
||||
editor_range: { start: 0, end: 3 },
|
||||
before: "old",
|
||||
after: "new",
|
||||
};
|
||||
|
||||
const documentResponse: DocumentComparisonResponse = {
|
||||
schema_version: 1,
|
||||
document: documentSummary,
|
||||
components,
|
||||
original_markdown: "old",
|
||||
cleaned_markdown: "new",
|
||||
changes: [change],
|
||||
errors: [],
|
||||
residual_proposals: [],
|
||||
};
|
||||
|
||||
const stageResponse: ComponentStageResponse = {
|
||||
schema_version: 1,
|
||||
document_id: "paper",
|
||||
component: components[0]!,
|
||||
before_sha256: "1".repeat(64),
|
||||
after_sha256: "2".repeat(64),
|
||||
before_markdown: "old",
|
||||
after_markdown: "new",
|
||||
changes: [change],
|
||||
};
|
||||
|
||||
function response(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("App", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shows the run, document comparison, component order and component stage", async () => {
|
||||
const fetchMock = vi.fn((input: string | URL | Request) => {
|
||||
const pathname =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (pathname === "/api/v1/run") {
|
||||
return Promise.resolve(response(runResponse));
|
||||
}
|
||||
if (pathname.endsWith("/components/0")) {
|
||||
return Promise.resolve(response(stageResponse));
|
||||
}
|
||||
return Promise.resolve(response(documentResponse));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "review-run" })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("diff-view")).toHaveTextContent("清洗前 / 清洗后");
|
||||
expect(screen.getByText("paper.rule")).toBeInTheDocument();
|
||||
expect(screen.getByText("paper.zero")).toBeInTheDocument();
|
||||
expect(screen.getByText("替换测试单词")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /paper\.rule/ }));
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("components/0"), expect.anything());
|
||||
});
|
||||
expect(await screen.findByTestId("diff-view")).toHaveTextContent("组件 1 执行前 / 组件 1 执行后");
|
||||
});
|
||||
|
||||
it("shows API failures without rendering document text", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve(
|
||||
response({ error: { code: "unsupported_schema", message: "不支持这个产物版本。" } }, 409),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("不支持这个产物版本");
|
||||
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows failed audit evidence without inventing a cleaned result", async () => {
|
||||
const failedSummary = {
|
||||
...documentSummary,
|
||||
status: "failed" as const,
|
||||
current_sha256: documentSummary.input_sha256,
|
||||
change_count: 0,
|
||||
output_available: false,
|
||||
};
|
||||
const failedRun: RunSummaryResponse = {
|
||||
...runResponse,
|
||||
run: { ...runResponse.run, status: "failed" },
|
||||
documents: [failedSummary],
|
||||
summary: {
|
||||
document_count: 1,
|
||||
success_count: 0,
|
||||
failed_count: 1,
|
||||
unstable_count: 0,
|
||||
change_count: 0,
|
||||
},
|
||||
};
|
||||
const failedDocument: DocumentComparisonResponse = {
|
||||
schema_version: 1,
|
||||
document: failedSummary,
|
||||
components: components.map((component) => ({ ...component, change_count: 0 })),
|
||||
original_markdown: "原文",
|
||||
cleaned_markdown: null,
|
||||
changes: [],
|
||||
errors: [
|
||||
{
|
||||
component_id: "paper.rule",
|
||||
component_version: "1.0.0",
|
||||
component_position: 0,
|
||||
stage: "transform",
|
||||
error_type: "SyntheticError",
|
||||
message: "测试组件失败。",
|
||||
},
|
||||
],
|
||||
residual_proposals: [],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const pathname =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
return Promise.resolve(response(pathname === "/api/v1/run" ? failedRun : failedDocument));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "失败文档只展示审计证据" })).toBeInTheDocument();
|
||||
expect(screen.getByText("测试组件失败。")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps audit details visible when a successful run has lost its original source", async () => {
|
||||
const unavailableSummary = {
|
||||
...documentSummary,
|
||||
source_available: false,
|
||||
availability_error: "原文路径已经失效。",
|
||||
};
|
||||
const unavailableRun: RunSummaryResponse = {
|
||||
...runResponse,
|
||||
documents: [unavailableSummary],
|
||||
};
|
||||
const unavailableDocument: DocumentComparisonResponse = {
|
||||
...documentResponse,
|
||||
document: unavailableSummary,
|
||||
original_markdown: null,
|
||||
changes: [{ ...change, editor_range: null }],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const pathname =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
return Promise.resolve(
|
||||
response(pathname === "/api/v1/run" ? unavailableRun : unavailableDocument),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("原文路径已经失效");
|
||||
expect(screen.getByText("替换测试单词")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DiffView } from "../src/client/DiffView.js";
|
||||
|
||||
describe("DiffView", () => {
|
||||
it("keeps Markdown and raw HTML as inert editor text", () => {
|
||||
render(
|
||||
<DiffView
|
||||
before={'# title\n<img src="https://example.com/private.png" onerror="alert(1)">'}
|
||||
after={'# title\n<script>alert("x")</script>'}
|
||||
beforeLabel="清洗前"
|
||||
afterLabel="清洗后"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("region", { name: "清洗前与清洗后对比" })).toBeInTheDocument();
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
expect(document.querySelector("script")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchRun } from "../src/client/api-client.js";
|
||||
|
||||
describe("API response validation", () => {
|
||||
it("rejects a successful HTTP response with an unknown schema", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ schema_version: 2 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchRun()).rejects.toMatchObject({
|
||||
code: "invalid_response",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["node", "vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"]
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:4174",
|
||||
changeOrigin: true,
|
||||
configure(proxy) {
|
||||
proxy.on("proxyReq", (request) => request.removeHeader("origin"));
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./tests/setup.ts",
|
||||
css: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user