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[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(); 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}`); }