chore: initialize repository baseline
Import the existing Electron + React + TypeScript app as the version-control baseline before the scanner rework (C# input/capture sidecar, resolution-anchored layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop). Housekeeping in this commit: - Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script). - Ignore .claude/scheduled_tasks.lock local session state. - Add .gitattributes to normalize line endings (LF in repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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 { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } };
|
||||
}
|
||||
|
||||
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 = {
|
||||
textReplacements: {
|
||||
...currentRules.textReplacements,
|
||||
...(nextRules.textReplacements ?? {}),
|
||||
},
|
||||
};
|
||||
context.setScannerLearningRules(merged);
|
||||
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
|
||||
return { result, 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)]);
|
||||
if (result?.ok) {
|
||||
setStoredTotal(result.total);
|
||||
void onStoredArtifactsChanged?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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 result = await reviewSamplesRepo.saveSample({
|
||||
reason,
|
||||
capture: {
|
||||
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,
|
||||
ocr: capture.ocr,
|
||||
},
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user