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:
@@ -19,6 +19,17 @@ import type {
|
||||
SaveSnapshotResult,
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
NativeScannerStartCategory,
|
||||
ScannerStatusPayload,
|
||||
} from "../../src/types/global.js";
|
||||
import type {
|
||||
@@ -33,6 +44,17 @@ interface AppHandlersDependencies {
|
||||
moveMainWindowOffGenshin: () => Promise<void>;
|
||||
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
||||
nativeScannerDataStatus: () => Promise<NativeScannerDataStatus>;
|
||||
nativeScannerCatalog: () => Promise<NativeScannerCatalogStatus>;
|
||||
nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory | string }) => Promise<NativeScannerPreflightStatus>;
|
||||
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
|
||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
|
||||
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||
readRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||
loadSnapshotFromDisk: () => Promise<AppSnapshot | null>;
|
||||
saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
|
||||
@@ -73,6 +95,17 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
||||
moveMainWindowOffGenshin: dependencies.moveMainWindowOffGenshin,
|
||||
focusGenshinForScanStart: dependencies.focusGenshinForScanStart,
|
||||
publishScannerStatus: dependencies.publishScannerStatus,
|
||||
nativeScannerDataStatus: dependencies.nativeScannerDataStatus,
|
||||
nativeScannerCatalog: dependencies.nativeScannerCatalog,
|
||||
nativeScannerPreflight: dependencies.nativeScannerPreflight,
|
||||
nativeScannerStart: dependencies.nativeScannerStart,
|
||||
nativeScannerStop: dependencies.nativeScannerStop,
|
||||
nativeScannerStatus: dependencies.nativeScannerStatus,
|
||||
nativeScannerProcessRun: dependencies.nativeScannerProcessRun,
|
||||
nativeScannerLoadResults: dependencies.nativeScannerLoadResults,
|
||||
nativeScannerPromoteResults: dependencies.nativeScannerPromoteResults,
|
||||
nativeScannerReviewResult: dependencies.nativeScannerReviewResult,
|
||||
nativeScannerLoadImage: dependencies.nativeScannerLoadImage,
|
||||
getRuntimeInfo: dependencies.readRuntimeInfo,
|
||||
loadSnapshot: dependencies.loadSnapshotFromDisk,
|
||||
saveSnapshot: dependencies.saveSnapshotToDisk,
|
||||
|
||||
+104
-32
@@ -9,6 +9,13 @@ import type {
|
||||
CaptureSourceInfo,
|
||||
ClickResult,
|
||||
ReviewSampleListResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
ScannerCommand,
|
||||
ScannerStatusPayload,
|
||||
AppRuntimeInfo,
|
||||
@@ -24,7 +31,16 @@ interface DevControlServerDependencies {
|
||||
sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scannerStatus: () => ScannerStatusPayload;
|
||||
warmOcr: (engine: "current" | "ik-traineddata") => Promise<unknown>;
|
||||
nativeScannerDataStatus: () => Promise<NativeScannerDataStatus>;
|
||||
nativeScannerCatalog: () => Promise<NativeScannerCatalogStatus>;
|
||||
nativeScannerPreflight: (options?: { category?: string }) => Promise<NativeScannerPreflightStatus>;
|
||||
nativeScannerStart: (options?: { limit?: number; category?: string }) => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||
warmOcr: (engine: "current") => Promise<unknown>;
|
||||
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||
listCaptureSources: () => Promise<CaptureSourceInfo[]>;
|
||||
captureSource: (
|
||||
@@ -161,23 +177,17 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
}
|
||||
if (url.pathname === "/scanner/start") {
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
const entry = url.searchParams.get("entry");
|
||||
const engine = url.searchParams.get("engine");
|
||||
const scanEntryMode = entry === "paimon-menu" || entry === "visible-inventory" || entry === "direct-inventory" || entry === "auto-entry"
|
||||
? entry
|
||||
: undefined;
|
||||
const ocrEngine = engine === "ik-traineddata" ? "ik-traineddata" : engine === "current" ? "current" : undefined;
|
||||
const hasLimit = Number.isFinite(limit) && limit > 0;
|
||||
const command: ScannerCommand = hasLimit || scanEntryMode || ocrEngine
|
||||
? { type: "start-auto", scanLimit: hasLimit ? limit : undefined, scanEntryMode, ocrEngine }
|
||||
: "start-auto";
|
||||
deps.sendScannerCommand(command);
|
||||
writeDevJson(res, 200, { ok: true, command });
|
||||
const category = url.searchParams.get("category") ?? undefined;
|
||||
deps.nativeScannerStart({ ...(hasLimit ? { limit } : {}), ...(category ? { category } : {}) })
|
||||
.then((scanner) => writeDevJson(res, 200, { ok: true, scanner }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/stop") {
|
||||
deps.sendScannerCommand("stop");
|
||||
writeDevJson(res, 200, { ok: true, command: "stop" });
|
||||
deps.nativeScannerStop()
|
||||
.then((scanner) => writeDevJson(res, 200, { ok: true, scanner }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/probe") {
|
||||
@@ -198,13 +208,68 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/status") {
|
||||
writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() });
|
||||
deps.nativeScannerStatus()
|
||||
.then((nativeScanner) => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus(), nativeScanner }))
|
||||
.catch(() => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/data") {
|
||||
deps.nativeScannerDataStatus()
|
||||
.then((status) => writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/catalog") {
|
||||
deps.nativeScannerCatalog()
|
||||
.then((catalog) => writeDevJson(res, catalog.data.valid ? 200 : 409, { ok: catalog.data.valid, catalog }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/preflight") {
|
||||
const category = url.searchParams.get("category") ?? undefined;
|
||||
deps.nativeScannerPreflight(category ? { category } : undefined)
|
||||
.then((status) => writeDevJson(res, status.ready ? 200 : 409, { ok: status.ready, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/process") {
|
||||
const runDir = url.searchParams.get("runDir") ?? undefined;
|
||||
const persist = url.searchParams.get("persist") === "1";
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
deps.nativeScannerProcessRun({
|
||||
runDir,
|
||||
persist,
|
||||
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
||||
})
|
||||
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/results") {
|
||||
const runDir = url.searchParams.get("runDir") ?? undefined;
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
deps.nativeScannerLoadResults({
|
||||
runDir,
|
||||
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
||||
})
|
||||
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/image") {
|
||||
const runDir = url.searchParams.get("runDir") ?? undefined;
|
||||
const imagePath = url.searchParams.get("imagePath");
|
||||
if (!imagePath) {
|
||||
writeDevJson(res, 400, { ok: false, error: "imagePath query param is required" });
|
||||
return;
|
||||
}
|
||||
deps.nativeScannerLoadImage({ runDir, imagePath })
|
||||
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/ocr/warmup") {
|
||||
const engineParam = url.searchParams.get("engine");
|
||||
const engine = engineParam === "ik-traineddata" ? "ik-traineddata" : "current";
|
||||
deps.warmOcr(engine)
|
||||
deps.warmOcr("current")
|
||||
.then((status) => writeDevJson(res, 200, { ok: true, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
@@ -226,14 +291,8 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
if (url.pathname === "/scanner/benchmark-ocr") {
|
||||
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1));
|
||||
const sourceId = url.searchParams.get("sourceId");
|
||||
const engineParam = url.searchParams.get("engine");
|
||||
const profileParam = url.searchParams.get("profile");
|
||||
const ocrProfile: "full" | "fast" = profileParam === "full" ? "full" : "fast";
|
||||
const engines: Array<"current" | "ik-traineddata"> = engineParam === "compare"
|
||||
? ["current", "ik-traineddata"]
|
||||
: engineParam === "ik-traineddata"
|
||||
? ["ik-traineddata"]
|
||||
: ["current"];
|
||||
deps.listCaptureSources()
|
||||
.then(async (sources) => {
|
||||
const source = findGenshinSource(sources, sourceId);
|
||||
@@ -243,7 +302,7 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
}
|
||||
const benchmarkSource = source;
|
||||
|
||||
async function runEngineBenchmark(engine: "current" | "ik-traineddata") {
|
||||
async function runCurrentBenchmark() {
|
||||
const startedAt = Date.now();
|
||||
const captures: Array<{
|
||||
index: number;
|
||||
@@ -265,7 +324,7 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, {
|
||||
ocrMode: "artifact",
|
||||
ocrProfile,
|
||||
ocrEngine: engine,
|
||||
ocrEngine: "current",
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
@@ -316,7 +375,7 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
]),
|
||||
);
|
||||
return {
|
||||
engine,
|
||||
engine: "current",
|
||||
nativeTesseract: "not-enabled",
|
||||
workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null,
|
||||
ocrProfile,
|
||||
@@ -340,13 +399,10 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
captures,
|
||||
};
|
||||
}
|
||||
const summaries = [];
|
||||
for (const engine of engines) {
|
||||
summaries.push(await runEngineBenchmark(engine));
|
||||
}
|
||||
const summary = await runCurrentBenchmark();
|
||||
writeDevJson(res, 200, {
|
||||
ok: true,
|
||||
summary: summaries.length === 1 ? summaries[0] : { mode: "compare", limit, ocrProfile, engines: summaries },
|
||||
summary,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
@@ -390,6 +446,22 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
}
|
||||
|
||||
const before = await deps.captureSource(source.id, 150, true, { skipOcr: true });
|
||||
const grid = before.inventoryGrid;
|
||||
const detail = before.artifactDetail;
|
||||
const gridReady = before.captureTarget === "genshin-client"
|
||||
&& grid?.source === "detected"
|
||||
&& (grid.confidence ?? 0) >= 60;
|
||||
const detailReady = Boolean(detail?.present && detail.confidence >= 45);
|
||||
if (!gridReady || !detailReady) {
|
||||
writeDevJson(res, 409, {
|
||||
ok: false,
|
||||
error: "Artifact inventory detail view is not ready for probe-click.",
|
||||
captureTarget: before.captureTarget,
|
||||
grid: grid ? { rows: grid.rows, cols: grid.cols, source: grid.source, confidence: grid.confidence } : null,
|
||||
artifactDetail: detail ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const centers = before.inventoryGrid?.centers ?? [];
|
||||
const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol)
|
||||
? centers.find((center) => center.row === requestedRow && center.col === requestedCol)
|
||||
|
||||
@@ -2,6 +2,17 @@ import { ipcMain } from "electron";
|
||||
import type {
|
||||
BooleanResult,
|
||||
FocusGenshinResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
NativeScannerStartCategory,
|
||||
RuntimeInfo,
|
||||
SaveSnapshotResult,
|
||||
ScannerStatusPayload,
|
||||
@@ -13,6 +24,17 @@ interface AppCommandDependencies {
|
||||
moveMainWindowOffGenshin: () => Promise<void>;
|
||||
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
||||
nativeScannerDataStatus: () => Promise<NativeScannerDataStatus>;
|
||||
nativeScannerCatalog: () => Promise<NativeScannerCatalogStatus>;
|
||||
nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory | string }) => Promise<NativeScannerPreflightStatus>;
|
||||
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
|
||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
|
||||
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||
getRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||
loadSnapshot: () => Promise<AppSnapshot | null>;
|
||||
saveSnapshot: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
|
||||
@@ -26,6 +48,17 @@ export function registerAppHandlers({
|
||||
moveMainWindowOffGenshin,
|
||||
focusGenshinForScanStart,
|
||||
publishScannerStatus,
|
||||
nativeScannerDataStatus,
|
||||
nativeScannerCatalog,
|
||||
nativeScannerPreflight,
|
||||
nativeScannerStart,
|
||||
nativeScannerStop,
|
||||
nativeScannerStatus,
|
||||
nativeScannerProcessRun,
|
||||
nativeScannerLoadResults,
|
||||
nativeScannerPromoteResults,
|
||||
nativeScannerReviewResult,
|
||||
nativeScannerLoadImage,
|
||||
getRuntimeInfo,
|
||||
loadSnapshot,
|
||||
saveSnapshot,
|
||||
@@ -42,6 +75,17 @@ export function registerAppHandlers({
|
||||
await publishScannerStatus(status);
|
||||
return { ok: true };
|
||||
});
|
||||
ipcMain.handle("scanner:nativeDataStatus", async () => nativeScannerDataStatus());
|
||||
ipcMain.handle("scanner:nativeCatalog", async () => nativeScannerCatalog());
|
||||
ipcMain.handle("scanner:nativePreflight", async (_event, options?: { category?: NativeScannerStartCategory | string }) => nativeScannerPreflight(options));
|
||||
ipcMain.handle("scanner:nativeStart", async (_event, options?: { limit?: number; category?: NativeScannerStartCategory }) => nativeScannerStart(options));
|
||||
ipcMain.handle("scanner:nativeStop", async () => nativeScannerStop());
|
||||
ipcMain.handle("scanner:nativeStatus", async () => nativeScannerStatus());
|
||||
ipcMain.handle("scanner:nativeProcessRun", async (_event, options?: { runDir?: string; persist?: boolean; limit?: number }) => nativeScannerProcessRun(options));
|
||||
ipcMain.handle("scanner:nativeLoadResults", async (_event, options?: { runDir?: string; limit?: number }) => nativeScannerLoadResults(options));
|
||||
ipcMain.handle("scanner:nativePromoteResults", async (_event, options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options));
|
||||
ipcMain.handle("scanner:nativeReviewResult", async (_event, options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => nativeScannerReviewResult(options));
|
||||
ipcMain.handle("scanner:nativeLoadImage", async (_event, options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options));
|
||||
ipcMain.handle("app:getRuntimeInfo", async () => getRuntimeInfo());
|
||||
ipcMain.handle("snapshot:load", async () => loadSnapshot());
|
||||
ipcMain.handle("snapshot:save", async (_event, snapshot: AppSnapshot) => saveSnapshot(snapshot));
|
||||
|
||||
+355
-45
@@ -6,17 +6,33 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createWorker, PSM } from "tesseract.js";
|
||||
import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js";
|
||||
import {
|
||||
createNativeScannerProcessingService,
|
||||
nativeScannerProcessStats,
|
||||
type NativeCaptureJobPayload,
|
||||
} from "./services/nativeScannerProcessingService.js";
|
||||
import { pngBufferToBitmap } from "./services/pngBitmap.js";
|
||||
import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js";
|
||||
import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
|
||||
import { createDevControlServer } from "./devControlServer.js";
|
||||
import { createAppWindowManager, type AppWindowManager } from "./appWindowManager.js";
|
||||
import { createGoodFileService, type GoodFileService } from "./services/goodFileService.js";
|
||||
import type { IkArtifactCatalog } from "../src/lib/ikArtifactMatcher.js";
|
||||
import type { AppSnapshot } from "../src/types/domain.js";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
GoodDatabase,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
OcrResult,
|
||||
AppRuntimeInfo,
|
||||
ScannerCommand,
|
||||
@@ -54,7 +70,7 @@ app.commandLine.appendSwitch("disable-gpu-sandbox");
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
|
||||
const APP_RUNTIME_STARTED_AT = new Date().toISOString();
|
||||
const APP_RUNTIME_SIGNATURE = "2026-07-08-direct-gdi-reviewfix";
|
||||
const APP_RUNTIME_SIGNATURE = "2026-07-09-native-artifact-pipeline";
|
||||
|
||||
let registeredHotkeys: Record<string, boolean> = {};
|
||||
let devControlServer: Server | null = null;
|
||||
@@ -214,6 +230,305 @@ async function publishScannerStatus(status: ScannerStatusPayload) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function nativeScannerDataDir() {
|
||||
const candidates = [
|
||||
process.env.IK_INVENTORYLISTS_DIR,
|
||||
path.join(process.cwd(), "data", "ik-inventorylists"),
|
||||
path.join(app.getAppPath(), "data", "ik-inventorylists"),
|
||||
path.join(process.resourcesPath, "ik-inventorylists"),
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
return candidates.find((candidate) => existsSync(path.join(candidate, "version.txt"))) ?? candidates[0];
|
||||
}
|
||||
|
||||
function nativeScannerOutputRoot() {
|
||||
return path.join(app.getPath("userData"), "native-scans");
|
||||
}
|
||||
|
||||
function nativeScannerStats(status: NativeScannerRunStatus): Record<string, number> {
|
||||
return {
|
||||
clicked: Number(status.clicked ?? 0),
|
||||
attempted: Number(status.captured ?? 0),
|
||||
verified: Number(status.captured ?? 0),
|
||||
parsed: 0,
|
||||
stored: 0,
|
||||
review: 0,
|
||||
duplicates: 0,
|
||||
misses: 0,
|
||||
pages: Number(status.pages ?? 0),
|
||||
elapsedMs: Number(status.activeMs ?? 0),
|
||||
activeScanMs: Number(status.activeMs ?? 0),
|
||||
queued: Number(status.queued ?? 0),
|
||||
captured: Number(status.captured ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function publishNativeScannerStatus(status: NativeScannerRunStatus, extra: Partial<ScannerStatusPayload> = {}) {
|
||||
const summary = {
|
||||
mode: "Native IK Scanner",
|
||||
status: status.status,
|
||||
...nativeScannerStats(status),
|
||||
targetCount: status.target,
|
||||
gridLabel: status.message,
|
||||
runId: status.runId,
|
||||
outputRoot: status.outputRoot,
|
||||
runDir: status.runDir,
|
||||
manifestPath: status.manifestPath,
|
||||
jobsPath: status.jobsPath,
|
||||
lastArtifactPath: status.lastArtifactPath,
|
||||
};
|
||||
scannerDevStatus = {
|
||||
...scannerDevStatus,
|
||||
running: Boolean(status.running),
|
||||
reviewStatus: status.message || "Native scanner ready.",
|
||||
captureStatus: status.status,
|
||||
stats: nativeScannerStats(status),
|
||||
summary,
|
||||
automationLog: [
|
||||
...(scannerDevStatus.automationLog ?? []).slice(-10),
|
||||
status.message || `native scanner ${status.status}`,
|
||||
],
|
||||
nativeScanner: status,
|
||||
...extra,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return scannerDevStatus;
|
||||
}
|
||||
|
||||
async function nativeScannerDataStatus(): Promise<NativeScannerDataStatus> {
|
||||
return getInputHelperService().nativeScannerDataStatus(nativeScannerDataDir());
|
||||
}
|
||||
|
||||
async function nativeScannerCatalog(): Promise<NativeScannerCatalogStatus> {
|
||||
return getInputHelperService().nativeScannerCatalog(nativeScannerDataDir());
|
||||
}
|
||||
|
||||
async function loadNativeIkArtifactCatalog(): Promise<IkArtifactCatalog | null> {
|
||||
const catalog = await nativeScannerCatalog();
|
||||
return catalog.data.valid ? { artifacts: catalog.artifacts } : null;
|
||||
}
|
||||
|
||||
async function nativeScannerPreflight(options: { category?: string } = {}): Promise<NativeScannerPreflightStatus> {
|
||||
const preflight = await getInputHelperService().nativeScannerPreflight(nativeScannerDataDir(), options.category ?? "artifacts");
|
||||
scannerDevStatus = {
|
||||
...scannerDevStatus,
|
||||
nativeScannerPreflight: preflight,
|
||||
lookupStatus: preflight.data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return preflight;
|
||||
}
|
||||
|
||||
async function nativeScannerStart(options: { limit?: number; category?: string } = {}): Promise<NativeScannerRunStatus> {
|
||||
const status = await getInputHelperService().nativeScannerStart({
|
||||
dataDir: nativeScannerDataDir(),
|
||||
outputRoot: nativeScannerOutputRoot(),
|
||||
limit: options.limit ?? 100,
|
||||
category: options.category ?? "artifacts",
|
||||
});
|
||||
publishNativeScannerStatus(status, { lookupStatus: await nativeScannerDataStatus().catch(() => undefined) });
|
||||
return status;
|
||||
}
|
||||
|
||||
async function nativeScannerStop(): Promise<NativeScannerRunStatus> {
|
||||
const status = await getInputHelperService().nativeScannerStop();
|
||||
publishNativeScannerStatus(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
async function nativeScannerStatus(): Promise<NativeScannerRunStatus> {
|
||||
const status = await getInputHelperService().nativeScannerStatus();
|
||||
publishNativeScannerStatus(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
function resolveNativeProcessRunDir(runDir?: string) {
|
||||
const native = scannerDevStatus.nativeScanner as NativeScannerRunStatus | undefined;
|
||||
const candidate = runDir?.trim() || native?.runDir || "";
|
||||
if (!candidate) return "";
|
||||
const root = path.resolve(nativeScannerOutputRoot());
|
||||
const resolved = path.resolve(candidate);
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) return "";
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function buildNativeCardCaptureResult(
|
||||
imagePath: string,
|
||||
job: NativeCaptureJobPayload,
|
||||
): Promise<CaptureResult> {
|
||||
const sourceImage = nativeImage.createFromPath(imagePath);
|
||||
const size = sourceImage.getSize();
|
||||
if (!size.width || !size.height) throw new Error(`Native card crop is empty: ${imagePath}`);
|
||||
const detailRect = { x: 0, y: 0, width: size.width, height: size.height };
|
||||
const inventoryRect = { x: 0, y: 0, width: 1, height: 1 };
|
||||
const crops = createCrops(
|
||||
sourceImage,
|
||||
size,
|
||||
detailRect,
|
||||
inventoryRect,
|
||||
{
|
||||
ocrMode: "artifact",
|
||||
ocrProfile: "full",
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCropImages: true,
|
||||
omitLockState: true,
|
||||
},
|
||||
undefined,
|
||||
{ skipOcr: false },
|
||||
);
|
||||
const croppedPayload = crops
|
||||
.filter((crop) => crop.ocrEnabled !== false)
|
||||
.map((crop) => crop.ocrImage
|
||||
? {
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
image: crop.ocrImage,
|
||||
}
|
||||
: null)
|
||||
.filter((crop): crop is OcrCropPayload => Boolean(crop));
|
||||
const recognized = await runOcrOnCropsWithTimeout(croppedPayload, "current", 6500);
|
||||
return {
|
||||
id: `native-card-${job.sequence}`,
|
||||
name: path.basename(imagePath),
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
dataUrl: "",
|
||||
capturedAt: new Date().toISOString(),
|
||||
captureTarget: "genshin-client",
|
||||
detailFingerprint: `${path.basename(imagePath)}:${size.width}x${size.height}`,
|
||||
ocr: recognized.ocr,
|
||||
ocrTimedOut: recognized.timedOut,
|
||||
ocrSkipped: false,
|
||||
crops: crops.map((crop) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
rect: {
|
||||
x: crop.rect.x,
|
||||
y: crop.rect.y,
|
||||
width: crop.rect.width,
|
||||
height: crop.rect.height,
|
||||
},
|
||||
dataUrl: crop.dataUrl,
|
||||
})),
|
||||
artifactDetail: {
|
||||
present: true,
|
||||
confidence: 70,
|
||||
orangeHits: 0,
|
||||
greenHits: 0,
|
||||
textHits: 0,
|
||||
},
|
||||
layout: {
|
||||
aspect: aspectRatioLabel(size),
|
||||
isSixteenNine: false,
|
||||
warning: "Native card-crop processing uses the detail card as its own coordinate space.",
|
||||
},
|
||||
timings: {
|
||||
prepareMs: 0,
|
||||
ocrMs: 0,
|
||||
totalMs: 0,
|
||||
ocrProfile: "full",
|
||||
ocrEngine: "current",
|
||||
ocrWorkerPoolSize: OCR_WORKER_POOL_SIZE,
|
||||
cropCount: croppedPayload.length,
|
||||
ocrSkipped: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNativeScannerService() {
|
||||
return createNativeScannerProcessingService({
|
||||
resolveRunDir: resolveNativeProcessRunDir,
|
||||
buildCaptureResult: buildNativeCardCaptureResult,
|
||||
loadArtifacts: () => getArtifactStoreRepository().loadAll(),
|
||||
saveArtifacts: (records) => getArtifactStoreRepository().saveMany(records),
|
||||
loadIkArtifactCatalog: loadNativeIkArtifactCatalog,
|
||||
saveReviewSample: (sample) => getReviewSamplesRepository().append(sample),
|
||||
});
|
||||
}
|
||||
|
||||
async function nativeScannerProcessRun(options: { runDir?: string; persist?: boolean; limit?: number } = {}): Promise<NativeScannerProcessStatus> {
|
||||
const service = createNativeScannerService();
|
||||
const status = await service.processRun(options);
|
||||
scannerDevStatus = {
|
||||
...scannerDevStatus,
|
||||
reviewStatus: `Native post-processing: ${status.parsed}/${status.processed} parsed, ${status.review} review, ${status.stored} stored.`,
|
||||
stats: {
|
||||
...(scannerDevStatus.stats ?? {}),
|
||||
...nativeScannerProcessStats(status),
|
||||
},
|
||||
nativeScannerProcessing: status,
|
||||
automationLog: [
|
||||
...(scannerDevStatus.automationLog ?? []).slice(-10),
|
||||
`native process: ${status.parsed}/${status.processed} parsed, report ${status.reportPath}`,
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return status;
|
||||
}
|
||||
|
||||
async function nativeScannerLoadResults(options: { runDir?: string; limit?: number } = {}): Promise<NativeScannerResultsLoadStatus> {
|
||||
const service = createNativeScannerService();
|
||||
const loaded = await service.loadResults(options);
|
||||
scannerDevStatus = {
|
||||
...scannerDevStatus,
|
||||
nativeScannerResults: loaded,
|
||||
automationLog: [
|
||||
...(scannerDevStatus.automationLog ?? []).slice(-10),
|
||||
`native results: ${loaded.results.length}/${loaded.total} loaded from ${loaded.path || "scan-results.json"}`,
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async function nativeScannerPromoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus> {
|
||||
const service = createNativeScannerService();
|
||||
const status = await service.promoteResults(options);
|
||||
scannerDevStatus = {
|
||||
...scannerDevStatus,
|
||||
reviewStatus: status.ok
|
||||
? `Native promotion: ${status.promoted} promoted, ${status.alreadyStored} already stored.`
|
||||
: `Native promotion blocked: ${status.error ?? "unknown error"}`,
|
||||
nativeScannerPromotion: status,
|
||||
automationLog: [
|
||||
...(scannerDevStatus.automationLog ?? []).slice(-10),
|
||||
`native promotion: ${status.promoted}/${status.requested}, log ${status.logPath || "unavailable"}`,
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return status;
|
||||
}
|
||||
|
||||
async function nativeScannerReviewResult(options: {
|
||||
runDir?: string;
|
||||
resultId: string;
|
||||
action: "approve" | "reject";
|
||||
artifact?: NativeScannerReviewArtifactInput;
|
||||
note?: string;
|
||||
}): Promise<NativeScannerReviewStatus> {
|
||||
const service = createNativeScannerService();
|
||||
const status = await service.reviewResult(options);
|
||||
scannerDevStatus = {
|
||||
...scannerDevStatus,
|
||||
reviewStatus: status.ok
|
||||
? `Native review ${status.action}: ${status.resultId}`
|
||||
: `Native review blocked: ${status.error ?? "unknown error"}`,
|
||||
nativeScannerReview: status,
|
||||
automationLog: [
|
||||
...(scannerDevStatus.automationLog ?? []).slice(-10),
|
||||
`native review ${status.action}: ${status.ok ? "ok" : status.error}`,
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return status;
|
||||
}
|
||||
|
||||
async function nativeScannerLoadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus> {
|
||||
const service = createNativeScannerService();
|
||||
return service.loadImage(options);
|
||||
}
|
||||
|
||||
async function readRuntimeInfo() {
|
||||
try {
|
||||
const result = await getInputHelperService().getRuntimeInfo();
|
||||
@@ -446,6 +761,9 @@ function focusMainWindow() {
|
||||
}
|
||||
|
||||
function sendScannerCommand(command: ScannerCommand | "probe-click") {
|
||||
if (command === "stop") {
|
||||
void nativeScannerStop().catch(() => undefined);
|
||||
}
|
||||
getAppWindowManager().sendScannerCommand(command);
|
||||
}
|
||||
|
||||
@@ -468,6 +786,15 @@ function startDevControlServer() {
|
||||
sendScannerCommand,
|
||||
clickScreen: clickScreenCommand,
|
||||
scannerStatus: () => ({ ...scannerDevStatus, appBuild: appRuntimeInfo(), ocrWarmup: getOcrWarmupStatus() }),
|
||||
nativeScannerDataStatus,
|
||||
nativeScannerCatalog,
|
||||
nativeScannerPreflight,
|
||||
nativeScannerStart,
|
||||
nativeScannerStop,
|
||||
nativeScannerStatus,
|
||||
nativeScannerProcessRun,
|
||||
nativeScannerLoadResults,
|
||||
nativeScannerLoadImage,
|
||||
warmOcr: (engine) => warmOcrWorkerPool(engine),
|
||||
loadReviewSamples,
|
||||
listCaptureSources,
|
||||
@@ -483,15 +810,13 @@ function createOverlayWindow() {
|
||||
getAppWindowManager().createOverlayWindow();
|
||||
}
|
||||
|
||||
// Inventory Kamera keeps a pool of native Tesseract engines and scans artifact
|
||||
// fields concurrently. Our fast artifact profile has four useful OCR parameter
|
||||
// groups, so the default pool is four workers unless the machine is smaller or
|
||||
// GAA_OCR_WORKERS explicitly overrides it.
|
||||
// The fast artifact profile has four useful OCR parameter groups, so the
|
||||
// default pool is four workers unless the machine is smaller or GAA_OCR_WORKERS
|
||||
// explicitly overrides it.
|
||||
const OCR_WORKER_POOL_SIZE = resolveOcrWorkerPoolSize();
|
||||
const IK_TRAINEDDATA_LANG = "genshin_fast_09_04_21";
|
||||
|
||||
type OcrWorker = Awaited<ReturnType<typeof createWorker>>;
|
||||
type OcrWorkerEngine = "current" | "ik-traineddata";
|
||||
type OcrWorkerEngine = "current";
|
||||
type OcrCropPayload = { id: string; label: string; image: Buffer };
|
||||
type OcrWarmupStatus = {
|
||||
engine: OcrWorkerEngine;
|
||||
@@ -513,11 +838,9 @@ type OcrWorkerPoolState = {
|
||||
|
||||
const ocrWorkerPools: Record<OcrWorkerEngine, OcrWorkerPoolState> = {
|
||||
current: { poolPromise: null, runQueue: Promise.resolve() },
|
||||
"ik-traineddata": { poolPromise: null, runQueue: Promise.resolve() },
|
||||
};
|
||||
const ocrWarmupStatuses: Record<OcrWorkerEngine, OcrWarmupStatus> = {
|
||||
current: { engine: "current", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE },
|
||||
"ik-traineddata": { engine: "ik-traineddata", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE },
|
||||
};
|
||||
|
||||
function resolveOcrWorkerPoolSize() {
|
||||
@@ -541,42 +864,13 @@ function appRuntimeInfo(): AppRuntimeInfo {
|
||||
}
|
||||
|
||||
function ocrEngineFromOptions(options: CaptureOptions = {}): OcrWorkerEngine {
|
||||
return options.ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current";
|
||||
}
|
||||
|
||||
function ikTessdataCandidates() {
|
||||
const envDir = process.env.IK_TESSDATA_DIR;
|
||||
return [
|
||||
envDir,
|
||||
path.resolve(process.cwd(), "data", "tessdata"),
|
||||
path.resolve(process.cwd(), "work", "Inventory_Kamera", "InventoryKamera", "tessdata"),
|
||||
path.resolve(process.cwd(), "work", "refs", "Inventory_Kamera", "InventoryKamera", "tessdata"),
|
||||
path.resolve(process.cwd(), "..", "_ik_ref_fork", "InventoryKamera", "tessdata"),
|
||||
path.resolve(process.cwd(), "..", "_ik_ref", "InventoryKamera", "tessdata"),
|
||||
path.resolve(process.env.USERPROFILE ?? "", "Desktop", "_ik_ref_fork", "InventoryKamera", "tessdata"),
|
||||
path.resolve(process.env.USERPROFILE ?? "", "Desktop", "_ik_ref", "InventoryKamera", "tessdata"),
|
||||
process.resourcesPath ? path.resolve(process.resourcesPath, "tessdata") : "",
|
||||
].filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
function findIkTessdataDir() {
|
||||
return ikTessdataCandidates().find((candidate) => existsSync(path.join(candidate, `${IK_TRAINEDDATA_LANG}.traineddata`))) ?? "";
|
||||
void options;
|
||||
return "current";
|
||||
}
|
||||
|
||||
function getOcrWorkerOptions(engine: OcrWorkerEngine) {
|
||||
if (engine !== "ik-traineddata") return { lang: "eng", options: undefined };
|
||||
const langPath = findIkTessdataDir();
|
||||
if (!langPath) {
|
||||
throw new Error(`IK traineddata not found. Set IK_TESSDATA_DIR or place ${IK_TRAINEDDATA_LANG}.traineddata in data/tessdata.`);
|
||||
}
|
||||
return {
|
||||
lang: IK_TRAINEDDATA_LANG,
|
||||
options: {
|
||||
langPath,
|
||||
gzip: false,
|
||||
cachePath: app.isReady() ? path.join(app.getPath("userData"), "tessdata-cache") : path.resolve(process.cwd(), "outputs", "tessdata-cache"),
|
||||
},
|
||||
};
|
||||
void engine;
|
||||
return { lang: "eng", options: undefined };
|
||||
}
|
||||
|
||||
function getOcrWorkerPool(engine: OcrWorkerEngine) {
|
||||
@@ -594,7 +888,7 @@ function getOcrWorkerPool(engine: OcrWorkerEngine) {
|
||||
}
|
||||
|
||||
async function resetOcrWorker(engine?: OcrWorkerEngine) {
|
||||
const engines: OcrWorkerEngine[] = engine ? [engine] : ["current", "ik-traineddata"];
|
||||
const engines: OcrWorkerEngine[] = engine ? [engine] : ["current"];
|
||||
await Promise.all(engines.map(async (engineId) => {
|
||||
const state = ocrWorkerPools[engineId];
|
||||
const broken = state.poolPromise;
|
||||
@@ -710,7 +1004,6 @@ function warmOcrWorkerPool(engine: OcrWorkerEngine = "current") {
|
||||
function getOcrWarmupStatus() {
|
||||
return {
|
||||
current: ocrWarmupStatuses.current,
|
||||
"ik-traineddata": ocrWarmupStatuses["ik-traineddata"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1334,7 +1627,7 @@ async function buildCaptureResult(
|
||||
: await runOcrOnCropsWithTimeout(croppedPayload, ocrEngine);
|
||||
const ocrMs = Date.now() - ocrStartedAt;
|
||||
const totalMs = Date.now() - buildStartedAt;
|
||||
const captureOcrEngine: CaptureOptions["ocrEngine"] = ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current";
|
||||
const captureOcrEngine: CaptureOptions["ocrEngine"] = "current";
|
||||
|
||||
const count = parseInventoryCount(recognized.ocr);
|
||||
return {
|
||||
@@ -1458,6 +1751,23 @@ function initializeAppLifecycle() {
|
||||
moveMainWindowOffGenshin: async () => moveMainWindowOffGenshin(),
|
||||
focusGenshinForScanStart: () => focusGenshinForScanStart(),
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => publishScannerStatus(status),
|
||||
nativeScannerDataStatus: () => nativeScannerDataStatus(),
|
||||
nativeScannerCatalog: () => nativeScannerCatalog(),
|
||||
nativeScannerPreflight: (options?: { category?: string }) => nativeScannerPreflight(options),
|
||||
nativeScannerStart: (options?: { limit?: number; category?: string }) => nativeScannerStart(options),
|
||||
nativeScannerStop: () => nativeScannerStop(),
|
||||
nativeScannerStatus: () => nativeScannerStatus(),
|
||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => nativeScannerProcessRun(options),
|
||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => nativeScannerLoadResults(options),
|
||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options),
|
||||
nativeScannerReviewResult: (options: {
|
||||
runDir?: string;
|
||||
resultId: string;
|
||||
action: "approve" | "reject";
|
||||
artifact?: NativeScannerReviewArtifactInput;
|
||||
note?: string;
|
||||
}) => nativeScannerReviewResult(options),
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options),
|
||||
readRuntimeInfo: () => readRuntimeInfo(),
|
||||
loadSnapshotFromDisk: () => loadSnapshotFromDisk(),
|
||||
saveSnapshotToDisk: (snapshot: AppSnapshot) => saveSnapshotToDisk(snapshot),
|
||||
|
||||
@@ -23,6 +23,17 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
|
||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||
nativeScannerDataStatus: () => ipcRenderer.invoke("scanner:nativeDataStatus"),
|
||||
nativeScannerCatalog: () => ipcRenderer.invoke("scanner:nativeCatalog"),
|
||||
nativeScannerPreflight: (options) => ipcRenderer.invoke("scanner:nativePreflight", options),
|
||||
nativeScannerStart: (options) => ipcRenderer.invoke("scanner:nativeStart", options),
|
||||
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
|
||||
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
|
||||
nativeScannerProcessRun: (options) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
|
||||
nativeScannerLoadResults: (options) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
|
||||
nativeScannerPromoteResults: (options) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
|
||||
nativeScannerReviewResult: (options) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
|
||||
nativeScannerLoadImage: (options) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
|
||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||
onScannerCommand: (callback) => {
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||
import type { CaptureOptions, GoodDatabase, NativeScannerReviewArtifactInput, NativeScannerStartCategory, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||
import type { StoredArtifactRecord } from "../src/types/storage.js";
|
||||
import type { AppSnapshot } from "../src/types/domain.js";
|
||||
|
||||
@@ -26,6 +26,17 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
|
||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||
nativeScannerDataStatus: () => ipcRenderer.invoke("scanner:nativeDataStatus"),
|
||||
nativeScannerCatalog: () => ipcRenderer.invoke("scanner:nativeCatalog"),
|
||||
nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativePreflight", options),
|
||||
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativeStart", options),
|
||||
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
|
||||
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
|
||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
|
||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
|
||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
|
||||
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
|
||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||
onScannerCommand: (callback: (command: ScannerCommand) => void) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user