170 lines
6.3 KiB
TypeScript
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: []
|
|
}
|
|
};
|
|
}
|