Let advisors load preference ranks from a converted CSV with fuzzy matching, known aliases, and a parse-preview-save page instead of relying on the ranking editor. Co-authored-by: Cursor <cursoragent@cursor.com>
242 lines
9.8 KiB
C#
242 lines
9.8 KiB
C#
using Core.Entities;
|
|
using Core.Models;
|
|
using FuzzySharp;
|
|
|
|
namespace Core.Parsers;
|
|
|
|
public class StudentEventRankingParser : CsvParserBase
|
|
{
|
|
public const int StudentMatchThreshold = 90;
|
|
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 = FindStudent(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 (Student Student, int Score)? FindStudent(ICollection<Student> students, string name)
|
|
{
|
|
var ranked = students
|
|
.Select(s => (Student: s, Score: ScoreStudent(s, name)))
|
|
.Where(x => x.Score >= StudentMatchThreshold)
|
|
.OrderByDescending(x => x.Score)
|
|
.ToList();
|
|
|
|
return ranked.Count == 0 ? null : ranked[0];
|
|
}
|
|
|
|
private static int ScoreStudent(Student student, string name)
|
|
{
|
|
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
|
|
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
|
|
}
|
|
|
|
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);
|
|
}
|