实现本地 Markdown 清洗评审器
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user