Prepare scanner branch for merge
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
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 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 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}` : "",
|
||||
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}.`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function readCandidates(inputPath, limit) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
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);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
}
|
||||
candidates.sort((left, right) => {
|
||||
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));
|
||||
});
|
||||
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;
|
||||
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++;
|
||||
const missingFields = candidate.missingFastFields || [];
|
||||
if (missingFields.length === 0) completeFastFields++;
|
||||
for (const field of missingFields) increment(missingFastFieldCounts, field);
|
||||
}
|
||||
return {
|
||||
completeFastFields,
|
||||
likelyStaleCaptures,
|
||||
equippedFooterCandidates,
|
||||
lockedTrueCandidates,
|
||||
lockedFalseCandidates,
|
||||
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}`,
|
||||
"",
|
||||
"These cases are not ground truth yet. 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(`- 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,
|
||||
candidatePriority,
|
||||
defaultReviewSamplesPath,
|
||||
markdownFor,
|
||||
readCandidates,
|
||||
};
|
||||
Reference in New Issue
Block a user