feat(scanner): complete localized artifact quality checkpoint

This commit is contained in:
AzuTear
2026-07-11 15:59:19 +02:00
parent 639b0b7f59
commit 8b9f948c6b
215 changed files with 35440 additions and 7273 deletions
+165
View File
@@ -0,0 +1,165 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { parseArtifactCandidate } from "../src/lib/artifactOcrParser.js";
import { artifactReviewReasons } from "../src/lib/scannerLearning.js";
import type { CaptureResult } from "../src/types/global.js";
type ProcessingOcrEntry = NonNullable<CaptureResult["ocr"]>[number];
type ProcessingResult = {
sequence: number;
page?: number;
capturedAt?: string;
parsed?: boolean;
needsReview?: boolean;
artifactName?: string;
slot?: string;
setName?: string;
ikMatch?: { matched?: boolean; notes?: string[] };
notes?: string[];
ocr?: ProcessingOcrEntry[];
};
type ProcessingReport = {
results?: ProcessingResult[];
};
const options = parseArgs(process.argv.slice(2));
const scanRoot = path.join(
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
"genshin-artifact-assistant",
"native-scans",
);
const runDir = options.runDir ? path.resolve(options.runDir) : await latestRunDir(scanRoot);
const sourcePath = path.join(runDir, "processing-report.json");
const source = JSON.parse(await fs.readFile(sourcePath, "utf8")) as ProcessingReport;
const sourceResults = Array.isArray(source.results) ? source.results : [];
if (sourceResults.length === 0) throw new Error(`No processing results found in ${sourcePath}`);
const analyzed = sourceResults.map((sourceResult) => {
const parsed = parseArtifactCandidate(captureFromProcessingResult(sourceResult));
const reasons = artifactReviewReasons(parsed);
if (sourceResult.ikMatch && !sourceResult.ikMatch.matched) reasons.push("ik_mismatch");
const uniqueReasons = [...new Set(reasons)];
const needsReview = uniqueReasons.length > 0;
const oldNeedsReview = Boolean(sourceResult.needsReview);
const repairedInitialMainValue = Boolean(
parsed
&& parsed.level === 0
&& parsed.substats.length === 4
&& parsed.fields.mainValue.source === "derived"
&& parsed.fields.mainValue.confidence >= 90
&& sourceResult.notes?.some((note) => /mainValue confidence is low/i.test(note)),
);
return {
sequence: sourceResult.sequence,
page: sourceResult.page ?? 0,
oldNeedsReview,
needsReview,
reasons: uniqueReasons,
level: parsed?.level ?? null,
artifactName: parsed?.name ?? "Unknown artifact",
slot: parsed?.slot ?? "Unknown slot",
mainStat: parsed?.mainStat ?? "Unknown main stat",
mainValue: parsed?.mainValue ?? "?",
substatCount: parsed?.substats.length ?? 0,
extractionConfidence: parsed?.confidence ?? 0,
repairedInitialMainValue,
};
});
const review = analyzed.filter((entry) => entry.needsReview);
const report = {
version: "native-review-analysis-v1",
createdAt: new Date().toISOString(),
runId: path.basename(runDir),
runDir,
sourcePath,
total: analyzed.length,
parsed: analyzed.filter((entry) => entry.artifactName !== "Unknown artifact").length,
oldReview: analyzed.filter((entry) => entry.oldNeedsReview).length,
review: review.length,
reviewRate: review.length / analyzed.length,
reclassifiedToClean: analyzed.filter((entry) => entry.oldNeedsReview && !entry.needsReview).length,
reclassifiedToReview: analyzed.filter((entry) => !entry.oldNeedsReview && entry.needsReview).length,
repairedInitialMainValues: analyzed.filter((entry) => entry.repairedInitialMainValue).length,
reasons: groupStrings(review.flatMap((entry) => entry.reasons)),
levels: groupStrings(review.map((entry) => String(entry.level ?? "missing"))),
pageBands: groupStrings(review.map((entry) => pageBand(entry.page))),
remainingSamples: review.slice(0, 25),
};
const outputDir = options.outputDir
? path.resolve(options.outputDir)
: path.resolve("outputs", "native-review-analysis", report.runId);
await fs.mkdir(outputDir, { recursive: true });
const reportPath = path.join(outputDir, "native-review-analysis.json");
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
console.log(`Native review analysis: ${report.runId}`);
console.log(`Results: ${report.total}; old review=${report.oldReview}; current review=${report.review} (${(report.reviewRate * 100).toFixed(2)}%)`);
console.log(`Reclassified: clean=${report.reclassifiedToClean}; review=${report.reclassifiedToReview}; initial-main repairs=${report.repairedInitialMainValues}`);
console.log(`Top reasons: ${report.reasons.slice(0, 8).map((entry) => `${entry.name}=${entry.count}`).join(", ") || "none"}`);
console.log(`Report: ${reportPath}`);
if (options.maxReviewRate !== null && report.reviewRate > options.maxReviewRate) {
throw new Error(`Review rate ${(report.reviewRate * 100).toFixed(2)}% exceeds ${(options.maxReviewRate * 100).toFixed(2)}%.`);
}
function captureFromProcessingResult(result: ProcessingResult): CaptureResult {
return {
id: `native-review-${result.sequence}`,
name: result.artifactName || `native-review-${result.sequence}`,
width: 492,
height: 838,
dataUrl: "",
capturedAt: result.capturedAt || new Date(0).toISOString(),
captureTarget: "genshin-client",
ocr: Array.isArray(result.ocr) ? result.ocr : [],
};
}
function groupStrings(values: string[]) {
const counts = new Map<string, number>();
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
return [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
}
function pageBand(page: number) {
if (!Number.isFinite(page) || page < 1) return "missing";
const start = Math.floor((page - 1) / 5) * 5 + 1;
return `${String(start).padStart(2, "0")}-${String(start + 4).padStart(2, "0")}`;
}
function parseArgs(args: string[]) {
const runDir = valueArg(args, "--run-dir=");
const outputDir = valueArg(args, "--output-dir=");
const rawMaximum = valueArg(args, "--max-review-rate=");
const parsedMaximum = rawMaximum ? Number(rawMaximum) : Number.NaN;
const maxReviewRate = Number.isFinite(parsedMaximum)
? Math.max(0, Math.min(1, parsedMaximum))
: null;
return { runDir, outputDir, maxReviewRate };
}
function valueArg(args: string[], prefix: string) {
return args.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? "";
}
async function latestRunDir(root: string) {
const entries = await fs.readdir(root, { withFileTypes: true });
const candidates = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
for (const candidate of candidates) {
const runDir = path.join(root, candidate);
try {
await fs.access(path.join(runDir, "processing-report.json"));
return runDir;
} catch {
// Continue; this is a read-only saved-run analysis.
}
}
throw new Error(`No processing-report.json found below ${root}`);
}