import type { CandidateFieldMappingDecision, CandidateMappingResult } from '../domain/candidate-mapping'; import type { ImportProfile } from '../domain/import-profile'; import type { ProfileFieldRule } from '../domain/tool-profile-definition'; import type { ImportRecognitionResult, RecognitionOverrides } from '../domain/import-recognition'; import type { FindingCandidate, SourceExtractionResult, SourceFieldCandidate } from '../domain/source-extraction'; import { recognitionConfidence } from '../recognition/confidence'; import { evaluateFieldRoleEvidence } from '../recognition/field-role-evidence'; interface CandidateFieldGroup { id: string; identity: string; fieldKey: string; sourceLabel: string; tableIndex?: number; values: string[]; headers: string[]; } function groupIdentity(field: SourceFieldCandidate): string { return JSON.stringify([field.source.tableIndex ?? null, field.key, field.label]); } function fieldGroups(candidates: FindingCandidate[], rules: ProfileFieldRule[]) { const groups = new Map(); const contexts = new Map(); const conditions = rules.flatMap(rule => [...(rule.whenHeadersPresent ?? []), ...(rule.whenHeadersAbsent ?? [])]); candidates.forEach(candidate => { const headers = conditions.length ? candidate.fields.map(item => item.label) : []; const contextKey = conditions.map(pattern => headers.some(header => { pattern.lastIndex = 0; return pattern.test(header.trim()); }) ? '1' : '0').join(''); contexts.set(candidate, contextKey); candidate.fields.forEach(field => { const identity = `${groupIdentity(field)}:${contextKey}`; const existing = groups.get(identity); if (existing) { if (existing.values.length < 250 && field.decodedValue.trim()) existing.values.push(field.decodedValue.trim()); return; } groups.set(identity, { id: `candidate-field-${groups.size}`, identity, fieldKey: field.key, sourceLabel: field.label, tableIndex: field.source.tableIndex, headers, values: field.decodedValue.trim() ? [field.decodedValue.trim()] : [] }); }); }); return { groups: [...groups.values()], contexts }; } function confirmedFieldDecision( recognition: ImportRecognitionResult | undefined, group: CandidateFieldGroup ) { if (group.tableIndex === undefined) return undefined; const columnIndex = Number(/^column-(\d+)$/.exec(group.fieldKey)?.[1]); if (!Number.isInteger(columnIndex)) return undefined; return recognition?.fields.find(field => field.tableIndex === group.tableIndex && field.columnIndex === columnIndex && field.decisionSource === 'user-confirmed' ); } function mapGroup( group: CandidateFieldGroup, profile: ImportProfile, recognition?: ImportRecognitionResult ): CandidateFieldMappingDecision { const confirmed = confirmedFieldDecision(recognition, group); if (confirmed) { return { id: group.id, fieldKey: group.fieldKey, sourceLabel: group.sourceLabel, sourceScope: { tableIndex: group.tableIndex }, proposedRole: confirmed.proposedRole, appliedRole: confirmed.appliedRole, alternatives: confirmed.alternatives, confidence: confirmed.confidence, evidence: confirmed.evidence, decisionSource: 'user-confirmed', requiresConfirmation: false }; } const alternatives = evaluateFieldRoleEvidence(group.sourceLabel, group.values, profile.definition.fieldRules, group.headers); const best = alternatives[0]; const proposedRole = best && best.confidence.level !== 'unknown' ? best.role : null; const automaticallyApplied = best && ['high', 'medium'].includes(best.confidence.level); return { id: group.id, fieldKey: group.fieldKey, sourceLabel: group.sourceLabel, sourceScope: { tableIndex: group.tableIndex }, proposedRole, appliedRole: automaticallyApplied ? best.role : 'additional', alternatives: alternatives.map(({ role, confidence }) => ({ role, confidence })), confidence: best?.confidence ?? recognitionConfidence(0, []), evidence: best?.evidence ?? [], decisionSource: proposedRole ? 'automatic' : 'fallback', requiresConfirmation: best?.confidence.level === 'low' }; } export function mapFindingCandidates( extraction: SourceExtractionResult, profile: ImportProfile, recognition?: ImportRecognitionResult, overrides?: RecognitionOverrides ): CandidateMappingResult { const { groups, contexts } = fieldGroups(extraction.candidates, profile.definition.fieldRules); const candidateRoleOverrides = new Map((overrides?.candidateFieldRoles ?? []).map(item => [item.decisionId, item.role])); const fieldDecisions = groups.map(group => mapGroup(group, profile, recognition)).map(decision => { const role = decision.sourceScope.tableIndex === undefined ? candidateRoleOverrides.get(decision.id) : undefined; if (!role) return decision; const evidence = [{ signal: 'user-confirmation', contribution: 1, message: 'Die Candidate-Feldrolle wurde durch den Benutzer bestätigt oder korrigiert.' }]; return { ...decision, proposedRole: role, appliedRole: role, confidence: recognitionConfidence(1, evidence), evidence, decisionSource: 'user-confirmed' as const, requiresConfirmation: false }; }); const decisionsByIdentity = new Map(groups.map((group, index) => [ group.identity, fieldDecisions[index] ])); const mappingProfile = { kind: 'mapping-profile' as const, id: profile.id, version: profile.definition.processing.mappingVersion }; return { version: 1, extractor: extraction.extractor, mappingProfile, fieldDecisions, candidates: extraction.candidates.map(candidate => ({ version: 1, id: candidate.id, source: candidate.source, fields: candidate.fields.map(field => { const decision = decisionsByIdentity.get(`${groupIdentity(field)}:${contexts.get(candidate) ?? ''}`); if (!decision) throw new Error(`Keine Mappingentscheidung für Quellfeld ${field.key} vorhanden.`); return { ...field, mappingDecisionId: decision.id, role: decision.appliedRole }; }) })), diagnostics: [], processing: { stage: 'mapping', status: 'complete', processor: mappingProfile, diagnosticCodes: [] } }; }