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 | null { const entries = record.sample?.capture?.ocr; if (!entries?.length) return null; const map: Record = {}; 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; 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; }