import fs from "node:fs/promises"; import path from "node:path"; import { pngBufferToBitmap } from "./pngBitmap.js"; import { createNativeScannerResultWorkflowService } from "./nativeScannerResultWorkflowService.js"; import { loadNativeScannerDeletedResultIds } from "./nativeScannerResultTombstones.js"; import { parseArtifactCandidate } from "../../src/lib/artifactOcrParser.js"; import { evaluateStoredScanResult } from "../../src/lib/artifactEvaluation.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 { shouldFlagArtifactForReview } from "../../src/lib/scannerLearning.js"; import type { ArtifactStoreLoadResult, ArtifactStoreSaveResult, CaptureResult, NativeScannerImageLoadStatus, NativeScannerDeleteResultStatus, NativeScannerProcessStatus, NativeScannerProcessingProgressStatus, NativeScannerPromotionStatus, NativeScannerReviewArtifactInput, NativeScannerReviewStatus, NativeScannerResultsLoadStatus, NativeScannerRunTiming, NativeScannerRunTimingPatch, ReviewSamplePayload, } from "../../src/types/global.js"; import type { ArtifactRarity, 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; /** Direct visual evidence from the native card crop; absent on legacy jobs. */ starCount?: number | null; starConfidence?: number; starSource?: string | null; }; export interface NativeScannerProcessingService { processRun(options?: { runDir?: string; persist?: boolean; limit?: number; stream?: boolean; expectedTotal?: number; }): Promise; loadResults(options?: { runDir?: string; limit?: number; afterSequence?: number }): Promise; promoteResults(options: { runDir?: string; resultIds: string[] }): Promise; reviewResult(options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string; }): Promise; deleteResult(options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean; }): Promise; loadImage(options: { runDir?: string; imagePath: string }): Promise; } interface NativeScannerProcessingServiceDependencies { resolveRunDir(runDir?: string): string; buildCaptureResult(imagePath: string, job: NativeCaptureJobPayload): Promise; loadArtifacts?: () => Promise; saveArtifacts(records: StoredArtifactRecord[]): Promise & Partial>; removeArtifacts?: (ids: string[]) => Promise<{ ok: boolean; removed: number }>; saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>; loadIkArtifactCatalog?: () => Promise; /** Durable timing is observational only and must never block OCR/parse work. */ recordRunTiming?: (runDir: string, patch: NativeScannerRunTimingPatch) => Promise; loadRunTiming?: (runDir: string) => Promise; onProgress?: (progress: NativeScannerProcessingProgressStatus) => void; onResult?: (result: StoredScanResultEntry) => void; } type ProcessedNativeJob = { result: NativeScannerProcessStatus["results"][number]; scanResult: StoredScanResultEntry; storedRecord: StoredArtifactRecord | null; }; const POST_CAPTURE_QUEUE_CONCURRENCY = 4; const STREAM_QUEUE_HIGH_WATERMARK = 8; const STREAM_READ_CHUNK_BYTES = 64 * 1024; const STREAM_POLL_INTERVAL_MS = 40; const STREAM_TERMINAL_DRAIN_GRACE_MS = 2_000; const MAX_CACHED_LIVE_RUNS = 8; type ProcessRunOptions = { runDir?: string; persist?: boolean; limit?: number; stream?: boolean; expectedTotal?: number; }; type NativeCaptureProducerStatus = { running: boolean; status: string; target: number; captured: number; }; const activeProcessingRuns = new Map>(); const liveResultsByRunDir = new Map(); export function createNativeScannerProcessingService( deps: NativeScannerProcessingServiceDependencies, ): NativeScannerProcessingService { const resultWorkflows = createNativeScannerResultWorkflowService({ ...deps, isProcessingRunActive: (runDir) => activeProcessingRuns.has(runDir), }); return { processRun(options: ProcessRunOptions = {}) { const runDir = deps.resolveRunDir(options.runDir); if (!runDir) { return Promise.resolve(emptyProcessStatus("No native scanner runDir available.")); } const active = activeProcessingRuns.get(runDir); if (active) return active; replaceCachedLiveResults(runDir, []); const runPromise = processNativeScannerRun({ deps, options, runDir }) .finally(() => { if (activeProcessingRuns.get(runDir) === runPromise) { activeProcessingRuns.delete(runDir); } }); activeProcessingRuns.set(runDir, runPromise); return runPromise; }, 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 cached = liveResultsByRunDir.get(runDir); const rawResults = cached ? [...cached] : await readStoredScanResults(resultsPath); const deletedIds = await loadNativeScannerDeletedResultIds(runDir); const results = rawResults.filter((entry) => !deletedIds.has(entry.id)); const afterSequence = Number.isFinite(options.afterSequence) ? Math.max(0, Math.round(options.afterSequence ?? 0)) : null; const candidates = afterSequence === null ? results : results.filter((entry) => entry.sequence > afterSequence); const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(candidates.length, Math.round(options.limit ?? candidates.length))) : candidates.length; const selectedResults = afterSequence === null ? candidates.slice(-limit) : candidates.slice(0, limit); const timing = await recordResultReconciliationIfComplete(deps, runDir, results); return { ok: true, runDir, path: resultsPath, total: results.length, results: selectedResults, ...(timing ? { timing } : {}), }; } catch (error) { return { ...emptyResultsStatus(error instanceof Error ? error.message : String(error)), runDir, path: resultsPath, }; } }, async promoteResults(options) { const status = await resultWorkflows.promoteResults(options); await refreshCachedResultsAfterMutation(deps, options.runDir); return status; }, async reviewResult(options) { const status = await resultWorkflows.reviewResult(options); await refreshCachedResultsAfterMutation(deps, options.runDir); return status; }, async deleteResult(options) { const status = await resultWorkflows.deleteResult(options); await refreshCachedResultsAfterMutation(deps, options.runDir); return status; }, 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, }; } }, }; } async function processNativeScannerRun({ deps, options, runDir, }: { deps: NativeScannerProcessingServiceDependencies; options: ProcessRunOptions; runDir: string; }) { await updateRunTiming(deps, runDir, { processingStartedAt: new Date().toISOString() }); return options.stream ? processStreamingNativeScannerRun({ deps, options, runDir }) : processBatchNativeScannerRun({ deps, options, runDir }); } async function processBatchNativeScannerRun({ deps, options, runDir, }: { deps: NativeScannerProcessingServiceDependencies; options: ProcessRunOptions; runDir: string; }): Promise { const started = Date.now(); const { jobsPath, jobs } = await readNativeCaptureJobs(runDir); const requestedLimit = Number.isFinite(options.limit) ? Math.max(1, Math.round(options.limit ?? jobs.length)) : jobs.length; const selectedJobs = jobs.slice(0, Math.min(jobs.length, requestedLimit)); const runId = path.basename(runDir); const ikCatalog = deps.loadIkArtifactCatalog ? await deps.loadIkArtifactCatalog().catch(() => null) : null; let processedCount = 0; let parsedCount = 0; let reviewCount = 0; let errorCount = 0; const publishProgress = (running: boolean, stored = 0) => safePublishProgress(deps, { running, runDir, total: selectedJobs.length, processed: processedCount, parsed: parsedCount, review: reviewCount, stored, errors: errorCount, elapsedMs: Date.now() - started, }); publishProgress(true); const processedJobs = await mapWithConcurrency( selectedJobs, POST_CAPTURE_QUEUE_CONCURRENCY, async (job) => { const processedJob = await processNativeCaptureJob({ deps, ikCatalog, job, persist: Boolean(options.persist), runDir, runId, }); processedCount += 1; if (processedJob.result.parsed) parsedCount += 1; if (processedJob.result.needsReview) reviewCount += 1; if (processedJob.result.error) errorCount += 1; publishProgress(true); return processedJob; }, ); const scanResults = processedJobs.map((entry) => entry.scanResult); replaceCachedLiveResults(runDir, scanResults); for (const result of scanResults) safePublishResult(deps, result); return finalizeProcessedRun({ deps, jobsPath, ok: true, options, processedJobs, runDir, started, }); } async function processStreamingNativeScannerRun({ deps, options, runDir, }: { deps: NativeScannerProcessingServiceDependencies; options: ProcessRunOptions; runDir: string; }): Promise { const started = Date.now(); const runId = path.basename(runDir); const jobsPath = path.join(runDir, "capture-jobs.jsonl"); const reportPath = path.join(runDir, "processing-report.json"); const scanResultsPath = path.join(runDir, "scan-results.json"); const streamLimit = Number.isFinite(options.limit) ? Math.max(1, Math.round(options.limit ?? 1)) : Number.POSITIVE_INFINITY; const configuredTotal = Number.isFinite(options.expectedTotal) ? Math.max(0, Math.round(options.expectedTotal ?? 0)) : Number.isFinite(options.limit) ? streamLimit : 0; const ikCatalog = deps.loadIkArtifactCatalog ? await deps.loadIkArtifactCatalog().catch(() => null) : null; const tailState: NativeCaptureJobTailState = { offset: 0 }; const pendingJobs: NativeCaptureJobPayload[] = []; const orderedSequences: number[] = []; const seenJobsBySequence = new Map(); const activeJobs = new Map>(); const completedBySequence = new Map(); const publishedJobs: ProcessedNativeJob[] = []; let nextPublishIndex = 0; let processedCount = 0; let parsedCount = 0; let reviewCount = 0; let errorCount = 0; let producerStatus: NativeCaptureProducerStatus | null = null; let terminalObservedAt = 0; let integrityError = ""; let snapshotWriteQueue = Promise.resolve(); const progressTotal = () => { if (producerStatus && !producerStatus.running) { return Math.min(producerStatus.captured, streamLimit); } return configuredTotal || producerStatus?.target || Math.min(orderedSequences.length, streamLimit); }; const publishProgress = (running: boolean, stored = 0) => safePublishProgress(deps, { running, runDir, total: progressTotal(), processed: processedCount, parsed: parsedCount, review: reviewCount, stored, errors: errorCount + (integrityError ? 1 : 0), elapsedMs: Date.now() - started, ...(integrityError ? { error: integrityError } : {}), }); const queueDurableSnapshot = () => { const snapshot = [...publishedJobs]; const status = buildProcessStatus({ jobsPath, ok: true, options, processedJobs: snapshot, runDir, started, stored: 0, }); snapshotWriteQueue = snapshotWriteQueue .catch(() => undefined) .then(() => writeProcessingOutputs({ reportPath, scanResultsPath, status, processedJobs: snapshot })); }; const flushOrderedResults = () => { let published = false; while (nextPublishIndex < orderedSequences.length) { const sequence = orderedSequences[nextPublishIndex]; const completed = completedBySequence.get(sequence); if (!completed) break; completedBySequence.delete(sequence); nextPublishIndex += 1; publishedJobs.push(completed); appendCachedLiveResult(runDir, completed.scanResult); safePublishResult(deps, completed.scanResult); published = true; } if (published && (publishedJobs.length === 1 || publishedJobs.length % 8 === 0)) { queueDurableSnapshot(); } }; const scheduleAvailableJobs = () => { while (activeJobs.size < POST_CAPTURE_QUEUE_CONCURRENCY && pendingJobs.length > 0) { const job = pendingJobs.shift(); if (!job) break; const task = processNativeCaptureJob({ deps, ikCatalog, job, persist: Boolean(options.persist), runDir, runId, }).then((processedJob) => { processedCount += 1; if (processedJob.result.parsed) parsedCount += 1; if (processedJob.result.needsReview) reviewCount += 1; if (processedJob.result.error) errorCount += 1; completedBySequence.set(job.sequence, processedJob); flushOrderedResults(); publishProgress(true); }).finally(() => { activeJobs.delete(job.sequence); }); activeJobs.set(job.sequence, task); } }; publishProgress(true); while (true) { const queueCapacity = Math.max( 0, STREAM_QUEUE_HIGH_WATERMARK - pendingJobs.length - activeJobs.size, ); const appendedJobs = queueCapacity > 0 ? await readAppendedNativeCaptureJobs(jobsPath, tailState, queueCapacity) : []; for (const job of appendedJobs) { const identity = nativeCaptureJobIdentity(job); const existingIdentity = seenJobsBySequence.get(job.sequence); if (existingIdentity) { if (existingIdentity !== identity && !integrityError) { integrityError = `Conflicting capture jobs use sequence ${job.sequence}.`; } continue; } if (orderedSequences.length >= streamLimit) continue; seenJobsBySequence.set(job.sequence, identity); orderedSequences.push(job.sequence); pendingJobs.push(job); } scheduleAvailableJobs(); const nextProducerStatus = await readNativeCaptureProducerStatus(runDir); if (nextProducerStatus) producerStatus = nextProducerStatus; if (producerStatus && !producerStatus.running && terminalObservedAt === 0) { terminalObservedAt = Date.now(); } const expectedCapturedJobs = producerStatus && !producerStatus.running ? Math.min(producerStatus.captured, streamLimit) : null; const producerDrained = expectedCapturedJobs !== null && orderedSequences.length >= expectedCapturedJobs; if (producerDrained && pendingJobs.length === 0 && activeJobs.size === 0) break; if ( expectedCapturedJobs !== null && terminalObservedAt > 0 && Date.now() - terminalObservedAt >= STREAM_TERMINAL_DRAIN_GRACE_MS && pendingJobs.length === 0 && activeJobs.size === 0 ) { integrityError = `Capture job stream ended with ${orderedSequences.length}/${expectedCapturedJobs} jobs.`; break; } if (activeJobs.size > 0) { await Promise.race([ Promise.race(activeJobs.values()), delay(STREAM_POLL_INTERVAL_MS), ]); } else { await delay(STREAM_POLL_INTERVAL_MS); } } if (activeJobs.size > 0) await Promise.all(activeJobs.values()); flushOrderedResults(); await snapshotWriteQueue.catch(() => undefined); const status = await finalizeProcessedRun({ deps, jobsPath, ok: !integrityError, options, processedJobs: publishedJobs, runDir, started, error: integrityError || undefined, }); return status; } async function finalizeProcessedRun({ deps, error, jobsPath, ok, options, processedJobs, runDir, started, }: { deps: NativeScannerProcessingServiceDependencies; error?: string; jobsPath: string; ok: boolean; options: ProcessRunOptions; processedJobs: ProcessedNativeJob[]; runDir: string; started: number; }) { 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 processingTiming = await updateRunTiming(deps, runDir, { processingCompletedAt: new Date().toISOString(), }); let status = buildProcessStatus({ error, jobsPath, ok, options, processedJobs, runDir, started, stored, timing: processingTiming, }); // The complete results file is the durable hand-off boundary. Keep its // timestamp distinct from a later renderer/service reconciliation read. await writeJsonAtomic(status.scanResultsPath, processedJobs.map((entry) => entry.scanResult)); const durableTiming = await updateRunTiming(deps, runDir, { resultsDurableAt: new Date().toISOString(), }); status = buildProcessStatus({ error, jobsPath, ok, options, processedJobs, runDir, started, stored, timing: durableTiming ?? processingTiming, }); await writeJsonAtomic(status.reportPath, status); replaceCachedLiveResults(runDir, processedJobs.map((entry) => entry.scanResult)); safePublishProgress(deps, { running: false, runDir, total: processedJobs.length, processed: status.processed, parsed: status.parsed, review: status.review, stored: status.stored, errors: status.errors, elapsedMs: status.elapsedMs, ...(status.error ? { error: status.error } : {}), }); return status; } function buildProcessStatus({ error, jobsPath, ok, options, processedJobs, runDir, started, stored, timing, }: { error?: string; jobsPath: string; ok: boolean; options: ProcessRunOptions; processedJobs: ProcessedNativeJob[]; runDir: string; started: number; stored: number; timing?: NativeScannerRunTiming; }): NativeScannerProcessStatus { const results = processedJobs.map((entry) => entry.result); return { ok, 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 + (error ? 1 : 0), elapsedMs: Date.now() - started, queueConcurrency: Math.min(POST_CAPTURE_QUEUE_CONCURRENCY, processedJobs.length), persisted: Boolean(options.persist), results, ...(timing ? { timing } : {}), ...(error ? { error } : {}), }; } async function writeProcessingOutputs({ processedJobs, reportPath, scanResultsPath, status, }: { processedJobs: ProcessedNativeJob[]; reportPath: string; scanResultsPath: string; status: NativeScannerProcessStatus; }) { await writeJsonAtomic(scanResultsPath, processedJobs.map((entry) => entry.scanResult)); await writeJsonAtomic(reportPath, status); } async function writeJsonAtomic(filePath: string, payload: unknown) { const temporaryPath = `${filePath}.tmp-${process.pid}`; await fs.writeFile(temporaryPath, JSON.stringify(payload, null, 2), "utf8"); await fs.rename(temporaryPath, filePath); } type NativeCaptureJobTailState = { offset: number; }; async function readAppendedNativeCaptureJobs( jobsPath: string, state: NativeCaptureJobTailState, maxJobs: number, ): Promise { let handle: Awaited> | null = null; try { handle = await fs.open(jobsPath, "r"); const stat = await handle.stat(); if (stat.size < state.offset) { state.offset = 0; } const length = Math.min(stat.size - state.offset, STREAM_READ_CHUNK_BYTES); if (length <= 0) return []; const chunk = Buffer.alloc(length); const { bytesRead } = await handle.read(chunk, 0, length, state.offset); const jobs: NativeCaptureJobPayload[] = []; let lineStart = 0; let consumedBytes = 0; for (let index = 0; index < bytesRead; index += 1) { if (chunk[index] !== 0x0a) continue; const line = chunk.subarray(lineStart, index).toString("utf8").trim(); lineStart = index + 1; consumedBytes = lineStart; if (!line) continue; const job = JSON.parse(line) as NativeCaptureJobPayload; if (Number.isFinite(job.sequence)) jobs.push(job); if (jobs.length >= maxJobs) break; } state.offset += consumedBytes; return jobs; } catch (error) { if (isMissingFileError(error)) return []; throw error; } finally { await handle?.close().catch(() => undefined); } } async function readNativeCaptureProducerStatus(runDir: string): Promise { try { const raw = JSON.parse(await fs.readFile(path.join(runDir, "status.json"), "utf8")); const scanner = raw?.scanner ?? raw; if (!scanner || typeof scanner !== "object") return null; return { running: Boolean(scanner.running), status: typeof scanner.status === "string" ? scanner.status : "unknown", target: Math.max(0, Math.round(Number(scanner.target) || 0)), captured: Math.max(0, Math.round(Number(scanner.captured) || 0)), }; } catch (error) { if (isMissingFileError(error) || error instanceof SyntaxError) return null; throw error; } } async function readStoredScanResults(resultsPath: string) { const raw = await fs.readFile(resultsPath, "utf8"); const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter(isStoredScanResultEntry).map(evaluateStoredScanResult) : []; } async function refreshCachedResultsAfterMutation( deps: NativeScannerProcessingServiceDependencies, requestedRunDir?: string, ) { const runDir = deps.resolveRunDir(requestedRunDir); if (!runDir) return; try { const [results, deletedIds] = await Promise.all([ readStoredScanResults(path.join(runDir, "scan-results.json")), loadNativeScannerDeletedResultIds(runDir), ]); replaceCachedLiveResults(runDir, results.filter((entry) => !deletedIds.has(entry.id))); } catch { // Mutation status remains authoritative; a later disk load can retry. liveResultsByRunDir.delete(runDir); } } async function recordResultReconciliationIfComplete( deps: NativeScannerProcessingServiceDependencies, runDir: string, results: StoredScanResultEntry[], ) { const currentTiming = await loadRunTiming(deps, runDir); // Legacy runs intentionally stay untouched. A reconciliation timestamp is // meaningful only for a run that already owns the new capture/processing // timeline and whose complete result file was durably written by this path. if (!currentTiming?.resultsDurableAt || currentTiming.resultsReconciledAt) return currentTiming ?? undefined; const producer = await readNativeCaptureProducerStatus(runDir).catch(() => null); const terminal = producer && !producer.running && (producer.status === "done" || producer.status === "completed") && producer.captured > 0 && results.length === producer.captured; if (!terminal) return currentTiming; return updateRunTiming(deps, runDir, { resultsReconciledAt: new Date().toISOString() }) ?? currentTiming; } async function updateRunTiming( deps: NativeScannerProcessingServiceDependencies, runDir: string, patch: NativeScannerRunTimingPatch, ) { if (!deps.recordRunTiming || !runDir) return undefined; try { return await deps.recordRunTiming(runDir, patch); } catch { // Timing is evidence, never a dependency of scanner correctness. A later // caller can still load the durable results and report the missing marker. return undefined; } } async function loadRunTiming( deps: NativeScannerProcessingServiceDependencies, runDir: string, ) { if (!deps.loadRunTiming || !runDir) return null; try { return await deps.loadRunTiming(runDir); } catch { return null; } } function replaceCachedLiveResults(runDir: string, results: StoredScanResultEntry[]) { liveResultsByRunDir.delete(runDir); liveResultsByRunDir.set(runDir, [...results]); while (liveResultsByRunDir.size > MAX_CACHED_LIVE_RUNS) { const oldest = liveResultsByRunDir.keys().next().value; if (typeof oldest !== "string") break; liveResultsByRunDir.delete(oldest); } } function appendCachedLiveResult(runDir: string, result: StoredScanResultEntry) { const current = liveResultsByRunDir.get(runDir) ?? []; if (current.some((entry) => entry.id === result.id)) return; current.push(result); liveResultsByRunDir.set(runDir, current); } function safePublishProgress( deps: NativeScannerProcessingServiceDependencies, progress: NativeScannerProcessingProgressStatus, ) { try { deps.onProgress?.(progress); } catch { // UI progress must never abort durable OCR/parse processing. } } function safePublishResult(deps: NativeScannerProcessingServiceDependencies, result: StoredScanResultEntry) { try { deps.onResult?.(result); } catch { // UI delivery is best effort; loadResults can reconcile from memory or disk. } } function nativeCaptureJobIdentity(job: NativeCaptureJobPayload) { return JSON.stringify({ sequence: job.sequence, category: job.category ?? "", page: job.page ?? null, row: job.row ?? null, col: job.col ?? null, relativePath: job.relativePath ?? "", absolutePath: job.absolutePath ?? "", starCount: job.starCount ?? null, starConfidence: job.starConfidence ?? null, starSource: job.starSource ?? "", }); } function isMissingFileError(error: unknown) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } export function nativeScannerProcessStats(status: NativeScannerProcessStatus): Record { 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 { 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 rarityEvidence = nativeJobRarityEvidence(job); const notes = [...new Set([ ...(parsed?.notes ?? []), ...(ikMatch?.notes ?? []), ...(rarityEvidence.uncertain ? ["Artifact star row could not be confirmed; keep this result in Review."] : []), ])]; const needsReview = shouldFlagArtifactForReview(parsed) || Boolean(ikMatch && !ikMatch.matched) || rarityEvidence.uncertain; const canPersist = parsedArtifactCanPersist(parsed, needsReview); const baseScanResult = 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, persistedArtifact: false, notes, locked: capture.locked, ikMatch: ikMatch ?? undefined, rarity: rarityEvidence.rarity, rarityConfidence: rarityEvidence.confidence, raritySource: rarityEvidence.rarity ? "native" : undefined, }); const shouldPersist = Boolean( persist && parsed && canPersist && !needsReview && baseScanResult.valueStatus === "evaluated" && baseScanResult.valueEvaluation?.status === "evaluated" && baseScanResult.valueEvaluation.score !== null && Number.isFinite(baseScanResult.valueEvaluation.score), ); const storedRecord = parsed && shouldPersist ? toStoredArtifact(parsed, "native-ik-scan", needsReview, capture.locked) : null; const scanResult = storedRecord ? { ...baseScanResult, artifactRecordId: storedRecord.id, persistedArtifact: true } : baseScanResult; 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, rarity: rarityEvidence.rarity, rarityConfidence: rarityEvidence.confidence, persisted: shouldPersist, ikMatch: ikMatch ?? undefined, notes, ocr: capture.ocr ?? [], }, scanResult, storedRecord, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return reviewJobResult({ category: category.scanResultCategory, error: message, imagePath, job, runId }); } } function nativeJobRarityEvidence(job: NativeCaptureJobPayload): { rarity?: ArtifactRarity; confidence?: number; /** The newer helper attempted direct visual detection but could not prove a count. */ uncertain: boolean; } { const hasDetectionPayload = Object.prototype.hasOwnProperty.call(job, "starCount") || Object.prototype.hasOwnProperty.call(job, "starConfidence") || Object.prototype.hasOwnProperty.call(job, "starSource"); if (!hasDetectionPayload) return { uncertain: false }; const starCount = Number(job.starCount); const confidence = Number(job.starConfidence); const validRarity = Number.isInteger(starCount) && starCount >= 1 && starCount <= 5; const confident = Number.isFinite(confidence) && confidence >= 0.8 && confidence <= 1; const hasSource = typeof job.starSource === "string" && job.starSource.trim().length > 0; if (!validRarity || !confident || !hasSource) return { uncertain: true }; return { rarity: starCount as ArtifactRarity, confidence, uncertain: false }; } 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( items: readonly TInput[], concurrency: number, worker: (item: TInput, index: number) => Promise, ) { const output = Array(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; 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 parsedArtifactCanPersist(parsed: ReturnType, 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; }