const fs = require("node:fs"); const path = require("node:path"); const readline = require("node:readline"); const os = require("node:os"); const DEFAULT_LIMIT = 80; function argValue(name, fallback = "") { const prefix = `--${name}=`; const match = process.argv.find((entry) => entry.startsWith(prefix)); return match ? match.slice(prefix.length) : fallback; } function defaultReviewSamplesPath() { const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"); return path.join(appData, "genshin-artifact-assistant", "review-samples.jsonl"); } function simplifyId(value) { return String(value || "") .replace(/[^0-9A-Za-z]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 80) || "unknown"; } function safeCaptureReference(value) { const reference = typeof value === "string" ? value.trim() : ""; if (!reference || reference.length > 160 || /[\r\n]/.test(reference)) return null; if (path.isAbsolute(reference) || /^[A-Za-z]:[\\/]/.test(reference) || reference.startsWith("\\\\")) return null; return reference; } function safeCapturedAt(value) { const capturedAt = typeof value === "string" ? value.trim() : ""; if (!capturedAt || capturedAt.length > 80 || /[\r\n]/.test(capturedAt)) return null; return capturedAt; } function hasRetrievableLocalPng(value) { if (typeof value !== "string" || path.extname(value).toLowerCase() !== ".png") return false; try { return fs.statSync(value).isFile(); } catch { return false; } } function visualEvidenceForCapture(capture) { const captureId = safeCaptureReference(capture?.id); const capturedAt = safeCapturedAt(capture?.capturedAt); const available = hasRetrievableLocalPng(capture?.name); return { captureId, capturedAt, visualEvidence: { status: available ? "available" : "unavailable", source: available ? "local-crop" : "none", reference: captureId, }, }; } function parsedSummary(parsed) { if (!parsed || typeof parsed !== "object") return {}; return { name: parsed.name, slot: parsed.slot, level: parsed.level, mainStat: parsed.mainStat, mainValue: parsed.mainValue, setName: parsed.setName, equipped: parsed.equipped, substats: Array.isArray(parsed.substats) ? parsed.substats : [], confidence: parsed.confidence, notes: Array.isArray(parsed.notes) ? parsed.notes : [], }; } const REQUIRED_FAST_OCR_FIELDS = [ "artifact-name", "artifact-slot", "artifact-main-stat-label", "artifact-level", "artifact-substats", ]; function ocrMap(record) { const entries = record?.sample?.capture?.ocr; if (!Array.isArray(entries) || entries.length === 0) return null; const result = {}; for (const entry of entries) { if (typeof entry?.id === "string" && typeof entry?.text === "string") { result[entry.id] = entry.text; } } return Object.keys(result).length ? result : null; } function candidateFromRecord(record, index) { const ocr = ocrMap(record); if (!ocr) return null; const parsed = parsedSummary(record?.sample?.parsed); const reason = record?.sample?.reason || "missing-reason"; const savedAt = record?.savedAt || "unknown"; const capture = record?.sample?.capture || {}; const visualEvidence = visualEvidenceForCapture(capture); const ocrFieldIds = Object.keys(ocr).sort(); const missingFastFields = REQUIRED_FAST_OCR_FIELDS.filter((field) => !ocr[field]); const hasEquippedFooterOcr = Boolean(ocr["artifact-footer"]); const locked = typeof capture.locked === "boolean" ? capture.locked : null; return { id: `review-${simplifyId(savedAt)}-${index}`, savedAt, reason, confirmed: false, resolution: capture.width && capture.height ? `${capture.width}x${capture.height}` : "", captureId: visualEvidence.captureId, capturedAt: visualEvidence.capturedAt, visualEvidence: visualEvidence.visualEvidence, ocrFieldIds, missingFastFields, hasEquippedFooterOcr, locked, likelyStaleCapture: missingFastFields.includes("artifact-slot"), parsed, ocr, reviewPrompt: { action: "Confirm or correct parsed fields before moving this case into src/eval/corpus/confirmedReviewCorpus.ts.", expectedFields: { name: parsed.name || "", slot: parsed.slot || "", level: parsed.level ?? "", mainStat: parsed.mainStat || "", mainValue: parsed.mainValue || "", setName: parsed.setName || "", equipped: parsed.equipped || "", substats: parsed.substats || [], }, validationChecks: { equipped: hasEquippedFooterOcr ? "Confirm character name or mark Not detected." : "No footer OCR in this sample.", locked: locked === null ? "No lock-state payload in this sample." : `Confirm locked=${locked}.`, visualEvidence: visualEvidence.visualEvidence.status === "available" ? "A local PNG was retrievable at export. Inspect it before confirming labels." : "No retrievable local PNG. Do not promote this case to the confirmed corpus.", }, }, }; } function candidateKey(candidate) { return [ candidate.reason, candidate.parsed.name, candidate.parsed.slot, candidate.parsed.mainStat, candidate.parsed.setName, JSON.stringify(candidate.parsed.substats || []), JSON.stringify(candidate.ocr), ].join("\u001f"); } function candidatePriority(candidate) { const reason = candidate.reason || ""; const parsed = candidate.parsed || {}; const missingCount = candidate.missingFastFields?.length || 0; const hasUnknownCritical = [parsed.name, parsed.slot, parsed.mainStat, parsed.setName].some((value) => String(value || "").startsWith("Unknown")); if (candidate.likelyStaleCapture) return 4; if (missingCount === 0 && /low-field|low-total|capture-rejected|initial-selection/i.test(reason)) return 0; if (missingCount === 0 && (parsed.notes || []).some((note) => /fuzzy|incomplete|not confidently|low/i.test(note))) return 1; if (missingCount === 0) return 2; if (missingCount <= 1 && !hasUnknownCritical) return 3; return 5; } function candidateComparator(left, right) { const visualEvidencePriority = Number(left.visualEvidence?.status !== "available") - Number(right.visualEvidence?.status !== "available"); if (visualEvidencePriority) return visualEvidencePriority; const priority = candidatePriority(left) - candidatePriority(right); if (priority) return priority; const missingDiff = (left.missingFastFields?.length || 0) - (right.missingFastFields?.length || 0); if (missingDiff) return missingDiff; return String(right.savedAt).localeCompare(String(left.savedAt)); } async function readCandidates(inputPath, limit) { const candidatesByKey = new Map(); let total = 0; let invalid = 0; const rl = readline.createInterface({ input: fs.createReadStream(inputPath, { encoding: "utf8" }) }); for await (const line of rl) { if (!line.trim()) continue; total++; let record; try { record = JSON.parse(line); } catch { invalid++; continue; } const candidate = candidateFromRecord(record, total); if (!candidate) continue; const key = candidateKey(candidate); const existing = candidatesByKey.get(key); if (!existing || candidateComparator(candidate, existing) < 0) { candidatesByKey.set(key, candidate); } } const candidates = [...candidatesByKey.values()]; candidates.sort((left, right) => { return candidateComparator(left, right); }); return { total, invalid, candidates: candidates.slice(0, limit), uniqueCandidates: candidates.length }; } function increment(map, key) { const normalizedKey = key || "unknown"; map[normalizedKey] = (map[normalizedKey] || 0) + 1; } function buildExportStats(candidates) { const reasonCounts = {}; const missingFastFieldCounts = {}; let completeFastFields = 0; let likelyStaleCaptures = 0; let equippedFooterCandidates = 0; let lockedTrueCandidates = 0; let lockedFalseCandidates = 0; let visualEvidenceAvailable = 0; let visualEvidenceUnavailable = 0; for (const candidate of candidates) { increment(reasonCounts, candidate.reason); if (candidate.likelyStaleCapture) likelyStaleCaptures++; if (candidate.hasEquippedFooterOcr) equippedFooterCandidates++; if (candidate.locked === true) lockedTrueCandidates++; if (candidate.locked === false) lockedFalseCandidates++; if (candidate.visualEvidence?.status === "available") visualEvidenceAvailable++; else visualEvidenceUnavailable++; const missingFields = candidate.missingFastFields || []; if (missingFields.length === 0) completeFastFields++; for (const field of missingFields) increment(missingFastFieldCounts, field); } return { completeFastFields, likelyStaleCaptures, equippedFooterCandidates, lockedTrueCandidates, lockedFalseCandidates, visualEvidenceAvailable, visualEvidenceUnavailable, reasonCounts, missingFastFieldCounts, }; } function markdownFor(summary, candidates) { const lines = [ "# Review Eval Candidates", "", `Source: ${summary.inputPath}`, `Generated: ${summary.generatedAt}`, `Records read: ${summary.recordsRead}`, `Unique candidates: ${summary.uniqueCandidates}`, `Exported candidates: ${summary.exportedCandidates}`, `Complete fast-field candidates: ${summary.exportStats.completeFastFields}`, `Likely stale captures: ${summary.exportStats.likelyStaleCaptures}`, `Equipped footer candidates: ${summary.exportStats.equippedFooterCandidates}`, `Locked=true candidates: ${summary.exportStats.lockedTrueCandidates}`, `Locked=false candidates: ${summary.exportStats.lockedFalseCandidates}`, `Visual evidence available: ${summary.exportStats.visualEvidenceAvailable}`, `Visual evidence unavailable: ${summary.exportStats.visualEvidenceUnavailable}`, "", "These cases are not ground truth yet. Only manually label candidates with `visualEvidence: available`; then confirm or correct the expected fields before committing any case into `src/eval/corpus/`.", "", "## Export stats", "", "Reason counts:", "", ...Object.entries(summary.exportStats.reasonCounts).map(([reason, count]) => `- ${reason}: ${count}`), "", "Missing fast-field counts:", "", ...Object.entries(summary.exportStats.missingFastFieldCounts).map(([field, count]) => `- ${field}: ${count}`), "", ]; for (const candidate of candidates) { lines.push(`## ${candidate.id}`); lines.push(""); lines.push(`- savedAt: ${candidate.savedAt}`); lines.push(`- reason: ${candidate.reason}`); lines.push(`- captureId: ${candidate.captureId || "unavailable"}`); lines.push(`- capturedAt: ${candidate.capturedAt || "unavailable"}`); lines.push(`- visualEvidence: ${candidate.visualEvidence?.status || "unavailable"} (${candidate.visualEvidence?.source || "none"})`); lines.push(`- missingFastFields: ${candidate.missingFastFields.join(", ") || "none"}`); lines.push(`- likelyStaleCapture: ${candidate.likelyStaleCapture ? "yes" : "no"}`); lines.push(`- hasEquippedFooterOcr: ${candidate.hasEquippedFooterOcr ? "yes" : "no"}`); lines.push(`- locked: ${candidate.locked === null ? "unknown" : candidate.locked}`); lines.push(`- parsed: ${candidate.parsed.name || "?"} | ${candidate.parsed.slot || "?"} | ${candidate.parsed.mainStat || "?"} | ${candidate.parsed.setName || "?"} | equipped=${candidate.parsed.equipped || "?"}`); lines.push(`- substats: ${(candidate.parsed.substats || []).join(", ") || "?"}`); lines.push(""); lines.push("OCR:"); for (const [id, text] of Object.entries(candidate.ocr)) { lines.push(`- ${id}: ${JSON.stringify(text)}`); } lines.push(""); } return `${lines.join("\n")}\n`; } async function main() { const inputPath = path.resolve(argValue("input", defaultReviewSamplesPath())); const outputDir = path.resolve(argValue("out", path.join(process.cwd(), "outputs", "review-eval-candidates"))); const limit = Math.max(1, Math.min(500, Number(argValue("limit", String(DEFAULT_LIMIT))) || DEFAULT_LIMIT)); if (!fs.existsSync(inputPath)) { throw new Error(`Review sample file not found: ${inputPath}`); } fs.mkdirSync(outputDir, { recursive: true }); const result = await readCandidates(inputPath, limit); const summary = { inputPath, outputDir, generatedAt: new Date().toISOString(), recordsRead: result.total, invalidRecords: result.invalid, uniqueCandidates: result.uniqueCandidates, exportedCandidates: result.candidates.length, exportStats: buildExportStats(result.candidates), }; const payload = { summary, candidates: result.candidates }; const jsonPath = path.join(outputDir, "review-eval-candidates.json"); const mdPath = path.join(outputDir, "review-eval-candidates.md"); fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); fs.writeFileSync(mdPath, markdownFor(summary, result.candidates), "utf8"); console.log(JSON.stringify({ ok: true, ...summary, jsonPath, mdPath }, null, 2)); } if (require.main === module) { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); } module.exports = { buildExportStats, candidateFromRecord, candidateComparator, candidatePriority, defaultReviewSamplesPath, hasRetrievableLocalPng, markdownFor, readCandidates, visualEvidenceForCapture, };