feat: add CSV import for student event rankings
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>
This commit is contained in:
@@ -1,77 +1,241 @@
|
||||
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 StudentEventRanking[] Parse(ICollection<Student> students, ICollection<EventDefinition> events)
|
||||
{
|
||||
var rankings = new List<StudentEventRanking>();
|
||||
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();
|
||||
|
||||
while (CsvReader.Read())
|
||||
{
|
||||
var name = CsvReader.GetField("Student Name");
|
||||
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 student = students.FirstOrDefault(s => Fuzz.Ratio(s.FirstNameLastName, name) > 90);
|
||||
if (student == null)
|
||||
continue;
|
||||
|
||||
|
||||
var competitiveEvents = new List<EventDefinition>();
|
||||
|
||||
for (var i = 1; i <= 6; i++)
|
||||
var studentMatch = FindStudent(students, name);
|
||||
if (studentMatch is null)
|
||||
{
|
||||
var eventName = CsvReader.GetField(i.ToString());
|
||||
if (string.IsNullOrEmpty(eventName) || eventName == "") continue;
|
||||
|
||||
eventName = eventName.Trim();
|
||||
|
||||
if (eventName == "I&I")
|
||||
eventName = "Inventions & Innovations";
|
||||
if (eventName == "Med Tech")
|
||||
eventName = "Medical Technology";
|
||||
if (eventName.StartsWith("Challenging Tech"))
|
||||
eventName = "Challenging Technology Issues";
|
||||
|
||||
var matches =
|
||||
(from e in events
|
||||
let rat = Fuzz.Ratio(e.Name, eventName)
|
||||
where rat > 90
|
||||
orderby rat descending
|
||||
select e).ToList();
|
||||
|
||||
if (!matches.Any())
|
||||
result.Issues.Add(new StudentEventRankingIssue
|
||||
{
|
||||
matches =
|
||||
(from e in events
|
||||
where e.Name.StartsWith(eventName)
|
||||
select e).ToList();
|
||||
}
|
||||
|
||||
var competitiveEvent = matches.FirstOrDefault();
|
||||
if (competitiveEvent == null)
|
||||
{
|
||||
|
||||
//todo: throw new ArgumentException($"Event named '{eventName}' not found");
|
||||
continue;
|
||||
}
|
||||
|
||||
rankings.Add(new StudentEventRanking{
|
||||
Student = student, EventDefinition = competitiveEvent,
|
||||
Rank = i});
|
||||
RowNumber = rowNumber,
|
||||
RawStudentName = name,
|
||||
IssueType = StudentEventRankingIssueType.UnmatchedStudent,
|
||||
Message = $"No student matched '{name}'."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return rankings.ToArray();
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user