diff --git a/src/data/rmp_matching.rs b/src/data/rmp_matching.rs index 4102882..a4db74d 100644 --- a/src/data/rmp_matching.rs +++ b/src/data/rmp_matching.rs @@ -78,8 +78,9 @@ fn department_similarity(subjects: &[String], rmp_department: Option<&str>) -> f /// Expand common subject abbreviations used at UTSA and check for overlap. fn matches_known_abbreviation(subject: &str, department: &str) -> bool { const MAPPINGS: &[(&str, &[&str])] = &[ + // Core subjects (original mappings, corrected) ("cs", &["computer science"]), - ("ece", &["electrical", "computer engineering"]), + ("ece", &["early childhood education", "early childhood"]), ("ee", &["electrical engineering", "electrical"]), ("me", &["mechanical engineering", "mechanical"]), ("ce", &["civil engineering", "civil"]), @@ -105,6 +106,85 @@ fn matches_known_abbreviation(subject: &str, department: &str) -> bool { ("ms", &["management science"]), ("kin", &["kinesiology"]), ("com", &["communication"]), + // Architecture & Design + ("arc", &["architecture"]), + ("ide", &["interior design", "design"]), + // Anthropology & Ethnic Studies + ("ant", &["anthropology"]), + ("aas", &["african american studies", "ethnic studies"]), + ("mas", &["mexican american studies", "ethnic studies"]), + ("regs", &["ethnic studies", "gender"]), + // Languages + ("lng", &["linguistics", "applied linguistics"]), + ("spn", &["spanish"]), + ("frn", &["french"]), + ("ger", &["german"]), + ("chn", &["chinese"]), + ("jpn", &["japanese"]), + ("kor", &["korean"]), + ("itl", &["italian"]), + ("rus", &["russian"]), + ("lat", &["latin"]), + ("grk", &["greek"]), + ("asl", &["american sign language", "sign language"]), + ( + "fl", + &["foreign languages", "languages", "modern languages"], + ), + // Education + ("edu", &["education"]), + ("ci", &["curriculum", "education"]), + ("edl", &["educational leadership", "education"]), + ("edp", &["educational psychology", "education"]), + ("bbl", &["bilingual education"]), + ("spe", &["special education", "education"]), + // Business + ("ent", &["entrepreneurship"]), + ("gba", &["general business", "business"]), + ("blw", &["business law", "law"]), + ("rfd", &["real estate"]), + ("mot", &["management of technology", "management"]), + // Engineering + ("egr", &["engineering"]), + ("bme", &["biomedical engineering", "engineering"]), + ("cme", &["chemical engineering", "engineering"]), + ("cpe", &["computer engineering", "engineering"]), + ("ise", &["industrial", "systems engineering", "engineering"]), + ("mate", &["materials engineering", "engineering"]), + // Sciences + ("che", &["chemistry"]), + ("bch", &["biochemistry", "chemistry"]), + ("geo", &["geology"]), + ("phy", &["physics"]), + ("ast", &["astronomy"]), + ("es", &["environmental science"]), + // Social Sciences + ("crj", &["criminal justice"]), + ("swk", &["social work"]), + ("pad", &["public administration"]), + ("grg", &["geography"]), + ("ges", &["geography"]), + // Humanities + ("cla", &["classics"]), + ("hum", &["humanities"]), + ("wgss", &["women's studies"]), + // Health + ("hth", &["health"]), + ("hcp", &["health science", "health"]), + ("ntr", &["nutrition"]), + // Military + ("msc", &["military science"]), + ("asc", &["aerospace"]), + // Arts + ("dan", &["dance"]), + ("thr", &["theater"]), + ("ahc", &["art history"]), + // Other + ("cou", &["counseling"]), + ("hon", &["honors"]), + ("csm", &["construction"]), + ("wrc", &["writing"]), + ("set", &["tourism management", "tourism"]), ]; for &(abbr, expansions) in MAPPINGS { @@ -164,6 +244,7 @@ pub fn compute_match_score( pub struct MatchingStats { pub total_unmatched: usize, pub candidates_created: usize, + pub candidates_rescored: usize, pub auto_matched: usize, pub skipped_unparseable: usize, pub skipped_no_candidates: usize, @@ -200,6 +281,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result { return Ok(MatchingStats { total_unmatched: 0, candidates_created: 0, + candidates_rescored: 0, auto_matched: 0, skipped_unparseable: 0, skipped_no_candidates: 0, @@ -245,24 +327,34 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result { }); } - // 4. Load existing candidate pairs (and rejected subset) in a single query + // 4. Load existing candidate pairs — only skip resolved (accepted/rejected) pairs. + // Pending candidates are rescored so updated mappings take effect. let candidate_rows: Vec<(i32, i32, String)> = sqlx::query_as("SELECT instructor_id, rmp_legacy_id, status FROM rmp_match_candidates") .fetch_all(db_pool) .await?; - let mut existing_pairs: HashSet<(i32, i32)> = HashSet::with_capacity(candidate_rows.len()); + let mut resolved_pairs: HashSet<(i32, i32)> = HashSet::new(); + let mut pending_pairs: HashSet<(i32, i32)> = HashSet::new(); let mut rejected_pairs: HashSet<(i32, i32)> = HashSet::new(); for (iid, lid, status) in candidate_rows { - existing_pairs.insert((iid, lid)); - if status == "rejected" { - rejected_pairs.insert((iid, lid)); + match status.as_str() { + "accepted" | "rejected" => { + resolved_pairs.insert((iid, lid)); + if status == "rejected" { + rejected_pairs.insert((iid, lid)); + } + } + _ => { + pending_pairs.insert((iid, lid)); + } } } - // 5. Score and collect candidates + // 5. Score and collect candidates (new + rescored pending) let empty_subjects: Vec = Vec::new(); - let mut candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new(); + let mut new_candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new(); + let mut rescored_candidates: Vec<(i32, i32, f32, serde_json::Value)> = Vec::new(); let mut auto_accept: Vec<(i32, i32)> = Vec::new(); // (instructor_id, legacy_id) let mut skipped_unparseable = 0usize; let mut skipped_no_candidates = 0usize; @@ -290,7 +382,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result { for prof in rmp_candidates { let pair = (*instructor_id, prof.legacy_id); - if existing_pairs.contains(&pair) { + if resolved_pairs.contains(&pair) { continue; } @@ -308,7 +400,16 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result { let breakdown_json = serde_json::to_value(&ms.breakdown).unwrap_or_else(|_| serde_json::json!({})); - candidates.push((*instructor_id, prof.legacy_id, ms.score, breakdown_json)); + if pending_pairs.contains(&pair) { + rescored_candidates.push(( + *instructor_id, + prof.legacy_id, + ms.score, + breakdown_json, + )); + } else { + new_candidates.push((*instructor_id, prof.legacy_id, ms.score, breakdown_json)); + } match best { Some((s, _)) if ms.score > s => best = Some((ms.score, prof.legacy_id)), @@ -327,19 +428,20 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result { } } - // 6–7. Write candidates and auto-accept within a single transaction - let candidates_created = candidates.len(); + // 6–7. Write candidates, rescore, and auto-accept within a single transaction + let candidates_created = new_candidates.len(); + let candidates_rescored = rescored_candidates.len(); let auto_matched = auto_accept.len(); let mut tx = db_pool.begin().await?; - // 6. Batch-insert candidates - if !candidates.is_empty() { - let c_instructor_ids: Vec = candidates.iter().map(|(iid, _, _, _)| *iid).collect(); - let c_legacy_ids: Vec = candidates.iter().map(|(_, lid, _, _)| *lid).collect(); - let c_scores: Vec = candidates.iter().map(|(_, _, s, _)| *s).collect(); + // 6a. Batch-insert new candidates + if !new_candidates.is_empty() { + let c_instructor_ids: Vec = new_candidates.iter().map(|(iid, _, _, _)| *iid).collect(); + let c_legacy_ids: Vec = new_candidates.iter().map(|(_, lid, _, _)| *lid).collect(); + let c_scores: Vec = new_candidates.iter().map(|(_, _, s, _)| *s).collect(); let c_breakdowns: Vec = - candidates.into_iter().map(|(_, _, _, b)| b).collect(); + new_candidates.into_iter().map(|(_, _, _, b)| b).collect(); sqlx::query( r#" @@ -358,6 +460,40 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result { .await?; } + // 6b. Batch-update rescored pending candidates + if !rescored_candidates.is_empty() { + let r_instructor_ids: Vec = rescored_candidates + .iter() + .map(|(iid, _, _, _)| *iid) + .collect(); + let r_legacy_ids: Vec = rescored_candidates + .iter() + .map(|(_, lid, _, _)| *lid) + .collect(); + let r_scores: Vec = rescored_candidates.iter().map(|(_, _, s, _)| *s).collect(); + let r_breakdowns: Vec = rescored_candidates + .into_iter() + .map(|(_, _, _, b)| b) + .collect(); + + sqlx::query( + r#" + UPDATE rmp_match_candidates mc + SET score = v.score, score_breakdown = v.score_breakdown + FROM UNNEST($1::int4[], $2::int4[], $3::real[], $4::jsonb[]) + AS v(instructor_id, rmp_legacy_id, score, score_breakdown) + WHERE mc.instructor_id = v.instructor_id + AND mc.rmp_legacy_id = v.rmp_legacy_id + "#, + ) + .bind(&r_instructor_ids) + .bind(&r_legacy_ids) + .bind(&r_scores) + .bind(&r_breakdowns) + .execute(&mut *tx) + .await?; + } + // 7. Auto-accept top candidates if !auto_accept.is_empty() { let aa_instructor_ids: Vec = auto_accept.iter().map(|(iid, _)| *iid).collect(); @@ -411,6 +547,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result { let stats = MatchingStats { total_unmatched, candidates_created, + candidates_rescored, auto_matched, skipped_unparseable, skipped_no_candidates, @@ -419,6 +556,7 @@ pub async fn generate_candidates(db_pool: &PgPool) -> Result { info!( total_unmatched = stats.total_unmatched, candidates_created = stats.candidates_created, + candidates_rescored = stats.candidates_rescored, auto_matched = stats.auto_matched, skipped_unparseable = stats.skipped_unparseable, skipped_no_candidates = stats.skipped_no_candidates, diff --git a/src/scraper/scheduler.rs b/src/scraper/scheduler.rs index c7a9d09..99f9f45 100644 --- a/src/scraper/scheduler.rs +++ b/src/scraper/scheduler.rs @@ -310,6 +310,7 @@ impl Scheduler { total, stats.total_unmatched, stats.candidates_created, + stats.candidates_rescored, stats.auto_matched, stats.skipped_unparseable, stats.skipped_no_candidates, diff --git a/src/web/admin_rmp.rs b/src/web/admin_rmp.rs index f77e914..68db567 100644 --- a/src/web/admin_rmp.rs +++ b/src/web/admin_rmp.rs @@ -180,6 +180,7 @@ pub struct InstructorDetailResponse { pub struct RescoreResponse { pub total_unmatched: usize, pub candidates_created: usize, + pub candidates_rescored: usize, pub auto_matched: usize, pub skipped_unparseable: usize, pub skipped_no_candidates: usize, @@ -858,6 +859,7 @@ pub async fn rescore( Ok(Json(RescoreResponse { total_unmatched: stats.total_unmatched, candidates_created: stats.candidates_created, + candidates_rescored: stats.candidates_rescored, auto_matched: stats.auto_matched, skipped_unparseable: stats.skipped_unparseable, skipped_no_candidates: stats.skipped_no_candidates, diff --git a/web/src/routes/(app)/admin/instructors/+page.svelte b/web/src/routes/(app)/admin/instructors/+page.svelte index 553ed3f..fda09dd 100644 --- a/web/src/routes/(app)/admin/instructors/+page.svelte +++ b/web/src/routes/(app)/admin/instructors/+page.svelte @@ -1,5 +1,6 @@ -
+ + + +

