Files
LehrerApp/LehrerApp.Core/Services/CompetencyAnalysisService.cs
T
adminandClaude Sonnet 5 dc2f58c4dc feat: Kompetenzzuordnungen von Freitext-Code auf stabile CompetencyItem.Id umgestellt (TODO 16.5)
Unit/Lesson/ExamTask/ParticipationSession referenzierten Kompetenzen bisher per Freitext-Code,
case-insensitiv gegen den Katalog gematcht - fragil bei Umbenennungen und seit der
Bildungsplan-Versionierung nicht mehr eindeutig (zwei Versionen können denselben Code vergeben).
Neuer Werttyp CompetencyReference (ItemId + Code/Description-Momentaufnahme) löst das: solange die
Id noch im Katalog auflösbar ist, zählt die live Fassung (Rename wirkt sofort), sonst bleibt die
gespeicherte historische Fassung sichtbar statt spurlos zu verschwinden.

Migration (Schema-Version 6) löst bestehende Codes gegen den Katalog auf; die zuvor dreifach
duplizierte Kompetenz-Tag-UI ist jetzt in CompetencyTagGroupBuilder konsolidiert.
add_lesson_competency/remove_lesson_competency nehmen jetzt itemId statt code und validieren gegen
den Katalog (vorher ungeprüft).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 00:41:54 +02:00

170 lines
7.3 KiB
C#

