Files
genshin-assistant/src/eval/reviewSampleCorpus.ts
T
2026-07-09 08:44:50 +02:00

93 lines
3.4 KiB
TypeScript

import type { ReviewSampleRecord } from "../types/global";
import type { OcrEvalCase } from "./ocrEvalHarness";
// Turns saved review samples into eval cases so the review queue becomes a
// growing labeled corpus (see docs/ocr-eval.md).
//
// IMPORTANT: a review sample's `parsed` block is the *auto*-parse that was
// flagged for review - it is a label CANDIDATE, not verified ground truth.
// Using it directly as an expectation would be circular (the parser grading
// itself). The intended flow is:
// 1. reviewSampleToEvalCase() extracts the OCR + the parser's current guess.
// 2. A human confirms or corrects `expect` in the produced case.
// 3. The corrected case is committed into src/eval/corpus/confirmedReviewCorpus.ts.
// The `confirmed` flag records whether step 2 happened.
export interface ReviewSampleEvalCase extends OcrEvalCase {
/** False until a human has verified/corrected the `expect` values. */
confirmed: boolean;
}
function ocrMapFromRecord(record: ReviewSampleRecord): Record<string, string> | null {
const entries = record.sample?.capture?.ocr;
if (!entries?.length) return null;
const map: Record<string, string> = {};
for (const entry of entries) {
if (typeof entry?.id === "string" && typeof entry?.text === "string") {
map[entry.id] = entry.text;
}
}
return Object.keys(map).length > 0 ? map : null;
}
function expectFromParsed(parsed: unknown): OcrEvalCase["expect"] {
if (!parsed || typeof parsed !== "object") return {};
const candidate = parsed as Record<string, unknown>;
const expect: OcrEvalCase["expect"] = {};
const stringField = (key: "name" | "slot" | "mainStat" | "mainValue" | "setName" | "equipped") => {
const value = candidate[key];
// Skip the parser's own "Unknown ..." / "Not detected" / "?" sentinels -
// those are non-answers, not labels a human would confirm.
if (typeof value === "string" && value && !value.startsWith("Unknown") && value !== "Not detected" && value !== "?") {
expect[key] = value;
}
};
stringField("name");
stringField("slot");
stringField("mainStat");
stringField("mainValue");
stringField("setName");
stringField("equipped");
if (typeof candidate.level === "number" && Number.isFinite(candidate.level) && candidate.level > 0) {
expect.level = candidate.level;
}
if (Array.isArray(candidate.substats) && candidate.substats.every((entry) => typeof entry === "string")) {
expect.substats = candidate.substats as string[];
}
return expect;
}
export function reviewSampleToEvalCase(record: ReviewSampleRecord, index = 0): ReviewSampleEvalCase | null {
const ocr = ocrMapFromRecord(record);
if (!ocr) return null;
const savedAt = record.savedAt ?? "unknown";
const idSuffix = savedAt.replace(/[^0-9A-Za-z]/g, "").slice(0, 14) || String(index);
const capture = record.sample?.capture;
return {
id: `review-${idSuffix}-${index}`,
ocr,
expect: expectFromParsed(record.sample?.parsed),
confirmed: false,
meta: {
source: "review-sample",
resolution: capture?.width && capture?.height ? `${capture.width}x${capture.height}` : undefined,
note: record.sample?.reason,
},
};
}
export function reviewSamplesToEvalCases(records: readonly ReviewSampleRecord[] | null | undefined) {
const cases: ReviewSampleEvalCase[] = [];
(records ?? []).forEach((record, index) => {
const evalCase = reviewSampleToEvalCase(record, index);
if (evalCase) cases.push(evalCase);
});
return cases;
}