Assessor_2.0/src/importers/candidate-mapping.ts

170 lines
6.3 KiB
TypeScript

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<string, CandidateFieldGroup>();
const contexts = new Map<FindingCandidate, string>();
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: []
}
};
}