feat(scanner): add native artifact pipeline

Add native IK-style capture processing, Artifact Inventory, explicit promotion and single-result review. Confirm the three live OCR corrections in the eval corpus and preserve extraction/value separation.
This commit is contained in:
AzuTear
2026-07-09 23:30:42 +02:00
parent 28d60eb915
commit 639b0b7f59
92 changed files with 13606 additions and 1703 deletions
+49
View File
@@ -8,6 +8,10 @@ import type {
GdiCaptureResult,
HelperOperationResponse,
KeyPressResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerPreflightStatus,
NativeScannerRunStatus,
WindowBounds,
RuntimeInfo,
ScrollResult,
@@ -153,6 +157,12 @@ export interface InputHelperService {
keyPress(key: string): Promise<KeyPressResult>;
getAutomationGuard(): Promise<AutomationGuard>;
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
nativeScannerDataStatus(dataDir: string): Promise<NativeScannerDataStatus>;
nativeScannerCatalog(dataDir: string): Promise<NativeScannerCatalogStatus>;
nativeScannerPreflight(dataDir: string, category?: string): Promise<NativeScannerPreflightStatus>;
nativeScannerStart(options: { dataDir: string; outputRoot: string; limit?: number; category?: string }): Promise<NativeScannerRunStatus>;
nativeScannerStop(): Promise<NativeScannerRunStatus>;
nativeScannerStatus(): Promise<NativeScannerRunStatus>;
dispose(): void;
}
@@ -303,6 +313,39 @@ export function createInputHelperService(options: { userDataPath: string; exePat
};
}
function scannerPayload<T>(result: HelperOperationResponse): T {
return result.scanner as T;
}
async function nativeScannerDataStatus(dataDir: string) {
return scannerPayload<NativeScannerDataStatus>(await request("scanner-data-status", { dataDir }, 5000));
}
async function nativeScannerCatalog(dataDir: string) {
return scannerPayload<NativeScannerCatalogStatus>(await request("scanner-catalog", { dataDir }, 8000));
}
async function nativeScannerPreflight(dataDir: string, category = "artifacts") {
return scannerPayload<NativeScannerPreflightStatus>(await request("scanner-preflight", { dataDir, category }, 8000));
}
async function nativeScannerStart(options: { dataDir: string; outputRoot: string; limit?: number; category?: string }) {
return scannerPayload<NativeScannerRunStatus>(await request("scanner-start", {
dataDir: options.dataDir,
outputRoot: options.outputRoot,
limit: options.limit ?? 100,
category: options.category ?? "artifacts",
}, 8000));
}
async function nativeScannerStop() {
return scannerPayload<NativeScannerRunStatus>(await request("scanner-stop", {}, 4000));
}
async function nativeScannerStatus() {
return scannerPayload<NativeScannerRunStatus>(await request("scanner-status", {}, 4000));
}
return {
getRuntimeInfo,
focusGenshinWindow,
@@ -313,6 +356,12 @@ export function createInputHelperService(options: { userDataPath: string; exePat
keyPress,
getAutomationGuard,
capturePrimaryScreenViaGdi,
nativeScannerDataStatus,
nativeScannerCatalog,
nativeScannerPreflight,
nativeScannerStart,
nativeScannerStop,
nativeScannerStatus,
dispose: () => inputHelper.dispose(),
};
}
@@ -87,11 +87,9 @@ function Send-MouseInput {
return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize)
}
# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves
# with bare SetCursorPos, then clicks via the InputSimulator library's
# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE
# SendInput call (two INPUT structs in the same array) - back-to-back with no
# artificial delay between them, unlike two separate SendInput calls with a
# Uses bare SetCursorPos, then sends button-down and button-up as ONE SendInput
# call (two INPUT structs in the same array) - back-to-back with no artificial
# delay between them, unlike two separate SendInput calls with a
# Start-Sleep in between. Returns the number of injected events (2 = ok).
function Send-MouseClickBatch {
$down = New-Object Native.InputHelper+INPUT
@@ -208,7 +206,7 @@ function Find-GenshinWindow {
# Plain SetForegroundWindow from this background helper process is silently
# refused by Windows' foreground lock. Attach our thread's input queue to the
# target (and current foreground) window thread and clear the lock timeout, so
# the foreground change is honored - the same technique Inventory Kamera uses.
# the foreground change is honored.
function Force-Foreground {
param([IntPtr]$hwnd)
$current = [Native.InputHelper]::GetCurrentThreadId()
@@ -313,13 +311,10 @@ while ($true) {
}
$targetX = [int]$cmd.x
$targetY = [int]$cmd.y
# Matches Inventory Kamera's verified-working sequence exactly: bare
# SetCursorPos immediately followed by a click, with NO extra move
# event and NO artificial delay between moving and clicking - IK's
# Navigation.Click(x, y) does SetCursor() then Click() back-to-back,
# zero gap. Settling delays only happen after the click, in the scan
# loop. Down+up are sent as one SendInput call (see
# Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick().
# Bare SetCursorPos immediately followed by a click, with NO extra move
# event and NO artificial delay between moving and clicking. Settling
# delays only happen after the click, in the scan loop. Down+up are sent
# as one SendInput call (see Send-MouseClickBatch).
[Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null
$point = Get-CursorPoint
$onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2))
@@ -0,0 +1,496 @@
import fs from "node:fs/promises";
import path from "node:path";
import { pngBufferToBitmap } from "./pngBitmap.js";
import { createNativeScannerResultWorkflowService } from "./nativeScannerResultWorkflowService.js";
import { parseArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
import { toStoredArtifact } from "../../src/lib/artifactStore.js";
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
import { createStoredScanResultEntry } from "../../src/lib/scanResultEntry.js";
import type {
ArtifactStoreLoadResult,
ArtifactStoreSaveResult,
CaptureResult,
NativeScannerImageLoadStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
ReviewSamplePayload,
} from "../../src/types/global.js";
import type { ScanResultCategory, StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
export type NativeCaptureJobPayload = {
sequence: number;
category?: string;
page?: number;
row?: number;
col?: number;
capturedAt?: string;
relativePath?: string;
absolutePath?: string;
};
export interface NativeScannerProcessingService {
processRun(options?: { runDir?: string; persist?: boolean; limit?: number }): Promise<NativeScannerProcessStatus>;
loadResults(options?: { runDir?: string; limit?: number }): Promise<NativeScannerResultsLoadStatus>;
promoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus>;
reviewResult(options: {
runDir?: string;
resultId: string;
action: "approve" | "reject";
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}): Promise<NativeScannerReviewStatus>;
loadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus>;
}
interface NativeScannerProcessingServiceDependencies {
resolveRunDir(runDir?: string): string;
buildCaptureResult(imagePath: string, job: NativeCaptureJobPayload): Promise<CaptureResult>;
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
}
type ProcessedNativeJob = {
result: NativeScannerProcessStatus["results"][number];
scanResult: StoredScanResultEntry;
storedRecord: StoredArtifactRecord | null;
};
const POST_CAPTURE_QUEUE_CONCURRENCY = 4;
export function createNativeScannerProcessingService(
deps: NativeScannerProcessingServiceDependencies,
): NativeScannerProcessingService {
const resultWorkflows = createNativeScannerResultWorkflowService(deps);
return {
async processRun(options = {}) {
const started = Date.now();
const runDir = deps.resolveRunDir(options.runDir);
if (!runDir) {
return emptyProcessStatus("No native scanner runDir available.");
}
const { jobsPath, jobs } = await readNativeCaptureJobs(runDir);
const limit = Math.max(1, Math.min(jobs.length, Math.round(options.limit ?? jobs.length)));
const selectedJobs = jobs.slice(0, limit);
const runId = path.basename(runDir);
const ikCatalog = deps.loadIkArtifactCatalog
? await deps.loadIkArtifactCatalog().catch(() => null)
: null;
const processedJobs = await mapWithConcurrency(
selectedJobs,
POST_CAPTURE_QUEUE_CONCURRENCY,
(job) => processNativeCaptureJob({
deps,
ikCatalog,
job,
persist: Boolean(options.persist),
runDir,
runId,
}),
);
const results = processedJobs.map((entry) => entry.result);
const scanResults = processedJobs.map((entry) => entry.scanResult);
const recordsToPersist = processedJobs
.map((entry) => entry.storedRecord)
.filter((record): record is StoredArtifactRecord => Boolean(record));
let stored = 0;
if (options.persist && recordsToPersist.length > 0) {
const saved = await deps.saveArtifacts(recordsToPersist);
stored = saved.added + saved.updated;
}
const status: NativeScannerProcessStatus = {
ok: true,
runDir,
jobsPath,
reportPath: path.join(runDir, "processing-report.json"),
scanResultsPath: path.join(runDir, "scan-results.json"),
processed: results.length,
parsed: results.filter((result) => result.parsed).length,
review: results.filter((result) => result.needsReview).length,
stored,
errors: results.filter((result) => result.error).length,
elapsedMs: Date.now() - started,
queueConcurrency: Math.min(POST_CAPTURE_QUEUE_CONCURRENCY, selectedJobs.length),
persisted: Boolean(options.persist),
results,
};
await fs.writeFile(status.scanResultsPath, JSON.stringify(scanResults, null, 2), "utf8");
await fs.writeFile(status.reportPath, JSON.stringify(status, null, 2), "utf8");
return status;
},
async loadResults(options = {}) {
const runDir = deps.resolveRunDir(options.runDir);
if (!runDir) {
return emptyResultsStatus("No native scanner runDir available.");
}
const resultsPath = path.join(runDir, "scan-results.json");
try {
const raw = await fs.readFile(resultsPath, "utf8");
const parsed = JSON.parse(raw);
const results = Array.isArray(parsed)
? parsed.filter(isStoredScanResultEntry)
: [];
const limit = Number.isFinite(options.limit)
? Math.max(1, Math.min(results.length, Math.round(options.limit ?? results.length)))
: results.length;
return {
ok: true,
runDir,
path: resultsPath,
total: results.length,
results: results.slice(-limit),
};
} catch (error) {
return {
...emptyResultsStatus(error instanceof Error ? error.message : String(error)),
runDir,
path: resultsPath,
};
}
},
promoteResults: resultWorkflows.promoteResults,
reviewResult: resultWorkflows.reviewResult,
async loadImage(options) {
const runDir = deps.resolveRunDir(options.runDir);
if (!runDir) {
return emptyImageStatus("No native scanner runDir available.");
}
const resolvedRunDir = path.resolve(runDir);
const imagePath = path.isAbsolute(options.imagePath)
? path.resolve(options.imagePath)
: path.resolve(resolvedRunDir, options.imagePath);
if (!isPathInside(resolvedRunDir, imagePath)) {
return { ...emptyImageStatus("Image path is outside the native scanner run directory."), runDir: resolvedRunDir, path: imagePath };
}
if (!/\.png$/i.test(imagePath)) {
return { ...emptyImageStatus("Native scanner previews must be PNG files."), runDir: resolvedRunDir, path: imagePath };
}
try {
const stat = await fs.stat(imagePath);
if (stat.size > 20 * 1024 * 1024) {
return { ...emptyImageStatus("Native scanner preview is too large."), runDir: resolvedRunDir, path: imagePath };
}
const buffer = await fs.readFile(imagePath);
const bitmap = pngBufferToBitmap(buffer);
return {
ok: true,
runDir: resolvedRunDir,
path: imagePath,
dataUrl: `data:image/png;base64,${buffer.toString("base64")}`,
width: bitmap.width,
height: bitmap.height,
};
} catch (error) {
return {
...emptyImageStatus(error instanceof Error ? error.message : String(error)),
runDir: resolvedRunDir,
path: imagePath,
};
}
},
};
}
export function nativeScannerProcessStats(status: NativeScannerProcessStatus): Record<string, number> {
return {
processed: status.processed,
parsed: status.parsed,
review: status.review,
stored: status.stored,
errors: status.errors,
elapsedMs: status.elapsedMs,
queueConcurrency: status.queueConcurrency,
};
}
async function processNativeCaptureJob({
deps,
ikCatalog,
job,
persist,
runDir,
runId,
}: {
deps: NativeScannerProcessingServiceDependencies;
ikCatalog: IkArtifactCatalog | null;
job: NativeCaptureJobPayload;
persist: boolean;
runDir: string;
runId: string;
}): Promise<ProcessedNativeJob> {
const category = nativeJobCategory(job.category);
const imagePath = job.absolutePath || (job.relativePath ? path.join(runDir, job.relativePath) : "");
if (!category.artifactProcessingSupported) {
return reviewJobResult({
category: category.scanResultCategory,
error: `Native post-capture processing for category '${category.nativeCategory}' is not implemented yet; IK catalog is available only.`,
imagePath,
job,
runId,
});
}
if (!imagePath || !(await fileExists(imagePath))) {
const error = "Card crop image missing.";
return reviewJobResult({ category: category.scanResultCategory, error, imagePath, job, runId });
}
try {
const capture = await deps.buildCaptureResult(imagePath, job);
const parsed = parseArtifactCandidate(capture);
const ikMatch = parsed && ikCatalog ? matchParsedArtifactToIk(parsed, ikCatalog) : null;
const notes = [...new Set([...(parsed?.notes ?? []), ...(ikMatch?.notes ?? [])])];
const needsReview = parsedArtifactNeedsReview(parsed) || Boolean(ikMatch && !ikMatch.matched);
const canPersist = parsedArtifactCanPersist(parsed, needsReview);
const shouldPersist = Boolean(persist && parsed && canPersist && !needsReview);
const storedRecord = parsed && shouldPersist
? toStoredArtifact(parsed, "native-ik-scan", needsReview, capture.locked)
: null;
return {
result: {
sequence: job.sequence,
category: category.scanResultCategory,
page: job.page,
row: job.row,
col: job.col,
imagePath,
parsed: Boolean(parsed),
artifactName: parsed?.name,
setName: parsed?.setName,
slot: parsed?.slot,
confidence: parsed?.confidence ?? 0,
needsReview,
persisted: shouldPersist,
ikMatch: ikMatch ?? undefined,
notes,
ocr: capture.ocr ?? [],
},
scanResult: createStoredScanResultEntry({
runId,
sequence: job.sequence,
page: job.page,
row: job.row,
col: job.col,
category: category.scanResultCategory,
source: "native-ik-scan",
imagePath,
parsed,
needsReview,
confidence: parsed?.confidence ?? 0,
capturedAt: job.capturedAt,
artifactRecordId: storedRecord?.id,
persistedArtifact: shouldPersist,
notes,
locked: capture.locked,
ikMatch: ikMatch ?? undefined,
}),
storedRecord,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return reviewJobResult({ category: category.scanResultCategory, error: message, imagePath, job, runId });
}
}
function reviewJobResult({
category,
error,
imagePath,
job,
runId,
}: {
category?: ScanResultCategory;
error: string;
imagePath: string;
job: NativeCaptureJobPayload;
runId: string;
}): ProcessedNativeJob {
const scanResultCategory = category ?? nativeJobCategory(job.category).scanResultCategory;
return {
result: {
sequence: job.sequence,
category: scanResultCategory,
page: job.page,
row: job.row,
col: job.col,
imagePath,
parsed: false,
needsReview: true,
confidence: 0,
ocr: [],
error,
},
scanResult: createStoredScanResultEntry({
runId,
sequence: job.sequence,
page: job.page,
row: job.row,
col: job.col,
category: scanResultCategory,
source: "native-ik-scan",
imagePath,
parsed: null,
needsReview: true,
confidence: 0,
capturedAt: job.capturedAt,
error,
notes: [error],
}),
storedRecord: null,
};
}
function nativeJobCategory(category: string | undefined): {
nativeCategory: string;
scanResultCategory: ScanResultCategory;
artifactProcessingSupported: boolean;
} {
const nativeCategory = (category ?? "artifacts").trim().toLowerCase() || "artifacts";
if (nativeCategory === "artifact" || nativeCategory === "artifacts") {
return { nativeCategory, scanResultCategory: "artifact", artifactProcessingSupported: true };
}
if (nativeCategory === "weapon" || nativeCategory === "weapons") {
return { nativeCategory, scanResultCategory: "weapon", artifactProcessingSupported: false };
}
if (nativeCategory === "character" || nativeCategory === "characters") {
return { nativeCategory, scanResultCategory: "character", artifactProcessingSupported: false };
}
if (nativeCategory === "material" || nativeCategory === "materials") {
return { nativeCategory, scanResultCategory: "material", artifactProcessingSupported: false };
}
return { nativeCategory, scanResultCategory: "unknown", artifactProcessingSupported: false };
}
async function mapWithConcurrency<TInput, TOutput>(
items: readonly TInput[],
concurrency: number,
worker: (item: TInput, index: number) => Promise<TOutput>,
) {
const output = Array<TOutput>(items.length);
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(items.length, Math.floor(concurrency)));
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
output[index] = await worker(items[index], index);
}
}));
return output;
}
async function readNativeCaptureJobs(runDir: string): Promise<{ jobsPath: string; jobs: NativeCaptureJobPayload[] }> {
const jobsPath = path.join(runDir, "capture-jobs.jsonl");
const raw = await fs.readFile(jobsPath, "utf8");
const jobs = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as NativeCaptureJobPayload)
.filter((job) => Number.isFinite(job.sequence));
return { jobsPath, jobs };
}
async function fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function emptyProcessStatus(error: string): NativeScannerProcessStatus {
return {
ok: false,
runDir: "",
jobsPath: "",
reportPath: "",
scanResultsPath: "",
processed: 0,
parsed: 0,
review: 0,
stored: 0,
errors: 1,
elapsedMs: 0,
queueConcurrency: 0,
persisted: false,
results: [],
error,
};
}
function emptyResultsStatus(error: string): NativeScannerResultsLoadStatus {
return {
ok: false,
runDir: "",
path: "",
total: 0,
results: [],
error,
};
}
function emptyImageStatus(error: string): NativeScannerImageLoadStatus {
return {
ok: false,
runDir: "",
path: "",
dataUrl: "",
width: 0,
height: 0,
error,
};
}
function isPathInside(root: string, candidate: string) {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<StoredScanResultEntry>;
return typeof entry.id === "string"
&& typeof entry.runId === "string"
&& Number.isFinite(entry.sequence)
&& typeof entry.source === "string"
&& typeof entry.imagePath === "string"
&& typeof entry.extractionStatus === "string"
&& typeof entry.valueStatus === "string"
&& Array.isArray(entry.notes);
}
function parsedArtifactNeedsReview(parsed: ReturnType<typeof parseArtifactCandidate>) {
if (!parsed) return true;
if (parsed.confidence < 78) return true;
const criticalFields: Array<"name" | "slot" | "mainStat" | "mainValue" | "setName"> = ["name", "slot", "mainStat", "mainValue", "setName"];
if (criticalFields.some((field) => (parsed.fields[field]?.confidence ?? 0) < 70)) return true;
if (parsed.substats.length === 0) return true;
return parsed.notes.some((note) => /not confidently parsed|substats look incomplete|likely OCR misread/i.test(note));
}
function parsedArtifactCanPersist(parsed: ReturnType<typeof parseArtifactCandidate>, needsReview: boolean) {
if (!parsed) return false;
if (parsed.name === "Unknown artifact") return false;
if (parsed.slot === "Unknown slot") return false;
if (parsed.setName === "Unknown set") return false;
if (parsed.mainStat === "Unknown main stat") return false;
if (parsed.mainValue === "?") return false;
if (parsed.substats.length === 0) return false;
if (!needsReview && parsed.confidence < 68) return false;
if (needsReview && parsed.confidence < 60) return false;
return true;
}
@@ -0,0 +1,357 @@
import fs from "node:fs/promises";
import path from "node:path";
import { reviewedMainValueError, type ParsedArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
import { buildScanResultPromotionSummary, scanResultToStoredArtifact } from "../../src/lib/scanResultPromotion.js";
import { implausibleSubstats } from "../../src/lib/substatRolls.js";
import type {
ArtifactStoreLoadResult,
ArtifactStoreSaveResult,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
ReviewSamplePayload,
} from "../../src/types/global.js";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
export interface NativeScannerResultWorkflowDependencies {
resolveRunDir(runDir?: string): string;
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
}
export interface NativeScannerResultWorkflowService {
promoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus>;
reviewResult(options: {
runDir?: string;
resultId: string;
action: "approve" | "reject";
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}): Promise<NativeScannerReviewStatus>;
}
export function createNativeScannerResultWorkflowService(
deps: NativeScannerResultWorkflowDependencies,
): NativeScannerResultWorkflowService {
return {
async promoteResults(options) {
const runDir = deps.resolveRunDir(options.runDir);
const logPath = runDir ? path.join(runDir, "promotion-log.jsonl") : "";
const requestedIds = [...new Set((options.resultIds ?? []).filter((id) => typeof id === "string" && id.trim()))];
if (!runDir || requestedIds.length === 0 || !deps.loadArtifacts) {
return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Promotion requires a run directory, selected result IDs, and artifact-store access.");
}
try {
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
const selectedIds = new Set(requestedIds);
const selectedResults = scanResults.filter((entry) => selectedIds.has(entry.id));
const store = await deps.loadArtifacts();
if (!store.ok) return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Artifact store could not be loaded.");
const summary = buildScanResultPromotionSummary(selectedResults, store.artifacts);
const readyIds = new Set(summary.decisions.filter((decision) => decision.canPersist).map((decision) => decision.resultId));
const records = selectedResults
.filter((entry) => readyIds.has(entry.id))
.map(scanResultToStoredArtifact)
.filter((record): record is StoredArtifactRecord => Boolean(record));
const saved = records.length > 0
? await deps.saveArtifacts(records)
: { ok: true, added: 0, updated: 0, total: store.total, path: store.path };
if (saved.ok === false) return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Artifact store write failed.");
const recordIdByResultId = new Map(selectedResults.map((entry) => [entry.id, scanResultToStoredArtifact(entry)?.id]));
const promotedResultIds = selectedResults.filter((entry) => readyIds.has(entry.id)).map((entry) => entry.id);
const promotedSet = new Set(promotedResultIds);
const updatedResults = scanResults.map((entry) => promotedSet.has(entry.id)
? { ...entry, artifactRecordId: recordIdByResultId.get(entry.id), persistedArtifact: true }
: entry);
await writeScanResults(resultsPath, updatedResults);
const status: NativeScannerPromotionStatus = {
ok: true,
runDir,
logPath,
requested: requestedIds.length,
selected: selectedResults.length,
promoted: promotedResultIds.length,
alreadyStored: summary.alreadyStored + summary.persisted,
review: summary.review,
blocked: summary.blocked + Math.max(0, requestedIds.length - selectedResults.length),
added: saved.added,
updated: saved.updated,
total: saved.total ?? store.total,
promotedResultIds,
};
await appendWorkflowLog(logPath, { at: new Date().toISOString(), ...status });
return status;
} catch (error) {
return emptyPromotionStatus(runDir, logPath, requestedIds.length, errorMessage(error));
}
},
async reviewResult(options) {
const runDir = deps.resolveRunDir(options.runDir);
const logPath = runDir ? path.join(runDir, "review-log.jsonl") : "";
const resultId = String(options.resultId ?? "").trim();
if (!runDir || !resultId || !["approve", "reject"].includes(options.action)) {
return emptyReviewStatus(runDir, logPath, resultId, options.action, "Review requires a run directory, result ID, and valid action.");
}
try {
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
const index = scanResults.findIndex((entry) => entry.id === resultId);
if (index < 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, "Selected scan result was not found.");
const current = scanResults[index];
if (current.persistedArtifact) return emptyReviewStatus(runDir, logPath, resultId, options.action, "Persisted results cannot be edited through review.");
const reviewedAt = new Date().toISOString();
const note = String(options.note ?? "").trim().slice(0, 500);
let correctedFields: string[] = [];
let evalSampleSaved = false;
let updated: StoredScanResultEntry;
if (options.action === "reject") {
updated = rejectResult(current, reviewedAt, note);
} else {
const artifact = normalizeReviewedArtifact(options.artifact);
const errors = reviewedArtifactErrors(artifact);
if (errors.length > 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, errors.join(" "));
const parsed = reviewedArtifactToParsed(artifact);
const catalog = deps.loadIkArtifactCatalog ? await deps.loadIkArtifactCatalog() : null;
const ikMatch = matchParsedArtifactToIk(parsed, catalog);
if (!ikMatch?.matched) {
return emptyReviewStatus(runDir, logPath, resultId, options.action, `IK validation failed: ${ikMatch?.notes.join(" ") || "catalog unavailable"}`);
}
correctedFields = artifactChangedFields(current.artifact, artifact);
updated = approveResult(current, artifact, ikMatch, reviewedAt, note, correctedFields);
evalSampleSaved = await saveApprovedEvalSample(deps, runDir, current, artifact, parsed);
}
scanResults[index] = updated;
await writeScanResults(resultsPath, scanResults);
const status: NativeScannerReviewStatus = {
ok: true,
runDir,
logPath,
resultId,
action: options.action,
evalSampleSaved,
correctedFields,
};
await appendWorkflowLog(logPath, { at: reviewedAt, ...status, note });
return status;
} catch (error) {
return emptyReviewStatus(runDir, logPath, resultId, options.action, errorMessage(error));
}
},
};
}
async function loadScanResults(runDir: string) {
const resultsPath = path.join(runDir, "scan-results.json");
const raw = JSON.parse(await fs.readFile(resultsPath, "utf8"));
const results = Array.isArray(raw) ? raw.filter(isStoredScanResultEntry) : [];
return { path: resultsPath, results };
}
async function writeScanResults(resultsPath: string, results: StoredScanResultEntry[]) {
await fs.writeFile(resultsPath, JSON.stringify(results, null, 2), "utf8");
}
async function appendWorkflowLog(logPath: string, payload: object) {
await fs.appendFile(logPath, `${JSON.stringify(payload)}\n`, "utf8");
}
function rejectResult(current: StoredScanResultEntry, reviewedAt: string, note: string): StoredScanResultEntry {
return {
...current,
extractionStatus: "review",
needsReview: true,
valueStatus: "review",
notes: [...new Set([...current.notes, note || "Manual review rejected this result."])],
review: { status: "rejected", reviewedAt, note: note || undefined, correctedFields: [] },
};
}
function approveResult(
current: StoredScanResultEntry,
artifact: NativeScannerReviewArtifactInput,
ikMatch: NonNullable<StoredScanResultEntry["ikMatch"]>,
reviewedAt: string,
note: string,
correctedFields: string[],
): StoredScanResultEntry {
return {
...current,
extractionStatus: "parsed",
extractionConfidence: 100,
needsReview: false,
valueStatus: "deferred",
valueScore: null,
artifact,
ikMatch,
fieldConfidences: reviewedFieldConfidences(artifact),
artifactRecordId: undefined,
persistedArtifact: false,
notes: note ? [`Manual review approved: ${note}`] : ["Manual review approved."],
error: undefined,
review: { status: "approved", reviewedAt, note: note || undefined, correctedFields },
};
}
async function saveApprovedEvalSample(
deps: NativeScannerResultWorkflowDependencies,
runDir: string,
current: StoredScanResultEntry,
artifact: NativeScannerReviewArtifactInput,
parsed: ParsedArtifactCandidate,
) {
if (!deps.saveReviewSample) return false;
const ocr = await loadReviewOcr(runDir, current.sequence);
if (ocr.length === 0) return false;
const saved = await deps.saveReviewSample({
reason: "native-review-approved",
parsed,
capture: {
id: `${current.runId}:${current.sequence}`,
name: current.imagePath,
width: 492,
height: 838,
capturedAt: current.capturedAt,
locked: artifact.locked,
ocr,
},
});
return Boolean(saved.ok);
}
function emptyPromotionStatus(runDir: string, logPath: string, requested: number, error: string): NativeScannerPromotionStatus {
return {
ok: false,
runDir,
logPath,
requested,
selected: 0,
promoted: 0,
alreadyStored: 0,
review: 0,
blocked: requested,
added: 0,
updated: 0,
total: 0,
promotedResultIds: [],
error,
};
}
function emptyReviewStatus(
runDir: string,
logPath: string,
resultId: string,
action: "approve" | "reject",
error: string,
): NativeScannerReviewStatus {
return { ok: false, runDir, logPath, resultId, action, evalSampleSaved: false, correctedFields: [], error };
}
function normalizeReviewedArtifact(input?: NativeScannerReviewArtifactInput): NativeScannerReviewArtifactInput {
return {
name: String(input?.name ?? "").trim(),
slot: String(input?.slot ?? "").trim(),
level: Math.round(Number(input?.level ?? -1)),
setName: String(input?.setName ?? "").trim(),
mainStat: String(input?.mainStat ?? "").trim(),
mainValue: String(input?.mainValue ?? "").trim(),
substats: [...new Set((input?.substats ?? []).map((entry) => String(entry).trim()).filter(Boolean))].slice(0, 4),
equipped: String(input?.equipped ?? "Not detected").trim() || "Not detected",
locked: typeof input?.locked === "boolean" ? input.locked : undefined,
};
}
function reviewedArtifactErrors(artifact: NativeScannerReviewArtifactInput) {
const errors: string[] = [];
if (!artifact.name || artifact.name === "Unknown artifact") errors.push("Artifact name is required.");
if (!artifact.slot || artifact.slot === "Unknown slot") errors.push("Artifact slot is required.");
if (!artifact.setName || artifact.setName === "Unknown set") errors.push("Artifact set is required.");
if (!artifact.mainStat || artifact.mainStat === "Unknown main stat") errors.push("Main stat is required.");
if (!artifact.mainValue || artifact.mainValue === "?") errors.push("Main value is required.");
if (!Number.isInteger(artifact.level) || artifact.level < 0 || artifact.level > 20) errors.push("Level must be between 0 and 20.");
if (artifact.substats.length === 0) errors.push("At least one substat is required.");
const mainValueError = reviewedMainValueError(artifact.slot, artifact.mainStat, artifact.level, artifact.mainValue);
if (mainValueError) errors.push(mainValueError);
const implausible = implausibleSubstats(artifact.substats, artifact.level > 16 ? 5 : undefined);
if (implausible.length > 0) errors.push(`Implausible substats: ${implausible.join(", ")}.`);
return errors;
}
function reviewedArtifactToParsed(artifact: NativeScannerReviewArtifactInput): ParsedArtifactCandidate {
const manual = (value: string) => ({ value, confidence: 100, source: "database" as const });
return {
...artifact,
confidence: 100,
notes: ["Manually reviewed and approved."],
fields: {
name: manual(artifact.name),
slot: manual(artifact.slot),
level: manual(String(artifact.level)),
mainStat: manual(artifact.mainStat),
mainValue: manual(artifact.mainValue),
setName: manual(artifact.setName),
equipped: manual(artifact.equipped),
substats: manual(artifact.substats.join(", ")),
},
};
}
function artifactChangedFields(
before: StoredScanResultEntry["artifact"],
after: NativeScannerReviewArtifactInput,
) {
if (!before) return ["name", "slot", "level", "setName", "mainStat", "mainValue", "substats", "equipped", "locked"];
return (["name", "slot", "level", "setName", "mainStat", "mainValue", "substats", "equipped", "locked"] as const)
.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]));
}
function reviewedFieldConfidences(artifact: NativeScannerReviewArtifactInput) {
return [
{ key: "name", label: "Name", value: artifact.name },
{ key: "slot", label: "Slot", value: artifact.slot },
{ key: "level", label: "Level", value: String(artifact.level) },
{ key: "mainStat", label: "Main stat", value: artifact.mainStat },
{ key: "mainValue", label: "Main value", value: artifact.mainValue },
{ key: "setName", label: "Set", value: artifact.setName },
{ key: "equipped", label: "Equipped", value: artifact.equipped },
{ key: "substats", label: "Substats", value: artifact.substats.join(", ") },
].map((field) => ({ ...field, confidence: 100, source: "database" as const }));
}
async function loadReviewOcr(runDir: string, sequence: number) {
try {
const report = JSON.parse(await fs.readFile(path.join(runDir, "processing-report.json"), "utf8"));
const result = Array.isArray(report?.results) ? report.results.find((entry: { sequence?: number }) => entry.sequence === sequence) : null;
return Array.isArray(result?.ocr) ? result.ocr : [];
} catch {
return [];
}
}
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<StoredScanResultEntry>;
return typeof entry.id === "string"
&& typeof entry.runId === "string"
&& Number.isFinite(entry.sequence)
&& typeof entry.source === "string"
&& typeof entry.imagePath === "string"
&& typeof entry.extractionStatus === "string"
&& typeof entry.valueStatus === "string"
&& Array.isArray(entry.notes);
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}