438 lines
16 KiB
TypeScript
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);
|
|
}
|