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; setReviewSampleTotal: Dispatch>; setReviewSamples: Dispatch>; setLearningRulesLoaded: Dispatch>; setScannerLearningRules: Dispatch>; setReviewStatus: Dispatch>; setStoredTotal: Dispatch>; appendAutomationLog: (line: string) => void; } async function loadReviewSamplesForContext( context: ReviewStateContext, limit: number, ): Promise | 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 { 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(); 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 { 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 | 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 | null | undefined, next: Partial | 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> | undefined, next: Record> | undefined, ) { const merged: Record> = {}; 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[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[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 { const { setReviewSamples } = context; const result = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE); if (!result?.ok) return; setReviewSamples(result.samples); }