using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public sealed record CompetencyScore(
double AchievedPoints, double MaxPoints, int EvidenceCount, int ParticipantCount)
{
public double Percent => MaxPoints <= 0 ? 0 : AchievedPoints / MaxPoints * 100.0;
}
public sealed class CompetencyAnalysisItem
{
public required string DomainName { get; init; }
public required string DomainCode { get; init; }
public required string Code { get; init; }
public required string Description { get; init; }
public int PlannedUnitCount { get; init; }
public int TaughtUnitCount { get; init; }
public int PlannedExamCount { get; init; }
public int AssessedExamCount { get; init; }
public CompetencyScore? GroupScore { get; init; }
public IReadOnlyDictionary<Guid, CompetencyScore> StudentScores { get; init; } =
new Dictionary<Guid, CompetencyScore>();
}
public sealed class CompetencyAnalysisService
{
public List<CompetencyAnalysisItem> Analyze(
IReadOnlyList<CompetencyDomain> catalog,
IReadOnlyList<Unit> units,
IReadOnlyList<Exam> exams,
IReadOnlyList<ExamResult> results)
{
var builders = new List<ItemBuilder>();
var byId = new Dictionary<Guid, ItemBuilder>();
// Referenzen, die nie einem Katalogeintrag zugeordnet werden konnten (ItemId == Guid.Empty,
// z.B. aus einem plattformübergreifenden Import) haben keine gemeinsame Id, über die
// mehrfache Zuordnungen derselben Kompetenz zusammengeführt werden könnten - dafür wird
// ersatzweise der Code-Text als Merge-Schlüssel verwendet.
var byUnresolvedCode = new Dictionary<string, ItemBuilder>(StringComparer.OrdinalIgnoreCase);
foreach (var domain in catalog.OrderBy(x => x.SortOrder))
foreach (var item in domain.Items.OrderBy(x => x.SortOrder))
{
var builder = new ItemBuilder(domain.Name, domain.Code, item.Code, item.Description);
builders.Add(builder);
byId.TryAdd(item.Id, builder);
}
// Solange die Referenz noch im aktuellen Katalog auflösbar ist, zählt die LIVE-Fassung
// (ein Rename wirkt sich damit sofort aus). Ist sie es nicht mehr (gelöscht, andere
// Bildungsplan-Version, oder nie aufgelöster plattformübergreifender Import), wird ein
// Platzhalter aus der zum Zuordnungszeitpunkt gespeicherten Momentaufnahme synthetisiert -
// dadurch bleibt (anders als bei der bisherigen reinen Code-Textkonserve) sogar die echte
// historische Beschreibung erhalten.
ItemBuilder GetOrAdd(CompetencyReference reference)
{
var description = string.IsNullOrWhiteSpace(reference.Description)
? "Diese Kompetenz wird noch verwendet, ist aber nicht (mehr) im aktuellen Katalog enthalten."
: reference.Description;
if (reference.ItemId == Guid.Empty)
{
var codeKey = reference.Code.Trim();
if (byUnresolvedCode.TryGetValue(codeKey, out var existingUnresolved)) return existingUnresolved;
var addedUnresolved = new ItemBuilder("Nicht im Katalog", "", reference.Code, description);
byUnresolvedCode[codeKey] = addedUnresolved;
builders.Add(addedUnresolved);
return addedUnresolved;
}
if (byId.TryGetValue(reference.ItemId, out var existing)) return existing;
var added = new ItemBuilder("Nicht im Katalog", "", reference.Code, description);
byId[reference.ItemId] = added;
builders.Add(added);
return added;
}
foreach (var unit in units)
foreach (var reference in NormalizeReferences(unit.Competencies))
{
var builder = GetOrAdd(reference);
if (unit.Status == UnitStatus.Planned) builder.PlannedUnits++;
else builder.TaughtUnits++;
}
var resultsByExam = results.GroupBy(x => x.ExamId).ToDictionary(x => x.Key, x => x.ToList());
foreach (var exam in exams)
{
var examReferences = NormalizeReferences(exam.Tasks.SelectMany(x => x.Competencies));
foreach (var reference in examReferences)
{
var builder = GetOrAdd(reference);
if (exam.Status == ExamStatus.Planned) builder.PlannedExams++;
else builder.AssessedExams++;
}
if (exam.Status is ExamStatus.Planned or ExamStatus.Conducted
|| !resultsByExam.TryGetValue(exam.Id, out var examResults)) continue;
foreach (var result in examResults.Where(x => !x.Absent))
for (var taskIndex = 0; taskIndex < exam.Tasks.Count && taskIndex < result.Points.Count; taskIndex++)
{
var task = exam.Tasks[taskIndex];
if (task.MaxPoints <= 0) continue;
var achieved = Math.Clamp(result.Points[taskIndex], 0, task.MaxPoints);
foreach (var reference in NormalizeReferences(task.Competencies))
GetOrAdd(reference).AddScore(result.StudentId, achieved, task.MaxPoints);
}
}
return builders.Select(x => x.Build()).ToList();
}
private static IEnumerable<CompetencyReference> NormalizeReferences(IEnumerable<CompetencyReference> references) =>
references.DistinctBy(x => x.ItemId);
private sealed class ItemBuilder(
string domainName, string domainCode, string code, string description)
{
private readonly Dictionary<Guid, MutableScore> _scores = [];
public int PlannedUnits { get; set; }
public int TaughtUnits { get; set; }
public int PlannedExams { get; set; }
public int AssessedExams { get; set; }
public void AddScore(Guid studentId, double achieved, double maximum)
{
if (!_scores.TryGetValue(studentId, out var score))
_scores[studentId] = score = new MutableScore();
score.Achieved += achieved;
score.Maximum += maximum;
score.EvidenceCount++;
}
public CompetencyAnalysisItem Build()
{
var studentScores = _scores.ToDictionary(x => x.Key, x => x.Value.ToScore(1));
var achieved = _scores.Values.Sum(x => x.Achieved);
var maximum = _scores.Values.Sum(x => x.Maximum);
var evidence = _scores.Values.Sum(x => x.EvidenceCount);
return new CompetencyAnalysisItem
{
DomainName = domainName,
DomainCode = domainCode,
Code = code,
Description = description,
PlannedUnitCount = PlannedUnits,
TaughtUnitCount = TaughtUnits,
PlannedExamCount = PlannedExams,
AssessedExamCount = AssessedExams,
GroupScore = maximum > 0
? new CompetencyScore(achieved, maximum, evidence, _scores.Count)
: null,
StudentScores = studentScores,
};
}
}
private sealed class MutableScore
{
public double Achieved { get; set; }
public double Maximum { get; set; }
public int EvidenceCount { get; set; }
public CompetencyScore ToScore(int participantCount) =>
new(Achieved, Maximum, EvidenceCount, participantCount);
}
}