mirror of
https://github.com/Xevion/banner.git
synced 2026-01-30 18:23:30 -06:00
feat: add confidence-based RMP matching with manual review workflow
Replace simple auto-matching with scored candidate generation that considers department overlap, name uniqueness, and rating volume. Candidates above 0.85 auto-accept; others require admin approval.
This commit is contained in:
Vendored
+1
-1
@@ -2,5 +2,5 @@
|
||||
/target
|
||||
|
||||
# ts-rs bindings
|
||||
web/src/lib/bindings/*.ts
|
||||
web/src/lib/bindings/**/*.ts
|
||||
!web/src/lib/bindings/index.ts
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Collapse instructors from per-banner-id rows to per-person rows (deduped by lowercased email).
|
||||
-- All existing RMP matches are deliberately dropped; the new auto-matcher will re-score from scratch.
|
||||
|
||||
-- 1. Create the new instructors table (1 row per person, keyed by email)
|
||||
CREATE TABLE instructors_new (
|
||||
id SERIAL PRIMARY KEY,
|
||||
display_name VARCHAR NOT NULL,
|
||||
email VARCHAR NOT NULL,
|
||||
rmp_professor_id INTEGER UNIQUE REFERENCES rmp_professors(legacy_id),
|
||||
rmp_match_status VARCHAR NOT NULL DEFAULT 'unmatched',
|
||||
CONSTRAINT instructors_email_unique UNIQUE (email)
|
||||
);
|
||||
|
||||
-- 2. Populate from existing data, deduplicating by lowercased email.
|
||||
-- For each email, pick the display_name from the row with the highest banner_id
|
||||
-- (deterministic tiebreaker). All rmp fields start fresh (NULL / 'unmatched').
|
||||
INSERT INTO instructors_new (display_name, email)
|
||||
SELECT DISTINCT ON (LOWER(email))
|
||||
display_name,
|
||||
LOWER(email)
|
||||
FROM instructors
|
||||
ORDER BY LOWER(email), banner_id DESC;
|
||||
|
||||
-- 3. Create the new course_instructors table with integer FK and banner_id column
|
||||
CREATE TABLE course_instructors_new (
|
||||
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||
instructor_id INTEGER NOT NULL REFERENCES instructors_new(id) ON DELETE CASCADE,
|
||||
banner_id VARCHAR NOT NULL,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (course_id, instructor_id)
|
||||
);
|
||||
|
||||
-- 4. Populate from old data, mapping old banner_id → new instructor id via lowercased email.
|
||||
-- Use DISTINCT ON to handle cases where multiple old banner_ids for the same person
|
||||
-- taught the same course (would cause duplicate (course_id, instructor_id) pairs).
|
||||
INSERT INTO course_instructors_new (course_id, instructor_id, banner_id, is_primary)
|
||||
SELECT DISTINCT ON (ci.course_id, inew.id)
|
||||
ci.course_id,
|
||||
inew.id,
|
||||
ci.instructor_id, -- old banner_id
|
||||
ci.is_primary
|
||||
FROM course_instructors ci
|
||||
JOIN instructors iold ON iold.banner_id = ci.instructor_id
|
||||
JOIN instructors_new inew ON inew.email = LOWER(iold.email)
|
||||
ORDER BY ci.course_id, inew.id, ci.is_primary DESC;
|
||||
|
||||
-- 5. Drop old tables (course_instructors first due to FK dependency)
|
||||
DROP TABLE course_instructors;
|
||||
DROP TABLE instructors;
|
||||
|
||||
-- 6. Rename new tables into place
|
||||
ALTER TABLE instructors_new RENAME TO instructors;
|
||||
ALTER TABLE course_instructors_new RENAME TO course_instructors;
|
||||
|
||||
-- 7. Rename constraints to match the final table names
|
||||
ALTER TABLE instructors RENAME CONSTRAINT instructors_new_pkey TO instructors_pkey;
|
||||
ALTER TABLE instructors RENAME CONSTRAINT instructors_new_rmp_professor_id_key TO instructors_rmp_professor_id_key;
|
||||
ALTER TABLE course_instructors RENAME CONSTRAINT course_instructors_new_pkey TO course_instructors_pkey;
|
||||
|
||||
-- 8. Recreate indexes
|
||||
CREATE INDEX idx_course_instructors_instructor ON course_instructors (instructor_id);
|
||||
CREATE INDEX idx_instructors_rmp_status ON instructors (rmp_match_status);
|
||||
CREATE INDEX idx_instructors_email ON instructors (email);
|
||||
|
||||
-- 9. Create rmp_match_candidates table
|
||||
CREATE TABLE rmp_match_candidates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
instructor_id INTEGER NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
|
||||
rmp_legacy_id INTEGER NOT NULL REFERENCES rmp_professors(legacy_id),
|
||||
score REAL NOT NULL,
|
||||
score_breakdown JSONB NOT NULL DEFAULT '{}',
|
||||
status VARCHAR NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by BIGINT REFERENCES users(discord_id),
|
||||
CONSTRAINT uq_candidate_pair UNIQUE (instructor_id, rmp_legacy_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_match_candidates_instructor ON rmp_match_candidates (instructor_id);
|
||||
CREATE INDEX idx_match_candidates_status ON rmp_match_candidates (status);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Multi-RMP profile support: allow many RMP profiles per instructor.
|
||||
-- Each RMP profile still links to at most one instructor (rmp_legacy_id UNIQUE).
|
||||
|
||||
-- 1. Create junction table
|
||||
CREATE TABLE instructor_rmp_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
instructor_id INTEGER NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
|
||||
rmp_legacy_id INTEGER NOT NULL UNIQUE REFERENCES rmp_professors(legacy_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by BIGINT REFERENCES users(discord_id),
|
||||
source VARCHAR NOT NULL DEFAULT 'manual' -- 'auto' | 'manual'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_instructor_rmp_links_instructor ON instructor_rmp_links (instructor_id);
|
||||
|
||||
-- 2. Migrate existing matches
|
||||
INSERT INTO instructor_rmp_links (instructor_id, rmp_legacy_id, source)
|
||||
SELECT id, rmp_professor_id,
|
||||
CASE rmp_match_status WHEN 'auto' THEN 'auto' ELSE 'manual' END
|
||||
FROM instructors
|
||||
WHERE rmp_professor_id IS NOT NULL;
|
||||
|
||||
-- 3. Drop old column (and its unique constraint)
|
||||
ALTER TABLE instructors DROP COLUMN rmp_professor_id;
|
||||
+59
-33
@@ -392,11 +392,11 @@ pub async fn batch_upsert_courses(courses: &[Course], db_pool: &PgPool) -> Resul
|
||||
insert_audits(&audits, &mut tx).await?;
|
||||
insert_metrics(&metrics, &mut tx).await?;
|
||||
|
||||
// Step 5: Upsert instructors (deduplicated across batch)
|
||||
upsert_instructors(courses, &mut tx).await?;
|
||||
// Step 5: Upsert instructors (returns email -> id map)
|
||||
let email_to_id = upsert_instructors(courses, &mut tx).await?;
|
||||
|
||||
// Step 6: Link courses to instructors via junction table
|
||||
upsert_course_instructors(courses, &course_ids, &mut tx).await?;
|
||||
upsert_course_instructors(courses, &course_ids, &email_to_id, &mut tx).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -596,62 +596,85 @@ async fn upsert_courses(courses: &[Course], conn: &mut PgConnection) -> Result<V
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Deduplicate and upsert all instructors from the batch.
|
||||
async fn upsert_instructors(courses: &[Course], conn: &mut PgConnection) -> Result<()> {
|
||||
/// Deduplicate and upsert all instructors from the batch by email.
|
||||
/// Returns a map of lowercased_email -> instructor id for junction linking.
|
||||
async fn upsert_instructors(
|
||||
courses: &[Course],
|
||||
conn: &mut PgConnection,
|
||||
) -> Result<HashMap<String, i32>> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut banner_ids = Vec::new();
|
||||
let mut display_names = Vec::new();
|
||||
let mut emails: Vec<Option<&str>> = Vec::new();
|
||||
let mut display_names: Vec<&str> = Vec::new();
|
||||
let mut emails_lower: Vec<String> = Vec::new();
|
||||
let mut skipped_no_email = 0u32;
|
||||
|
||||
for course in courses {
|
||||
for faculty in &course.faculty {
|
||||
if seen.insert(faculty.banner_id.as_str()) {
|
||||
banner_ids.push(faculty.banner_id.as_str());
|
||||
display_names.push(faculty.display_name.as_str());
|
||||
emails.push(faculty.email_address.as_deref());
|
||||
if let Some(email) = &faculty.email_address {
|
||||
let email_lower = email.to_lowercase();
|
||||
if seen.insert(email_lower.clone()) {
|
||||
display_names.push(faculty.display_name.as_str());
|
||||
emails_lower.push(email_lower);
|
||||
}
|
||||
} else {
|
||||
skipped_no_email += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if banner_ids.is_empty() {
|
||||
return Ok(());
|
||||
if skipped_no_email > 0 {
|
||||
tracing::warn!(
|
||||
count = skipped_no_email,
|
||||
"Skipped instructors with no email address"
|
||||
);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
if display_names.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let email_refs: Vec<&str> = emails_lower.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let rows: Vec<(i32, String)> = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO instructors (banner_id, display_name, email)
|
||||
SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[])
|
||||
ON CONFLICT (banner_id)
|
||||
DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
email = COALESCE(EXCLUDED.email, instructors.email)
|
||||
INSERT INTO instructors (display_name, email)
|
||||
SELECT * FROM UNNEST($1::text[], $2::text[])
|
||||
ON CONFLICT (email)
|
||||
DO UPDATE SET display_name = EXCLUDED.display_name
|
||||
RETURNING id, email
|
||||
"#,
|
||||
)
|
||||
.bind(&banner_ids)
|
||||
.bind(&display_names)
|
||||
.bind(&emails)
|
||||
.execute(&mut *conn)
|
||||
.bind(&email_refs)
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to batch upsert instructors: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
Ok(rows.into_iter().map(|(id, email)| (email, id)).collect())
|
||||
}
|
||||
|
||||
/// Link courses to their instructors via the junction table.
|
||||
async fn upsert_course_instructors(
|
||||
courses: &[Course],
|
||||
course_ids: &[i32],
|
||||
email_to_id: &HashMap<String, i32>,
|
||||
conn: &mut PgConnection,
|
||||
) -> Result<()> {
|
||||
let mut cids = Vec::new();
|
||||
let mut iids = Vec::new();
|
||||
let mut instructor_ids: Vec<i32> = Vec::new();
|
||||
let mut banner_ids: Vec<&str> = Vec::new();
|
||||
let mut primaries = Vec::new();
|
||||
|
||||
for (course, &course_id) in courses.iter().zip(course_ids) {
|
||||
for faculty in &course.faculty {
|
||||
cids.push(course_id);
|
||||
iids.push(faculty.banner_id.as_str());
|
||||
primaries.push(faculty.primary_indicator);
|
||||
if let Some(email) = &faculty.email_address {
|
||||
let email_lower = email.to_lowercase();
|
||||
if let Some(&instructor_id) = email_to_id.get(&email_lower) {
|
||||
cids.push(course_id);
|
||||
instructor_ids.push(instructor_id);
|
||||
banner_ids.push(faculty.banner_id.as_str());
|
||||
primaries.push(faculty.primary_indicator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,14 +691,17 @@ async fn upsert_course_instructors(
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO course_instructors (course_id, instructor_id, is_primary)
|
||||
SELECT * FROM UNNEST($1::int4[], $2::text[], $3::bool[])
|
||||
INSERT INTO course_instructors (course_id, instructor_id, banner_id, is_primary)
|
||||
SELECT * FROM UNNEST($1::int4[], $2::int4[], $3::text[], $4::bool[])
|
||||
ON CONFLICT (course_id, instructor_id)
|
||||
DO UPDATE SET is_primary = EXCLUDED.is_primary
|
||||
DO UPDATE SET
|
||||
banner_id = EXCLUDED.banner_id,
|
||||
is_primary = EXCLUDED.is_primary
|
||||
"#,
|
||||
)
|
||||
.bind(&cids)
|
||||
.bind(&iids)
|
||||
.bind(&instructor_ids)
|
||||
.bind(&banner_ids)
|
||||
.bind(&primaries)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
|
||||
+23
-9
@@ -55,7 +55,7 @@ fn sort_clause(column: Option<SortColumn>, direction: Option<SortDirection>) ->
|
||||
Some(SortColumn::Instructor) => {
|
||||
format!(
|
||||
"(SELECT i.display_name FROM course_instructors ci \
|
||||
JOIN instructors i ON i.banner_id = ci.instructor_id \
|
||||
JOIN instructors i ON i.id = ci.instructor_id \
|
||||
WHERE ci.course_id = courses.id AND ci.is_primary = true \
|
||||
LIMIT 1) {dir} NULLS LAST"
|
||||
)
|
||||
@@ -147,12 +147,19 @@ pub async fn get_course_instructors(
|
||||
) -> Result<Vec<CourseInstructorDetail>> {
|
||||
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
|
||||
r#"
|
||||
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
rp.avg_rating, rp.num_ratings, i.rmp_legacy_id,
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
|
||||
ci.course_id
|
||||
FROM course_instructors ci
|
||||
JOIN instructors i ON i.banner_id = ci.instructor_id
|
||||
LEFT JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id
|
||||
JOIN instructors i ON i.id = ci.instructor_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT rp.avg_rating, rp.num_ratings, rp.legacy_id as rmp_legacy_id
|
||||
FROM instructor_rmp_links irl
|
||||
JOIN rmp_professors rp ON rp.legacy_id = irl.rmp_legacy_id
|
||||
WHERE irl.instructor_id = i.id
|
||||
ORDER BY rp.num_ratings DESC NULLS LAST, rp.legacy_id ASC
|
||||
LIMIT 1
|
||||
) rmp ON true
|
||||
WHERE ci.course_id = $1
|
||||
ORDER BY ci.is_primary DESC, i.display_name
|
||||
"#,
|
||||
@@ -176,12 +183,19 @@ pub async fn get_instructors_for_courses(
|
||||
|
||||
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
|
||||
r#"
|
||||
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
rp.avg_rating, rp.num_ratings, i.rmp_legacy_id,
|
||||
SELECT i.id as instructor_id, ci.banner_id, i.display_name, i.email, ci.is_primary,
|
||||
rmp.avg_rating, rmp.num_ratings, rmp.rmp_legacy_id,
|
||||
ci.course_id
|
||||
FROM course_instructors ci
|
||||
JOIN instructors i ON i.banner_id = ci.instructor_id
|
||||
LEFT JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id
|
||||
JOIN instructors i ON i.id = ci.instructor_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT rp.avg_rating, rp.num_ratings, rp.legacy_id as rmp_legacy_id
|
||||
FROM instructor_rmp_links irl
|
||||
JOIN rmp_professors rp ON rp.legacy_id = irl.rmp_legacy_id
|
||||
WHERE irl.instructor_id = i.id
|
||||
ORDER BY rp.num_ratings DESC NULLS LAST, rp.legacy_id ASC
|
||||
LIMIT 1
|
||||
) rmp ON true
|
||||
WHERE ci.course_id = ANY($1)
|
||||
ORDER BY ci.course_id, ci.is_primary DESC, i.display_name
|
||||
"#,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod courses;
|
||||
pub mod models;
|
||||
pub mod reference;
|
||||
pub mod rmp;
|
||||
pub mod rmp_matching;
|
||||
pub mod scrape_jobs;
|
||||
pub mod sessions;
|
||||
pub mod users;
|
||||
|
||||
+7
-4
@@ -99,25 +99,28 @@ pub struct Course {
|
||||
#[allow(dead_code)]
|
||||
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||
pub struct Instructor {
|
||||
pub banner_id: String,
|
||||
pub id: i32,
|
||||
pub display_name: String,
|
||||
pub email: Option<String>,
|
||||
pub email: String,
|
||||
pub rmp_match_status: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||
pub struct CourseInstructor {
|
||||
pub course_id: i32,
|
||||
pub instructor_id: String,
|
||||
pub instructor_id: i32,
|
||||
pub banner_id: String,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
/// Joined instructor data for a course (from course_instructors + instructors + rmp_professors).
|
||||
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||
pub struct CourseInstructorDetail {
|
||||
pub instructor_id: i32,
|
||||
pub banner_id: String,
|
||||
pub display_name: String,
|
||||
pub email: Option<String>,
|
||||
pub email: String,
|
||||
pub is_primary: bool,
|
||||
pub avg_rating: Option<f32>,
|
||||
pub num_ratings: Option<i32>,
|
||||
|
||||
+13
-115
@@ -3,8 +3,7 @@
|
||||
use crate::error::Result;
|
||||
use crate::rmp::RmpProfessor;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tracing::{debug, info, warn};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Bulk upsert RMP professors using the UNNEST pattern.
|
||||
///
|
||||
@@ -93,14 +92,14 @@ pub async fn batch_upsert_rmp_professors(
|
||||
}
|
||||
|
||||
/// Normalize a name for matching: lowercase, trim, strip trailing periods.
|
||||
fn normalize(s: &str) -> String {
|
||||
pub(crate) fn normalize(s: &str) -> String {
|
||||
s.trim().to_lowercase().trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// Parse Banner's "Last, First Middle" display name into (last, first) tokens.
|
||||
///
|
||||
/// Returns `None` if the format is unparseable (no comma, empty parts).
|
||||
fn parse_display_name(display_name: &str) -> Option<(String, String)> {
|
||||
pub(crate) fn parse_display_name(display_name: &str) -> Option<(String, String)> {
|
||||
let (last_part, first_part) = display_name.split_once(',')?;
|
||||
let last = normalize(last_part);
|
||||
// Take only the first token of the first-name portion to drop middle names/initials.
|
||||
@@ -111,128 +110,27 @@ fn parse_display_name(display_name: &str) -> Option<(String, String)> {
|
||||
Some((last, first))
|
||||
}
|
||||
|
||||
/// Auto-match instructors to RMP professors by normalized name.
|
||||
/// Retrieve RMP rating data for an instructor by instructor id.
|
||||
///
|
||||
/// Loads all pending instructors and all RMP professors, then matches in Rust
|
||||
/// using normalized name comparison. Only assigns a match when exactly one RMP
|
||||
/// professor matches a given instructor.
|
||||
pub async fn auto_match_instructors(db_pool: &PgPool) -> Result<u64> {
|
||||
// Load pending instructors
|
||||
let instructors: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT banner_id, display_name FROM instructors WHERE rmp_match_status = 'pending'",
|
||||
)
|
||||
.fetch_all(db_pool)
|
||||
.await?;
|
||||
|
||||
if instructors.is_empty() {
|
||||
info!(matched = 0, "No pending instructors to match");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Load all RMP professors
|
||||
let professors: Vec<(i32, String, String)> =
|
||||
sqlx::query_as("SELECT legacy_id, first_name, last_name FROM rmp_professors")
|
||||
.fetch_all(db_pool)
|
||||
.await?;
|
||||
|
||||
// Build a lookup: (normalized_last, normalized_first) -> list of legacy_ids
|
||||
let mut rmp_index: HashMap<(String, String), Vec<i32>> = HashMap::new();
|
||||
for (legacy_id, first, last) in &professors {
|
||||
let key = (normalize(last), normalize(first));
|
||||
rmp_index.entry(key).or_default().push(*legacy_id);
|
||||
}
|
||||
|
||||
// Match each instructor
|
||||
let mut matches: Vec<(i32, String)> = Vec::new(); // (legacy_id, banner_id)
|
||||
let mut no_comma = 0u64;
|
||||
let mut no_match = 0u64;
|
||||
let mut ambiguous = 0u64;
|
||||
|
||||
for (banner_id, display_name) in &instructors {
|
||||
let Some((last, first)) = parse_display_name(display_name) else {
|
||||
no_comma += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let key = (last, first);
|
||||
match rmp_index.get(&key) {
|
||||
Some(ids) if ids.len() == 1 => {
|
||||
matches.push((ids[0], banner_id.clone()));
|
||||
}
|
||||
Some(ids) => {
|
||||
ambiguous += 1;
|
||||
debug!(
|
||||
banner_id,
|
||||
display_name,
|
||||
candidates = ids.len(),
|
||||
"Ambiguous RMP match, skipping"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
no_match += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if no_comma > 0 || ambiguous > 0 {
|
||||
warn!(
|
||||
total_pending = instructors.len(),
|
||||
no_comma,
|
||||
no_match,
|
||||
ambiguous,
|
||||
matched = matches.len(),
|
||||
"RMP matching diagnostics"
|
||||
);
|
||||
}
|
||||
|
||||
// Batch update matches
|
||||
if matches.is_empty() {
|
||||
info!(matched = 0, "Auto-matched instructors to RMP professors");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let legacy_ids: Vec<i32> = matches.iter().map(|(id, _)| *id).collect();
|
||||
let banner_ids: Vec<&str> = matches.iter().map(|(_, bid)| bid.as_str()).collect();
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE instructors i
|
||||
SET
|
||||
rmp_legacy_id = m.legacy_id,
|
||||
rmp_match_status = 'auto'
|
||||
FROM UNNEST($1::int4[], $2::text[]) AS m(legacy_id, banner_id)
|
||||
WHERE i.banner_id = m.banner_id
|
||||
"#,
|
||||
)
|
||||
.bind(&legacy_ids)
|
||||
.bind(&banner_ids)
|
||||
.execute(db_pool)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to update instructor RMP matches: {}", e))?;
|
||||
|
||||
let matched = result.rows_affected();
|
||||
info!(matched, "Auto-matched instructors to RMP professors");
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
/// Retrieve RMP rating data for an instructor by banner_id.
|
||||
///
|
||||
/// Returns `(avg_rating, num_ratings)` if the instructor has an RMP match.
|
||||
/// Returns `(avg_rating, num_ratings)` for the best linked RMP profile
|
||||
/// (most ratings). Returns `None` if no link exists.
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_instructor_rmp_data(
|
||||
db_pool: &PgPool,
|
||||
banner_id: &str,
|
||||
instructor_id: i32,
|
||||
) -> Result<Option<(f32, i32)>> {
|
||||
let row: Option<(f32, i32)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT rp.avg_rating, rp.num_ratings
|
||||
FROM instructors i
|
||||
JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id
|
||||
WHERE i.banner_id = $1
|
||||
FROM instructor_rmp_links irl
|
||||
JOIN rmp_professors rp ON rp.legacy_id = irl.rmp_legacy_id
|
||||
WHERE irl.instructor_id = $1
|
||||
AND rp.avg_rating IS NOT NULL
|
||||
ORDER BY rp.num_ratings DESC NULLS LAST
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(banner_id)
|
||||
.bind(instructor_id)
|
||||
.fetch_optional(db_pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
//! Confidence scoring and candidate generation for RMP instructor matching.
|
||||
|
||||
use crate::data::rmp::{normalize, parse_display_name};
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tracing::{debug, info};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Breakdown of individual scoring signals.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ScoreBreakdown {
|
||||
pub department: f32,
|
||||
pub uniqueness: f32,
|
||||
pub volume: f32,
|
||||
}
|
||||
|
||||
/// Result of scoring a single instructor–RMP candidate pair.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchScore {
|
||||
pub score: f32,
|
||||
pub breakdown: ScoreBreakdown,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thresholds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum composite score to store a candidate row.
|
||||
const MIN_CANDIDATE_THRESHOLD: f32 = 0.40;
|
||||
|
||||
/// Score at or above which a candidate is auto-accepted.
|
||||
const AUTO_ACCEPT_THRESHOLD: f32 = 0.85;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WEIGHT_DEPARTMENT: f32 = 0.50;
|
||||
const WEIGHT_UNIQUENESS: f32 = 0.30;
|
||||
const WEIGHT_VOLUME: f32 = 0.20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure scoring functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check if an instructor's subjects overlap with an RMP department.
|
||||
///
|
||||
/// Returns `1.0` for a match, `0.2` for a mismatch, `0.5` when the RMP
|
||||
/// department is unknown.
|
||||
fn department_similarity(subjects: &[String], rmp_department: Option<&str>) -> f32 {
|
||||
let Some(dept) = rmp_department else {
|
||||
return 0.5;
|
||||
};
|
||||
let dept_lower = dept.to_lowercase();
|
||||
|
||||
// Quick check: does any subject appear directly in the department string
|
||||
// or vice-versa?
|
||||
for subj in subjects {
|
||||
let subj_lower = subj.to_lowercase();
|
||||
if dept_lower.contains(&subj_lower) || subj_lower.contains(&dept_lower) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Handle common UTSA abbreviation mappings.
|
||||
if matches_known_abbreviation(&subj_lower, &dept_lower) {
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
0.2
|
||||
}
|
||||
|
||||
/// Expand common subject abbreviations used at UTSA and check for overlap.
|
||||
fn matches_known_abbreviation(subject: &str, department: &str) -> bool {
|
||||
const MAPPINGS: &[(&str, &[&str])] = &[
|
||||
("cs", &["computer science"]),
|
||||
("ece", &["electrical", "computer engineering"]),
|
||||
("ee", &["electrical engineering", "electrical"]),
|
||||
("me", &["mechanical engineering", "mechanical"]),
|
||||
("ce", &["civil engineering", "civil"]),
|
||||
("bio", &["biology", "biological"]),
|
||||
("chem", &["chemistry"]),
|
||||
("phys", &["physics"]),
|
||||
("math", &["mathematics"]),
|
||||
("sta", &["statistics"]),
|
||||
("eng", &["english"]),
|
||||
("his", &["history"]),
|
||||
("pol", &["political science"]),
|
||||
("psy", &["psychology"]),
|
||||
("soc", &["sociology"]),
|
||||
("mus", &["music"]),
|
||||
("art", &["art"]),
|
||||
("phi", &["philosophy"]),
|
||||
("eco", &["economics"]),
|
||||
("acc", &["accounting"]),
|
||||
("fin", &["finance"]),
|
||||
("mgt", &["management"]),
|
||||
("mkt", &["marketing"]),
|
||||
("is", &["information systems"]),
|
||||
("ms", &["management science"]),
|
||||
("kin", &["kinesiology"]),
|
||||
("com", &["communication"]),
|
||||
];
|
||||
|
||||
for &(abbr, expansions) in MAPPINGS {
|
||||
if subject == abbr {
|
||||
return expansions
|
||||
.iter()
|
||||
.any(|expansion| department.contains(expansion));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Compute match confidence score (0.0–1.0) for an instructor–RMP pair.
|
||||
///
|
||||
/// Name matching is handled by the caller via pre-filtering on exact
|
||||
/// normalized `(last, first)`, so only department, uniqueness, and volume
|
||||
/// signals are scored here.
|
||||
pub fn compute_match_score(
|
||||
instructor_subjects: &[String],
|
||||
rmp_department: Option<&str>,
|
||||
candidate_count: usize,
|
||||
rmp_num_ratings: i32,
|
||||
) -> MatchScore {
|
||||
// --- Department (0.50) ---
|
||||
let dept_score = department_similarity(instructor_subjects, rmp_department);
|
||||
|
||||
// --- Uniqueness (0.30) ---
|
||||
let uniqueness_score = match candidate_count {
|
||||
0 | 1 => 1.0,
|
||||
2 => 0.5,
|
||||
_ => 0.2,
|
||||
};
|
||||
|
||||
// --- Volume (0.20) ---
|
||||
let volume_score = ((rmp_num_ratings as f32).ln_1p() / 5.0_f32.ln_1p()).clamp(0.0, 1.0);
|
||||
|
||||
let composite = dept_score * WEIGHT_DEPARTMENT
|
||||
+ uniqueness_score * WEIGHT_UNIQUENESS
|
||||
+ volume_score * WEIGHT_VOLUME;
|
||||
|
||||
MatchScore {
|
||||
score: composite,
|
||||
breakdown: ScoreBreakdown {
|
||||
department: dept_score,
|
||||
uniqueness: uniqueness_score,
|
||||
volume: volume_score,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Candidate generation (DB)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Statistics returned from candidate generation.
|
||||
#[derive(Debug)]
|
||||
pub struct MatchingStats {
|
||||
pub total_unmatched: usize,
|
||||
pub candidates_created: usize,
|
||||
pub auto_matched: usize,
|
||||
pub skipped_unparseable: usize,
|
||||
pub skipped_no_candidates: usize,
|
||||
}
|
||||
|
||||
/// Lightweight row for building the in-memory RMP name index.
|
||||
struct RmpProfForMatching {
|
||||
legacy_id: i32,
|
||||
department: Option<String>,
|
||||
num_ratings: i32,
|
||||
}
|
||||
|
||||
/// Generate match candidates for all unmatched instructors.
|
||||
///
|
||||
/// For each unmatched instructor:
|
||||
/// 1. Parse `display_name` into (last, first).
|
||||
/// 2. Find RMP professors with matching normalized name.
|
||||
/// 3. Score each candidate.
|
||||
/// 4. Store candidates scoring above [`MIN_CANDIDATE_THRESHOLD`].
|
||||
/// 5. Auto-accept if the top candidate scores ≥ [`AUTO_ACCEPT_THRESHOLD`]
|
||||
/// and no existing rejected candidate exists for that pair.
|
||||
///
|
||||
/// Already-evaluated instructor–RMP pairs (any status) are skipped.
|
||||
pub async fn generate_candidates(db_pool: &PgPool) -> Result<MatchingStats> {
|
||||
// 1. Load unmatched instructors
|
||||
let instructors: Vec<(i32, String)> = sqlx::query_as(
|
||||
"SELECT id, display_name FROM instructors WHERE rmp_match_status = 'unmatched'",
|
||||
)
|
||||
.fetch_all(db_pool)
|
||||
.await?;
|
||||
|
||||
if instructors.is_empty() {
|
||||
info!("No unmatched instructors to generate candidates for");
|
||||
return Ok(MatchingStats {
|
||||
total_unmatched: 0,
|
||||
candidates_created: 0,
|
||||
auto_matched: 0,
|
||||
skipped_unparseable: 0,
|
||||
skipped_no_candidates: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let instructor_ids: Vec<i32> = instructors.iter().map(|(id, _)| *id).collect();
|
||||
let total_unmatched = instructors.len();
|
||||
|
||||
// 2. Load instructor subjects
|
||||
let subject_rows: Vec<(i32, String)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT DISTINCT ci.instructor_id, c.subject
|
||||
FROM course_instructors ci
|
||||
JOIN courses c ON c.id = ci.course_id
|
||||
WHERE ci.instructor_id = ANY($1)
|
||||
"#,
|
||||
)
|
||||
.bind(&instructor_ids)
|
||||
.fetch_all(db_pool)
|
||||
.await?;
|
||||
|
||||
let mut subject_map: HashMap<i32, Vec<String>> = HashMap::new();
|
||||
for (iid, subject) in subject_rows {
|
||||
subject_map.entry(iid).or_default().push(subject);
|
||||
}
|
||||
|
||||
// 3. Load all RMP professors
|
||||
let prof_rows: Vec<(i32, String, String, Option<String>, i32)> = sqlx::query_as(
|
||||
"SELECT legacy_id, first_name, last_name, department, num_ratings FROM rmp_professors",
|
||||
)
|
||||
.fetch_all(db_pool)
|
||||
.await?;
|
||||
|
||||
// Build name index: (normalized_last, normalized_first) -> Vec<RmpProfForMatching>
|
||||
let mut name_index: HashMap<(String, String), Vec<RmpProfForMatching>> = HashMap::new();
|
||||
for (legacy_id, first_name, last_name, department, num_ratings) in prof_rows {
|
||||
let key = (normalize(&last_name), normalize(&first_name));
|
||||
name_index.entry(key).or_default().push(RmpProfForMatching {
|
||||
legacy_id,
|
||||
department,
|
||||
num_ratings,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Load existing candidate pairs (and rejected subset) in a single query
|
||||
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 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));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Score and collect candidates
|
||||
let empty_subjects: Vec<String> = Vec::new();
|
||||
let mut 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;
|
||||
|
||||
for (instructor_id, display_name) in &instructors {
|
||||
let Some((norm_last, norm_first)) = parse_display_name(display_name) else {
|
||||
skipped_unparseable += 1;
|
||||
debug!(
|
||||
instructor_id,
|
||||
display_name, "Unparseable display name, skipping"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let subjects = subject_map.get(instructor_id).unwrap_or(&empty_subjects);
|
||||
|
||||
let key = (norm_last.clone(), norm_first.clone());
|
||||
let Some(rmp_candidates) = name_index.get(&key) else {
|
||||
skipped_no_candidates += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let candidate_count = rmp_candidates.len();
|
||||
let mut best: Option<(f32, i32)> = None;
|
||||
|
||||
for prof in rmp_candidates {
|
||||
let pair = (*instructor_id, prof.legacy_id);
|
||||
if existing_pairs.contains(&pair) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ms = compute_match_score(
|
||||
subjects,
|
||||
prof.department.as_deref(),
|
||||
candidate_count,
|
||||
prof.num_ratings,
|
||||
);
|
||||
|
||||
if ms.score < MIN_CANDIDATE_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
match best {
|
||||
Some((s, _)) if ms.score > s => best = Some((ms.score, prof.legacy_id)),
|
||||
None => best = Some((ms.score, prof.legacy_id)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-accept the top candidate if it meets the threshold and is not
|
||||
// previously rejected.
|
||||
if let Some((score, legacy_id)) = best
|
||||
&& score >= AUTO_ACCEPT_THRESHOLD
|
||||
&& !rejected_pairs.contains(&(*instructor_id, legacy_id))
|
||||
{
|
||||
auto_accept.push((*instructor_id, legacy_id));
|
||||
}
|
||||
}
|
||||
|
||||
// 6–7. Write candidates and auto-accept within a single transaction
|
||||
let candidates_created = 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<i32> = candidates.iter().map(|(iid, _, _, _)| *iid).collect();
|
||||
let c_legacy_ids: Vec<i32> = candidates.iter().map(|(_, lid, _, _)| *lid).collect();
|
||||
let c_scores: Vec<f32> = candidates.iter().map(|(_, _, s, _)| *s).collect();
|
||||
let c_breakdowns: Vec<serde_json::Value> =
|
||||
candidates.into_iter().map(|(_, _, _, b)| b).collect();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO rmp_match_candidates (instructor_id, rmp_legacy_id, score, score_breakdown)
|
||||
SELECT v.instructor_id, v.rmp_legacy_id, v.score, v.score_breakdown
|
||||
FROM UNNEST($1::int4[], $2::int4[], $3::real[], $4::jsonb[])
|
||||
AS v(instructor_id, rmp_legacy_id, score, score_breakdown)
|
||||
ON CONFLICT (instructor_id, rmp_legacy_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(&c_instructor_ids)
|
||||
.bind(&c_legacy_ids)
|
||||
.bind(&c_scores)
|
||||
.bind(&c_breakdowns)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 7. Auto-accept top candidates
|
||||
if !auto_accept.is_empty() {
|
||||
let aa_instructor_ids: Vec<i32> = auto_accept.iter().map(|(iid, _)| *iid).collect();
|
||||
let aa_legacy_ids: Vec<i32> = auto_accept.iter().map(|(_, lid)| *lid).collect();
|
||||
|
||||
// Mark the candidate row as accepted
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE rmp_match_candidates mc
|
||||
SET status = 'accepted', resolved_at = NOW()
|
||||
FROM UNNEST($1::int4[], $2::int4[]) AS v(instructor_id, rmp_legacy_id)
|
||||
WHERE mc.instructor_id = v.instructor_id
|
||||
AND mc.rmp_legacy_id = v.rmp_legacy_id
|
||||
"#,
|
||||
)
|
||||
.bind(&aa_instructor_ids)
|
||||
.bind(&aa_legacy_ids)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Insert links into instructor_rmp_links
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO instructor_rmp_links (instructor_id, rmp_legacy_id, source)
|
||||
SELECT v.instructor_id, v.rmp_legacy_id, 'auto'
|
||||
FROM UNNEST($1::int4[], $2::int4[]) AS v(instructor_id, rmp_legacy_id)
|
||||
ON CONFLICT (rmp_legacy_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(&aa_instructor_ids)
|
||||
.bind(&aa_legacy_ids)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Update instructor match status
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE instructors i
|
||||
SET rmp_match_status = 'auto'
|
||||
FROM UNNEST($1::int4[]) AS v(instructor_id)
|
||||
WHERE i.id = v.instructor_id
|
||||
"#,
|
||||
)
|
||||
.bind(&aa_instructor_ids)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let stats = MatchingStats {
|
||||
total_unmatched,
|
||||
candidates_created,
|
||||
auto_matched,
|
||||
skipped_unparseable,
|
||||
skipped_no_candidates,
|
||||
};
|
||||
|
||||
info!(
|
||||
total_unmatched = stats.total_unmatched,
|
||||
candidates_created = stats.candidates_created,
|
||||
auto_matched = stats.auto_matched,
|
||||
skipped_unparseable = stats.skipped_unparseable,
|
||||
skipped_no_candidates = stats.skipped_no_candidates,
|
||||
"Candidate generation complete"
|
||||
);
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ideal_candidate_high_score() {
|
||||
let ms = compute_match_score(
|
||||
&["CS".to_string()],
|
||||
Some("Computer Science"),
|
||||
1, // unique candidate
|
||||
50, // decent ratings
|
||||
);
|
||||
// dept 1.0*0.50 + unique 1.0*0.30 + volume ~0.97*0.20 ≈ 0.99
|
||||
assert!(ms.score >= 0.85, "Expected score >= 0.85, got {}", ms.score);
|
||||
assert_eq!(ms.breakdown.uniqueness, 1.0);
|
||||
assert_eq!(ms.breakdown.department, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ambiguous_candidates_lower_score() {
|
||||
let unique = compute_match_score(&[], None, 1, 10);
|
||||
let ambiguous = compute_match_score(&[], None, 3, 10);
|
||||
assert!(
|
||||
unique.score > ambiguous.score,
|
||||
"Unique ({}) should outscore ambiguous ({})",
|
||||
unique.score,
|
||||
ambiguous.score
|
||||
);
|
||||
assert_eq!(unique.breakdown.uniqueness, 1.0);
|
||||
assert_eq!(ambiguous.breakdown.uniqueness, 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_department_neutral() {
|
||||
let ms = compute_match_score(&["CS".to_string()], None, 1, 10);
|
||||
assert_eq!(ms.breakdown.department, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_department_match() {
|
||||
let ms = compute_match_score(&["CS".to_string()], Some("Computer Science"), 1, 10);
|
||||
assert_eq!(ms.breakdown.department, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_department_mismatch() {
|
||||
let ms = compute_match_score(&["CS".to_string()], Some("History"), 1, 10);
|
||||
assert_eq!(ms.breakdown.department, 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_department_match_outscores_mismatch() {
|
||||
let matched = compute_match_score(&["CS".to_string()], Some("Computer Science"), 1, 10);
|
||||
let mismatched = compute_match_score(&["CS".to_string()], Some("History"), 1, 10);
|
||||
assert!(
|
||||
matched.score > mismatched.score,
|
||||
"Department match ({}) should outscore mismatch ({})",
|
||||
matched.score,
|
||||
mismatched.score
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_scaling() {
|
||||
let zero = compute_match_score(&[], None, 1, 0);
|
||||
let many = compute_match_score(&[], None, 1, 100);
|
||||
assert!(
|
||||
many.breakdown.volume > zero.breakdown.volume,
|
||||
"100 ratings ({}) should outscore 0 ratings ({})",
|
||||
many.breakdown.volume,
|
||||
zero.breakdown.volume
|
||||
);
|
||||
assert_eq!(zero.breakdown.volume, 0.0);
|
||||
assert!(
|
||||
many.breakdown.volume > 0.9,
|
||||
"100 ratings should be near max"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -250,8 +250,16 @@ impl Scheduler {
|
||||
crate::data::rmp::batch_upsert_rmp_professors(&professors, db_pool).await?;
|
||||
info!(total, "RMP professors upserted");
|
||||
|
||||
let matched = crate::data::rmp::auto_match_instructors(db_pool).await?;
|
||||
info!(total, matched, "RMP sync complete");
|
||||
let stats = crate::data::rmp_matching::generate_candidates(db_pool).await?;
|
||||
info!(
|
||||
total,
|
||||
stats.total_unmatched,
|
||||
stats.candidates_created,
|
||||
stats.auto_matched,
|
||||
stats.skipped_unparseable,
|
||||
stats.skipped_no_candidates,
|
||||
"RMP sync complete"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
//! Admin API handlers for RMP instructor matching management.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::web::extractors::AdminUser;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query / body types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListInstructorsParams {
|
||||
status: Option<String>,
|
||||
search: Option<String>,
|
||||
page: Option<i32>,
|
||||
per_page: Option<i32>,
|
||||
sort: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MatchBody {
|
||||
rmp_legacy_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RejectCandidateBody {
|
||||
rmp_legacy_id: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Simple acknowledgement response for mutating operations.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct OkResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// A top-candidate summary shown in the instructor list view.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct TopCandidateResponse {
|
||||
pub rmp_legacy_id: i32,
|
||||
pub score: Option<f32>,
|
||||
#[ts(as = "Option<std::collections::HashMap<String, f32>>")]
|
||||
pub score_breakdown: Option<serde_json::Value>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub department: Option<String>,
|
||||
pub avg_rating: Option<f32>,
|
||||
pub num_ratings: Option<i32>,
|
||||
}
|
||||
|
||||
/// An instructor row in the paginated list.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct InstructorListItem {
|
||||
pub id: i32,
|
||||
pub display_name: String,
|
||||
pub email: String,
|
||||
pub rmp_match_status: String,
|
||||
#[ts(as = "i32")]
|
||||
pub rmp_link_count: i64,
|
||||
#[ts(as = "i32")]
|
||||
pub candidate_count: i64,
|
||||
#[ts(as = "i32")]
|
||||
pub course_subject_count: i64,
|
||||
pub top_candidate: Option<TopCandidateResponse>,
|
||||
}
|
||||
|
||||
/// Aggregate status counts for the instructor list.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct InstructorStats {
|
||||
#[ts(as = "i32")]
|
||||
pub total: i64,
|
||||
#[ts(as = "i32")]
|
||||
pub unmatched: i64,
|
||||
#[ts(as = "i32")]
|
||||
pub auto: i64,
|
||||
#[ts(as = "i32")]
|
||||
pub confirmed: i64,
|
||||
#[ts(as = "i32")]
|
||||
pub rejected: i64,
|
||||
#[ts(as = "i32")]
|
||||
pub with_candidates: i64,
|
||||
}
|
||||
|
||||
/// Response for `GET /api/admin/instructors`.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct ListInstructorsResponse {
|
||||
pub instructors: Vec<InstructorListItem>,
|
||||
#[ts(as = "i32")]
|
||||
pub total: i64,
|
||||
pub page: i32,
|
||||
pub per_page: i32,
|
||||
pub stats: InstructorStats,
|
||||
}
|
||||
|
||||
/// Instructor summary in the detail view.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct InstructorDetail {
|
||||
pub id: i32,
|
||||
pub display_name: String,
|
||||
pub email: String,
|
||||
pub rmp_match_status: String,
|
||||
pub subjects_taught: Vec<String>,
|
||||
#[ts(as = "i32")]
|
||||
pub course_count: i64,
|
||||
}
|
||||
|
||||
/// A linked RMP profile in the detail view.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct LinkedRmpProfile {
|
||||
pub link_id: i32,
|
||||
pub legacy_id: i32,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub department: Option<String>,
|
||||
pub avg_rating: Option<f32>,
|
||||
pub avg_difficulty: Option<f32>,
|
||||
pub num_ratings: Option<i32>,
|
||||
pub would_take_again_pct: Option<f32>,
|
||||
}
|
||||
|
||||
/// A match candidate in the detail view.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct CandidateResponse {
|
||||
pub id: i32,
|
||||
pub rmp_legacy_id: i32,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub department: Option<String>,
|
||||
pub avg_rating: Option<f32>,
|
||||
pub avg_difficulty: Option<f32>,
|
||||
pub num_ratings: Option<i32>,
|
||||
pub would_take_again_pct: Option<f32>,
|
||||
pub score: Option<f32>,
|
||||
#[ts(as = "Option<std::collections::HashMap<String, f32>>")]
|
||||
pub score_breakdown: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Response for `GET /api/admin/instructors/{id}` and `POST .../match`.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct InstructorDetailResponse {
|
||||
pub instructor: InstructorDetail,
|
||||
pub current_matches: Vec<LinkedRmpProfile>,
|
||||
pub candidates: Vec<CandidateResponse>,
|
||||
}
|
||||
|
||||
/// Response for `POST /api/admin/rmp/rescore`.
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct RescoreResponse {
|
||||
pub total_unmatched: usize,
|
||||
pub candidates_created: usize,
|
||||
pub auto_matched: usize,
|
||||
pub skipped_unparseable: usize,
|
||||
pub skipped_no_candidates: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: map sqlx errors to the standard admin error tuple
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn db_error(context: &str, e: sqlx::Error) -> (StatusCode, Json<Value>) {
|
||||
tracing::error!(error = %e, "{context}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": context})),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row types for SQL queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct InstructorRow {
|
||||
id: i32,
|
||||
display_name: String,
|
||||
email: String,
|
||||
rmp_match_status: String,
|
||||
rmp_link_count: Option<i64>,
|
||||
top_candidate_rmp_id: Option<i32>,
|
||||
top_candidate_score: Option<f32>,
|
||||
top_candidate_breakdown: Option<serde_json::Value>,
|
||||
tc_first_name: Option<String>,
|
||||
tc_last_name: Option<String>,
|
||||
tc_department: Option<String>,
|
||||
tc_avg_rating: Option<f32>,
|
||||
tc_num_ratings: Option<i32>,
|
||||
candidate_count: Option<i64>,
|
||||
course_subject_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct StatusCount {
|
||||
rmp_match_status: String,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CandidateRow {
|
||||
id: i32,
|
||||
rmp_legacy_id: i32,
|
||||
score: Option<f32>,
|
||||
score_breakdown: Option<serde_json::Value>,
|
||||
status: String,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
department: Option<String>,
|
||||
avg_rating: Option<f32>,
|
||||
avg_difficulty: Option<f32>,
|
||||
num_ratings: Option<i32>,
|
||||
would_take_again_pct: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LinkedRmpProfileRow {
|
||||
link_id: i32,
|
||||
legacy_id: i32,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
department: Option<String>,
|
||||
avg_rating: Option<f32>,
|
||||
avg_difficulty: Option<f32>,
|
||||
num_ratings: Option<i32>,
|
||||
would_take_again_pct: Option<f32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. GET /api/admin/instructors — paginated list with filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/admin/instructors` — List instructors with filtering and pagination.
|
||||
pub async fn list_instructors(
|
||||
AdminUser(_user): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ListInstructorsParams>,
|
||||
) -> Result<Json<ListInstructorsResponse>, (StatusCode, Json<Value>)> {
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let per_page = params.per_page.unwrap_or(50).clamp(1, 100);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let sort_clause = match params.sort.as_deref() {
|
||||
Some("name_asc") => "i.display_name ASC",
|
||||
Some("name_desc") => "i.display_name DESC",
|
||||
Some("status") => "i.rmp_match_status ASC, i.display_name ASC",
|
||||
_ => "tc.score DESC NULLS LAST, i.display_name ASC",
|
||||
};
|
||||
|
||||
// Build WHERE clause
|
||||
let mut conditions = Vec::new();
|
||||
let mut bind_idx = 0u32;
|
||||
|
||||
if params.status.is_some() {
|
||||
bind_idx += 1;
|
||||
conditions.push(format!("i.rmp_match_status = ${bind_idx}"));
|
||||
}
|
||||
if params.search.is_some() {
|
||||
bind_idx += 1;
|
||||
conditions.push(format!(
|
||||
"(i.display_name ILIKE ${bind_idx} OR i.email ILIKE ${bind_idx})"
|
||||
));
|
||||
}
|
||||
|
||||
let where_clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", conditions.join(" AND "))
|
||||
};
|
||||
|
||||
let query_str = format!(
|
||||
r#"
|
||||
SELECT
|
||||
i.id, i.display_name, i.email, i.rmp_match_status,
|
||||
(SELECT COUNT(*) FROM instructor_rmp_links irl WHERE irl.instructor_id = i.id) as rmp_link_count,
|
||||
tc.rmp_legacy_id as top_candidate_rmp_id,
|
||||
tc.score as top_candidate_score,
|
||||
tc.score_breakdown as top_candidate_breakdown,
|
||||
rp.first_name as tc_first_name,
|
||||
rp.last_name as tc_last_name,
|
||||
rp.department as tc_department,
|
||||
rp.avg_rating as tc_avg_rating,
|
||||
rp.num_ratings as tc_num_ratings,
|
||||
(SELECT COUNT(*) FROM rmp_match_candidates mc WHERE mc.instructor_id = i.id AND mc.status = 'pending') as candidate_count,
|
||||
(SELECT COUNT(DISTINCT c.subject) FROM course_instructors ci JOIN courses c ON c.id = ci.course_id WHERE ci.instructor_id = i.id) as course_subject_count
|
||||
FROM instructors i
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT mc.rmp_legacy_id, mc.score, mc.score_breakdown
|
||||
FROM rmp_match_candidates mc
|
||||
WHERE mc.instructor_id = i.id AND mc.status = 'pending'
|
||||
ORDER BY mc.score DESC
|
||||
LIMIT 1
|
||||
) tc ON true
|
||||
LEFT JOIN rmp_professors rp ON rp.legacy_id = tc.rmp_legacy_id
|
||||
{where_clause}
|
||||
ORDER BY {sort_clause}
|
||||
LIMIT {per_page} OFFSET {offset}
|
||||
"#
|
||||
);
|
||||
|
||||
// Build the query with dynamic binds
|
||||
let mut query = sqlx::query_as::<_, InstructorRow>(&query_str);
|
||||
if let Some(ref status) = params.status {
|
||||
query = query.bind(status);
|
||||
}
|
||||
if let Some(ref search) = params.search {
|
||||
query = query.bind(format!("%{search}%"));
|
||||
}
|
||||
|
||||
let rows = query
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to list instructors", e))?;
|
||||
|
||||
// Count total with filters
|
||||
let count_query_str = format!("SELECT COUNT(*) FROM instructors i {where_clause}");
|
||||
let mut count_query = sqlx::query_as::<_, (i64,)>(&count_query_str);
|
||||
if let Some(ref status) = params.status {
|
||||
count_query = count_query.bind(status);
|
||||
}
|
||||
if let Some(ref search) = params.search {
|
||||
count_query = count_query.bind(format!("%{search}%"));
|
||||
}
|
||||
|
||||
let (total,) = count_query
|
||||
.fetch_one(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to count instructors", e))?;
|
||||
|
||||
// Aggregate stats (unfiltered)
|
||||
let stats_rows = sqlx::query_as::<_, StatusCount>(
|
||||
"SELECT rmp_match_status, COUNT(*) as count FROM instructors GROUP BY rmp_match_status",
|
||||
)
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to get instructor stats", e))?;
|
||||
|
||||
// Count instructors with at least one candidate (for progress bar denominator)
|
||||
let (with_candidates,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(DISTINCT instructor_id) FROM rmp_match_candidates")
|
||||
.fetch_one(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to count instructors with candidates", e))?;
|
||||
|
||||
let mut stats = InstructorStats {
|
||||
total: 0,
|
||||
unmatched: 0,
|
||||
auto: 0,
|
||||
confirmed: 0,
|
||||
rejected: 0,
|
||||
with_candidates,
|
||||
};
|
||||
for row in &stats_rows {
|
||||
stats.total += row.count;
|
||||
match row.rmp_match_status.as_str() {
|
||||
"unmatched" => stats.unmatched = row.count,
|
||||
"auto" => stats.auto = row.count,
|
||||
"confirmed" => stats.confirmed = row.count,
|
||||
"rejected" => stats.rejected = row.count,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let instructors: Vec<InstructorListItem> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let top_candidate = r.top_candidate_rmp_id.map(|rmp_id| TopCandidateResponse {
|
||||
rmp_legacy_id: rmp_id,
|
||||
score: r.top_candidate_score,
|
||||
score_breakdown: r.top_candidate_breakdown.clone(),
|
||||
first_name: r.tc_first_name.clone(),
|
||||
last_name: r.tc_last_name.clone(),
|
||||
department: r.tc_department.clone(),
|
||||
avg_rating: r.tc_avg_rating,
|
||||
num_ratings: r.tc_num_ratings,
|
||||
});
|
||||
|
||||
InstructorListItem {
|
||||
id: r.id,
|
||||
display_name: r.display_name.clone(),
|
||||
email: r.email.clone(),
|
||||
rmp_match_status: r.rmp_match_status.clone(),
|
||||
rmp_link_count: r.rmp_link_count.unwrap_or(0),
|
||||
candidate_count: r.candidate_count.unwrap_or(0),
|
||||
course_subject_count: r.course_subject_count.unwrap_or(0),
|
||||
top_candidate,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ListInstructorsResponse {
|
||||
instructors,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
stats,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. GET /api/admin/instructors/{id} — full detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/admin/instructors/{id}` — Full instructor detail with candidates.
|
||||
pub async fn get_instructor(
|
||||
AdminUser(_user): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<InstructorDetailResponse>, (StatusCode, Json<Value>)> {
|
||||
build_instructor_detail(&state, id).await
|
||||
}
|
||||
|
||||
/// Shared helper that builds the full instructor detail response.
|
||||
async fn build_instructor_detail(
|
||||
state: &AppState,
|
||||
id: i32,
|
||||
) -> Result<Json<InstructorDetailResponse>, (StatusCode, Json<Value>)> {
|
||||
// Fetch instructor
|
||||
let instructor: Option<(i32, String, String, String)> = sqlx::query_as(
|
||||
"SELECT id, display_name, email, rmp_match_status FROM instructors WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to fetch instructor", e))?;
|
||||
|
||||
let (inst_id, display_name, email, rmp_match_status) = instructor.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "instructor not found"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Subjects taught
|
||||
let subjects: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT DISTINCT c.subject FROM course_instructors ci JOIN courses c ON c.id = ci.course_id WHERE ci.instructor_id = $1 ORDER BY c.subject",
|
||||
)
|
||||
.bind(inst_id)
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to fetch subjects", e))?;
|
||||
|
||||
// Course count
|
||||
let (course_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(DISTINCT ci.course_id) FROM course_instructors ci WHERE ci.instructor_id = $1",
|
||||
)
|
||||
.bind(inst_id)
|
||||
.fetch_one(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to count courses", e))?;
|
||||
|
||||
// Candidates with RMP professor info
|
||||
let candidates = sqlx::query_as::<_, CandidateRow>(
|
||||
r#"
|
||||
SELECT mc.id, mc.rmp_legacy_id, mc.score, mc.score_breakdown, mc.status,
|
||||
rp.first_name, rp.last_name, rp.department,
|
||||
rp.avg_rating, rp.avg_difficulty, rp.num_ratings, rp.would_take_again_pct
|
||||
FROM rmp_match_candidates mc
|
||||
JOIN rmp_professors rp ON rp.legacy_id = mc.rmp_legacy_id
|
||||
WHERE mc.instructor_id = $1
|
||||
ORDER BY mc.score DESC
|
||||
"#,
|
||||
)
|
||||
.bind(inst_id)
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to fetch candidates", e))?;
|
||||
|
||||
// Current matches (all linked RMP profiles)
|
||||
let current_matches = sqlx::query_as::<_, LinkedRmpProfileRow>(
|
||||
r#"
|
||||
SELECT irl.id as link_id,
|
||||
rp.legacy_id, rp.first_name, rp.last_name, rp.department,
|
||||
rp.avg_rating, rp.avg_difficulty, rp.num_ratings, rp.would_take_again_pct
|
||||
FROM instructor_rmp_links irl
|
||||
JOIN rmp_professors rp ON rp.legacy_id = irl.rmp_legacy_id
|
||||
WHERE irl.instructor_id = $1
|
||||
ORDER BY rp.num_ratings DESC NULLS LAST
|
||||
"#,
|
||||
)
|
||||
.bind(inst_id)
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to fetch linked rmp profiles", e))?;
|
||||
|
||||
let current_matches_resp: Vec<LinkedRmpProfile> = current_matches
|
||||
.into_iter()
|
||||
.map(|p| LinkedRmpProfile {
|
||||
link_id: p.link_id,
|
||||
legacy_id: p.legacy_id,
|
||||
first_name: p.first_name,
|
||||
last_name: p.last_name,
|
||||
department: p.department,
|
||||
avg_rating: p.avg_rating,
|
||||
avg_difficulty: p.avg_difficulty,
|
||||
num_ratings: p.num_ratings,
|
||||
would_take_again_pct: p.would_take_again_pct,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let candidates_resp: Vec<CandidateResponse> = candidates
|
||||
.into_iter()
|
||||
.map(|c| CandidateResponse {
|
||||
id: c.id,
|
||||
rmp_legacy_id: c.rmp_legacy_id,
|
||||
first_name: c.first_name,
|
||||
last_name: c.last_name,
|
||||
department: c.department,
|
||||
avg_rating: c.avg_rating,
|
||||
avg_difficulty: c.avg_difficulty,
|
||||
num_ratings: c.num_ratings,
|
||||
would_take_again_pct: c.would_take_again_pct,
|
||||
score: c.score,
|
||||
score_breakdown: c.score_breakdown,
|
||||
status: c.status,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(InstructorDetailResponse {
|
||||
instructor: InstructorDetail {
|
||||
id: inst_id,
|
||||
display_name,
|
||||
email,
|
||||
rmp_match_status,
|
||||
subjects_taught: subjects.into_iter().map(|(s,)| s).collect(),
|
||||
course_count,
|
||||
},
|
||||
current_matches: current_matches_resp,
|
||||
candidates: candidates_resp,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. POST /api/admin/instructors/{id}/match — accept a candidate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/admin/instructors/{id}/match` — Accept a candidate match.
|
||||
pub async fn match_instructor(
|
||||
AdminUser(user): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
Json(body): Json<MatchBody>,
|
||||
) -> Result<Json<InstructorDetailResponse>, (StatusCode, Json<Value>)> {
|
||||
// Verify the candidate exists and is pending
|
||||
let candidate: Option<(i32,)> = sqlx::query_as(
|
||||
"SELECT id FROM rmp_match_candidates WHERE instructor_id = $1 AND rmp_legacy_id = $2 AND status = 'pending'",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(body.rmp_legacy_id)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to check candidate", e))?;
|
||||
|
||||
if candidate.is_none() {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "pending candidate not found for this instructor"})),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if this RMP profile is already linked to a different instructor
|
||||
let conflict: Option<(i32,)> = sqlx::query_as(
|
||||
"SELECT instructor_id FROM instructor_rmp_links WHERE rmp_legacy_id = $1 AND instructor_id != $2",
|
||||
)
|
||||
.bind(body.rmp_legacy_id)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to check rmp uniqueness", e))?;
|
||||
|
||||
if let Some((other_id,)) = conflict {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({
|
||||
"error": "RMP profile already linked to another instructor",
|
||||
"conflictingInstructorId": other_id,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = state
|
||||
.db_pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| db_error("failed to begin transaction", e))?;
|
||||
|
||||
// Insert link into instructor_rmp_links
|
||||
sqlx::query(
|
||||
"INSERT INTO instructor_rmp_links (instructor_id, rmp_legacy_id, created_by, source) VALUES ($1, $2, $3, 'manual') ON CONFLICT (rmp_legacy_id) DO NOTHING",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(body.rmp_legacy_id)
|
||||
.bind(user.discord_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to insert rmp link", e))?;
|
||||
|
||||
// Update instructor match status
|
||||
sqlx::query("UPDATE instructors SET rmp_match_status = 'confirmed' WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to update instructor match status", e))?;
|
||||
|
||||
// Accept the candidate
|
||||
sqlx::query(
|
||||
"UPDATE rmp_match_candidates SET status = 'accepted', resolved_at = NOW(), resolved_by = $1 WHERE instructor_id = $2 AND rmp_legacy_id = $3",
|
||||
)
|
||||
.bind(user.discord_id)
|
||||
.bind(id)
|
||||
.bind(body.rmp_legacy_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to accept candidate", e))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| db_error("failed to commit transaction", e))?;
|
||||
|
||||
build_instructor_detail(&state, id).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. POST /api/admin/instructors/{id}/reject-candidate — reject one candidate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/admin/instructors/{id}/reject-candidate` — Reject a single candidate.
|
||||
pub async fn reject_candidate(
|
||||
AdminUser(user): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
Json(body): Json<RejectCandidateBody>,
|
||||
) -> Result<Json<OkResponse>, (StatusCode, Json<Value>)> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE rmp_match_candidates SET status = 'rejected', resolved_at = NOW(), resolved_by = $1 WHERE instructor_id = $2 AND rmp_legacy_id = $3 AND status = 'pending'",
|
||||
)
|
||||
.bind(user.discord_id)
|
||||
.bind(id)
|
||||
.bind(body.rmp_legacy_id)
|
||||
.execute(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to reject candidate", e))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "pending candidate not found"})),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Json(OkResponse { ok: true }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. POST /api/admin/instructors/{id}/reject-all — no valid match
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/admin/instructors/{id}/reject-all` — Mark instructor as having no valid RMP match.
|
||||
pub async fn reject_all(
|
||||
AdminUser(user): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<OkResponse>, (StatusCode, Json<Value>)> {
|
||||
let mut tx = state
|
||||
.db_pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| db_error("failed to begin transaction", e))?;
|
||||
|
||||
// Check current status — cannot reject an instructor with confirmed matches
|
||||
let current_status: Option<(String,)> =
|
||||
sqlx::query_as("SELECT rmp_match_status FROM instructors WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to fetch instructor status", e))?;
|
||||
|
||||
let (status,) = current_status.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "instructor not found"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if status == "confirmed" {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(
|
||||
json!({"error": "cannot reject instructor with confirmed matches — unmatch first"}),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Update instructor status
|
||||
sqlx::query("UPDATE instructors SET rmp_match_status = 'rejected' WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to update instructor status", e))?;
|
||||
|
||||
// Reject all pending candidates
|
||||
sqlx::query(
|
||||
"UPDATE rmp_match_candidates SET status = 'rejected', resolved_at = NOW(), resolved_by = $1 WHERE instructor_id = $2 AND status = 'pending'",
|
||||
)
|
||||
.bind(user.discord_id)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to reject candidates", e))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| db_error("failed to commit transaction", e))?;
|
||||
|
||||
Ok(Json(OkResponse { ok: true }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. POST /api/admin/instructors/{id}/unmatch — remove current match
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Body for unmatch — optional `rmpLegacyId` to remove a specific link.
|
||||
/// If omitted (or null), all links are removed.
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnmatchBody {
|
||||
rmp_legacy_id: Option<i32>,
|
||||
}
|
||||
|
||||
/// `POST /api/admin/instructors/{id}/unmatch` — Remove RMP link(s).
|
||||
///
|
||||
/// Send `{ "rmpLegacyId": N }` to remove a specific link, or an empty body / `{}`
|
||||
/// to remove all links for the instructor.
|
||||
pub async fn unmatch_instructor(
|
||||
AdminUser(_user): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
body: Option<Json<UnmatchBody>>,
|
||||
) -> Result<Json<OkResponse>, (StatusCode, Json<Value>)> {
|
||||
let rmp_legacy_id = body.and_then(|b| b.rmp_legacy_id);
|
||||
|
||||
let mut tx = state
|
||||
.db_pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| db_error("failed to begin transaction", e))?;
|
||||
|
||||
// Verify instructor exists
|
||||
let exists: Option<(i32,)> = sqlx::query_as("SELECT id FROM instructors WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to check instructor", e))?;
|
||||
|
||||
if exists.is_none() {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "instructor not found"})),
|
||||
));
|
||||
}
|
||||
|
||||
// Delete specific link or all links
|
||||
if let Some(legacy_id) = rmp_legacy_id {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM instructor_rmp_links WHERE instructor_id = $1 AND rmp_legacy_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(legacy_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to remove rmp link", e))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "link not found for this instructor"})),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
sqlx::query("DELETE FROM instructor_rmp_links WHERE instructor_id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to remove rmp links", e))?;
|
||||
}
|
||||
|
||||
// Check if any links remain; update status accordingly
|
||||
let (remaining,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM instructor_rmp_links WHERE instructor_id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to count remaining links", e))?;
|
||||
|
||||
if remaining == 0 {
|
||||
sqlx::query("UPDATE instructors SET rmp_match_status = 'unmatched' WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_error("failed to update instructor status", e))?;
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| db_error("failed to commit transaction", e))?;
|
||||
|
||||
Ok(Json(OkResponse { ok: true }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. POST /api/admin/rmp/rescore — re-run candidate generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/admin/rmp/rescore` — Re-run RMP candidate generation.
|
||||
pub async fn rescore(
|
||||
AdminUser(_user): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<RescoreResponse>, (StatusCode, Json<Value>)> {
|
||||
let stats = crate::data::rmp_matching::generate_candidates(&state.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to run candidate generation");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "candidate generation failed"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(RescoreResponse {
|
||||
total_unmatched: stats.total_unmatched,
|
||||
candidates_created: stats.candidates_created,
|
||||
auto_matched: stats.auto_matched,
|
||||
skipped_unparseable: stats.skipped_unparseable,
|
||||
skipped_no_candidates: stats.skipped_no_candidates,
|
||||
}))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Web API module for the banner application.
|
||||
|
||||
pub mod admin;
|
||||
pub mod admin_rmp;
|
||||
#[cfg(feature = "embed-assets")]
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
|
||||
+23
-1
@@ -10,6 +10,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::web::admin;
|
||||
use crate::web::admin_rmp;
|
||||
use crate::web::auth::{self, AuthConfig};
|
||||
use crate::web::ws;
|
||||
#[cfg(feature = "embed-assets")]
|
||||
@@ -66,6 +67,25 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
|
||||
.route("/admin/scrape-jobs", get(admin::list_scrape_jobs))
|
||||
.route("/admin/scrape-jobs/ws", get(ws::scrape_jobs_ws))
|
||||
.route("/admin/audit-log", get(admin::list_audit_log))
|
||||
.route("/admin/instructors", get(admin_rmp::list_instructors))
|
||||
.route("/admin/instructors/{id}", get(admin_rmp::get_instructor))
|
||||
.route(
|
||||
"/admin/instructors/{id}/match",
|
||||
post(admin_rmp::match_instructor),
|
||||
)
|
||||
.route(
|
||||
"/admin/instructors/{id}/reject-candidate",
|
||||
post(admin_rmp::reject_candidate),
|
||||
)
|
||||
.route(
|
||||
"/admin/instructors/{id}/reject-all",
|
||||
post(admin_rmp::reject_all),
|
||||
)
|
||||
.route(
|
||||
"/admin/instructors/{id}/unmatch",
|
||||
post(admin_rmp::unmatch_instructor),
|
||||
)
|
||||
.route("/admin/rmp/rescore", post(admin_rmp::rescore))
|
||||
.with_state(app_state);
|
||||
|
||||
let mut router = Router::new()
|
||||
@@ -435,9 +455,10 @@ pub struct CourseResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct InstructorResponse {
|
||||
instructor_id: i32,
|
||||
banner_id: String,
|
||||
display_name: String,
|
||||
email: Option<String>,
|
||||
email: String,
|
||||
is_primary: bool,
|
||||
rmp_rating: Option<f32>,
|
||||
rmp_num_ratings: Option<i32>,
|
||||
@@ -470,6 +491,7 @@ fn build_course_response(
|
||||
let instructors = instructors
|
||||
.into_iter()
|
||||
.map(|i| InstructorResponse {
|
||||
instructor_id: i.instructor_id,
|
||||
banner_id: i.banner_id,
|
||||
display_name: i.display_name,
|
||||
email: i.email,
|
||||
|
||||
+118
-7
@@ -1,12 +1,21 @@
|
||||
import type {
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DbMeetingTime,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
InstructorListItem,
|
||||
InstructorResponse,
|
||||
InstructorStats,
|
||||
LinkedRmpProfile,
|
||||
ListInstructorsResponse,
|
||||
RescoreResponse,
|
||||
SearchResponse as SearchResponseGenerated,
|
||||
ServiceInfo,
|
||||
ServiceStatus,
|
||||
StatusResponse,
|
||||
TopCandidateResponse,
|
||||
User,
|
||||
} from "$lib/bindings";
|
||||
|
||||
@@ -14,13 +23,22 @@ const API_BASE_URL = "/api";
|
||||
|
||||
// Re-export generated types under their canonical names
|
||||
export type {
|
||||
CandidateResponse,
|
||||
CodeDescription,
|
||||
CourseResponse,
|
||||
DbMeetingTime,
|
||||
InstructorDetail,
|
||||
InstructorDetailResponse,
|
||||
InstructorListItem,
|
||||
InstructorResponse,
|
||||
InstructorStats,
|
||||
LinkedRmpProfile,
|
||||
ListInstructorsResponse,
|
||||
RescoreResponse,
|
||||
ServiceInfo,
|
||||
ServiceStatus,
|
||||
StatusResponse,
|
||||
TopCandidateResponse,
|
||||
};
|
||||
|
||||
// Semantic aliases — these all share the CodeDescription shape
|
||||
@@ -112,6 +130,15 @@ export interface SearchParams {
|
||||
sort_dir?: SortDirection;
|
||||
}
|
||||
|
||||
// Admin instructor query params (client-only, not generated)
|
||||
export interface AdminInstructorListParams {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export class BannerApiClient {
|
||||
private baseUrl: string;
|
||||
private fetchFn: typeof fetch;
|
||||
@@ -121,8 +148,30 @@ export class BannerApiClient {
|
||||
this.fetchFn = fetchFn;
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string): Promise<T> {
|
||||
const response = await this.fetchFn(`${this.baseUrl}${endpoint}`);
|
||||
private buildInit(options?: { method?: string; body?: unknown }): RequestInit | undefined {
|
||||
if (!options) return undefined;
|
||||
const init: RequestInit = {};
|
||||
if (options.method) {
|
||||
init.method = options.method;
|
||||
}
|
||||
if (options.body !== undefined) {
|
||||
init.headers = { "Content-Type": "application/json" };
|
||||
init.body = JSON.stringify(options.body);
|
||||
} else if (options.method) {
|
||||
init.headers = { "Content-Type": "application/json" };
|
||||
}
|
||||
return Object.keys(init).length > 0 ? init : undefined;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options?: { method?: string; body?: unknown }
|
||||
): Promise<T> {
|
||||
const init = this.buildInit(options);
|
||||
const args: [string, RequestInit?] = [`${this.baseUrl}${endpoint}`];
|
||||
if (init) args.push(init);
|
||||
|
||||
const response = await this.fetchFn(...args);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
@@ -131,6 +180,21 @@ export class BannerApiClient {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private async requestVoid(
|
||||
endpoint: string,
|
||||
options?: { method?: string; body?: unknown }
|
||||
): Promise<void> {
|
||||
const init = this.buildInit(options);
|
||||
const args: [string, RequestInit?] = [`${this.baseUrl}${endpoint}`];
|
||||
if (init) args.push(init);
|
||||
|
||||
const response = await this.fetchFn(...args);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(): Promise<StatusResponse> {
|
||||
return this.request<StatusResponse>("/status");
|
||||
}
|
||||
@@ -174,13 +238,10 @@ export class BannerApiClient {
|
||||
}
|
||||
|
||||
async setUserAdmin(discordId: string, isAdmin: boolean): Promise<User> {
|
||||
const response = await this.fetchFn(`${this.baseUrl}/admin/users/${discordId}/admin`, {
|
||||
return this.request<User>(`/admin/users/${discordId}/admin`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_admin: isAdmin }),
|
||||
body: { is_admin: isAdmin },
|
||||
});
|
||||
if (!response.ok) throw new Error(`API request failed: ${response.status}`);
|
||||
return (await response.json()) as User;
|
||||
}
|
||||
|
||||
async getAdminScrapeJobs(): Promise<ScrapeJobsResponse> {
|
||||
@@ -230,6 +291,56 @@ export class BannerApiClient {
|
||||
const qs = query.toString();
|
||||
return this.request<MetricsResponse>(`/metrics${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
// Admin instructor endpoints
|
||||
|
||||
async getAdminInstructors(params?: AdminInstructorListParams): Promise<ListInstructorsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set("status", params.status);
|
||||
if (params?.search) query.set("search", params.search);
|
||||
if (params?.page !== undefined) query.set("page", String(params.page));
|
||||
if (params?.per_page !== undefined) query.set("per_page", String(params.per_page));
|
||||
if (params?.sort) query.set("sort", params.sort);
|
||||
const qs = query.toString();
|
||||
return this.request<ListInstructorsResponse>(`/admin/instructors${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
async getAdminInstructor(id: number): Promise<InstructorDetailResponse> {
|
||||
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}`);
|
||||
}
|
||||
|
||||
async matchInstructor(id: number, rmpLegacyId: number): Promise<InstructorDetailResponse> {
|
||||
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}/match`, {
|
||||
method: "POST",
|
||||
body: { rmpLegacyId },
|
||||
});
|
||||
}
|
||||
|
||||
async rejectCandidate(id: number, rmpLegacyId: number): Promise<void> {
|
||||
return this.requestVoid(`/admin/instructors/${id}/reject-candidate`, {
|
||||
method: "POST",
|
||||
body: { rmpLegacyId },
|
||||
});
|
||||
}
|
||||
|
||||
async rejectAllCandidates(id: number): Promise<void> {
|
||||
return this.requestVoid(`/admin/instructors/${id}/reject-all`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async unmatchInstructor(id: number, rmpLegacyId?: number): Promise<void> {
|
||||
return this.requestVoid(`/admin/instructors/${id}/unmatch`, {
|
||||
method: "POST",
|
||||
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async rescoreInstructors(): Promise<RescoreResponse> {
|
||||
return this.request<RescoreResponse>("/admin/rmp/rescore", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const client = new BannerApiClient();
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
export type { CandidateResponse } from "./CandidateResponse";
|
||||
export type { CodeDescription } from "./CodeDescription";
|
||||
export type { CourseResponse } from "./CourseResponse";
|
||||
export type { DbMeetingTime } from "./DbMeetingTime";
|
||||
export type { InstructorDetail } from "./InstructorDetail";
|
||||
export type { InstructorDetailResponse } from "./InstructorDetailResponse";
|
||||
export type { InstructorListItem } from "./InstructorListItem";
|
||||
export type { InstructorResponse } from "./InstructorResponse";
|
||||
export type { InstructorStats } from "./InstructorStats";
|
||||
export type { LinkedRmpProfile } from "./LinkedRmpProfile";
|
||||
export type { ListInstructorsResponse } from "./ListInstructorsResponse";
|
||||
export type { OkResponse } from "./OkResponse";
|
||||
export type { RescoreResponse } from "./RescoreResponse";
|
||||
export type { SearchResponse } from "./SearchResponse";
|
||||
export type { ServiceInfo } from "./ServiceInfo";
|
||||
export type { ServiceStatus } from "./ServiceStatus";
|
||||
export type { StatusResponse } from "./StatusResponse";
|
||||
export type { TopCandidateResponse } from "./TopCandidateResponse";
|
||||
export type { User } from "./User";
|
||||
|
||||
@@ -187,18 +187,20 @@ describe("getPrimaryInstructor", () => {
|
||||
it("returns primary instructor", () => {
|
||||
const instructors: InstructorResponse[] = [
|
||||
{
|
||||
instructorId: 1,
|
||||
bannerId: "1",
|
||||
displayName: "A",
|
||||
email: null,
|
||||
email: "a@utsa.edu",
|
||||
isPrimary: false,
|
||||
rmpRating: null,
|
||||
rmpNumRatings: null,
|
||||
rmpLegacyId: null,
|
||||
},
|
||||
{
|
||||
instructorId: 2,
|
||||
bannerId: "2",
|
||||
displayName: "B",
|
||||
email: null,
|
||||
email: "b@utsa.edu",
|
||||
isPrimary: true,
|
||||
rmpRating: null,
|
||||
rmpNumRatings: null,
|
||||
@@ -210,9 +212,10 @@ describe("getPrimaryInstructor", () => {
|
||||
it("returns first instructor when no primary", () => {
|
||||
const instructors: InstructorResponse[] = [
|
||||
{
|
||||
instructorId: 3,
|
||||
bannerId: "1",
|
||||
displayName: "A",
|
||||
email: null,
|
||||
email: "a@utsa.edu",
|
||||
isPrimary: false,
|
||||
rmpRating: null,
|
||||
rmpNumRatings: null,
|
||||
|
||||
@@ -415,3 +415,24 @@ export function formatCreditHours(course: CourseResponse): string {
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Banner "Last, First Middle" → "First Middle Last".
|
||||
* Handles: no comma (returned as-is), trailing/leading spaces,
|
||||
* middle names/initials preserved.
|
||||
*/
|
||||
export function formatInstructorName(displayName: string): string {
|
||||
const commaIdx = displayName.indexOf(",");
|
||||
if (commaIdx === -1) return displayName.trim();
|
||||
|
||||
const last = displayName.slice(0, commaIdx).trim();
|
||||
const rest = displayName.slice(commaIdx + 1).trim();
|
||||
if (!rest) return last;
|
||||
|
||||
return `${rest} ${last}`;
|
||||
}
|
||||
|
||||
/** Check if a rating value represents real data (not the 0.0 placeholder for unrated professors). */
|
||||
export function isRatingValid(avgRating: number | null, numRatings: number): boolean {
|
||||
return avgRating !== null && !(avgRating === 0 && numRatings === 0);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte"
|
||||
import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Settings,
|
||||
@@ -61,6 +62,7 @@ const adminItems = [
|
||||
{ href: "/admin/jobs", label: "Scrape Jobs", icon: ClipboardList },
|
||||
{ href: "/admin/audit", label: "Audit Log", icon: FileText },
|
||||
{ href: "/admin/users", label: "Users", icon: Users },
|
||||
{ href: "/admin/instructors", label: "Instructors", icon: GraduationCap },
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import {
|
||||
client,
|
||||
type InstructorListItem,
|
||||
type InstructorStats,
|
||||
type InstructorDetailResponse,
|
||||
type CandidateResponse,
|
||||
} from "$lib/api";
|
||||
import { formatInstructorName, isRatingValid, ratingStyle, rmpUrl } from "$lib/course";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
X,
|
||||
XCircle,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
// --- State ---
|
||||
let instructors = $state<InstructorListItem[]>([]);
|
||||
let stats = $state<InstructorStats>({
|
||||
total: 0,
|
||||
unmatched: 0,
|
||||
auto: 0,
|
||||
confirmed: 0,
|
||||
rejected: 0,
|
||||
withCandidates: 0,
|
||||
});
|
||||
let totalCount = $state(0);
|
||||
let currentPage = $state(1);
|
||||
let perPage = $state(25);
|
||||
let activeFilter = $state<string | undefined>(undefined);
|
||||
let searchQuery = $state("");
|
||||
let searchInput = $state("");
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
// Expanded row detail
|
||||
let expandedId = $state<number | null>(null);
|
||||
let detail = $state<InstructorDetailResponse | null>(null);
|
||||
let detailLoading = $state(false);
|
||||
let detailError = $state<string | null>(null);
|
||||
|
||||
// Action states
|
||||
let actionLoading = $state<string | null>(null);
|
||||
let rescoreLoading = $state(false);
|
||||
let rescoreResult = $state<string | null>(null);
|
||||
|
||||
// Search debounce
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const filterOptions = [
|
||||
{ label: "All", value: undefined as string | undefined },
|
||||
{ label: "Needs Review", value: "unmatched" },
|
||||
{ label: "Auto-matched", value: "auto" },
|
||||
{ label: "Confirmed", value: "confirmed" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
];
|
||||
|
||||
let matchedLegacyIds = $derived(
|
||||
new Set(detail?.currentMatches.map((m: { legacyId: number }) => m.legacyId) ?? [])
|
||||
);
|
||||
|
||||
let progressDenom = $derived(stats.total || 1);
|
||||
let autoPct = $derived((stats.auto / progressDenom) * 100);
|
||||
let confirmedPct = $derived((stats.confirmed / progressDenom) * 100);
|
||||
let unmatchedPct = $derived((stats.unmatched / progressDenom) * 100);
|
||||
|
||||
let totalPages = $derived(Math.max(1, Math.ceil(totalCount / perPage)));
|
||||
|
||||
// --- Data fetching ---
|
||||
async function fetchInstructors() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await client.getAdminInstructors({
|
||||
status: activeFilter,
|
||||
search: searchQuery || undefined,
|
||||
page: currentPage,
|
||||
per_page: perPage,
|
||||
});
|
||||
instructors = res.instructors;
|
||||
totalCount = res.total;
|
||||
stats = res.stats;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to load instructors";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetail(id: number) {
|
||||
detailLoading = true;
|
||||
detailError = null;
|
||||
detail = null;
|
||||
try {
|
||||
detail = await client.getAdminInstructor(id);
|
||||
} catch (e) {
|
||||
detailError = e instanceof Error ? e.message : "Failed to load details";
|
||||
} finally {
|
||||
detailLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchInstructors();
|
||||
});
|
||||
|
||||
onDestroy(() => clearTimeout(searchTimeout));
|
||||
|
||||
function setFilter(value: string | undefined) {
|
||||
activeFilter = value;
|
||||
currentPage = 1;
|
||||
expandedId = null;
|
||||
fetchInstructors();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchQuery = searchInput;
|
||||
currentPage = 1;
|
||||
expandedId = null;
|
||||
fetchInstructors();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
currentPage = page;
|
||||
expandedId = null;
|
||||
fetchInstructors();
|
||||
}
|
||||
|
||||
async function toggleExpand(id: number) {
|
||||
if (expandedId === id) {
|
||||
expandedId = null;
|
||||
detail = null;
|
||||
return;
|
||||
}
|
||||
expandedId = id;
|
||||
await fetchDetail(id);
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
async function handleMatch(instructorId: number, rmpLegacyId: number) {
|
||||
actionLoading = `match-${rmpLegacyId}`;
|
||||
try {
|
||||
detail = await client.matchInstructor(instructorId, rmpLegacyId);
|
||||
await fetchInstructors();
|
||||
} catch (e) {
|
||||
detailError = e instanceof Error ? e.message : "Match failed";
|
||||
} finally {
|
||||
actionLoading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(instructorId: number, rmpLegacyId: number) {
|
||||
actionLoading = `reject-${rmpLegacyId}`;
|
||||
try {
|
||||
await client.rejectCandidate(instructorId, rmpLegacyId);
|
||||
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
|
||||
} catch (e) {
|
||||
detailError = e instanceof Error ? e.message : "Reject failed";
|
||||
} finally {
|
||||
actionLoading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectAll(instructorId: number) {
|
||||
actionLoading = "reject-all";
|
||||
try {
|
||||
await client.rejectAllCandidates(instructorId);
|
||||
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
|
||||
} catch (e) {
|
||||
detailError = e instanceof Error ? e.message : "Reject all failed";
|
||||
} finally {
|
||||
actionLoading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnmatch(instructorId: number, rmpLegacyId: number) {
|
||||
actionLoading = `unmatch-${rmpLegacyId}`;
|
||||
try {
|
||||
await client.unmatchInstructor(instructorId, rmpLegacyId);
|
||||
await Promise.all([fetchDetail(instructorId), fetchInstructors()]);
|
||||
} catch (e) {
|
||||
detailError = e instanceof Error ? e.message : "Unmatch failed";
|
||||
} finally {
|
||||
actionLoading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRescore() {
|
||||
rescoreLoading = true;
|
||||
rescoreResult = null;
|
||||
try {
|
||||
const res = await client.rescoreInstructors();
|
||||
rescoreResult = `Rescored: ${res.totalUnmatched} unmatched, ${res.candidatesCreated} candidates created, ${res.autoMatched} auto-matched`;
|
||||
await fetchInstructors();
|
||||
} catch (e) {
|
||||
rescoreResult = e instanceof Error ? e.message : "Rescore failed";
|
||||
} finally {
|
||||
rescoreLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
function statusBadge(status: string): { label: string; classes: string } {
|
||||
switch (status) {
|
||||
case "unmatched":
|
||||
return {
|
||||
label: "Unmatched",
|
||||
classes: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
|
||||
};
|
||||
case "auto":
|
||||
return {
|
||||
label: "Auto",
|
||||
classes: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
};
|
||||
case "confirmed":
|
||||
return {
|
||||
label: "Confirmed",
|
||||
classes: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
};
|
||||
case "rejected":
|
||||
return {
|
||||
label: "Rejected",
|
||||
classes: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
};
|
||||
default:
|
||||
return { label: status, classes: "bg-muted text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
||||
function formatScore(score: number): string {
|
||||
return (score * 100).toFixed(0);
|
||||
}
|
||||
|
||||
const scoreWeights: Record<string, number> = {
|
||||
name: 0.5,
|
||||
department: 0.25,
|
||||
uniqueness: 0.15,
|
||||
volume: 0.1,
|
||||
};
|
||||
const scoreColors: Record<string, string> = {
|
||||
name: "bg-blue-500",
|
||||
department: "bg-purple-500",
|
||||
uniqueness: "bg-amber-500",
|
||||
volume: "bg-emerald-500",
|
||||
};
|
||||
const scoreLabels: Record<string, string> = {
|
||||
name: "Name",
|
||||
department: "Dept",
|
||||
uniqueness: "Unique",
|
||||
volume: "Volume",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-lg font-semibold text-foreground">Instructors</h1>
|
||||
<button
|
||||
onclick={handleRescore}
|
||||
disabled={rescoreLoading}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-muted px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<RefreshCw size={14} class={rescoreLoading ? "animate-spin" : ""} />
|
||||
Rescore
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if rescoreResult}
|
||||
<div class="mb-4 rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||
{rescoreResult}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-destructive mb-4">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading && instructors.length === 0}
|
||||
<!-- Skeleton stats cards -->
|
||||
<div class="mb-4 grid grid-cols-5 gap-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="bg-card border-border rounded-lg border p-3">
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-muted mb-2"></div>
|
||||
<div class="h-6 w-12 animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="bg-muted mb-6 h-2 rounded-full overflow-hidden"></div>
|
||||
<!-- Skeleton table rows -->
|
||||
<div class="bg-card border-border overflow-hidden rounded-lg border">
|
||||
<div class="divide-y divide-border">
|
||||
{#each Array(8) as _}
|
||||
<div class="flex items-center gap-4 px-4 py-3">
|
||||
<div class="space-y-1.5 flex-1">
|
||||
<div class="h-4 w-40 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-3 w-28 animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
<div class="h-5 w-20 animate-pulse rounded-full bg-muted"></div>
|
||||
<div class="h-4 w-32 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-4 w-8 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-6 w-16 animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="mb-4 grid grid-cols-5 gap-3">
|
||||
<div class="bg-card border-border rounded-lg border p-3">
|
||||
<div class="text-muted-foreground text-xs">Total</div>
|
||||
<div class="text-lg font-semibold">{stats.total}</div>
|
||||
</div>
|
||||
<div class="bg-card border-border rounded-lg border p-3">
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400">Unmatched</div>
|
||||
<div class="text-lg font-semibold">{stats.unmatched}</div>
|
||||
</div>
|
||||
<div class="bg-card border-border rounded-lg border p-3">
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400">Auto</div>
|
||||
<div class="text-lg font-semibold">{stats.auto}</div>
|
||||
</div>
|
||||
<div class="bg-card border-border rounded-lg border p-3">
|
||||
<div class="text-xs text-green-600 dark:text-green-400">Confirmed</div>
|
||||
<div class="text-lg font-semibold">{stats.confirmed}</div>
|
||||
</div>
|
||||
<div class="bg-card border-border rounded-lg border p-3">
|
||||
<div class="text-xs text-red-600 dark:text-red-400">Rejected</div>
|
||||
<div class="text-lg font-semibold">{stats.rejected}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="bg-muted mb-6 h-2 rounded-full overflow-hidden flex">
|
||||
<div class="bg-blue-500 h-full transition-all duration-300"
|
||||
style="width: {autoPct}%" title="Auto: {stats.auto}"></div>
|
||||
<div class="bg-green-500 h-full transition-all duration-300"
|
||||
style="width: {confirmedPct}%" title="Confirmed: {stats.confirmed}"></div>
|
||||
<div class="bg-amber-500 h-full transition-all duration-300"
|
||||
style="width: {unmatchedPct}%" title="Unmatched: {stats.unmatched}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<div class="flex gap-2">
|
||||
{#each filterOptions as opt}
|
||||
<button
|
||||
onclick={() => setFilter(opt.value)}
|
||||
class="rounded-md px-3 py-1.5 text-sm transition-colors cursor-pointer
|
||||
{activeFilter === opt.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent'}"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or email..."
|
||||
bind:value={searchInput}
|
||||
oninput={handleSearch}
|
||||
class="bg-card border-border rounded-md border px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if instructors.length === 0}
|
||||
<p class="text-muted-foreground py-8 text-center text-sm">No instructors found.</p>
|
||||
{:else}
|
||||
<div class="bg-card border-border overflow-hidden rounded-lg border">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-border border-b text-left text-muted-foreground">
|
||||
<th class="px-4 py-2.5 font-medium">Name</th>
|
||||
<th class="px-4 py-2.5 font-medium">Status</th>
|
||||
<th class="px-4 py-2.5 font-medium">Top Candidate</th>
|
||||
<th class="px-4 py-2.5 font-medium text-center">Candidates</th>
|
||||
<th class="px-4 py-2.5 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each instructors as instructor (instructor.id)}
|
||||
{@const badge = statusBadge(instructor.rmpMatchStatus)}
|
||||
{@const isExpanded = expandedId === instructor.id}
|
||||
<tr
|
||||
class="border-border border-b cursor-pointer hover:bg-muted/50 transition-colors {isExpanded ? 'bg-muted/30' : ''}"
|
||||
onclick={() => toggleExpand(instructor.id)}
|
||||
>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="font-medium text-foreground">{formatInstructorName(instructor.displayName)}</div>
|
||||
<div class="text-xs text-muted-foreground">{instructor.email}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {badge.classes}">
|
||||
{badge.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{#if instructor.topCandidate}
|
||||
{@const tc = instructor.topCandidate}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-foreground">{tc.firstName} {tc.lastName}</span>
|
||||
{#if isRatingValid(tc.avgRating, tc.numRatings ?? 0)}
|
||||
<span class="font-semibold tabular-nums" style={ratingStyle(tc.avgRating!, themeStore.isDark)}>
|
||||
{tc.avgRating!.toFixed(1)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">N/A</span>
|
||||
{/if}
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
({formatScore(tc.score ?? 0)}%)
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-xs">No candidates</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-center tabular-nums text-muted-foreground">
|
||||
{instructor.candidateCount}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
{#if instructor.topCandidate && instructor.rmpMatchStatus === "unmatched"}
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); handleMatch(instructor.id, instructor.topCandidate!.rmpLegacyId); }}
|
||||
disabled={actionLoading !== null}
|
||||
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
title="Accept top candidate"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); toggleExpand(instructor.id); }}
|
||||
class="rounded p-1 text-muted-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
<ChevronRight size={16} class="transition-transform duration-200 {isExpanded ? 'rotate-90' : ''}" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded detail panel -->
|
||||
{#if isExpanded}
|
||||
<tr class="border-border border-b bg-muted/20">
|
||||
<td colspan="5" class="p-0">
|
||||
<div class="p-4">
|
||||
{#if detailLoading}
|
||||
<p class="text-muted-foreground text-sm py-4 text-center">Loading details...</p>
|
||||
{:else if detailError}
|
||||
<p class="text-destructive text-sm py-2">{detailError}</p>
|
||||
{:else if detail}
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<!-- Left: Instructor info -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-medium text-foreground">Instructor</h3>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
<dt class="text-muted-foreground">Name</dt>
|
||||
<dd class="text-foreground">{formatInstructorName(detail.instructor.displayName)}</dd>
|
||||
|
||||
<dt class="text-muted-foreground">Email</dt>
|
||||
<dd class="text-foreground">{detail.instructor.email}</dd>
|
||||
|
||||
<dt class="text-muted-foreground">Courses</dt>
|
||||
<dd class="text-foreground tabular-nums">{detail.instructor.courseCount}</dd>
|
||||
|
||||
{#if detail.instructor.subjectsTaught.length > 0}
|
||||
<dt class="text-muted-foreground">Subjects</dt>
|
||||
<dd class="flex flex-wrap gap-1">
|
||||
{#each detail.instructor.subjectsTaught as subj}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 text-xs">{subj}</span>
|
||||
{/each}
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Right: Candidates (spans 2 cols) -->
|
||||
<div class="col-span-2 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-foreground">
|
||||
Candidates ({detail.candidates.length})
|
||||
</h3>
|
||||
{#if detail.candidates.some((c: CandidateResponse) => c.status !== "rejected" && !matchedLegacyIds.has(c.rmpLegacyId))}
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); handleRejectAll(detail!.instructor.id); }}
|
||||
disabled={actionLoading !== null}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<X size={12} /> Reject All
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if detail.candidates.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No candidates available.</p>
|
||||
{:else}
|
||||
<div class="max-h-80 overflow-y-auto space-y-2 pr-1">
|
||||
{#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}
|
||||
<div class="rounded-md border p-3 {isMatched ? 'border-l-4 border-l-green-500 bg-green-500/5 border-border' : isRejected ? 'border-border bg-card opacity-50' : 'border-border bg-card'}">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground text-sm">
|
||||
{candidate.firstName} {candidate.lastName}
|
||||
</span>
|
||||
{#if isMatched}
|
||||
<span class="text-[10px] rounded px-1 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
Matched
|
||||
</span>
|
||||
{:else if isRejected}
|
||||
<span class="text-[10px] rounded px-1 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
Rejected
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if candidate.department}
|
||||
<div class="text-xs text-muted-foreground">{candidate.department}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{#if isMatched}
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); handleUnmatch(detail!.instructor.id, candidate.rmpLegacyId); }}
|
||||
disabled={actionLoading !== null}
|
||||
class="inline-flex items-center gap-1 rounded p-1 text-xs text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
title="Unmatch"
|
||||
>
|
||||
<XCircle size={14} /> Unmatch
|
||||
</button>
|
||||
{:else if isPending}
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); handleMatch(detail!.instructor.id, candidate.rmpLegacyId); }}
|
||||
disabled={actionLoading !== null}
|
||||
class="rounded p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
title="Accept"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); handleReject(detail!.instructor.id, candidate.rmpLegacyId); }}
|
||||
disabled={actionLoading !== null}
|
||||
class="rounded p-1 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
title="Reject"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
href={rmpUrl(candidate.rmpLegacyId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="rounded p-1 text-muted-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||
title="View on RMP"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="mt-2 flex items-center gap-3 text-xs">
|
||||
{#if isRatingValid(candidate.avgRating, candidate.numRatings ?? 0)}
|
||||
<span class="font-semibold tabular-nums" style={ratingStyle(candidate.avgRating!, themeStore.isDark)}>
|
||||
{candidate.avgRating!.toFixed(1)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">N/A</span>
|
||||
{/if}
|
||||
{#if candidate.avgDifficulty !== null}
|
||||
<span class="text-muted-foreground tabular-nums">{candidate.avgDifficulty.toFixed(1)} diff</span>
|
||||
{/if}
|
||||
<span class="text-muted-foreground">{candidate.numRatings} ratings</span>
|
||||
{#if candidate.wouldTakeAgainPct !== null}
|
||||
<span class="text-muted-foreground">{candidate.wouldTakeAgainPct.toFixed(0)}% again</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Score bar (weighted segments) -->
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="text-muted-foreground shrink-0">Score:</span>
|
||||
<div class="bg-muted h-2 flex-1 rounded-full overflow-hidden flex">
|
||||
{#each Object.entries(candidate.scoreBreakdown ?? {}) as [key, value]}
|
||||
{@const w = scoreWeights[key] ?? 0}
|
||||
{@const widthPct = (value as number) * w * 100}
|
||||
<div
|
||||
class="{scoreColors[key] ?? 'bg-primary'} h-full transition-all"
|
||||
style="width: {widthPct}%"
|
||||
title="{scoreLabels[key] ?? key}: {((value as number) * 100).toFixed(0)}% (×{w})"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="tabular-nums font-medium text-foreground shrink-0">{formatScore(candidate.score ?? 0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-4 flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">
|
||||
Showing {(currentPage - 1) * perPage + 1}–{Math.min(currentPage * perPage, totalCount)} of {totalCount}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-sm text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<ChevronLeft size={14} /> Prev
|
||||
</button>
|
||||
<span class="text-muted-foreground tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-sm text-foreground hover:bg-accent transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Next <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
Reference in New Issue
Block a user