Files
genshin-assistant/electron/services/nativeScannerResultWorkflowService.ts

569 lines
24 KiB
TypeScript

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<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>;
}
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>;
deleteResult(options: {
runDir?: string;
resultId: string;
removeLinkedStoreRecord?: boolean;
}): Promise<NativeScannerDeleteResultStatus>;
}
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<StoredScanResultEntry["ikMatch"]>,
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<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);
}