feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user