173 lines
5.4 KiB
TypeScript
173 lines
5.4 KiB
TypeScript
import type { Ampelfarbe, ScoringDimensionDefinition, ScoringFarbe } from "./scoring-model";
|
|
|
|
export interface LlmSubcriterionAssessment {
|
|
id: string;
|
|
farbe: ScoringFarbe;
|
|
evidence: string;
|
|
begruendung: string;
|
|
confidence: number;
|
|
missingReason: string;
|
|
}
|
|
|
|
export interface LlmDimensionAssessment {
|
|
id: string;
|
|
subcriteria: LlmSubcriterionAssessment[];
|
|
}
|
|
|
|
export interface LlmScoringAssessment {
|
|
ausschlussgruende: string[];
|
|
dimensionen: LlmDimensionAssessment[];
|
|
}
|
|
|
|
export interface ScoredSubcriterion extends LlmSubcriterionAssessment {
|
|
name: string;
|
|
indikator: string;
|
|
weight: number;
|
|
score: number | null;
|
|
weightedScore: number;
|
|
}
|
|
|
|
export interface ScoringDimension {
|
|
id: string;
|
|
name: string;
|
|
weight: number;
|
|
farbe: ScoringFarbe;
|
|
score: number;
|
|
weightedScore: number;
|
|
scorableWeight: number;
|
|
subcriteria: ScoredSubcriterion[];
|
|
}
|
|
|
|
export interface ScoringResult {
|
|
farbe: Ampelfarbe;
|
|
gesamtScore: number;
|
|
unbewertbareKriterien: number;
|
|
missingDataWarnings: string[];
|
|
dimensionen: ScoringDimension[];
|
|
}
|
|
|
|
const COLOR_SCORE: Record<ScoringFarbe, number | null> = {
|
|
gruen: 100,
|
|
gelb: 50,
|
|
rot: 0,
|
|
unbewertbar: null,
|
|
};
|
|
|
|
export function normalizeScoringFarbe(value: unknown): ScoringFarbe {
|
|
return value === "gruen" || value === "gelb" || value === "rot" || value === "unbewertbar"
|
|
? value
|
|
: "unbewertbar";
|
|
}
|
|
|
|
export function scoreToFarbe(score: number): Ampelfarbe {
|
|
if (score >= 75) return "gruen";
|
|
return "gelb";
|
|
}
|
|
|
|
export function calculateScoringResult(
|
|
model: ScoringDimensionDefinition[],
|
|
assessment: LlmScoringAssessment,
|
|
): ScoringResult {
|
|
const assessmentByDimension = new Map(assessment.dimensionen.map((dimension) => [dimension.id, dimension]));
|
|
let totalWeightedScore = 0;
|
|
let unbewertbareKriterien = 0;
|
|
const missingDataWarnings: string[] = [];
|
|
|
|
const dimensionen = model.map((dimensionDefinition): ScoringDimension => {
|
|
const dimensionAssessment = assessmentByDimension.get(dimensionDefinition.id);
|
|
const assessmentBySubcriterion = new Map(
|
|
(dimensionAssessment?.subcriteria ?? []).map((subcriterion) => [subcriterion.id, subcriterion]),
|
|
);
|
|
|
|
let achievedScore = 0;
|
|
let scorableWeight = 0;
|
|
const subcriteria = dimensionDefinition.subcriteria.map((subcriterionDefinition): ScoredSubcriterion => {
|
|
const rawAssessment = assessmentBySubcriterion.get(subcriterionDefinition.id);
|
|
const farbe = normalizeScoringFarbe(rawAssessment?.farbe);
|
|
const score = COLOR_SCORE[farbe];
|
|
const confidence = Number.isFinite(rawAssessment?.confidence)
|
|
? Math.max(0, Math.min(1, Number(rawAssessment?.confidence)))
|
|
: 0;
|
|
const missingReason = String(rawAssessment?.missingReason ?? "").trim();
|
|
const evidence = String(rawAssessment?.evidence ?? "").trim();
|
|
|
|
if (score == null) {
|
|
unbewertbareKriterien += 1;
|
|
if (missingReason) {
|
|
missingDataWarnings.push(`${dimensionDefinition.name} / ${subcriterionDefinition.name}: ${missingReason}`);
|
|
}
|
|
} else {
|
|
scorableWeight += subcriterionDefinition.weight;
|
|
}
|
|
|
|
achievedScore += ((score ?? 0) * subcriterionDefinition.weight) / 100;
|
|
|
|
return {
|
|
id: subcriterionDefinition.id,
|
|
name: subcriterionDefinition.name,
|
|
indikator: subcriterionDefinition.indikator,
|
|
farbe,
|
|
evidence,
|
|
begruendung: String(rawAssessment?.begruendung ?? "").trim(),
|
|
confidence,
|
|
missingReason,
|
|
weight: subcriterionDefinition.weight,
|
|
score,
|
|
weightedScore: 0,
|
|
};
|
|
});
|
|
|
|
const roundedScorableWeight = Number(scorableWeight.toFixed(2));
|
|
const roundedDimensionScore = roundedScorableWeight
|
|
? Number(((achievedScore / roundedScorableWeight) * 100).toFixed(2))
|
|
: 0;
|
|
const weightedScore = roundedScorableWeight ? (roundedDimensionScore * dimensionDefinition.weight) / 100 : 0;
|
|
if (roundedScorableWeight) {
|
|
totalWeightedScore += weightedScore;
|
|
}
|
|
const normalizedSubcriteria = subcriteria.map((subcriterion) => ({
|
|
...subcriterion,
|
|
weightedScore:
|
|
subcriterion.score == null || !roundedScorableWeight
|
|
? 0
|
|
: Number(((subcriterion.score * subcriterion.weight) / roundedScorableWeight).toFixed(2)),
|
|
}));
|
|
|
|
return {
|
|
id: dimensionDefinition.id,
|
|
name: dimensionDefinition.name,
|
|
weight: dimensionDefinition.weight,
|
|
farbe: scoreToFarbe(roundedDimensionScore),
|
|
score: roundedDimensionScore,
|
|
weightedScore: Number(weightedScore.toFixed(2)),
|
|
scorableWeight: roundedScorableWeight,
|
|
subcriteria: normalizedSubcriteria,
|
|
};
|
|
});
|
|
|
|
const scorableDimensionWeight = dimensionen
|
|
.filter((dimension) => dimension.scorableWeight > 0)
|
|
.reduce((sum, dimension) => sum + dimension.weight, 0);
|
|
const gesamtScore = scorableDimensionWeight
|
|
? Number(((totalWeightedScore / scorableDimensionWeight) * 100).toFixed(2))
|
|
: 0;
|
|
const hasWeakDimension = dimensionen.some((dimension) => dimension.scorableWeight > 0 && dimension.score < 35);
|
|
const rawFarbe = scoreToFarbe(gesamtScore);
|
|
const farbe = rawFarbe === "gruen" && hasWeakDimension ? "gelb" : rawFarbe;
|
|
|
|
return {
|
|
farbe,
|
|
gesamtScore,
|
|
unbewertbareKriterien,
|
|
missingDataWarnings,
|
|
dimensionen,
|
|
};
|
|
}
|
|
|
|
export function deriveTrafficLight(
|
|
scoring: ScoringResult,
|
|
ausschlussgruende: string[],
|
|
): Ampelfarbe {
|
|
return ausschlussgruende.length ? "rot" : scoring.farbe;
|
|
}
|