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; saveArtifacts(records: StoredArtifactRecord[]): Promise & Partial>; saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>; loadIkArtifactCatalog?: () => Promise; } export interface NativeScannerResultWorkflowService { promoteResults(options: { runDir?: string; resultIds: string[] }): Promise; reviewResult(options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string; }): Promise; } 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, 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; 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); }