166 lines
6.7 KiB
TypeScript
166 lines
6.7 KiB
TypeScript
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}`);
|
|
}
|