Instructors

+
+ + +
+ + + {#if searchInput} + + {/if} +
+ +
+ {#if rescoreResult} -
- {rescoreResult} +
+ {rescoreResult.message} +
{/if} + {#if error} -

{error}

+
+ {error} +
{/if} {#if loading && instructors.length === 0} -
+
{#each Array(5) as _}
@@ -311,341 +496,368 @@ const scoreLabels: Record = {
{:else} - - -
-
-
Total
-
{stats.total}
-
-
-
Unmatched
-
{stats.unmatched}
-
-
-
Auto
-
{stats.auto}
-
-
-
Confirmed
-
{stats.confirmed}
-
-
-
Rejected
-
{stats.rejected}
-
-
- - -
-
-
-
-
- - -
-
- {#each filterOptions as opt} - - {/each} -
+ +
+ {/if} - -
- -{#if instructors.length === 0} -

No instructors found.

-{:else} -
- - - - - - - - - - - - {#each instructors as instructor (instructor.id)} - {@const badge = statusBadge(instructor.rmpMatchStatus)} - {@const isExpanded = expandedId === instructor.id} - toggleExpand(instructor.id)} - > - - - - - - - - - {#if isExpanded} - - - - {/if} - {/each} - -
NameStatusTop CandidateCandidatesActions
-
{formatInstructorName(instructor.displayName)}
-
{instructor.email}
-
- - {badge.label} - - - {#if instructor.topCandidate} - {@const tc = instructor.topCandidate} -
- {tc.firstName} {tc.lastName} - {#if isRatingValid(tc.avgRating, tc.numRatings ?? 0)} - - {tc.avgRating!.toFixed(1)} - - {:else} - N/A - {/if} - - ({formatScore(tc.score ?? 0)}%) - -
- {:else} - No candidates - {/if} -
- {instructor.candidateCount} - -
- {#if instructor.topCandidate && instructor.rmpMatchStatus === "unmatched"} - - {/if} - -
-
-
- {#if detailLoading} -

Loading details...

- {:else if detailError} -

{detailError}

- {:else if detail} -
- -
-

Instructor

-
-
Name
-
{formatInstructorName(detail.instructor.displayName)}
- -
Email
-
{detail.instructor.email}
- -
Courses
-
{detail.instructor.courseCount}
- - {#if detail.instructor.subjectsTaught.length > 0} -
Subjects
-
- {#each detail.instructor.subjectsTaught as subj} - {subj} - {/each} -
- {/if} -
-
- - -
-
-

- Candidates ({detail.candidates.length}) -

- {#if detail.candidates.some((c: CandidateResponse) => c.status !== "rejected" && !matchedLegacyIds.has(c.rmpLegacyId))} - - {/if} -
- - {#if detail.candidates.length === 0} -

No candidates available.

- {:else} -
- {#each detail.candidates as candidate (candidate.id)} - {@const isMatched = candidate.status === "matched" || matchedLegacyIds.has(candidate.rmpLegacyId)} - {@const isRejected = candidate.status === "rejected"} - {@const isPending = !isMatched && !isRejected} -
-
-
-
- - {candidate.firstName} {candidate.lastName} - - {#if isMatched} - - Matched - - {:else if isRejected} - - Rejected - - {/if} -
- {#if candidate.department} -
{candidate.department}
- {/if} -
-
- {#if isMatched} - - {:else if isPending} - - - {/if} - e.stopPropagation()} - class="rounded p-1 text-muted-foreground hover:bg-muted transition-colors cursor-pointer" - title="View on RMP" - > - - -
-
- - -
- {#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)} - - {candidate.avgRating!.toFixed(1)} - - {:else} - N/A - {/if} - {#if candidate.avgDifficulty !== null} - {candidate.avgDifficulty.toFixed(1)} diff - {/if} - {candidate.numRatings} ratings - {#if candidate.wouldTakeAgainPct !== null} - {candidate.wouldTakeAgainPct.toFixed(0)}% again - {/if} -
- - -
-
- Score: -
- {#each Object.entries(candidate.scoreBreakdown ?? {}) as [key, value]} - {@const w = scoreWeights[key] ?? 0} - {@const widthPct = (value as number) * w * 100} -
- {/each} -
- {formatScore(candidate.score ?? 0)}% -
-
-
- {/each} -
- {/if} -
-
- {/if} -
-
-
- - -
- - Showing {(currentPage - 1) * perPage + 1}–{Math.min(currentPage * perPage, totalCount)} of {totalCount} - -
- - - {currentPage} / {totalPages} - - + +
+ {#each filterCards as card} + + {/each}
+ + +
+
+ {#each progressSegments as seg} + {@const pct = (stats[seg.stat] / progressDenom) * 100} +
+ {/each} +
+
+ + {#if instructors.length === 0} +
+ {#if searchQuery || activeFilter} +

No instructors match your filters.

+ + {:else} +

No instructors found.

+ {/if} +
+ {:else} +
+ + + + + + + + + + + + {#each instructors as instructor (instructor.id)} + {@const badge = statusBadge(instructor.rmpMatchStatus)} + {@const isExpanded = expandedId === instructor.id} + {@const isStale = !matchesFilter(instructor.rmpMatchStatus)} + {@const isHighlighted = recentlyChanged.has(instructor.id)} + toggleExpand(instructor.id)} + > + + + + + + + + + {#if isExpanded} + + + + {/if} + {/each} + +
NameStatusTop CandidateCandidatesActions
+
+ {formatInstructorName(instructor.displayName)} +
+
{instructor.email}
+
+ + {badge.label} + + + {#if instructor.topCandidate} + {@const tc = instructor.topCandidate} +
+ {tc.firstName} {tc.lastName} + {#if isRatingValid(tc.avgRating, tc.numRatings ?? 0)} + + {tc.avgRating!.toFixed(1)} + + {:else} + N/A + {/if} + + ({formatScore(tc.score ?? 0)}%) + +
+ {:else} + No candidates + {/if} +
+ {instructor.candidateCount} + +
+ {#if instructor.topCandidate && instructor.rmpMatchStatus === "unmatched"} + + {/if} + +
+
+
+ {#if detailLoading} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {:else if detailError} +
+ {detailError} +
+ {:else if detail} +
+ +
+

Instructor

+
+
Name
+
+ {formatInstructorName(detail.instructor.displayName)} +
+ +
Email
+
{detail.instructor.email}
+ +
Courses
+
+ {detail.instructor.courseCount} +
+ + {#if detail.instructor.subjectsTaught.length > 0} +
Subjects
+
+ {#each detail.instructor.subjectsTaught as subj} + {#if subjectMap.has(subj)} + + {subj} + + {:else} + {subj} + {/if} + {/each} +
+ {/if} +
+
+ + +
+
+

+ Candidates + ({detail.candidates.length}) +

+ {#if detail.candidates.some((c: CandidateResponse) => c.status !== "rejected" && !matchedLegacyIds.has(c.rmpLegacyId))} + {#if showRejectConfirm === detail.instructor.id} +
+ Reject all candidates? + + +
+ {:else} + + {/if} + {/if} +
+ + {#if detail.candidates.length === 0} +

+ No candidates available. +

+ {:else} +
+ {#each detail.candidates as candidate (candidate.id)} + + handleMatch( + detail!.instructor.id, + candidate.rmpLegacyId + )} + onreject={() => + handleReject( + detail!.instructor.id, + candidate.rmpLegacyId + )} + onunmatch={() => + handleUnmatch( + detail!.instructor.id, + candidate.rmpLegacyId + )} + /> + {/each} +
+ {/if} +
+
+ {/if} +
+
+
+ + +
+ + Showing {(currentPage - 1) * perPage + 1}–{Math.min( + currentPage * perPage, + totalCount + )} of {totalCount} + +
+ + + {currentPage} / {totalPages} + + +
+
+ {/if}
{/if} -{/if} diff --git a/web/src/routes/(app)/admin/instructors/CandidateCard.svelte b/web/src/routes/(app)/admin/instructors/CandidateCard.svelte new file mode 100644 index 0000000..7c1c7d3 --- /dev/null +++ b/web/src/routes/(app)/admin/instructors/CandidateCard.svelte @@ -0,0 +1,160 @@ + + +
+
+
+
+ + {candidate.firstName} {candidate.lastName} + + {#if isMatched} + + Matched + + {:else if isRejected} + + Rejected + + {/if} +
+ {#if candidate.department} +
{candidate.department}
+ {/if} +
+ +
+ {#if isMatched} + + {:else if isPending} + + + {/if} + e.stopPropagation()} + class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer" + title="View on RateMyProfessors" + > + + +
+
+ + +
+ {#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)} + + {candidate.avgRating!.toFixed(1)} + + {:else} + No rating + {/if} + {#if candidate.avgDifficulty !== null} + {candidate.avgDifficulty.toFixed(1)} diff + {/if} + {candidate.numRatings} ratings + {#if candidate.wouldTakeAgainPct !== null} + {candidate.wouldTakeAgainPct.toFixed(0)}% again + {/if} +
+ + +
+ +
+
diff --git a/web/src/routes/(app)/admin/instructors/ScoreBreakdown.svelte b/web/src/routes/(app)/admin/instructors/ScoreBreakdown.svelte new file mode 100644 index 0000000..1ecf892 --- /dev/null +++ b/web/src/routes/(app)/admin/instructors/ScoreBreakdown.svelte @@ -0,0 +1,73 @@ + + +
+ Score: +
+ {#each segments as seg (seg.key)} +
+ {/each} +
+ + + {fmt(score)}% + + +