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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user