Files
chapter-organizer/Core/Parsers/StudentEventRankingParser.cs
T
poprhythmandCursor 4cfd85b902 feat: import leftover student fields into notes and show them on the roster
Store leftover CSV columns on hidden student notes, move catalog import to /events/import, and persist Students index columns from Chapter Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 23:55:03 -04:00

224 lines
9.1 KiB
C#

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;
/// <summary>
/// Informal names that share too little text with the catalog for fuzzy matching.
/// Keyed by official <see cref="EventDefinition.Name"/>.
/// </summary>
private static readonly Dictionary<string, string[]> 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<Student> students, ICollection<EventDefinition> 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<string>(StringComparer.OrdinalIgnoreCase);
var acceptedRanksForStudent = new HashSet<int>();
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<EventDefinition> 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<string> 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);
}