import fs from "node:fs/promises"; import path from "node:path"; import { reviewedMainValueError, type ParsedArtifactCandidate } from "../../src/lib/artifactOcrParser.js"; import { evaluateArtifactValue, evaluateStoredScanResult } from "../../src/lib/artifactEvaluation.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, NativeScannerDeleteResultStatus, NativeScannerPromotionStatus, NativeScannerReviewArtifactInput, NativeScannerReviewStatus, ReviewSamplePayload, } from "../../src/types/global.js"; import type { ArtifactRarity, ScanResultArtifactIdentity, StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js"; import { appendNativeScannerResultTombstone, loadNativeScannerDeletedResultIds, loadNativeScannerResultTombstones, nativeScannerResultTombstonePath, } from "./nativeScannerResultTombstones.js"; export interface NativeScannerResultWorkflowDependencies { resolveRunDir(runDir?: string): string; loadArtifacts?: () => Promise; saveArtifacts(records: StoredArtifactRecord[]): Promise & Partial>; removeArtifacts?: (ids: string[]) => Promise<{ ok: boolean; removed: number }>; isProcessingRunActive?: (runDir: string) => boolean; 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; deleteResult(options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean; }): 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 deletedIds = await loadNativeScannerDeletedResultIds(runDir); if (requestedIds.some((id) => deletedIds.has(id))) { return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Deleted scan results cannot be promoted."); } 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 deletedIds = await loadNativeScannerDeletedResultIds(runDir); if (deletedIds.has(resultId)) { return emptyReviewStatus(runDir, logPath, resultId, options.action, "Deleted scan results cannot be reviewed."); } 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, current.artifact?.rarity); 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 deleteResult(options) { const runDir = deps.resolveRunDir(options.runDir); const resultId = String(options.resultId ?? "").trim(); const tombstonePath = runDir ? nativeScannerResultTombstonePath(runDir) : ""; if (!runDir || !resultId) { return emptyDeleteStatus(runDir, tombstonePath, resultId, "Deleting a local scan result requires a run directory and result ID."); } try { if (await isNativeRunActive(runDir) || deps.isProcessingRunActive?.(runDir)) { return emptyDeleteStatus(runDir, tombstonePath, resultId, "An active native scan cannot be changed. Stop and finish the scan first."); } const existingTombstones = await loadNativeScannerResultTombstones(runDir); if (existingTombstones.some((entry) => entry.resultId === resultId)) { return { ok: true, runDir, tombstonePath, resultId, deletedResult: false, alreadyDeleted: true, cropPath: "", deletedCrop: false, deletedStoreRecord: false, warnings: [], }; } const { results } = await loadScanResults(runDir); const current = results.find((entry) => entry.id === resultId); if (!current) { return emptyDeleteStatus(runDir, tombstonePath, resultId, "Selected scan result was not found."); } const removeLinkedStoreRecord = Boolean(options.removeLinkedStoreRecord); const storeRecordId = removeLinkedStoreRecord ? current.artifactRecordId?.trim() : undefined; await appendNativeScannerResultTombstone(runDir, { version: 1, resultId, deletedAt: new Date().toISOString(), ...(current.imagePath ? { imagePath: current.imagePath } : {}), removeLinkedStoreRecord, ...(storeRecordId ? { storeRecordId } : {}), }); const warnings: string[] = []; const crop = await removeLocalCrop(runDir, current.imagePath); if (crop.warning) warnings.push(crop.warning); let deletedStoreRecord = false; if (removeLinkedStoreRecord) { if (!storeRecordId) { warnings.push("This scan result has no explicitly linked local store record to remove."); } else if (!deps.removeArtifacts) { warnings.push("The linked local store record was retained because local-store deletion is unavailable."); } else { try { const removal = await deps.removeArtifacts([storeRecordId]); if (!removal.ok) { warnings.push("The scan result was removed, but the linked local store record could not be removed."); } else { deletedStoreRecord = removal.removed > 0; if (!deletedStoreRecord) warnings.push("The linked local store record was already absent."); } } catch { warnings.push("The scan result was removed, but the linked local store record could not be removed."); } } } return { ok: true, runDir, tombstonePath, resultId, deletedResult: true, alreadyDeleted: false, cropPath: crop.path, deletedCrop: crop.deleted, ...(storeRecordId ? { storeRecordId } : {}), deletedStoreRecord, warnings, }; } catch (error) { return emptyDeleteStatus(runDir, tombstonePath, resultId, 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).map(evaluateStoredScanResult) : []; 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 evaluateStoredScanResult({ ...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 { const preservedRarity = current.artifact?.rarity; const preservedRarityMetadata = preservedRarity === undefined ? {} : { rarity: preservedRarity, ...(current.artifact?.rarityConfidence === undefined ? {} : { rarityConfidence: current.artifact.rarityConfidence }), ...(current.artifact?.raritySource === undefined ? {} : { raritySource: current.artifact.raritySource }), }; const approvedArtifact: ScanResultArtifactIdentity = { ...artifact, ...preservedRarityMetadata, }; return evaluateStoredScanResult({ ...current, extractionStatus: "parsed", extractionConfidence: 100, needsReview: false, valueStatus: "deferred", valueScore: null, artifact: approvedArtifact, ikMatch, fieldConfidences: reviewedFieldConfidences(artifact, current.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 emptyDeleteStatus( runDir: string, tombstonePath: string, resultId: string, error: string, ): NativeScannerDeleteResultStatus { return { ok: false, runDir, tombstonePath, resultId, deletedResult: false, alreadyDeleted: false, cropPath: "", deletedCrop: false, deletedStoreRecord: false, warnings: [], error, }; } async function isNativeRunActive(runDir: string) { try { const raw = JSON.parse(await fs.readFile(path.join(runDir, "status.json"), "utf8")); const scanner = raw?.scanner ?? raw; return Boolean(scanner?.running); } catch { // Older offline runs may not have a status file. A missing status is not // evidence of a running producer, while a present running state blocks. return false; } } async function removeLocalCrop(runDir: string, imagePath: string) { const resolvedRunDir = path.resolve(runDir); const candidate = imagePath ? path.isAbsolute(imagePath) ? path.resolve(imagePath) : path.resolve(resolvedRunDir, imagePath) : ""; if (!candidate) { return { path: "", deleted: false, warning: "No crop image was recorded for this local scan result." }; } if (!isPathInside(resolvedRunDir, candidate)) { return { path: candidate, deleted: false, warning: "The recorded crop path is outside the native scan run and was retained." }; } if (!/\.png$/i.test(candidate)) { return { path: candidate, deleted: false, warning: "The recorded crop is not a PNG file and was retained." }; } try { await fs.unlink(candidate); return { path: candidate, deleted: true }; } catch (error) { if (isMissingFileError(error)) { return { path: candidate, deleted: false, warning: "The crop image was already absent." }; } return { path: candidate, deleted: false, warning: "The scan result was removed, but its crop image could not be removed." }; } } function isPathInside(root: string, candidate: string) { const relative = path.relative(root, candidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function isMissingFileError(error: unknown) { return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "ENOENT"; } 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, confirmedRarity?: ArtifactRarity) { 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, confirmedRarity === 5 || artifact.level > 16 ? 5 : undefined); if (implausible.length > 0) errors.push(`Implausible substats: ${implausible.join(", ")}.`); if (errors.length === 0) { const evaluation = evaluateArtifactValue({ ...artifact, ...(confirmedRarity === undefined ? {} : { rarity: confirmedRarity }), }); if (evaluation.status !== "evaluated" && evaluation.status !== "excluded") errors.push(evaluation.summary); } 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, previous?: ScanResultArtifactIdentity, ) { const rarityField = previous?.rarity === undefined ? [] : [{ key: "rarity", label: "Stars", value: `${previous.rarity}★`, confidence: Math.round((previous.rarityConfidence ?? 0) * 100), source: "visual" as const, }]; const manualFields = [ { 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 })); return [...manualFields, ...rarityField]; } 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); }