feat(scanner): complete localized artifact quality checkpoint

This commit is contained in:
AzuTear
2026-07-11 15:59:19 +02:00
parent 639b0b7f59
commit 8b9f948c6b
215 changed files with 35440 additions and 7273 deletions
@@ -1,23 +1,33 @@
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 { StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.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<ArtifactStoreLoadResult>;
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
removeArtifacts?: (ids: string[]) => Promise<{ ok: boolean; removed: number }>;
isProcessingRunActive?: (runDir: string) => boolean;
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
}
@@ -31,6 +41,11 @@ export interface NativeScannerResultWorkflowService {
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}): Promise<NativeScannerReviewStatus>;
deleteResult(options: {
runDir?: string;
resultId: string;
removeLinkedStoreRecord?: boolean;
}): Promise<NativeScannerDeleteResultStatus>;
}
export function createNativeScannerResultWorkflowService(
@@ -46,6 +61,10 @@ export function createNativeScannerResultWorkflowService(
}
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));
@@ -102,6 +121,10 @@ export function createNativeScannerResultWorkflowService(
}
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.");
@@ -118,7 +141,7 @@ export function createNativeScannerResultWorkflowService(
updated = rejectResult(current, reviewedAt, note);
} else {
const artifact = normalizeReviewedArtifact(options.artifact);
const errors = reviewedArtifactErrors(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;
@@ -148,13 +171,102 @@ export function createNativeScannerResultWorkflowService(
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) : [];
const results = Array.isArray(raw) ? raw.filter(isStoredScanResultEntry).map(evaluateStoredScanResult) : [];
return { path: resultsPath, results };
}
@@ -167,14 +279,14 @@ async function appendWorkflowLog(logPath: string, payload: object) {
}
function rejectResult(current: StoredScanResultEntry, reviewedAt: string, note: string): StoredScanResultEntry {
return {
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(
@@ -185,22 +297,34 @@ function approveResult(
note: string,
correctedFields: string[],
): StoredScanResultEntry {
return {
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,
artifact: approvedArtifact,
ikMatch,
fieldConfidences: reviewedFieldConfidences(artifact),
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(
@@ -258,6 +382,73 @@ function emptyReviewStatus(
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(),
@@ -272,7 +463,7 @@ function normalizeReviewedArtifact(input?: NativeScannerReviewArtifactInput): Na
};
}
function reviewedArtifactErrors(artifact: NativeScannerReviewArtifactInput) {
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.");
@@ -283,8 +474,15 @@ function reviewedArtifactErrors(artifact: NativeScannerReviewArtifactInput) {
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);
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;
}
@@ -316,8 +514,20 @@ function artifactChangedFields(
.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]));
}
function reviewedFieldConfidences(artifact: NativeScannerReviewArtifactInput) {
return [
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) },
@@ -327,6 +537,7 @@ function reviewedFieldConfidences(artifact: NativeScannerReviewArtifactInput) {
{ 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) {