Files
genshin-assistant/src/features/scan/hooks/scanViewReviewHelpers.ts
T
2026-07-09 08:44:50 +02:00

438 lines
16 KiB
TypeScript

import type { Dispatch, SetStateAction } from "react";
import {
applyScannerLearningRules,
countScannerLearningRules,
deriveScannerLearningRules,
deriveScannerLearningRulesFromReviewSamples,
shouldFlagArtifactForReview,
type ScannerLearningRules,
} from "../../../lib/scannerLearning";
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "../../../lib/scannerCaptureQuality";
import { parseArtifactCandidate, type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import { isReviewOnlyArtifactSource, storedArtifactStrength, toStoredArtifact } from "../../../lib/artifactStore";
import {
type ArtifactRepositoryPort,
type LearningRepositoryPort,
type ReviewSampleRepositoryPort,
} from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { CaptureResult, ReviewSampleRecord, SaveResultWithPath } from "../../../types/global";
import type { StoredArtifactRecord } from "../../../types/storage";
const REVIEW_SAMPLE_LIMIT_INITIAL = 120;
const REVIEW_SAMPLE_LIMIT_QUEUE = 80;
export interface ReviewStateContext {
artifactRepo?: ArtifactRepositoryPort;
reviewSamplesRepo?: ReviewSampleRepositoryPort;
learningRepo?: LearningRepositoryPort;
onStoredArtifactsChanged?: () => Promise<void> | void;
setReviewSampleTotal: Dispatch<SetStateAction<number>>;
setReviewSamples: Dispatch<SetStateAction<ReviewSampleRecord[]>>;
setLearningRulesLoaded: Dispatch<SetStateAction<boolean>>;
setScannerLearningRules: Dispatch<SetStateAction<ScannerLearningRules>>;
setReviewStatus: Dispatch<SetStateAction<string>>;
setStoredTotal: Dispatch<SetStateAction<number | null>>;
appendAutomationLog: (line: string) => void;
}
async function loadReviewSamplesForContext(
context: ReviewStateContext,
limit: number,
): Promise<ReturnType<ReviewSampleRepositoryPort["loadSamples"]> | null> {
return context.reviewSamplesRepo?.loadSamples(limit).catch(() => null) ?? null;
}
async function loadReviewSamplesAndSetTotal(context: ReviewStateContext, limit: number) {
const result = await loadReviewSamplesForContext(context, limit);
if (result?.ok) {
context.setReviewSampleTotal(result.total);
}
return result;
}
export function parseLearnedArtifact(
capture: CaptureResult | null,
scannerLearningRules: ScannerLearningRules,
): ParsedArtifactCandidate | null {
return parseArtifactCandidate(applyScannerLearningRules(capture, scannerLearningRules));
}
export function createCaptureFromReviewSample(entry: ReviewSampleRecord): CaptureResult | null {
const capture = entry.sample?.capture;
if (!capture?.ocr) return null;
return {
id: `review-${entry.savedAt}`,
name: capture.name ?? "review-sample",
width: capture.width ?? 0,
height: capture.height ?? 0,
dataUrl: "",
capturedAt: capture.capturedAt ?? entry.savedAt,
captureTarget: capture.captureTarget,
crops: [],
inventoryDataUrl: capture.inventoryDataUrl,
ocr: capture.ocr,
inventoryGrid: capture.inventoryGrid,
};
}
function storedArtifactFingerprint(record: StoredArtifactRecord | null | undefined) {
if (!record) return "";
return [
record.name,
record.slot,
record.level ?? 0,
record.mainStat,
record.mainValue,
record.setName,
record.equipped,
(record.substats ?? []).join("|"),
record.needsReview ? "review" : "clean",
].join("::");
}
function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, incoming: StoredArtifactRecord) {
if (!existing) return true;
if (storedArtifactFingerprint(existing) === storedArtifactFingerprint(incoming)) return false;
const existingStrength = storedArtifactStrength(existing);
const incomingStrength = storedArtifactStrength(incoming);
const existingSubstats = existing.substats?.length ?? 0;
const incomingSubstats = incoming.substats?.length ?? 0;
const existingEquippedKnown = Boolean(existing.equipped && !/not detected/i.test(existing.equipped));
const incomingEquippedKnown = Boolean(incoming.equipped && !/not detected/i.test(incoming.equipped));
if (existing.needsReview && !incoming.needsReview) return true;
if (incomingSubstats > existingSubstats) return true;
if (!existingEquippedKnown && incomingEquippedKnown) return true;
if (incomingStrength >= existingStrength + 6) return true;
if (isReviewOnlyArtifactSource(existing.source)) return true;
return false;
}
function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules {
return mergeLearningRulePayloads({}, loadedRules?.rules);
}
async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) {
const reviewResult = await loadReviewSamplesAndSetTotal(context, limit);
if (reviewResult?.ok) {
await recoverArtifactsFromReviewSamples(reviewResult.samples, rules, context);
}
return reviewResult;
}
export async function recoverArtifactsFromReviewSamples(
samples: ReviewSampleRecord[],
rules: ScannerLearningRules,
context: ReviewStateContext,
): Promise<number> {
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
if (!artifactRepo?.saveMany || samples.length === 0) return 0;
const existingResult = await artifactRepo.loadAll().catch(() => null);
const existingById = new Map(
(existingResult?.artifacts ?? []).map((record: StoredArtifactRecord) => [record.id, record]),
);
const recoveredCandidates = samples.flatMap((entry) => {
const capture = createCaptureFromReviewSample(entry);
const parsed = parseLearnedArtifact(capture, rules);
if (!capture || !parsed) return [];
if (captureSourceRejectionReason(capture)) return [];
const needsReview = shouldFlagArtifactForReview(parsed);
if (!shouldPersistParsedArtifact(parsed, needsReview)) return [];
const stored = toStoredArtifact(parsed, "review-reprocess", needsReview);
return shouldRecoverIntoStore(existingById.get(stored.id), stored) ? [stored] : [];
});
const recoveredById = new Map<string, StoredArtifactRecord>();
for (const record of recoveredCandidates) {
const existing = recoveredById.get(record.id);
if (!existing || storedArtifactStrength(record) > storedArtifactStrength(existing)) {
recoveredById.set(record.id, record);
}
}
const recovered = [...recoveredById.values()];
if (recovered.length === 0) return 0;
const result = await artifactRepo.saveMany(recovered).catch(() => null);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
appendAutomationLog(`review reprocess: ${result.added} neu, ${result.updated} aktualisiert`);
return result.added + result.updated;
}
return 0;
}
export async function initializeLearningState(context: ReviewStateContext): Promise<void> {
const {
learningRepo,
setLearningRulesLoaded,
setScannerLearningRules,
} = context;
try {
const loadedRules = await learningRepo?.loadRules().catch(() => null);
const currentRules = getDefaultScannerRules(loadedRules);
if (countScannerLearningRules(currentRules) > 0) {
setScannerLearningRules(currentRules);
await loadReviewSamplesAndRecover(context, currentRules);
setLearningRulesLoaded(true);
return;
}
const reviewResult = await loadReviewSamplesAndRecover(context, { ...currentRules }, REVIEW_SAMPLE_LIMIT_INITIAL);
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
const activeRules = countScannerLearningRules(derived) > 0 ? derived : currentRules;
if (countScannerLearningRules(derived) > 0) {
setScannerLearningRules(derived);
await learningRepo?.saveRules?.(derived).catch(() => null);
} else {
setScannerLearningRules(currentRules);
}
setLearningRulesLoaded(true);
} catch {
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_INITIAL);
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
setScannerLearningRules(derived);
if (countScannerLearningRules(derived) > 0) {
await learningRepo?.saveRules?.(derived).catch(() => null);
}
if (reviewResult?.ok) {
await recoverArtifactsFromReviewSamples(reviewResult.samples, derived, context);
}
setLearningRulesLoaded(true);
}
}
export async function mergeLearningRules(
nextRules: Partial<ScannerLearningRules> | null | undefined,
currentRules: ScannerLearningRules,
context: ReviewStateContext,
) {
if (!nextRules || countScannerLearningRules(nextRules) === 0) return null;
const merged = mergeLearningRulePayloads(currentRules, nextRules);
context.setScannerLearningRules(merged);
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
return { result, merged };
}
function mergeLearningRulePayloads(
current: Partial<ScannerLearningRules> | null | undefined,
next: Partial<ScannerLearningRules> | null | undefined,
): ScannerLearningRules {
return {
textReplacements: {
...(current?.textReplacements ?? {}),
...(next?.textReplacements ?? {}),
},
fieldAliases: mergeNestedRuleMap(current?.fieldAliases, next?.fieldAliases),
constrainedFixes: {
...(current?.constrainedFixes ?? {}),
...(next?.constrainedFixes ?? {}),
},
cropAdjustments: {
...(current?.cropAdjustments ?? {}),
...(next?.cropAdjustments ?? {}),
},
uiProfileAdjustments: {
...(current?.uiProfileAdjustments ?? {}),
...(next?.uiProfileAdjustments ?? {}),
},
};
}
function mergeNestedRuleMap(
current: Record<string, Record<string, string>> | undefined,
next: Record<string, Record<string, string>> | undefined,
) {
const merged: Record<string, Record<string, string>> = {};
for (const [field, aliases] of Object.entries(current ?? {})) merged[field] = { ...(aliases ?? {}) };
for (const [field, aliases] of Object.entries(next ?? {})) merged[field] = { ...(merged[field] ?? {}), ...(aliases ?? {}) };
return merged;
}
export async function persistParsedArtifact(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate,
source: string,
needsReview: boolean,
context: ReviewStateContext,
) {
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
if (!artifactRepo?.saveMany) return false;
const rejection = captureRejectionReason(capture, parsed);
if (rejection) {
appendAutomationLog(`persist skip: ${rejection}`);
return false;
}
if (!shouldPersistParsedArtifact(parsed, needsReview)) {
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${parsed.name})`);
return false;
}
try {
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview, capture?.locked)]);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
return true;
}
return false;
} catch {
return false;
}
}
export async function persistParsedArtifactsBatch(
items: Array<{
capture: CaptureResult | null;
parsed: ParsedArtifactCandidate;
source: string;
needsReview: boolean;
}>,
context: ReviewStateContext,
) {
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
if (!artifactRepo?.saveMany || items.length === 0) return 0;
const records: StoredArtifactRecord[] = [];
for (const item of items) {
const rejection = captureRejectionReason(item.capture, item.parsed);
if (rejection) {
appendAutomationLog(`persist skip: ${rejection}`);
continue;
}
if (!shouldPersistParsedArtifact(item.parsed, item.needsReview)) {
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${item.parsed.name})`);
continue;
}
records.push(toStoredArtifact(item.parsed, item.source, item.needsReview, item.capture?.locked));
}
if (records.length === 0) return 0;
try {
const result = await artifactRepo.saveMany(records);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
return records.length;
}
return 0;
} catch {
return 0;
}
}
export async function saveReviewSample(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate | null,
reason: string,
scannerLearningRules: ScannerLearningRules,
context: ReviewStateContext,
): Promise<{ result: SaveResultWithPath | null; recoveredParsed: ParsedArtifactCandidate | null; recoveredToDb: boolean }> {
const {
reviewSamplesRepo,
setReviewStatus,
appendAutomationLog,
setReviewSamples,
onStoredArtifactsChanged,
} = context;
if (!reviewSamplesRepo?.saveSample) {
setReviewStatus("Review-Sample speichern ist nur in der Electron-App verfuegbar.");
return { result: null, recoveredParsed: null, recoveredToDb: false };
}
if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false };
const compactAutomaticSample = /^automatic:/i.test(reason);
const sampleCapture = compactAutomaticSample
? {
id: capture.id,
name: capture.name,
width: capture.width,
height: capture.height,
detailDataUrl: capture.detailDataUrl,
captureTarget: capture.captureTarget,
capturedAt: capture.capturedAt,
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
id: crop.id,
label: crop.label,
rect: crop.rect,
dataUrl: crop.dataUrl,
})),
inventoryGrid: capture.inventoryGrid,
inventoryCount: capture.inventoryCount,
locked: capture.locked,
ocr: capture.ocr,
}
: {
id: capture.id,
name: capture.name,
width: capture.width,
height: capture.height,
dataUrl: capture.dataUrl,
detailDataUrl: capture.detailDataUrl,
inventoryDataUrl: capture.inventoryDataUrl,
captureTarget: capture.captureTarget,
capturedAt: capture.capturedAt,
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
id: crop.id,
label: crop.label,
rect: crop.rect,
dataUrl: crop.dataUrl,
})),
inventoryGrid: capture.inventoryGrid,
inventoryCount: capture.inventoryCount,
locked: capture.locked,
ocr: capture.ocr,
};
const result = await reviewSamplesRepo.saveSample({
reason,
capture: sampleCapture,
parsed,
});
const learned = deriveScannerLearningRules(capture, parsed);
const learnedResult = await mergeLearningRules(learned, scannerLearningRules, context);
const learnedCount = countScannerLearningRules(learned);
const activeRules = learnedResult?.merged ?? scannerLearningRules;
const recoveredParsed = parseLearnedArtifact(capture, activeRules);
const recoveredNeedsReview = shouldFlagArtifactForReview(recoveredParsed);
let recoveredToDb = false;
if (recoveredParsed && shouldPersistParsedArtifact(recoveredParsed, recoveredNeedsReview)) {
const extendedContext: ReviewStateContext = {
...context,
artifactRepo: context.artifactRepo,
onStoredArtifactsChanged,
};
recoveredToDb = await persistParsedArtifact(capture, recoveredParsed, "review-recovered", recoveredNeedsReview, extendedContext);
}
setReviewStatus(
result?.path
? `Review-Sample gespeichert: ${result.path}${learnedCount > 0 ? ` - ${learnedCount} lokale Lernregeln aktualisiert` : ""}${recoveredToDb ? " - Artifact direkt in DB nachgezogen" : ""}`
: "Review-Sample konnte nicht gespeichert werden.",
);
if (learnedResult?.result?.ok) appendAutomationLog(`learning: ${learnedResult.result.total} aktive Textregeln`);
if (recoveredToDb && recoveredParsed) appendAutomationLog(`review recovered: ${recoveredParsed.name} +${recoveredParsed.level}`);
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
if (reviewResult?.ok) {
setReviewSamples(reviewResult.samples);
}
return { result, recoveredParsed, recoveredToDb };
}
export async function loadReviewQueue(context: ReviewStateContext): Promise<void> {
const { setReviewSamples } = context;
const result = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
if (!result?.ok) return;
setReviewSamples(result.samples);
}