639b0b7f59
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.
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
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);
|
|
}
|