using Core.Entities; using Core.Models; using FuzzySharp; namespace Core.Parsers; public class StudentEventRankingParser : CsvParserBase { public const int EventMatchThreshold = 70; public const int EventAmbiguityGap = 8; /// /// Informal names that share too little text with the catalog for fuzzy matching. /// Keyed by official . /// private static readonly Dictionary EventAliases = new(StringComparer.OrdinalIgnoreCase) { ["Junior Solar Sprint"] = ["Solar Racer", "Solar Race"], ["Challenging Technology Issues"] = ["Challenging Tech", "Challenging Technology"], ["Digital Photography"] = ["Digital Photo"], ["Forensic Technology"] = ["Forensics", "Forensic"], ["Microcontroller Design"] = ["Micro Controller", "Micro Controller Design"], ["Medical Technology"] = ["Med Tech"], ["Inventions & Innovations"] = ["Innovations & Inventions", "Inventions and Innovations"], ["System Control Technology"] = ["Systems Control Tech", "Systems Control Technology"], ["Structural Engineering"] = ["Structural Eng"], ["Drone Challenge (UAV)"] = ["Drone Challenge", "Drone"], ["Drone Challenge"] = ["Drone Challenge (UAV)", "Drone"], ["TSA Robotics"] = ["Robotics"], ["Audio Podcasting"] = ["Audio Podcast", "Podcasting"] }; public StudentEventRankingParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines) { } public StudentEventRankingParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines) { } public StudentEventRankingParseResult Parse(ICollection students, ICollection events) { var result = new StudentEventRankingParseResult(); CsvReader.Read(); CsvReader.ReadHeader(); if (CsvReader.HeaderRecord is null || !CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase)) { result.Errors.Add("CSV must include a 'Student Name' column."); return result; } while (CsvReader.Read()) { var rowNumber = CsvReader.Context.Parser?.Row ?? 0; var name = CsvReader.GetField("Student Name")?.Trim(); if (string.IsNullOrEmpty(name)) continue; var studentMatch = FuzzyStudentMatcher.Find(students, name); if (studentMatch is null) { result.Issues.Add(new StudentEventRankingIssue { RowNumber = rowNumber, RawStudentName = name, IssueType = StudentEventRankingIssueType.UnmatchedStudent, Message = $"No student matched '{name}'." }); continue; } var (student, studentScore) = studentMatch.Value; var acceptedEventsForStudent = new HashSet(StringComparer.OrdinalIgnoreCase); var acceptedRanksForStudent = new HashSet(); for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++) { var eventName = CsvReader.GetField(rank.ToString())?.Trim(); if (string.IsNullOrEmpty(eventName)) continue; var eventResolution = ResolveEvent(events, eventName); if (eventResolution.Status == EventMatchStatus.Unmatched) { result.Issues.Add(new StudentEventRankingIssue { RowNumber = rowNumber, Rank = rank, RawStudentName = name, RawEventName = eventName, IssueType = StudentEventRankingIssueType.UnmatchedEvent, Message = $"No event matched '{eventName}' for {student.FirstNameLastName} (rank {rank}).", SuggestedEventName = eventResolution.BestEvent?.Name, Score = eventResolution.BestScore }); continue; } if (eventResolution.Status == EventMatchStatus.Ambiguous) { result.Issues.Add(new StudentEventRankingIssue { RowNumber = rowNumber, Rank = rank, RawStudentName = name, RawEventName = eventName, IssueType = StudentEventRankingIssueType.AmbiguousEvent, Message = $"'{eventName}' is ambiguous between '{eventResolution.BestEvent?.Name}' and '{eventResolution.RunnerUpEvent?.Name}' for {student.FirstNameLastName} (rank {rank}).", SuggestedEventName = eventResolution.BestEvent?.Name, Score = eventResolution.BestScore }); continue; } var matchedEvent = eventResolution.BestEvent!; if (!acceptedEventsForStudent.Add($"{matchedEvent.Id}:{matchedEvent.Name}")) { result.Issues.Add(new StudentEventRankingIssue { RowNumber = rowNumber, Rank = rank, RawStudentName = name, RawEventName = eventName, IssueType = StudentEventRankingIssueType.DuplicateEvent, Message = $"{student.FirstNameLastName} already has '{matchedEvent.Name}' in this file.", SuggestedEventName = matchedEvent.Name, Score = eventResolution.BestScore }); continue; } if (!acceptedRanksForStudent.Add(rank)) { result.Issues.Add(new StudentEventRankingIssue { RowNumber = rowNumber, Rank = rank, RawStudentName = name, RawEventName = eventName, IssueType = StudentEventRankingIssueType.DuplicateRank, Message = $"{student.FirstNameLastName} already has a rank {rank} assignment in this file.", SuggestedEventName = matchedEvent.Name, Score = eventResolution.BestScore }); continue; } result.Matches.Add(new StudentEventRankingMatch { Ranking = new StudentEventRanking { Student = student, EventDefinition = matchedEvent, Rank = rank }, RawStudentName = name, RawEventName = eventName, StudentScore = studentScore, EventScore = eventResolution.BestScore, RowNumber = rowNumber }); } } if (result.Matches.Count == 0 && result.Errors.Count == 0) result.Warnings.Add("No rankings were accepted from the CSV."); return result; } private static EventResolution ResolveEvent(ICollection events, string eventName) { var scored = events .Select(e => (Event: e, Score: ScoreEvent(e, eventName))) .OrderByDescending(x => x.Score) .ToList(); if (scored.Count == 0) return new EventResolution(EventMatchStatus.Unmatched, null, 0, null, 0); var best = scored[0]; var runnerUp = scored.Count > 1 ? scored[1] : default; if (best.Score < EventMatchThreshold) return new EventResolution(EventMatchStatus.Unmatched, best.Event, best.Score, runnerUp.Event, runnerUp.Score); if (runnerUp.Event is not null && best.Score - runnerUp.Score < EventAmbiguityGap) return new EventResolution(EventMatchStatus.Ambiguous, best.Event, best.Score, runnerUp.Event, runnerUp.Score); return new EventResolution(EventMatchStatus.Matched, best.Event, best.Score, runnerUp.Event, runnerUp.Score); } private static int ScoreEvent(EventDefinition evt, string eventName) { List names = [evt.Name]; if (!string.IsNullOrWhiteSpace(evt.ShortName)) names.Add(evt.ShortName); if (EventAliases.TryGetValue(evt.Name, out var aliases)) names.AddRange(aliases); return names .Select(n => new[] { Fuzz.Ratio(n, eventName), Fuzz.TokenSetRatio(n, eventName), Fuzz.PartialRatio(n, eventName) }.Max()) .DefaultIfEmpty(0) .Max(); } private enum EventMatchStatus { Matched, Ambiguous, Unmatched } private readonly record struct EventResolution( EventMatchStatus Status, EventDefinition? BestEvent, int BestScore, EventDefinition? RunnerUpEvent, int RunnerUpScore); }