From dfa9ef0ced7ebb3e59aee205ecccf3551cd9cfed Mon Sep 17 00:00:00 2001 From: William-Hill Date: Sun, 22 Feb 2026 21:05:03 -0600 Subject: [PATCH 1/8] feat: add cohort and enrollment intensity filters to student roster (#81) (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add demo script with 6-minute talk track and screenshot guide * docs: move DEMO.md into docs/ directory * docs: move demo script and screenshots into docs/demo/ subdirectory * chore: untrack large presentation files, add *.pptx and docs PDFs to .gitignore * feat: student roster page with drill-down, filtering, sorting, and CSV export (#65) * feat: student roster with info popovers; fix gateway models using 'Y' not 'C' for completion target * fix: credential model — add sought-credential fallback and class_weight=balanced; add sorting for enrollment and credential type columns * fix: update credential type popover to reflect sought-credential fallback logic * feat: dashboard filtering by cohort, enrollment type, and credential type (#66) (#72) - Add filter bar above KPI tiles with Cohort, Enrollment Type, and Credential Type dropdowns (shadcn Select) and a Clear button with filtered-student count - All 4 dashboard API routes now accept cohort, enrollmentType, credentialType query params and apply parameterized WHERE clauses - Risk alerts and retention-risk routes use a CTE so percentage denominators are relative to the filtered set (not the full table) - Readiness route conditionally JOINs student_level_with_predictions when enrollment or credential filters are active; existing institution/cohort/level params unchanged - All fetch calls on the page are re-triggered when filter state changes * feat: audit log export endpoint with CSV download button (#67) (#73) - Add GET /api/query-history/export that reads logs/query-history.jsonl and streams a CSV with headers: timestamp, institution, prompt, vizType, rowCount - Accepts optional ?from=ISO_DATE&to=ISO_DATE query params for date-range filtering; returns 404 with clear message if the log file does not exist yet - Sets Content-Disposition: attachment; filename="query-audit-log.csv" - Add "Export" button with download icon in the QueryHistoryPanel header that triggers a direct browser download via * feat: student detail view with personalized recommendations (#77) (#79) - Add GET /api/students/[guid] joining student_level_with_predictions + llm_recommendations; returns 404 for unknown GUIDs - Add /students/[guid] page with: - Student header: GUID, cohort, enrollment, credential, at-risk + readiness badges - FERPA disclaimer (de-identified GUID only, no PII stored) - Six prediction score cards (retention, readiness, gateway math/English, GPA risk, time-to-credential) color-coded green/yellow/red - AI Readiness Assessment card: rationale, risk factors (orange dot list), and recommended actions (checkbox-style checklist) - Graceful fallback when no assessment has been generated yet - Back button uses router.back() to preserve roster filter state - Student roster rows are now fully clickable (onClick → router.push) with the GUID cell retaining its Link for ctrl/cmd+click support * feat: Supabase Auth + role-based access control (FR6, #75) (#80) * feat: Supabase Auth + role-based access control (FR6, #75) Auth layer - Install @supabase/supabase-js + @supabase/ssr - lib/supabase/client.ts — browser client (createBrowserClient) - lib/supabase/server.ts — server client (createServerClient + cookies) - lib/supabase/middleware-client.ts — session refresh helper for middleware Roles - lib/roles.ts — Role type, ROUTE_PERMISSIONS map, canAccess() helper, ROLE_LABELS and ROLE_COLORS per role - Five roles: admin | advisor | ir | faculty | leadership /students/** → admin, advisor, ir /query → admin, advisor, ir, faculty /api/students/** → admin, advisor, ir /api/query-history/export → admin, ir / and /methodology → all roles (public within auth) Middleware - middleware.ts — unauthenticated → redirect /login; role resolved from user_roles table; canAccess() enforced; role + user-id + email forwarded as request headers (x-user-role, x-user-id, x-user-email) for API routes Login page - app/login/page.tsx — email/password form using createBrowserClient - app/auth/callback/route.ts — PKCE code exchange handler Navigation - components/nav-header.tsx — sticky top bar: role badge, email, sign-out - app/layout.tsx — server component reads session + role, renders NavHeader when authenticated API guards - /api/students: 403 for faculty + leadership - /api/students/[guid]: 403 for faculty + leadership - /api/query-history/export: 403 for non-admin/ir Database & seed - migrations/001_user_roles.sql — user_roles table + RLS policy - scripts/seed-demo-users.ts — creates 5 demo users via service role key (admin/advisor/ir/faculty/leadership @bscc.edu, pw: BishopState2025!) * fix: seed script accepts NEXT_PUBLIC_ env var names; install tsx dev dep * feat: add cohort and enrollment intensity filters to student roster (#81) * fix: use correct DB enrollment intensity values (Full-Time/Part-Time with hyphens) --- .gitignore | 7 + ai_model/complete_ml_pipeline.py | 17 +- codebenders-dashboard/app/actions/auth.ts | 10 + .../app/api/dashboard/kpis/route.ts | 46 +- .../app/api/dashboard/readiness/route.ts | 130 ++- .../app/api/dashboard/retention-risk/route.ts | 50 +- .../app/api/dashboard/risk-alerts/route.ts | 48 +- .../app/api/query-history/export/route.ts | 81 ++ .../app/api/students/[guid]/route.ts | 66 ++ .../app/api/students/route.ts | 140 +++ .../app/auth/callback/route.ts | 18 + codebenders-dashboard/app/layout.tsx | 41 +- codebenders-dashboard/app/login/page.tsx | 101 ++ codebenders-dashboard/app/page.tsx | 128 ++- .../app/students/[guid]/page.tsx | 331 +++++++ codebenders-dashboard/app/students/page.tsx | 589 +++++++++++ .../components/nav-header.tsx | 45 + .../components/query-history-panel.tsx | 20 +- codebenders-dashboard/env.example | 5 + codebenders-dashboard/lib/roles.ts | 36 + codebenders-dashboard/lib/supabase/client.ts | 8 + .../lib/supabase/middleware-client.ts | 32 + codebenders-dashboard/lib/supabase/server.ts | 27 + codebenders-dashboard/middleware.ts | 72 ++ codebenders-dashboard/package.json | 3 + docs/README_api.md | 333 +++++++ docs/README_datageneration.md | 435 +++++++++ docs/demo/DEMO.md | 166 ++++ docs/demo/screenshots/README.md | 21 + ...2026-02-14-bishop-state-rebranding-plan.md | 895 +++++++++++++++++ ...kctcs-to-bishop-state-rebranding-design.md | 241 +++++ ...readiness-pdp-alignment-and-methodology.md | 919 ++++++++++++++++++ migrations/001_user_roles.sql | 20 + scripts/seed-demo-users.ts | 93 ++ 34 files changed, 5057 insertions(+), 117 deletions(-) create mode 100644 codebenders-dashboard/app/actions/auth.ts create mode 100644 codebenders-dashboard/app/api/query-history/export/route.ts create mode 100644 codebenders-dashboard/app/api/students/[guid]/route.ts create mode 100644 codebenders-dashboard/app/api/students/route.ts create mode 100644 codebenders-dashboard/app/auth/callback/route.ts create mode 100644 codebenders-dashboard/app/login/page.tsx create mode 100644 codebenders-dashboard/app/students/[guid]/page.tsx create mode 100644 codebenders-dashboard/app/students/page.tsx create mode 100644 codebenders-dashboard/components/nav-header.tsx create mode 100644 codebenders-dashboard/lib/roles.ts create mode 100644 codebenders-dashboard/lib/supabase/client.ts create mode 100644 codebenders-dashboard/lib/supabase/middleware-client.ts create mode 100644 codebenders-dashboard/lib/supabase/server.ts create mode 100644 codebenders-dashboard/middleware.ts create mode 100644 docs/README_api.md create mode 100644 docs/README_datageneration.md create mode 100644 docs/demo/DEMO.md create mode 100644 docs/demo/screenshots/README.md create mode 100644 docs/plans/2026-02-14-bishop-state-rebranding-plan.md create mode 100644 docs/plans/2026-02-14-kctcs-to-bishop-state-rebranding-design.md create mode 100644 docs/plans/2026-02-20-readiness-pdp-alignment-and-methodology.md create mode 100644 migrations/001_user_roles.sql create mode 100644 scripts/seed-demo-users.ts diff --git a/.gitignore b/.gitignore index a8b64cf..6fc7b59 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ Desktop.ini *.csv *.xlsx *.xls +*.pptx *.json !package.json !tsconfig.json @@ -158,6 +159,12 @@ docker-compose.override.yml # Git worktrees .worktrees/ +# Large presentation/doc files +docs/AI-Powered-Student-Success-Analytics.pptx +docs/Copy-of-AI-Powered-Student-Success-Analytics.pdf +docs/CodeBenders-PRD_Student_Success_Analytics.pdf +DOCUMENTATION_ISSUES.md + # Misc .cache/ *.seed diff --git a/ai_model/complete_ml_pipeline.py b/ai_model/complete_ml_pipeline.py index 3e66a31..acb1158 100644 --- a/ai_model/complete_ml_pipeline.py +++ b/ai_model/complete_ml_pipeline.py @@ -173,7 +173,17 @@ def assign_credential_type(row): else: return 2 # Default to Associate's (most common at community colleges) - # No credential completed + # Priority 5: No completion data — fall back to credential type sought as proxy + # (represents "what credential is this student on track for") + credential_sought = str(row.get('Credential_Type_Sought_Year_1', '')) + if credential_sought in ['01', '02', '03', 'C1', 'C2']: + return 1 # Certificate-track + elif credential_sought in ['A', '04', '05']: + return 2 # Associate-track + elif credential_sought in ['B', '06', '07', '08']: + return 3 # Bachelor-track + + # No credential completed or sought return 0 # No credential df['target_credential_type'] = df.apply(assign_credential_type, axis=1) @@ -646,6 +656,7 @@ def assign_alert_level(risk_score): n_estimators=50, max_depth=5, min_samples_split=30, + class_weight='balanced', random_state=42, n_jobs=-1 ) @@ -734,7 +745,7 @@ def assign_alert_level(risk_score): # Only include students who attempted gateway math (not NaN) gateway_math_raw = df['CompletedGatewayMathYear1'] valid_idx = gateway_math_raw.notna() -y_gateway_math = (gateway_math_raw[valid_idx] == 'C').astype(int) +y_gateway_math = (gateway_math_raw[valid_idx] == 'Y').astype(int) X_gateway_math = X_gateway_math_clean[valid_idx] print(f"\nDataset size: {len(X_gateway_math):,} students") @@ -845,7 +856,7 @@ def assign_alert_level(risk_score): # Only include students who attempted gateway English (not NaN) gateway_english_raw = df['CompletedGatewayEnglishYear1'] valid_idx = gateway_english_raw.notna() -y_gateway_english = (gateway_english_raw[valid_idx] == 'C').astype(int) +y_gateway_english = (gateway_english_raw[valid_idx] == 'Y').astype(int) X_gateway_english = X_gateway_english_clean[valid_idx] print(f"\nDataset size: {len(X_gateway_english):,} students") diff --git a/codebenders-dashboard/app/actions/auth.ts b/codebenders-dashboard/app/actions/auth.ts new file mode 100644 index 0000000..98507c4 --- /dev/null +++ b/codebenders-dashboard/app/actions/auth.ts @@ -0,0 +1,10 @@ +"use server" + +import { createClient } from "@/lib/supabase/server" +import { redirect } from "next/navigation" + +export async function signOut() { + const supabase = await createClient() + await supabase.auth.signOut() + redirect("/login") +} diff --git a/codebenders-dashboard/app/api/dashboard/kpis/route.ts b/codebenders-dashboard/app/api/dashboard/kpis/route.ts index e5b7d70..928932f 100644 --- a/codebenders-dashboard/app/api/dashboard/kpis/route.ts +++ b/codebenders-dashboard/app/api/dashboard/kpis/route.ts @@ -3,20 +3,44 @@ import { getPool } from "@/lib/db" export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url) + const cohort = searchParams.get("cohort") || "" + const enrollmentType = searchParams.get("enrollmentType") || "" + const credentialType = searchParams.get("credentialType") || "" + const pool = getPool() + const conditions: string[] = [] + const params: unknown[] = [] + + if (cohort) { + params.push(cohort) + conditions.push(`"Cohort" = $${params.length}`) + } + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`"Enrollment_Intensity_First_Term" = $${params.length}`) + } + if (credentialType) { + params.push(credentialType) + conditions.push(`predicted_credential_label = $${params.length}`) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + const sql = ` SELECT - AVG("Retention") * 100 as overall_retention_rate, - AVG(retention_probability) * 100 as avg_predicted_retention, - SUM(CASE WHEN at_risk_alert IN ('HIGH', 'URGENT') THEN 1 ELSE 0 END) as high_critical_risk_count, - AVG(course_completion_rate) * 100 as avg_course_completion_rate, - COUNT(*) as total_students + AVG("Retention") * 100 AS overall_retention_rate, + AVG(retention_probability) * 100 AS avg_predicted_retention, + SUM(CASE WHEN at_risk_alert IN ('HIGH', 'URGENT') THEN 1 ELSE 0 END) AS high_critical_risk_count, + AVG(course_completion_rate) * 100 AS avg_course_completion_rate, + COUNT(*) AS total_students FROM student_level_with_predictions + ${where} LIMIT 1 ` - const result = await pool.query(sql) + const result = await pool.query(sql, params) const kpis = result.rows[0] ?? null if (!kpis) { @@ -24,17 +48,17 @@ export async function GET(request: NextRequest) { } return NextResponse.json({ - overallRetentionRate: Number(kpis.overall_retention_rate || 0).toFixed(1), - avgPredictedRetention: Number(kpis.avg_predicted_retention || 0).toFixed(1), - highCriticalRiskCount: Number(kpis.high_critical_risk_count || 0), + overallRetentionRate: Number(kpis.overall_retention_rate || 0).toFixed(1), + avgPredictedRetention: Number(kpis.avg_predicted_retention || 0).toFixed(1), + highCriticalRiskCount: Number(kpis.high_critical_risk_count || 0), avgCourseCompletionRate: Number(kpis.avg_course_completion_rate || 0).toFixed(1), - totalStudents: Number(kpis.total_students || 0), + totalStudents: Number(kpis.total_students || 0), }) } catch (error) { console.error("KPI fetch error:", error) return NextResponse.json( { - error: "Failed to fetch KPIs", + error: "Failed to fetch KPIs", details: error instanceof Error ? error.message : String(error), }, { status: 500 } diff --git a/codebenders-dashboard/app/api/dashboard/readiness/route.ts b/codebenders-dashboard/app/api/dashboard/readiness/route.ts index 6e5c690..b06844c 100644 --- a/codebenders-dashboard/app/api/dashboard/readiness/route.ts +++ b/codebenders-dashboard/app/api/dashboard/readiness/route.ts @@ -4,31 +4,49 @@ import { getPool } from "@/lib/db" export async function GET(request: Request) { try { const { searchParams } = new URL(request.url) - const institution = searchParams.get("institution") - const cohort = searchParams.get("cohort") - const level = searchParams.get("level") // high, medium, low + const institution = searchParams.get("institution") + const cohort = searchParams.get("cohort") + const level = searchParams.get("level") // high, medium, low + const enrollmentType = searchParams.get("enrollmentType") + const credentialType = searchParams.get("credentialType") const pool = getPool() - // Build WHERE clause with $N Postgres placeholders + // Build WHERE clause with $N Postgres placeholders. + // llm_recommendations is aliased as lr; when enrollment/credential filters are + // present we JOIN student_level_with_predictions as s. const conditions: string[] = [] - const params: any[] = [] + const params: unknown[] = [] if (institution) { params.push(institution) - conditions.push(`"Institution_ID" = $${params.length}`) + conditions.push(`lr."Institution_ID" = $${params.length}`) } if (cohort) { params.push(cohort) - conditions.push(`"Cohort" = $${params.length}`) + conditions.push(`lr."Cohort" = $${params.length}`) } if (level) { params.push(level) - conditions.push(`readiness_level = $${params.length}`) + conditions.push(`lr.readiness_level = $${params.length}`) } + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`s."Enrollment_Intensity_First_Term" = $${params.length}`) + } + + if (credentialType) { + params.push(credentialType) + conditions.push(`s.predicted_credential_label = $${params.length}`) + } + + const needsJoin = !!(enrollmentType || credentialType) + const joinClause = needsJoin + ? `JOIN student_level_with_predictions s ON s."Student_GUID" = lr."Student_GUID"` + : "" const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" // Get overall statistics @@ -36,13 +54,14 @@ export async function GET(request: Request) { ` SELECT COUNT(*) as total_students, - AVG(readiness_score) as avg_score, - MIN(readiness_score) as min_score, - MAX(readiness_score) as max_score, - SUM(CASE WHEN readiness_level = 'high' THEN 1 ELSE 0 END) as high_count, - SUM(CASE WHEN readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count, - SUM(CASE WHEN readiness_level = 'low' THEN 1 ELSE 0 END) as low_count - FROM llm_recommendations + AVG(lr.readiness_score) as avg_score, + MIN(lr.readiness_score) as min_score, + MAX(lr.readiness_score) as max_score, + SUM(CASE WHEN lr.readiness_level = 'high' THEN 1 ELSE 0 END) as high_count, + SUM(CASE WHEN lr.readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count, + SUM(CASE WHEN lr.readiness_level = 'low' THEN 1 ELSE 0 END) as low_count + FROM llm_recommendations lr + ${joinClause} ${whereClause} `, params @@ -58,19 +77,20 @@ export async function GET(request: Request) { const distributionResult = await pool.query( ` SELECT - readiness_level, + lr.readiness_level, COUNT(*) as count, - AVG(readiness_score) as avg_score, - MIN(readiness_score) as min_score, - MAX(readiness_score) as max_score - FROM llm_recommendations + AVG(lr.readiness_score) as avg_score, + MIN(lr.readiness_score) as min_score, + MAX(lr.readiness_score) as max_score + FROM llm_recommendations lr + ${joinClause} ${whereClause} - GROUP BY readiness_level + GROUP BY lr.readiness_level ORDER BY - CASE readiness_level - WHEN 'high' THEN 1 + CASE lr.readiness_level + WHEN 'high' THEN 1 WHEN 'medium' THEN 2 - WHEN 'low' THEN 3 + WHEN 'low' THEN 3 END `, params @@ -81,21 +101,22 @@ export async function GET(request: Request) { ` SELECT CASE - WHEN readiness_score >= 0.8 THEN '0.8-1.0' - WHEN readiness_score >= 0.6 THEN '0.6-0.8' - WHEN readiness_score >= 0.4 THEN '0.4-0.6' - WHEN readiness_score >= 0.2 THEN '0.2-0.4' + WHEN lr.readiness_score >= 0.8 THEN '0.8-1.0' + WHEN lr.readiness_score >= 0.6 THEN '0.6-0.8' + WHEN lr.readiness_score >= 0.4 THEN '0.4-0.6' + WHEN lr.readiness_score >= 0.2 THEN '0.2-0.4' ELSE '0.0-0.2' END as score_range, COUNT(*) as count - FROM llm_recommendations + FROM llm_recommendations lr + ${joinClause} ${whereClause} GROUP BY CASE - WHEN readiness_score >= 0.8 THEN '0.8-1.0' - WHEN readiness_score >= 0.6 THEN '0.6-0.8' - WHEN readiness_score >= 0.4 THEN '0.4-0.6' - WHEN readiness_score >= 0.2 THEN '0.2-0.4' + WHEN lr.readiness_score >= 0.8 THEN '0.8-1.0' + WHEN lr.readiness_score >= 0.6 THEN '0.6-0.8' + WHEN lr.readiness_score >= 0.4 THEN '0.4-0.6' + WHEN lr.readiness_score >= 0.2 THEN '0.2-0.4' ELSE '0.0-0.2' END ORDER BY score_range DESC @@ -120,6 +141,7 @@ export async function GET(request: Request) { lr.generated_at, lr.model_name FROM llm_recommendations lr + ${joinClause} ${whereClause} ORDER BY lr.generated_at DESC LIMIT 100 @@ -130,15 +152,16 @@ export async function GET(request: Request) { // Parse JSON fields in recent assessments const assessments = recentResult.rows.map((row) => ({ ...row, - risk_factors: row.risk_factors ? JSON.parse(row.risk_factors) : [], + risk_factors: row.risk_factors ? JSON.parse(row.risk_factors) : [], suggested_actions: row.suggested_actions ? JSON.parse(row.suggested_actions) : [], })) // Get most common risk factors const riskFactorResult = await pool.query( ` - SELECT risk_factors - FROM llm_recommendations + SELECT lr.risk_factors + FROM llm_recommendations lr + ${joinClause} ${whereClause} `, params @@ -172,16 +195,17 @@ export async function GET(request: Request) { const cohortResult = await pool.query( ` SELECT - "Cohort", + lr."Cohort", COUNT(*) as total, - AVG(readiness_score) as avg_score, - SUM(CASE WHEN readiness_level = 'high' THEN 1 ELSE 0 END) as high_count, - SUM(CASE WHEN readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count, - SUM(CASE WHEN readiness_level = 'low' THEN 1 ELSE 0 END) as low_count - FROM llm_recommendations + AVG(lr.readiness_score) as avg_score, + SUM(CASE WHEN lr.readiness_level = 'high' THEN 1 ELSE 0 END) as high_count, + SUM(CASE WHEN lr.readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count, + SUM(CASE WHEN lr.readiness_level = 'low' THEN 1 ELSE 0 END) as low_count + FROM llm_recommendations lr + ${joinClause} ${whereClause} - GROUP BY "Cohort" - ORDER BY "Cohort" DESC + GROUP BY lr."Cohort" + ORDER BY lr."Cohort" DESC `, params ) @@ -191,16 +215,16 @@ export async function GET(request: Request) { data: { summary: { total_students: stats.total_students, - avg_score: parseFloat(stats.avg_score || 0).toFixed(4), - min_score: parseFloat(stats.min_score || 0).toFixed(4), - max_score: parseFloat(stats.max_score || 0).toFixed(4), - high_count: stats.high_count, - medium_count: stats.medium_count, - low_count: stats.low_count, + avg_score: parseFloat(stats.avg_score || 0).toFixed(4), + min_score: parseFloat(stats.min_score || 0).toFixed(4), + max_score: parseFloat(stats.max_score || 0).toFixed(4), + high_count: stats.high_count, + medium_count: stats.medium_count, + low_count: stats.low_count, }, - distribution: distributionResult.rows, + distribution: distributionResult.rows, score_distribution: scoreDistResult.rows, - assessments: assessments, + assessments: assessments, top_risk_factors: topRiskFactors, cohort_breakdown: cohortResult.rows, }, @@ -211,7 +235,7 @@ export async function GET(request: Request) { return NextResponse.json( { success: false, - error: "Failed to fetch readiness assessment data", + error: "Failed to fetch readiness assessment data", details: error instanceof Error ? error.message : "Unknown error", }, { status: 500 } diff --git a/codebenders-dashboard/app/api/dashboard/retention-risk/route.ts b/codebenders-dashboard/app/api/dashboard/retention-risk/route.ts index ded139b..a087bcb 100644 --- a/codebenders-dashboard/app/api/dashboard/retention-risk/route.ts +++ b/codebenders-dashboard/app/api/dashboard/retention-risk/route.ts @@ -3,27 +3,57 @@ import { getPool } from "@/lib/db" export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url) + const cohort = searchParams.get("cohort") || "" + const enrollmentType = searchParams.get("enrollmentType") || "" + const credentialType = searchParams.get("credentialType") || "" + const pool = getPool() + const conditions: string[] = [] + const params: unknown[] = [] + + if (cohort) { + params.push(cohort) + conditions.push(`"Cohort" = $${params.length}`) + } + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`"Enrollment_Intensity_First_Term" = $${params.length}`) + } + if (credentialType) { + params.push(credentialType) + conditions.push(`predicted_credential_label = $${params.length}`) + } + + const baseWhere = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + // Use a CTE so the percentage denominator is relative to the filtered set const sql = ` + WITH filtered AS ( + SELECT retention_risk_category + FROM student_level_with_predictions + ${baseWhere} + ), + total AS (SELECT COUNT(*) AS n FROM filtered) SELECT - retention_risk_category as category, - COUNT(*) as count, - ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM student_level_with_predictions), 1) as percentage - FROM student_level_with_predictions + retention_risk_category AS category, + COUNT(*) AS count, + ROUND(COUNT(*) * 100.0 / NULLIF((SELECT n FROM total), 0), 1) AS percentage + FROM filtered WHERE retention_risk_category IS NOT NULL GROUP BY retention_risk_category ORDER BY CASE retention_risk_category - WHEN 'Critical Risk' THEN 1 - WHEN 'High Risk' THEN 2 - WHEN 'Moderate Risk' THEN 3 - WHEN 'Low Risk' THEN 4 + WHEN 'Critical Risk' THEN 1 + WHEN 'High Risk' THEN 2 + WHEN 'Moderate Risk' THEN 3 + WHEN 'Low Risk' THEN 4 ELSE 5 END ` - const result = await pool.query(sql) + const result = await pool.query(sql, params) return NextResponse.json({ data: result.rows, @@ -32,7 +62,7 @@ export async function GET(request: NextRequest) { console.error("Retention risk fetch error:", error) return NextResponse.json( { - error: "Failed to fetch retention risk data", + error: "Failed to fetch retention risk data", details: error instanceof Error ? error.message : String(error), }, { status: 500 } diff --git a/codebenders-dashboard/app/api/dashboard/risk-alerts/route.ts b/codebenders-dashboard/app/api/dashboard/risk-alerts/route.ts index 544e5db..51e5a4f 100644 --- a/codebenders-dashboard/app/api/dashboard/risk-alerts/route.ts +++ b/codebenders-dashboard/app/api/dashboard/risk-alerts/route.ts @@ -3,27 +3,57 @@ import { getPool } from "@/lib/db" export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url) + const cohort = searchParams.get("cohort") || "" + const enrollmentType = searchParams.get("enrollmentType") || "" + const credentialType = searchParams.get("credentialType") || "" + const pool = getPool() + const conditions: string[] = [] + const params: unknown[] = [] + + if (cohort) { + params.push(cohort) + conditions.push(`"Cohort" = $${params.length}`) + } + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`"Enrollment_Intensity_First_Term" = $${params.length}`) + } + if (credentialType) { + params.push(credentialType) + conditions.push(`predicted_credential_label = $${params.length}`) + } + + const baseWhere = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + // Use a CTE so the percentage denominator is relative to the filtered set const sql = ` + WITH filtered AS ( + SELECT at_risk_alert + FROM student_level_with_predictions + ${baseWhere} + ), + total AS (SELECT COUNT(*) AS n FROM filtered) SELECT - at_risk_alert as category, - COUNT(*) as count, - ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM student_level_with_predictions), 1) as percentage - FROM student_level_with_predictions + at_risk_alert AS category, + COUNT(*) AS count, + ROUND(COUNT(*) * 100.0 / NULLIF((SELECT n FROM total), 0), 1) AS percentage + FROM filtered WHERE at_risk_alert IS NOT NULL GROUP BY at_risk_alert ORDER BY CASE at_risk_alert - WHEN 'URGENT' THEN 1 - WHEN 'HIGH' THEN 2 + WHEN 'URGENT' THEN 1 + WHEN 'HIGH' THEN 2 WHEN 'MODERATE' THEN 3 - WHEN 'LOW' THEN 4 + WHEN 'LOW' THEN 4 ELSE 5 END ` - const result = await pool.query(sql) + const result = await pool.query(sql, params) return NextResponse.json({ data: result.rows, @@ -32,7 +62,7 @@ export async function GET(request: NextRequest) { console.error("Risk alerts fetch error:", error) return NextResponse.json( { - error: "Failed to fetch risk alerts", + error: "Failed to fetch risk alerts", details: error instanceof Error ? error.message : String(error), }, { status: 500 } diff --git a/codebenders-dashboard/app/api/query-history/export/route.ts b/codebenders-dashboard/app/api/query-history/export/route.ts new file mode 100644 index 0000000..27b503f --- /dev/null +++ b/codebenders-dashboard/app/api/query-history/export/route.ts @@ -0,0 +1,81 @@ +import { type NextRequest, NextResponse } from "next/server" +import { readFile } from "fs/promises" +import path from "path" +import { canAccess, type Role } from "@/lib/roles" + +const LOG_FILE = path.join(process.cwd(), "logs", "query-history.jsonl") + +function escapeCsvField(value: unknown): string { + const str = String(value ?? "") + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/query-history/export", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + const fromParam = searchParams.get("from") + const toParam = searchParams.get("to") + + const fromDate = fromParam ? new Date(fromParam) : null + const toDate = toParam ? new Date(toParam) : null + + let raw: string + try { + raw = await readFile(LOG_FILE, "utf-8") + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code + if (code === "ENOENT") { + return NextResponse.json({ error: "Audit log does not exist yet" }, { status: 404 }) + } + return NextResponse.json({ error: "Failed to read audit log" }, { status: 500 }) + } + + const lines = raw.split("\n").filter(Boolean) + + const rows: string[] = [ + ["timestamp", "institution", "prompt", "vizType", "rowCount"].join(","), + ] + + for (const line of lines) { + let entry: Record + try { + entry = JSON.parse(line) + } catch { + continue + } + + // Date-range filter + if (fromDate || toDate) { + const ts = new Date(entry.timestamp as string) + if (fromDate && ts < fromDate) continue + if (toDate && ts > toDate) continue + } + + rows.push( + [ + escapeCsvField(entry.timestamp), + escapeCsvField(entry.institution), + escapeCsvField(entry.prompt), + escapeCsvField(entry.vizType), + escapeCsvField(entry.rowCount), + ].join(",") + ) + } + + const csv = rows.join("\n") + + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="query-audit-log.csv"', + }, + }) +} diff --git a/codebenders-dashboard/app/api/students/[guid]/route.ts b/codebenders-dashboard/app/api/students/[guid]/route.ts new file mode 100644 index 0000000..f1ff2d4 --- /dev/null +++ b/codebenders-dashboard/app/api/students/[guid]/route.ts @@ -0,0 +1,66 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guid: string }> } +) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/students", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { guid } = await params + + if (!guid) { + return NextResponse.json({ error: "Missing student GUID" }, { status: 400 }) + } + + const sql = ` + SELECT + s."Student_GUID" AS student_guid, + s."Cohort" AS cohort, + s."Enrollment_Intensity_First_Term" AS enrollment_intensity, + s.at_risk_alert, + ROUND((s.retention_probability * 100)::numeric, 1) AS retention_pct, + ROUND((s.gateway_math_probability * 100)::numeric, 1) AS gateway_math_pct, + ROUND((s.gateway_english_probability * 100)::numeric, 1) AS gateway_english_pct, + ROUND((s.low_gpa_probability * 100)::numeric, 1) AS gpa_risk_pct, + ROUND(s.predicted_time_to_credential::numeric, 1) AS time_to_credential, + s.predicted_credential_label AS credential_type, + ROUND((r.readiness_score * 100)::numeric, 1) AS readiness_pct, + r.readiness_level, + r.rationale, + r.risk_factors, + r.suggested_actions, + r.generated_at, + r.model_name + FROM student_level_with_predictions s + LEFT JOIN llm_recommendations r ON r."Student_GUID" = s."Student_GUID" + WHERE s."Student_GUID" = $1 + LIMIT 1 + ` + + try { + const pool = getPool() + const result = await pool.query(sql, [guid]) + + if (result.rows.length === 0) { + return NextResponse.json({ error: "Student not found" }, { status: 404 }) + } + + const row = result.rows[0] + return NextResponse.json({ + ...row, + risk_factors: row.risk_factors ? JSON.parse(row.risk_factors) : [], + suggested_actions: row.suggested_actions ? JSON.parse(row.suggested_actions) : [], + }) + } catch (error) { + console.error("Student detail fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch student", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/api/students/route.ts b/codebenders-dashboard/app/api/students/route.ts new file mode 100644 index 0000000..b68b588 --- /dev/null +++ b/codebenders-dashboard/app/api/students/route.ts @@ -0,0 +1,140 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/students", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + + const page = Math.max(1, Number(searchParams.get("page") || 1)) + const pageSize = Math.min(100, Math.max(1, Number(searchParams.get("pageSize") || 50))) + const offset = (page - 1) * pageSize + + const search = searchParams.get("search") || "" + const alertLevels = searchParams.get("alertLevel") || "" // comma-separated + const readinessTier = searchParams.get("readinessTier") || "" + const credentialType = searchParams.get("credentialType") || "" + const cohort = searchParams.get("cohort") || "" + const enrollmentType = searchParams.get("enrollmentType") || "" + const sortBy = searchParams.get("sortBy") || "at_risk_alert" + const sortDir = searchParams.get("sortDir") === "asc" ? "ASC" : "DESC" + + // Whitelist sortable columns to prevent injection + const SORT_COLS: Record = { + at_risk_alert: "s.at_risk_alert", + retention_probability: "s.retention_probability", + readiness_score: "r.readiness_score", + gateway_math_probability: "s.gateway_math_probability", + gateway_english_probability: "s.gateway_english_probability", + low_gpa_probability: "s.low_gpa_probability", + predicted_time_to_credential: "s.predicted_time_to_credential", + "Cohort": `s."Cohort"`, + enrollment_intensity: `s."Enrollment_Intensity_First_Term"`, + credential_type: "s.predicted_credential_label", + } + const orderExpr = SORT_COLS[sortBy] ?? "s.at_risk_alert" + + // Risk order for default sort (URGENT first) + const riskOrder = sortBy === "at_risk_alert" + ? `CASE s.at_risk_alert WHEN 'URGENT' THEN 0 WHEN 'HIGH' THEN 1 WHEN 'MODERATE' THEN 2 WHEN 'LOW' THEN 3 ELSE 4 END` + : null + + const conditions: string[] = [] + const params: unknown[] = [] + + if (search) { + params.push(`%${search}%`) + conditions.push(`s."Student_GUID" ILIKE $${params.length}`) + } + + if (alertLevels) { + const levels = alertLevels.split(",").filter(Boolean) + if (levels.length > 0) { + const placeholders = levels.map((_, i) => `$${params.length + i + 1}`).join(", ") + params.push(...levels) + conditions.push(`s.at_risk_alert IN (${placeholders})`) + } + } + + if (readinessTier) { + params.push(readinessTier) + conditions.push(`r.readiness_level = $${params.length}`) + } + + if (credentialType) { + params.push(credentialType) + conditions.push(`s.predicted_credential_label = $${params.length}`) + } + + if (cohort) { + params.push(cohort) + conditions.push(`s."Cohort" = $${params.length}`) + } + + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`s."Enrollment_Intensity_First_Term" = $${params.length}`) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + const orderClause = riskOrder + ? `ORDER BY ${riskOrder} ${sortDir}, s.retention_probability DESC` + : `ORDER BY ${orderExpr} ${sortDir}` + + const dataSql = ` + SELECT + s."Student_GUID" AS student_guid, + s."Cohort" AS cohort, + s."Enrollment_Intensity_First_Term" AS enrollment_intensity, + s.at_risk_alert, + ROUND((s.retention_probability * 100)::numeric, 1) AS retention_pct, + ROUND((r.readiness_score * 100)::numeric, 1) AS readiness_pct, + r.readiness_level, + ROUND((s.gateway_math_probability * 100)::numeric, 1) AS gateway_math_pct, + ROUND((s.gateway_english_probability * 100)::numeric, 1) AS gateway_english_pct, + ROUND((s.low_gpa_probability * 100)::numeric, 1) AS gpa_risk_pct, + ROUND(s.predicted_time_to_credential::numeric, 1) AS time_to_credential, + s.predicted_credential_label AS credential_type + FROM student_level_with_predictions s + LEFT JOIN llm_recommendations r ON r."Student_GUID" = s."Student_GUID" + ${where} + ${orderClause} + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + ` + + const countSql = ` + SELECT COUNT(*) AS total + FROM student_level_with_predictions s + LEFT JOIN llm_recommendations r ON r."Student_GUID" = s."Student_GUID" + ${where} + ` + + try { + const pool = getPool() + const [dataResult, countResult] = await Promise.all([ + pool.query(dataSql, [...params, pageSize, offset]), + pool.query(countSql, params), + ]) + + const total = Number(countResult.rows[0]?.total ?? 0) + + return NextResponse.json({ + students: dataResult.rows, + total, + page, + pageSize, + pageCount: Math.ceil(total / pageSize), + }) + } catch (error) { + console.error("Students fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch students", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/auth/callback/route.ts b/codebenders-dashboard/app/auth/callback/route.ts new file mode 100644 index 0000000..956fd02 --- /dev/null +++ b/codebenders-dashboard/app/auth/callback/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from "next/server" +import { createClient } from "@/lib/supabase/server" + +export async function GET(request: NextRequest) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get("code") + const next = searchParams.get("next") ?? "/" + + if (code) { + const supabase = await createClient() + const { error } = await supabase.auth.exchangeCodeForSession(code) + if (!error) { + return NextResponse.redirect(`${origin}${next}`) + } + } + + return NextResponse.redirect(`${origin}/login?error=auth_callback_failed`) +} diff --git a/codebenders-dashboard/app/layout.tsx b/codebenders-dashboard/app/layout.tsx index 4692fb4..91075a1 100644 --- a/codebenders-dashboard/app/layout.tsx +++ b/codebenders-dashboard/app/layout.tsx @@ -1,34 +1,51 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import type { Metadata } from "next" +import { Geist, Geist_Mono } from "next/font/google" +import "./globals.css" +import { NavHeader } from "@/components/nav-header" +import { createClient } from "@/lib/supabase/server" +import type { Role } from "@/lib/roles" const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], -}); +}) const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], -}); +}) export const metadata: Metadata = { title: "Bishop State Student Success Dashboard", description: "AI-Powered Student Success Analytics & Predictive Models for Bishop State Community College", -}; +} -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + let role: Role | null = null + if (user) { + const { data } = await supabase + .from("user_roles") + .select("role") + .eq("user_id", user.id) + .single() + role = (data?.role ?? "leadership") as Role + } + return ( - + + {user && role && ( + + )} {children} - ); + ) } diff --git a/codebenders-dashboard/app/login/page.tsx b/codebenders-dashboard/app/login/page.tsx new file mode 100644 index 0000000..6854ff6 --- /dev/null +++ b/codebenders-dashboard/app/login/page.tsx @@ -0,0 +1,101 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { createClient } from "@/lib/supabase/client" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { GraduationCap } from "lucide-react" + +export default function LoginPage() { + const router = useRouter() + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError(null) + + const supabase = createClient() + const { error } = await supabase.auth.signInWithPassword({ email, password }) + + if (error) { + setError(error.message) + setLoading(false) + return + } + + router.refresh() + router.push("/") + } + + return ( +
+
+ + {/* Branding */} +
+
+ + Bishop State +
+

+ Student Success Analytics +

+
+ + {/* Login form */} +
+

Sign in

+ + {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ + +
+ +

+ Contact your administrator if you need access. +

+
+
+ ) +} diff --git a/codebenders-dashboard/app/page.tsx b/codebenders-dashboard/app/page.tsx index 6ec4fc5..9fd84a5 100644 --- a/codebenders-dashboard/app/page.tsx +++ b/codebenders-dashboard/app/page.tsx @@ -7,7 +7,14 @@ import { RetentionRiskChart } from "@/components/retention-risk-chart" import { ReadinessAssessmentChart } from "@/components/readiness-assessment-chart" import { ExportButton } from "@/components/export-button" import { Button } from "@/components/ui/button" -import { TrendingUp, Users, AlertTriangle, BookOpen, Search } from "lucide-react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { TrendingUp, Users, AlertTriangle, BookOpen, Search, Table2, X } from "lucide-react" import Link from "next/link" interface KPIData { @@ -47,6 +54,10 @@ interface ReadinessData { cohort_breakdown: any[] } +const COHORTS = ["2019-20", "2020-21", "2021-22", "2022-23", "2023-24"] +const ENROLLMENT_TYPES = ["Full-Time", "Part-Time"] +const CREDENTIAL_TYPES = ["Certificate", "Associate", "Bachelor"] + export default function DashboardPage() { const [kpis, setKpis] = useState(null) const [riskAlerts, setRiskAlerts] = useState([]) @@ -57,17 +68,34 @@ export default function DashboardPage() { const [error, setError] = useState(null) const [readinessError, setReadinessError] = useState(null) + // Filter state + const [cohort, setCohort] = useState("") + const [enrollmentType, setEnrollmentType] = useState("") + const [credentialType, setCredentialType] = useState("") + + const hasFilters = !!(cohort || enrollmentType || credentialType) + + function buildFilterParams() { + const p = new URLSearchParams() + if (cohort) p.set("cohort", cohort) + if (enrollmentType) p.set("enrollmentType", enrollmentType) + if (credentialType) p.set("credentialType", credentialType) + const qs = p.toString() + return qs ? `?${qs}` : "" + } + useEffect(() => { + const qs = buildFilterParams() + const fetchDashboardData = async () => { try { setLoading(true) setError(null) - // Fetch all data in parallel const [kpisRes, riskAlertsRes, retentionRiskRes] = await Promise.all([ - fetch("/api/dashboard/kpis"), - fetch("/api/dashboard/risk-alerts"), - fetch("/api/dashboard/retention-risk"), + fetch(`/api/dashboard/kpis${qs}`), + fetch(`/api/dashboard/risk-alerts${qs}`), + fetch(`/api/dashboard/retention-risk${qs}`), ]) if (!kpisRes.ok || !riskAlertsRes.ok || !retentionRiskRes.ok) { @@ -96,14 +124,14 @@ export default function DashboardPage() { setReadinessLoading(true) setReadinessError(null) - const response = await fetch("/api/dashboard/readiness") - + const response = await fetch(`/api/dashboard/readiness${qs}`) + if (!response.ok) { throw new Error("Failed to fetch readiness assessment data") } const result = await response.json() - + if (result.success) { setReadinessData(result.data ?? null) } else { @@ -119,7 +147,8 @@ export default function DashboardPage() { fetchDashboardData() fetchReadinessData() - }, []) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cohort, enrollmentType, credentialType]) return (
@@ -135,11 +164,11 @@ export default function DashboardPage() {

- @@ -149,6 +178,12 @@ export default function DashboardPage() { Methodology + + +
+ {/* Filter Bar */} +
+ Filter by: + + + + + + + + {hasFilters && ( + + )} + + {hasFilters && ( + + Showing filtered results + {kpis ? ` · ${kpis.totalStudents.toLocaleString()} students` : ""} + + )} +
+ {/* Error State */} {error && (
@@ -250,8 +346,8 @@ export default function DashboardPage() { {/* Charts */}
- @@ -275,8 +371,8 @@ export default function DashboardPage() { } /> - @@ -305,7 +401,7 @@ export default function DashboardPage() { AI-powered analysis identifying student preparation levels and intervention needs

- = { + URGENT: "bg-red-100 text-red-800 border-red-200", + HIGH: "bg-orange-100 text-orange-800 border-orange-200", + MODERATE: "bg-yellow-100 text-yellow-800 border-yellow-200", + LOW: "bg-green-100 text-green-800 border-green-200", +} + +const READINESS_COLORS: Record = { + high: "bg-green-100 text-green-800 border-green-200", + medium: "bg-yellow-100 text-yellow-800 border-yellow-200", + low: "bg-red-100 text-red-800 border-red-200", +} + +function Badge({ label, colorClass }: { label: string; colorClass: string }) { + return ( + + {label} + + ) +} + +function PredictionCard({ + title, + value, + subtitle, + colorClass, +}: { + title: string + value: string + subtitle?: string + colorClass: string +}) { + return ( +
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+ ) +} + +function pctColor(value: string | null, invert = false): string { + if (!value) return "" + const n = parseFloat(value) + if (isNaN(n)) return "" + if (invert) return n >= 60 ? "text-red-600" : n >= 30 ? "text-yellow-600" : "text-green-600" + return n >= 60 ? "text-green-600" : n >= 30 ? "text-yellow-600" : "text-red-600" +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function StudentDetailPage() { + const { guid } = useParams<{ guid: string }>() + const router = useRouter() + + const [student, setStudent] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!guid) return + setLoading(true) + fetch(`/api/students/${encodeURIComponent(guid)}`) + .then(r => { + if (r.status === 404) throw new Error("Student not found") + if (!r.ok) throw new Error("Failed to load student data") + return r.json() + }) + .then(d => { setStudent(d); setLoading(false) }) + .catch(e => { setError(e.message); setLoading(false) }) + }, [guid]) + + // ─── Loading skeleton ──────────────────────────────────────────────────── + + if (loading) { + return ( +
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+
+
+ ) + } + + // ─── Error state ───────────────────────────────────────────────────────── + + if (error || !student) { + return ( +
+
+ +
+ {error ?? "Student not found"} +
+
+
+ ) + } + + const alertLevel = student.at_risk_alert ?? "LOW" + const readinessLevel = student.readiness_level ?? "" + + return ( +
+
+ + {/* Back nav */} + + + {/* Student header */} +
+
+
+

Student Identifier

+

+ {student.student_guid} +

+
+ Cohort {student.cohort ?? "—"} + · + {student.enrollment_intensity ?? "—"} + {student.credential_type && ( + <> + · + Expected: {student.credential_type} + + )} + {student.time_to_credential && ( + <> + · + ~{student.time_to_credential} yr to credential + + )} +
+
+
+ {student.at_risk_alert && ( + + )} + {student.readiness_level && ( + + )} +
+
+ + {/* FERPA notice */} +
+ + + Data displayed is limited to de-identified academic indicators per FERPA guidelines. + No personally identifiable information (name, SSN, address, or date of birth) is stored + in this system. The Student Identifier above is an anonymised GUID. + +
+
+ + {/* Prediction score cards */} +
+

Prediction Scores

+
+ + + + + + +
+
+ + {/* AI Readiness Assessment */} + {(student.rationale || student.risk_factors.length > 0 || student.suggested_actions.length > 0) && ( + + +
+ AI Readiness Assessment + {student.model_name && ( + {student.model_name} + )} +
+ {student.generated_at && ( +

+ Generated {new Date(student.generated_at).toLocaleDateString()} +

+ )} +
+ + + {/* Rationale */} + {student.rationale && ( +
+

+ Assessment Rationale +

+

{student.rationale}

+
+ )} + + {/* Risk factors */} + {student.risk_factors.length > 0 && ( +
+

+ Risk Factors +

+
    + {student.risk_factors.map((factor, i) => ( +
  • + + {factor} +
  • + ))} +
+
+ )} + + {/* Suggested actions */} + {student.suggested_actions.length > 0 && ( +
+

+ Recommended Actions +

+
    + {student.suggested_actions.map((action, i) => ( +
  • + + action + + {action} +
  • + ))} +
+
+ )} + +
+
+ )} + + {/* No AI assessment yet */} + {!student.rationale && student.risk_factors.length === 0 && student.suggested_actions.length === 0 && ( + + + No AI readiness assessment has been generated for this student yet. + + + )} + +
+
+ ) +} diff --git a/codebenders-dashboard/app/students/page.tsx b/codebenders-dashboard/app/students/page.tsx new file mode 100644 index 0000000..c987c75 --- /dev/null +++ b/codebenders-dashboard/app/students/page.tsx @@ -0,0 +1,589 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { ArrowLeft, ArrowUpDown, ArrowUp, ArrowDown, Download, Search, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { InfoPopover } from "@/components/info-popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface StudentRow { + student_guid: string + cohort: string + enrollment_intensity: string + at_risk_alert: "URGENT" | "HIGH" | "MODERATE" | "LOW" | null + retention_pct: string | null + readiness_pct: string | null + readiness_level: "High" | "Medium" | "Low" | null + gateway_math_pct: string | null + gateway_english_pct: string | null + gpa_risk_pct: string | null + time_to_credential: string | null + credential_type: string | null +} + +interface StudentsResponse { + students: StudentRow[] + total: number + page: number + pageSize: number + pageCount: number +} + +type SortKey = + | "at_risk_alert" + | "retention_probability" + | "readiness_score" + | "gateway_math_probability" + | "gateway_english_probability" + | "low_gpa_probability" + | "predicted_time_to_credential" + | "Cohort" + | "enrollment_intensity" + | "credential_type" + +const ALERT_LEVELS = ["URGENT", "HIGH", "MODERATE", "LOW"] as const +const READINESS_TIERS = ["High", "Medium", "Low"] as const +const CREDENTIAL_TYPES = ["Associate", "Certificate", "Bachelor"] as const +const COHORTS = ["2019-20", "2020-21", "2021-22", "2022-23", "2023-24"] as const +const ENROLLMENT_TYPES = [ + { value: "Full-Time", label: "Full-time" }, + { value: "Part-Time", label: "Part-time" }, +] as const + +// ─── Badge helpers ──────────────────────────────────────────────────────────── + +function AlertBadge({ level }: { level: StudentRow["at_risk_alert"] }) { + if (!level) return + const colors: Record = { + URGENT: "bg-red-100 text-red-800 border-red-200", + HIGH: "bg-orange-100 text-orange-800 border-orange-200", + MODERATE: "bg-yellow-100 text-yellow-800 border-yellow-200", + LOW: "bg-green-100 text-green-800 border-green-200", + } + return ( + + {level} + + ) +} + +function ReadinessBadge({ level }: { level: StudentRow["readiness_level"] }) { + if (!level) return + const colors: Record = { + High: "bg-green-100 text-green-800 border-green-200", + Medium: "bg-yellow-100 text-yellow-800 border-yellow-200", + Low: "bg-red-100 text-red-800 border-red-200", + } + return ( + + {level} + + ) +} + +function Pct({ value, invert = false }: { value: string | null; invert?: boolean }) { + if (value === null || value === undefined) return + const n = parseFloat(value) + let color = "" + if (!isNaN(n)) { + if (invert) { + color = n >= 60 ? "text-red-600" : n >= 30 ? "text-yellow-600" : "text-green-600" + } else { + color = n >= 60 ? "text-green-600" : n >= 30 ? "text-yellow-600" : "text-red-600" + } + } + return {value}% +} + +// ─── Sort icon ──────────────────────────────────────────────────────────────── + +function SortIcon({ active, dir }: { active: boolean; dir: "asc" | "desc" }) { + if (!active) return + return dir === "asc" + ? + : +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function StudentsPage() { + const router = useRouter() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Filters + const [search, setSearch] = useState("") + const [alertLevels, setAlertLevels] = useState([]) + const [readinessTier, setReadinessTier] = useState("") + const [credentialType, setCredentialType] = useState("") + const [cohort, setCohort] = useState("") + const [enrollmentType, setEnrollmentType] = useState("") + + // Sort + const [sortBy, setSortBy] = useState("at_risk_alert") + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc") + + // Pagination + const [page, setPage] = useState(1) + + // Debounce search + const searchTimer = useRef | null>(null) + + const buildParams = useCallback(() => { + const p = new URLSearchParams() + p.set("page", String(page)) + p.set("pageSize", "50") + if (search) p.set("search", search) + if (alertLevels.length) p.set("alertLevel", alertLevels.join(",")) + if (readinessTier) p.set("readinessTier", readinessTier) + if (credentialType) p.set("credentialType", credentialType) + if (cohort) p.set("cohort", cohort) + if (enrollmentType) p.set("enrollmentType", enrollmentType) + p.set("sortBy", sortBy) + p.set("sortDir", sortDir) + return p.toString() + }, [page, search, alertLevels, readinessTier, credentialType, cohort, enrollmentType, sortBy, sortDir]) + + useEffect(() => { + setLoading(true) + setError(null) + fetch(`/api/students?${buildParams()}`) + .then(r => r.json()) + .then(d => { setData(d); setLoading(false) }) + .catch(e => { setError(e.message); setLoading(false) }) + }, [buildParams]) + + // Reset to page 1 when filters change + const resetPage = () => setPage(1) + + function toggleAlertLevel(level: string) { + setAlertLevels(prev => + prev.includes(level) ? prev.filter(l => l !== level) : [...prev, level] + ) + resetPage() + } + + function handleSort(col: SortKey) { + if (col === sortBy) { + setSortDir(d => d === "asc" ? "desc" : "asc") + } else { + setSortBy(col) + setSortDir("desc") + } + resetPage() + } + + function handleSearchChange(value: string) { + setSearch(value) + if (searchTimer.current) clearTimeout(searchTimer.current) + searchTimer.current = setTimeout(resetPage, 400) + } + + function clearFilters() { + setSearch("") + setAlertLevels([]) + setReadinessTier("") + setCredentialType("") + setCohort("") + setEnrollmentType("") + resetPage() + } + + const hasFilters = search || alertLevels.length > 0 || readinessTier || credentialType || cohort || enrollmentType + + // CSV export of current filtered view (all pages) + async function exportCSV() { + const p = new URLSearchParams(buildParams()) + p.set("page", "1") + p.set("pageSize", "5000") + const res = await fetch(`/api/students?${p.toString()}`) + const json: StudentsResponse = await res.json() + const rows = json.students + if (!rows?.length) return + + const headers = [ + "Student GUID","Cohort","Enrollment","At-Risk Alert","Retention %", + "Readiness %","Readiness Tier","Gateway Math %","Gateway English %", + "GPA Risk %","Time to Credential","Credential Type", + ] + const lines = [ + headers.join(","), + ...rows.map(r => [ + r.student_guid, r.cohort, r.enrollment_intensity ?? "", + r.at_risk_alert ?? "", r.retention_pct ?? "", + r.readiness_pct ?? "", r.readiness_level ?? "", + r.gateway_math_pct ?? "", r.gateway_english_pct ?? "", + r.gpa_risk_pct ?? "", r.time_to_credential ?? "", + r.credential_type ?? "", + ].map(v => `"${String(v).replace(/"/g, '""')}"`).join(",")), + ] + const blob = new Blob([lines.join("\n")], { type: "text/csv" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = "bishop-state-students.csv" + a.click() + URL.revokeObjectURL(url) + } + + const students = data?.students ?? [] + const total = data?.total ?? 0 + const pageCount = data?.pageCount ?? 1 + + // ─── Render ─────────────────────────────────────────────────────────────── + + return ( +
+
+ + {/* Header */} +
+
+ + + +
+

Student Roster

+

+ Bishop State Community College — all prediction scores +

+
+
+ +
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + handleSearchChange(e.target.value)} + /> +
+ + {/* Readiness tier */} +
+ +
+ + {/* Credential type */} +
+ +
+ + {/* Cohort */} +
+ +
+ + {/* Enrollment intensity */} +
+ +
+ + {hasFilters && ( + + )} +
+ + {/* Alert level chips */} +
+ At-risk level: + {ALERT_LEVELS.map(level => { + const active = alertLevels.includes(level) + const chipColors: Record = { + URGENT: active ? "bg-red-100 border-red-400 text-red-800" : "border-border text-muted-foreground", + HIGH: active ? "bg-orange-100 border-orange-400 text-orange-800" : "border-border text-muted-foreground", + MODERATE: active ? "bg-yellow-100 border-yellow-400 text-yellow-800" : "border-border text-muted-foreground", + LOW: active ? "bg-green-100 border-green-400 text-green-800" : "border-border text-muted-foreground", + } + return ( + + ) + })} +
+
+ + {/* Results count */} +
+

+ {loading ? "Loading…" : `${total.toLocaleString()} student${total !== 1 ? "s" : ""}`} + {hasFilters ? " (filtered)" : ""} +

+ {pageCount > 1 && ( +
+ + + Page {page} of {pageCount} + + +
+ )} +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Table */} +
+ + + + + + + {loading ? ( + Array.from({ length: 10 }).map((_, i) => ( + + {Array.from({ length: 11 }).map((__, j) => ( + + ))} + + )) + ) : students.length === 0 ? ( + + + + ) : ( + students.map(s => ( + router.push(`/students/${encodeURIComponent(s.student_guid)}`)} + > + + + + + + + + + + + + + )) + )} + +
+ + +

Composite risk classification: URGENT — multiple high-risk signals, immediate outreach needed. HIGH — significant risk indicators. MODERATE — some risk factors present. LOW — on track. Based on retention probability, GPA risk, and gateway course signals.

} + /> +

Predicted probability (0–100%) that this student will re-enroll next academic year. Produced by a Logistic Regression model trained on 31 features. Academic placement levels account for 75% of predictive power.

} + /> +

PDP-aligned composite score: Academic (40%) + Engagement (30%) + ML Risk (30%). High ≥ 65%, Medium 40–64%, Low < 40%. See the Methodology page for the full formula and worked examples.

} + /> +

Predicted probability (0–100%) that the student will pass their gateway math course in Year 1. Produced by an XGBoost model. Students with low math placement scores or part-time enrollment tend to score lower.

} + /> +

Predicted probability (0–100%) that the student will pass their gateway English course in Year 1. Produced by an XGBoost model. Strong predictor of first-year persistence and long-term retention.

} + /> +

Predicted probability (0–100%) that the student will end their first semester with a GPA below 2.0. Higher values indicate higher academic risk. Produced by an XGBoost model. Color-coded red when high.

} + /> +

Estimated years from initial enrollment to credential completion, predicted by a Random Forest Regressor. Part-time students and those needing remediation typically show longer timelines.

} + /> +

Most likely credential this student will earn: Certificate, Associate, or Bachelor. Predicted by a Random Forest Classifier trained on program of study, enrollment intensity, academic preparation, and the credential the student is pursuing. Students without a completion record are classified based on their declared credential goal.

} + /> +
+
+
+ No students match the current filters. +
+ e.stopPropagation()} + > + {s.student_guid ? s.student_guid.slice(0, 12) + "…" : "—"} + + {s.cohort ?? "—"} + {s.enrollment_intensity === "Full-Time" || s.enrollment_intensity === "Full Time" || s.enrollment_intensity === "FT" + ? "Full-time" + : s.enrollment_intensity === "Part-Time" || s.enrollment_intensity === "Part Time" || s.enrollment_intensity === "PT" + ? "Part-time" + : s.enrollment_intensity ?? "—"} + +
+ {s.readiness_pct !== null && ( + {s.readiness_pct}% + )} + +
+
+ {s.time_to_credential ? `${s.time_to_credential} yr` : "—"} + {s.credential_type ?? "—"}
+
+ + {/* Bottom pagination */} + {pageCount > 1 && !loading && ( +
+ + Page {page} of {pageCount} + +
+ )} +
+
+ ) +} + +// ─── Table header helpers ───────────────────────────────────────────────────── + +function Th({ label, info }: { label: string; info?: React.ReactNode }) { + return ( + + + {label} + {info} + + + ) +} + +function ThSort({ + label, col, sortBy, sortDir, onSort, info, +}: { + label: string + col: SortKey + sortBy: SortKey + sortDir: "asc" | "desc" + onSort: (col: SortKey) => void + info?: React.ReactNode +}) { + return ( + + + + {info} + + + ) +} diff --git a/codebenders-dashboard/components/nav-header.tsx b/codebenders-dashboard/components/nav-header.tsx new file mode 100644 index 0000000..aa3ea14 --- /dev/null +++ b/codebenders-dashboard/components/nav-header.tsx @@ -0,0 +1,45 @@ +"use client" + +import { GraduationCap, LogOut } from "lucide-react" +import { Button } from "@/components/ui/button" +import { signOut } from "@/app/actions/auth" +import { ROLE_COLORS, ROLE_LABELS, type Role } from "@/lib/roles" + +interface NavHeaderProps { + email: string + role: Role +} + +export function NavHeader({ email, role }: NavHeaderProps) { + return ( +
+
+ + {/* Brand */} +
+ + Bishop State SSA +
+ + {/* Right side: role badge + email + logout */} +
+ + {ROLE_LABELS[role]} + + + {email} + +
+ +
+
+ +
+
+ ) +} diff --git a/codebenders-dashboard/components/query-history-panel.tsx b/codebenders-dashboard/components/query-history-panel.tsx index a443b5f..1347f66 100644 --- a/codebenders-dashboard/components/query-history-panel.tsx +++ b/codebenders-dashboard/components/query-history-panel.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" +import { Download } from "lucide-react" import type { HistoryEntry } from "@/lib/types" function relativeTime(isoTimestamp: string): string { @@ -42,9 +43,22 @@ export function QueryHistoryPanel({ entries, onRerun, onClear }: QueryHistoryPan Recent Queries - +
+ + +
    diff --git a/codebenders-dashboard/env.example b/codebenders-dashboard/env.example index 459ffe4..404d66a 100644 --- a/codebenders-dashboard/env.example +++ b/codebenders-dashboard/env.example @@ -10,3 +10,8 @@ DB_SSL=false # OpenAI Configuration (for AI-powered query generation) OPENAI_API_KEY=your-openai-api-key-here + +# Supabase Auth (required for RBAC) +# Find these in Supabase → Project Settings → API +NEXT_PUBLIC_SUPABASE_URL=https://.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here diff --git a/codebenders-dashboard/lib/roles.ts b/codebenders-dashboard/lib/roles.ts new file mode 100644 index 0000000..eef2bf5 --- /dev/null +++ b/codebenders-dashboard/lib/roles.ts @@ -0,0 +1,36 @@ +export type Role = "admin" | "advisor" | "ir" | "faculty" | "leadership" + +export const ALL_ROLES: Role[] = ["admin", "advisor", "ir", "faculty", "leadership"] + +// Route prefix → roles that may access it. Unmatched routes are public (/ and /methodology). +export const ROUTE_PERMISSIONS: Array<{ prefix: string; roles: Role[] }> = [ + { prefix: "/students", roles: ["admin", "advisor", "ir"] }, + { prefix: "/query", roles: ["admin", "advisor", "ir", "faculty"] }, + { prefix: "/api/students", roles: ["admin", "advisor", "ir"] }, + { prefix: "/api/query-history/export", roles: ["admin", "ir"] }, +] + +export function canAccess(pathname: string, role: Role): boolean { + for (const { prefix, roles } of ROUTE_PERMISSIONS) { + if (pathname === prefix || pathname.startsWith(prefix + "/") || pathname.startsWith(prefix + "?")) { + return roles.includes(role) + } + } + return true // dashboard, methodology, and other pages are open to all roles +} + +export const ROLE_LABELS: Record = { + admin: "Admin", + advisor: "Advisor", + ir: "IR", + faculty: "Faculty", + leadership: "Leadership", +} + +export const ROLE_COLORS: Record = { + admin: "bg-purple-100 text-purple-800 border-purple-200", + advisor: "bg-blue-100 text-blue-800 border-blue-200", + ir: "bg-indigo-100 text-indigo-800 border-indigo-200", + faculty: "bg-teal-100 text-teal-800 border-teal-200", + leadership: "bg-amber-100 text-amber-800 border-amber-200", +} diff --git a/codebenders-dashboard/lib/supabase/client.ts b/codebenders-dashboard/lib/supabase/client.ts new file mode 100644 index 0000000..0684873 --- /dev/null +++ b/codebenders-dashboard/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from "@supabase/ssr" + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} diff --git a/codebenders-dashboard/lib/supabase/middleware-client.ts b/codebenders-dashboard/lib/supabase/middleware-client.ts new file mode 100644 index 0000000..4d3a22f --- /dev/null +++ b/codebenders-dashboard/lib/supabase/middleware-client.ts @@ -0,0 +1,32 @@ +import { createServerClient } from "@supabase/ssr" +import { NextResponse, type NextRequest } from "next/server" + +export async function updateSession(request: NextRequest) { + let supabaseResponse = NextResponse.next({ request }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) + supabaseResponse = NextResponse.next({ request }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + // Refresh session — must call getUser(), not getSession(), to avoid stale tokens + const { + data: { user }, + } = await supabase.auth.getUser() + + return { supabaseResponse, user, supabase } +} diff --git a/codebenders-dashboard/lib/supabase/server.ts b/codebenders-dashboard/lib/supabase/server.ts new file mode 100644 index 0000000..502e299 --- /dev/null +++ b/codebenders-dashboard/lib/supabase/server.ts @@ -0,0 +1,27 @@ +import { createServerClient } from "@supabase/ssr" +import { cookies } from "next/headers" + +export async function createClient() { + const cookieStore = await cookies() + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll() + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ) + } catch { + // Server components cannot set cookies; middleware handles refresh + } + }, + }, + } + ) +} diff --git a/codebenders-dashboard/middleware.ts b/codebenders-dashboard/middleware.ts new file mode 100644 index 0000000..e5de5ed --- /dev/null +++ b/codebenders-dashboard/middleware.ts @@ -0,0 +1,72 @@ +import { type NextRequest, NextResponse } from "next/server" +import { updateSession } from "@/lib/supabase/middleware-client" +import { canAccess, type Role } from "@/lib/roles" + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + // Static assets and Next.js internals are handled by the matcher below + // Auth callback must be reachable without a session + if (pathname.startsWith("/auth/")) { + return NextResponse.next() + } + + const { supabaseResponse, user, supabase } = await updateSession(request) + + // ── Unauthenticated ──────────────────────────────────────────────────────── + if (!user) { + // Already heading to login — let through + if (pathname === "/login") return supabaseResponse + + const loginUrl = request.nextUrl.clone() + loginUrl.pathname = "/login" + return NextResponse.redirect(loginUrl) + } + + // Authenticated user trying to access /login — send to dashboard + if (pathname === "/login") { + const homeUrl = request.nextUrl.clone() + homeUrl.pathname = "/" + return NextResponse.redirect(homeUrl) + } + + // ── Role resolution ──────────────────────────────────────────────────────── + const { data: roleData } = await supabase + .from("user_roles") + .select("role") + .eq("user_id", user.id) + .single() + + const role = (roleData?.role ?? "leadership") as Role + + // ── Access check ─────────────────────────────────────────────────────────── + if (!canAccess(pathname, role)) { + if (pathname.startsWith("/api/")) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + const homeUrl = request.nextUrl.clone() + homeUrl.pathname = "/" + return NextResponse.redirect(homeUrl) + } + + // ── Forward role + user-id to route handlers via request headers ─────────── + const requestHeaders = new Headers(request.headers) + requestHeaders.set("x-user-role", role) + requestHeaders.set("x-user-id", user.id) + requestHeaders.set("x-user-email", user.email ?? "") + + const response = NextResponse.next({ request: { headers: requestHeaders } }) + + // Copy auth cookies set by updateSession + supabaseResponse.cookies.getAll().forEach(cookie => { + response.cookies.set(cookie.name, cookie.value, cookie) + }) + + return response +} + +export const config = { + matcher: [ + "/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +} diff --git a/codebenders-dashboard/package.json b/codebenders-dashboard/package.json index f77c6c1..9628cab 100644 --- a/codebenders-dashboard/package.json +++ b/codebenders-dashboard/package.json @@ -12,6 +12,8 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "2.1.7", "@radix-ui/react-switch": "1.2.6", + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.97.0", "@tailwindcss/postcss": "4.1.16", "ai": "^5.0.81", "class-variance-authority": "^0.7.1", @@ -34,6 +36,7 @@ "@types/react-dom": "19.2.2", "postcss": "8.5.6", "tailwindcss": "4.1.16", + "tsx": "^4.21.0", "typescript": "5.9.3" } } diff --git a/docs/README_api.md b/docs/README_api.md new file mode 100644 index 0000000..6636dd9 --- /dev/null +++ b/docs/README_api.md @@ -0,0 +1,333 @@ +# DevColor Schools API + +A RESTful API for accessing educational institution data across multiple schools and databases. This service provides standardized access to student, course, and financial aid information. + +## Features + +- **Multiple Institution Support**: Access data from multiple educational institutions through a unified API +- **Standardized Endpoints**: Consistent API structure across all institutions +- **Pagination**: Built-in support for large datasets +- **Filtering**: Query specific data subsets using query parameters + - **Data Uploads**: Upload CSV/Excel to append data with dynamic column mapping (see Data Upload section) + - **New Datasets**: Access LLM recommendations and analysis-ready tables across all databases + +## Prerequisites + +- Python 3.8+ +- MySQL/MariaDB +- pip (Python package manager) + +## Installation + +1. **Clone the repository** + ```bash + git clone https://github.com/syntex-data/devcolor-backend-schools.git + cd devcolor-backend-schools + ``` + +2. **Set up a virtual environment** + ```bash + python -m venv venv + .\venv\Scripts\Activate.ps1 # Windows + source venv/bin/activate # Linux/Mac + ``` + +3. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +4. **Configure environment variables** + Copy `.env.example` to `.env` and update with your database credentials: + ``` + DB_HOST=your_database_host + DB_USER=your_username + DB_PASSWORD=your_password + DB_PORT=3306 + ``` + +## Running the API + +Start the development server: +```bash +uvicorn api.main:app --reload +``` + +The API will be available at `http://localhost:8000` + +## API Documentation + +Once the server is running, access the interactive API documentation at: +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +## Available Endpoints + +### Institution Endpoints + +- `GET /` - List all available institutions +- `GET /{institution_code}/` - Get institution details + +### Data Endpoints + +### Supported Institutions + +| Code | Full Name | Database Name | +|-------|-----------|---------------| +| AL | Bishop State Community College | `Bishop_State` | +| CSUSB | California State University, San Bernardino | `CSUSB` | +| KCTCS | Kentucky Community and Technical College System | `KCTCS` | +| KY | Thomas More University | `Thomas_More` | +| OH | University of Akron | `Akron` | + +For each institution, the following endpoints are available: + +#### Cohorts +- `GET /{institution_code}/cohorts` - List cohorts +- `GET /{institution_code}/cohort/count` - Get count of cohorts + +#### Courses +- `GET /{institution_code}/courses` - List courses +- `GET /{institution_code}/course/count` - Get count of courses + +#### Financial Aid +- `GET /{institution_code}/financial-aid` - List financial aid records +- `GET /{institution_code}/financial_aid/count` - Get count of financial aid records + +#### LLM Recommendations +- `GET /{institution_code}/llm-recommendations` - List LLM recommendation records +- `GET /{institution_code}/llm_recommendations/count` - Get count of LLM recommendation records + +#### Analysis-Ready Data +- `GET /{institution_code}/analysis-ready` - List analysis-ready records +- `GET /{institution_code}/ar_{institution_code}/count` - Get count of analysis-ready records + +## Data Upload Feature + +- Quick start: see `QUICKSTART_UPLOAD.md` +- Full documentation: see `UPLOAD_FEATURE_README.md` +- Endpoints are available under `/upload` in Swagger UI. + +## Query Parameters + +### Pagination +- `limit`: Number of records to return (default: 100, max: 1000) +- `offset`: Number of records to skip (default: 0) + +Example: +``` +/AL/cohorts?limit=10&offset=20 +``` + +## Response Format + +All endpoints return JSON responses with the following structure: + +```json +{ + "data": [ + // Array of records + ], + "count": 123, // Total number of records + "page": 1, // Current page + "total_pages": 13 // Total number of pages +} +``` + +## Error Handling + +Standard HTTP status codes are used to indicate success or failure: + +- `200 OK`: Request was successful +- `400 Bad Request`: Invalid request parameters +- `404 Not Found`: Resource not found +- `500 Internal Server Error`: Server error + +## Docker Setup + +### Building the Docker Image + +### Using Docker + +1. **Build the Docker image:** +```bash +docker build -f docker/Dockerfile -t devcolor-backend:latest . +``` + +2. **Run the container:** +```bash +docker run -p 8000:8000 --env-file .env devcolor-backend:latest +``` + +### Using Docker Compose + +1. **Run with Docker Compose:** +```bash +docker-compose -f docker/docker-compose.yml up -d +``` + +2. **Stop the services:** +```bash +docker-compose -f docker/docker-compose.yml down +``` + +## CI/CD with GitHub Actions + +This project includes automated Docker image building and deployment using GitHub Actions. + +### Setting up GitHub Actions + +1. **Create the workflow directory:** +```bash +mkdir -p .github/workflows +``` + +2. **Create the GitHub Actions workflow file** `.github/workflows/docker-build.yml`: + +```yaml +name: Build and Push Docker Image + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: \\\{\\\{ secrets.DOCKER_USERNAME \\\\}\\\\} + password: \\\{\\\{ secrets.DOCKER_PASSWORD \\\\}\\\\} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: \\\{\\\{ secrets.DOCKER_USERNAME \\\\}\\\\}/devcolor-backend:prod + cache-from: type=gha + cache-to: type=gha,mode=max +``` + +### Required GitHub Secrets + +Add the following secrets to your GitHub repository settings: + +- `DOCKER_USERNAME`: Your Docker Hub username +- `DOCKER_PASSWORD`: Your Docker Hub access token + +### Workflow Triggers + +The workflow runs automatically on: +- Pushes to `main` or `develop` branches +- Pull requests to the `main` branch + +The Docker image will be tagged and pushed to Docker Hub (or your configured registry). + +**Install required packages:** +```bash +pip install -r requirements.txt +``` + +**Configure database connection in `.env`:** +``` +DB_HOST=your_database_host +DB_USER=your_username +DB_PASSWORD=your_password +DB_PORT=3306 +``` + +## Database Structure + +Each database in this project contains the following three tables: +- `financial_aid`: Contains financial aid information for students +- `course`: Contains course-related data +- `cohort`: Contains cohort information for tracking student groups + +## Database Setup + +### 1. Create Databases and Tables +```bash +python db_operations/db_setup.py +``` + +This creates 5 databases with 3 tables each: +- Bishop_State_Community_College (AL) +- California_State_University_San_Bernardino (CSUSB) +- Kentucky_Community_and_Technical_College_System (KCTCS) +- Thomas_More_University (KY) +- University_of_Akron (OH) + +## Data Summary + +**Per Database:** +- Course Records: 200 +- Financial Aid Records: 100 +- Total per school: 350 records + +**Grand Total: 1,750 records across all databases** + + +## Join-Ready Structure + +All tables include a `school` column with matching acronyms (AL, CSUSB, KCTCS, KY, OH) for easy joins across: +- course <-> cohort <-> financial_aid + +**Table Relationships:** +- Each table has an auto-incrementing `id` field (PRIMARY KEY) for unique record identification +- Tables can be joined using the `school` column to relate data across institutions +- The `id` fields serve as primary keys for referential integrity when creating relationships +- Example join: `SELECT * FROM course c JOIN cohort co ON c.school = co.school WHERE c.school = 'AL'` + +## Fallback Generation + +If Ollama is not available or fails, scripts automatically use rule-based synthetic data generation to ensure data is always created. + +## Project Structure + +``` +devcolor-backend/ +├── api/ +│ ├── __init__.py +│ ├── main.py # FastAPI app and router registration +│ ├── schemas.py # Pydantic models +│ └── routers/ +│ ├── __init__.py +│ ├── al.py # AL endpoints (incl. new endpoints) +│ ├── csusb.py # CSUSB endpoints (incl. new endpoints) +│ ├── kctcs.py # KCTCS endpoints (incl. new endpoints) +│ ├── ky.py # KY endpoints (incl. new endpoints) +│ ├── oh.py # OH endpoints (incl. new endpoints) +│ └── upload.py # Data upload endpoints +├── db_operations/ +│ ├── __init__.py +│ ├── connection.py # DB connection utilities +│ ├── db_setup.py # Database setup and table creation +│ ├── add_dynamic_columns.py # Migration for dynamic upload columns +│ ├── upload_handler.py # Upload processing logic +│ └── generate_db_summary.py # Database summary generation +├── docker/ +│ ├── Dockerfile +│ └── docker-compose.yml +├── testscripts/ +│ ├── check_databases.py +│ ├── check_schema.py +│ ├── check_tables.py +│ ├── count_records.py +│ └── test_new_endpoints.py +├── requirements.txt # Python dependencies +├── README.md # This file +├── QUICKSTART_UPLOAD.md # Upload quick start +├── UPLOAD_FEATURE_README.md # Upload feature docs +└── NEW_ENDPOINTS_SUMMARY.md # Summary of new endpoints diff --git a/docs/README_datageneration.md b/docs/README_datageneration.md new file mode 100644 index 0000000..b60b3ba --- /dev/null +++ b/docs/README_datageneration.md @@ -0,0 +1,435 @@ +# DevColor Data Generation + +This project contains scripts for setting up MariaDB databases and generating synthetic data for educational institutions using local or cloud-based LLMs. + +## Project Structure Overview + +This project is organized into four main components: + +### **data/** - Seed Data +Contains original seed data files (Excel/CSV) used as templates for generating synthetic data. These files provide the structure and examples for cohort, course, and financial aid data. + +### **dboperations/** - Database Operations +All database setup, management, and testing utilities: +- Database creation and table setup +- Record counting and summary generation +- Database testing and verification scripts +- Prediction table creation + +### **llm/** - LLM Operations +Scripts for generating and managing LLM-based student recommendations: +- Student readiness assessments +- LLM recommendation table management +- Progress monitoring and viewing recommendations + +### **generate_data/** - Data Generation +School-specific synthetic data generation scripts organized by institution: +- Individual school data generators (AL, CSUSB, KCTCS, KY, OH) +- Shared configuration and utilities +- Master scripts for bulk generation + +## Prerequisites + +### 1. Choose Your LLM Provider (Ollama or AWS Bedrock) + +#### Option 1: Ollama (Recommended for local development) +**Installation:** +- **Windows:** + 1. Go to [ollama.ai](https://ollama.ai) and download the Windows installer + 2. Run the installer and follow setup instructions + 3. Alternative: `winget install Ollama.Ollama` + +**Start Ollama Service:** +```bash +ollama serve +``` + +**Install Mistral Model:** +```bash +ollama pull mistral +``` + +**System Requirements:** +- RAM: At least 8GB (16GB recommended) +- Storage: 4-8GB for model files +- CPU: Any modern CPU (more cores = faster generation) + +#### Option 2: AWS Bedrock (For production use) +**Requirements:** +- AWS account with Bedrock access +- IAM user with `bedrock:InvokeModel` permissions +- AWS CLI configured with valid credentials + +**Environment Variables:** +``` +AWS_ACCESS_KEY_ID=your_access_key +AWS_SECRET_ACCESS_KEY=your_secret_key +AWS_DEFAULT_REGION=your_region +``` + +### 2. MariaDB Database Setup + +This project uses MariaDB as the database system. You can run it locally or connect to a remote instance. + +#### Option 1: Local MariaDB Installation (Recommended for development) + +**Windows:** +1. Download MariaDB from [mariadb.org](https://mariadb.org/download/) +2. Run the installer and follow setup instructions +3. Note the root password you set during installation +4. MariaDB will run as a Windows service + +**Verify Installation:** +```bash +mysql --version +``` + +**Connect to MariaDB:** +```bash +mysql -u root -p +``` + +#### Option 2: Remote MariaDB/MySQL Server + +Connect to an existing MariaDB or MySQL server by configuring the connection details in `.env`. + +### 3. Python Environment Setup + +**Create virtual environment:** +```bash +python -m venv venv +.\venv\Scripts\Activate.ps1 +``` + +**Install required packages:** +```bash +pip install -r requirements.txt +``` + +**Configure database connection in `.env`:** +``` +DB_HOST=localhost # Use 'localhost' for local MariaDB or remote host IP +DB_USER=root # Your database username +DB_PASSWORD=your_password # Your database password +DB_PORT=3306 # Default MariaDB/MySQL port +``` + +--- + +## Development Environment Options + +### Cloud Database vs Local Database + +This project supports both local and cloud-based database development. Choose the option that best fits your development needs: + +#### **Local Database Development (Recommended for Development)** + +**Advantages:** +- **Fast Development Cycle**: No network latency for database operations +- **Offline Development**: Work without internet connectivity +- **Full Control**: Complete control over database configuration and data +- **Cost-Effective**: No cloud hosting costs during development +- **Privacy**: All data stays on your local machine + +**Best For:** +- Initial development and testing +- Learning the codebase +- Experimenting with data generation +- Working with synthetic data only + +**Setup:** +- Install MariaDB locally (see Prerequisites section) +- Use `DB_HOST=localhost` in your `.env` file +- All database operations run on your local machine + +#### **Cloud Database Development (Recommended for Production/Team)** + +**Advantages:** +- **Team Collaboration**: Shared database access for multiple developers +- **Production-Like Environment**: Test against cloud infrastructure +- **Scalability**: Handle larger datasets and concurrent users +- **Backup & Recovery**: Automated backups and disaster recovery +- **Remote Access**: Access from anywhere with internet + +**Best For:** +- Team development projects +- Production deployments +- Working with large datasets +- Multi-developer collaboration +- Integration testing + +**Setup:** +- Use a cloud MariaDB service (AWS RDS MariaDB, Google Cloud SQL MariaDB, etc.) +- Configure remote connection details in `.env`: + ``` + DB_HOST=your-cloud-mariadb-host.com + DB_USER=your_username + DB_PASSWORD=your_password + DB_PORT=3306 + ``` +- Ensure proper security groups/firewall rules for database access + +#### **Choosing Your Database Setup** + +Choose **ONE** of the following options for your development environment: + +**Option 1: Local MariaDB Server** +```bash +# In your .env file +DB_HOST=localhost +DB_USER=root +DB_PASSWORD=your_password +DB_PORT=3306 +``` + +**Option 2: Cloud MariaDB Server** +```bash +# In your .env file +DB_HOST=your-cloud-mariadb-host.com +DB_USER=your_username +DB_PASSWORD=your_password +DB_PORT=3306 +``` + +All scripts and operations work identically with either setup. + +--- + +## Database Operations + +### Database Structure + +The project manages 5 institutional databases: +- **Bishop_State_Community_College** (AL) +- **California_State_University_San_Bernardino** (CSUSB) +- **Kentucky_Community_and_Technical_College_System** (KCTCS) +- **Thomas_More_University** (KY) +- **University_of_Akron** (OH) + +Each database contains core tables: +- `cohort` - Student cohort information +- `course` - Course enrollment data +- `financial_aid` - Financial aid records +- `llm_recommendations` - LLM-generated student recommendations +- `ar_*` - Analysis-ready tables (school-specific) + +### Setup Commands + +**Create all databases and tables:** +```bash +python dboperations/db_setup.py +``` + +**Test database connection:** +```bash +python dboperations/testing/test_db_connection.py +``` + +**Count records across all databases:** +```bash +python dboperations/count_records.py +``` + +**Generate Excel summary of all databases:** +```bash +python dboperations/generate_db_summary.py +``` + +**Create prediction tables:** +```bash +python dboperations/create_prediction_tables.py +``` + +**View database schemas:** +```bash +python dboperations/view_schema.py # View all databases +python dboperations/view_schema.py --database AL # View specific database +python dboperations/view_schema.py --table cohort # View specific table +python dboperations/view_schema.py --overview # Overview only +``` + +See `dboperations/README.md` for complete documentation. + +--- + +## Data Generation + +### Structure + +Data generation scripts are organized by school in `generate_data/schools/`: + +``` +generate_data/schools/ +├── shared/ +│ └── config.py # Shared database configuration +├── AL/ # Bishop State Community College +│ ├── cohort.py +│ ├── course.py +│ ├── financial_aid.py +│ └── generate_all.py +├── CSUSB/ # California State University San Bernardino +├── KCTCS/ # Kentucky Community and Technical College System +├── KY/ # Thomas More University +├── OH/ # University of Akron +└── generate_all_schools.py # Master script for all schools +``` + +### Generation Commands + +**Generate data for all schools:** +```bash +cd generate_data/schools +python generate_all_schools.py +``` + +**Generate data for a specific school:** +```bash +cd generate_data/schools/AL +python generate_all.py +``` + +**Generate specific data types:** +```bash +cd generate_data/schools/AL +python cohort.py # Cohort records +python course.py # Course records +python financial_aid.py # Financial aid records +``` + +### Data Generation Features +- Uses LLM (Ollama/Bedrock) for realistic synthetic data +- Falls back to rule-based generation if LLM unavailable +- All records marked with `dataset_type = 'S'` (Synthetic) +- Includes `school` column for cross-database joins + +--- + +## LLM Operations + +### Student Readiness Recommendations + +Generate AI-powered student readiness assessments using LLM: + +**Add LLM recommendations table to all databases:** +```bash +python llm/add_llm_table.py +``` + +**Generate student readiness recommendations:** +```bash +python llm/llm_student_readiness.py +``` + +**View recommendations:** +```bash +python llm/view_recommendations.py --database Kentucky_Community_and_Technical_College_System --limit 10 +``` + +**Check generation progress:** +```bash +python llm/check_progress.py +``` + +### LLM Recommendation Features +- Analyzes student academic performance and risk factors +- Generates personalized readiness scores and recommendations +- Stores results in `llm_recommendations` table +- Tracks model version and prompt version for reproducibility + +--- + +## Seed Data + +The `data/` folder contains original seed data files used as templates: +- Excel files with cohort, course, and financial aid structures +- Analysis-ready file templates +- Prediction schema definitions + +These files serve as the foundation for generating realistic synthetic data. + +--- + +## Data Summary + +**Current Population (Synthetic Data from seed_data01):** +- Total Cohorts: 21 (3-5 per school) +- Total Students: 21,153 (500-1,500 per cohort) +- Total Course Enrollments: 105,726 (4-6 per student) +- Total Financial Aid Records: 21,153 (1 per student) + +**Grand Total: 126,900 records across all databases** + +All records are marked with `dataset_type = 'S'` (Synthetic). Future real data will be marked with 'R'. + +--- + +## Database Schema + +For detailed table structures, column definitions, and relationships, see **[DATABASE_SCHEMA.md](DATABASE_SCHEMA.md)**. + +### Table Relationships + +**Student-Centric Connections:** +- Tables are linked through `student_id` or `Student_GUID` fields +- `financial_aid.student_id` connects student financial records +- `llm_recommendations.Student_GUID` links AI recommendations to students + +**Institution-Centric Connections:** +- All tables include a `school` column (AL, CSUSB, KCTCS, KY, OH) +- `llm_recommendations.Institution_ID` provides institutional linking +- Cross-database joins possible using `school` column + +**Example Joins:** +```sql +-- Get all data for a specific school +SELECT * FROM course c +JOIN cohort co ON c.school = co.school +WHERE c.school = 'AL'; + +-- Get student financial aid and recommendations +SELECT fa.*, lr.* FROM financial_aid fa +JOIN llm_recommendations lr ON fa.student_id = lr.Student_GUID +WHERE fa.school = 'AL'; +``` + +--- + +## Complete File Structure + +``` +devcolor-data-gen/ +├── .env # Database configuration +├── requirements.txt # Python dependencies +├── DATABASE_SCHEMA.md # Complete database schema documentation +├── data/ # Seed data files +│ └── course_analysis_ready_file_template_Identified_01_27_25.xlsx +├── dboperations/ # Database operations and utilities +│ ├── README.md # Database operations documentation +│ ├── db_setup.py # Creates databases and tables +│ ├── count_records.py # Counts records in all tables +│ ├── generate_db_summary.py # Generates Excel summary of databases +│ ├── view_schema.py # View database schemas and table structures +│ ├── create_complete_seed_structure.py # Seed data structure creation +│ ├── create_prediction_tables.py # Create prediction tables +│ ├── create_kctcs_prediction_tables.py # KCTCS-specific prediction tables +│ └── testing/ # Database testing and verification +│ ├── test_db_connection.py # Tests database connection +│ ├── verify_*.py # Various verification scripts +│ └── display_schema.py # Display database schemas +├── llm/ # LLM-related operations +│ ├── add_llm_table.py # Add LLM recommendations table +│ ├── llm_student_readiness.py # Generate student readiness recommendations +│ ├── view_recommendations.py # View LLM recommendations +│ ├── check_progress.py # Check recommendation progress +│ └── alter_add_school_column.py # Add school column to tables +└── generate_data/ # Synthetic data generation scripts + ├── schools/ # School-based generation scripts + │ ├── shared/config.py # Shared configuration + │ ├── AL/ # Bishop State Community College + │ ├── CSUSB/ # California State University San Bernardino + │ ├── KCTCS/ # Kentucky Community and Technical College System + │ ├── KY/ # Thomas More University + │ ├── OH/ # University of Akron + │ └── generate_all_schools.py + └── archive/ # Old data-type-based scripts (for reference) +``` diff --git a/docs/demo/DEMO.md b/docs/demo/DEMO.md new file mode 100644 index 0000000..462ca4c --- /dev/null +++ b/docs/demo/DEMO.md @@ -0,0 +1,166 @@ +# Demo Script — AI-Powered Student Success Analytics + +**Team:** CodeBenders — William Hill, Farron Rucker, Audrey Webb +**Duration:** 5–7 minutes +**Live URL:** `[your-vercel-url]` + +--- + +## Before You Start + +- Open the live dashboard in a browser tab (full screen, zoom 90%) +- Open a second tab ready on the `/query` page +- Open a third tab ready on the `/methodology` page +- Clear localStorage so prompt history is empty: DevTools → Application → Local Storage → delete `bishop_query_history` + +--- + +## Talk Track + +--- + +### [0:00 – 0:45] The Problem + +> "Bishop State Community College in Mobile, Alabama serves about 4,000 students a year. 59% are Black or African American. 68% enroll part-time — they're balancing work, family, and school simultaneously. These are the students for whom early intervention matters most, and they're the ones most likely to fall through the cracks." + +> "Bishop State's advisors had no unified way to see which students were at risk *before* academic difficulties compounded. PDP reporting is annual — there's no mid-cycle alerting. Gateway course failures in math and English are the number one predictor of non-retention, but that signal wasn't surfacing anywhere advisors could act on it." + +> "We built a live, deployed platform to change that." + +--- + +### [0:45 – 2:15] Dashboard Walkthrough + +*Navigate to the dashboard home page (`/`).* + +> "This is the live dashboard, backed by Supabase and deployed on Vercel. Every number here is live from our database." + +**Point to the four KPI tiles:** + +> "Four key metrics at a glance — overall retention rate, predicted retention from our ML models, the number of students at high or urgent risk right now, and average course completion rate across all 4,000 students." + +**Point to the At-Risk Breakdown chart (right side):** + +> "This is our at-risk alert breakdown. Our composite rule engine classifies every student as URGENT, HIGH, MODERATE, or LOW risk. Advisors can see immediately how many students need attention this week." + +**Point to the Retention Risk Distribution chart:** + +> "Retention risk distribution — the proportion of students in each risk band, powered by our Logistic Regression retention model." + +**Point to the Readiness Distribution chart:** + +> "And our PDP-aligned Readiness Index. 83.9% of Bishop State students score High Readiness — that's a strong baseline. The 16.1% in Medium is exactly where advisors should focus outreach." + +**Click Export button:** + +> "Everything on this dashboard is exportable — CSV, JSON, or a formatted Markdown report — ready to drop into a board presentation or grant application." + +--- + +### [2:15 – 4:00] Natural Language Query Interface + +*Switch to the `/query` tab. Institution is already set to Bishop State.* + +> "Now here's where it gets interesting. Advisors and institutional researchers don't know SQL. They shouldn't have to. So we built a natural-language query interface powered by OpenAI." + +**Type the first query:** + +``` +Show retention rate by credential type +``` + +> "I'll type a plain English question and hit Run." + +*Wait for chart to render.* + +> "OpenAI translates that into SQL against our `student_level_with_predictions` view, runs it against Supabase, and returns a bar chart — with the raw data table right below so you can verify every number." + +**Type the second query:** + +``` +How many students are at urgent or high risk? +``` + +*Wait for result.* + +> "Simple question, instant answer. No analyst needed, no ticket to file." + +**Type the third query:** + +``` +Compare average readiness score between full-time and part-time students +``` + +*Wait for result.* + +> "That's an equity gap query — exactly the kind of insight that strengthens a grant application or informs advising strategy." + +**Point to the Prompt History panel that has appeared:** + +> "Every query is logged here with a timestamp. One click to re-run any past query. And server-side, every query appends to a JSONL audit log — that's our FERPA compliance trail. No student PII is ever sent to OpenAI — only aggregate behavioral metrics." + +--- + +### [4:00 – 5:15] Methodology Page + +*Switch to the `/methodology` tab.* + +> "Predictive tools in education live or die on trust. Advisors won't act on a score they can't explain. So we built a full methodology page." + +**Scroll to the Readiness Index formula:** + +> "Our Readiness Index is grounded in three bodies of research — the PDP momentum metrics, CCRC Multiple Measures, and Bird et al. 2021. The formula is transparent: Academic sub-score at 40%, Engagement at 30%, ML risk at 30%. Every student's score is fully traceable to its inputs." + +**Scroll to the Worked Examples section:** + +> "And we built worked examples. Maria T. is a full-time student with strong placement scores — we walk through exactly how her 0.699 High Readiness score was calculated. Jordan M. is a part-time student with remediation needs — his 0.386 Low Readiness walks through the same math. Any advisor can follow this. No data science background required." + +--- + +### [5:15 – 6:00] Architecture & Impact + +> "Under the hood: a Python ML pipeline running 7 models — XGBoost, Random Forest, and Logistic Regression — trained on Bishop State's 4,000 students with cross-validation. Results upsert into Supabase Postgres. The frontend is Next.js 16 with React 19, deployed on Vercel." + +> "Seven prediction dimensions per student: retention probability, at-risk alert level, gateway math success, gateway English success, first-semester GPA risk, time-to-credential, and credential type. And the readiness engine runs as a separate step on top, producing the PDP-aligned composite score." + +**Delivered metrics:** + +> "4,000 students scored. Live platform deployed today. NLQ with audit trail. Methodology page with research citations. A deploy script that re-runs the full pipeline and re-deploys in one command." + +--- + +### [6:00 – 6:30] Closing + +> "This isn't a prototype — it's a deployed system advisors can use right now. The roadmap includes role-based access so advisors, leadership, and IR each see the right view, a student roster table with per-student drill-down, and dashboard filtering by cohort, demographic, and credential type." + +> "The question Bishop State has always had the data to answer. We just built the tool to ask it." + +--- + +## Example NLQ Queries (for live demo) + +Use these in order — they build a narrative: + +| Query | Why it works well | +|-------|------------------| +| `Show retention rate by credential type` | Clean bar chart, clear story | +| `How many students are at urgent or high risk?` | KPI-style answer, dramatic number | +| `Compare average readiness score between full-time and part-time students` | Equity gap — directly relevant to BSCC demographics | +| `Show the distribution of gateway math success probability` | Connects to the #1 retention risk factor | +| `Which cohort year has the lowest average readiness score?` | Useful for trend analysis demo | + +--- + +## Screenshots to Capture + +Save to `docs/screenshots/`. Use the live Vercel URL in the browser bar. + +| Filename | What to capture | +|----------|----------------| +| `01-dashboard-kpis.png` | Top of home page: all four KPI tiles visible | +| `02-charts-overview.png` | Scroll to show all three charts in one view | +| `03-query-bar-chart.png` | NLQ result: "Show retention rate by credential type" with bar chart + data table | +| `04-query-kpi-result.png` | NLQ result: "How many students are at urgent or high risk?" | +| `05-prompt-history.png` | After running 2–3 queries: the history panel visible with Re-run buttons | +| `06-methodology-formula.png` | Methodology page: readiness formula section | +| `07-worked-examples.png` | Methodology page: Maria T. and Jordan M. side-by-side | diff --git a/docs/demo/screenshots/README.md b/docs/demo/screenshots/README.md new file mode 100644 index 0000000..68e470e --- /dev/null +++ b/docs/demo/screenshots/README.md @@ -0,0 +1,21 @@ +# Screenshots + +Screenshots of the live platform for the datathon submission and slide deck. + +## How to capture + +1. Open the live Vercel URL in Chrome/Safari at 1440px width, zoom 90% +2. Use full-page screenshot or window screenshot (Command+Shift+4 on Mac) +3. Make sure the Vercel URL is visible in the browser address bar + +## Required screenshots + +| File | Page | What to show | +|------|------|-------------| +| `01-dashboard-kpis.png` | `/` | All four KPI tiles at top of page | +| `02-charts-overview.png` | `/` | Scroll to show all three charts | +| `03-query-bar-chart.png` | `/query` | Run "Show retention rate by credential type" — capture chart + data table | +| `04-query-kpi-result.png` | `/query` | Run "How many students are at urgent or high risk?" | +| `05-prompt-history.png` | `/query` | After 3+ queries — history panel with Re-run buttons visible | +| `06-methodology-formula.png` | `/methodology` | Readiness formula section (Academic 40% + Engagement 30% + ML Risk 30%) | +| `07-worked-examples.png` | `/methodology` | Maria T. and Jordan M. cards side by side | diff --git a/docs/plans/2026-02-14-bishop-state-rebranding-plan.md b/docs/plans/2026-02-14-bishop-state-rebranding-plan.md new file mode 100644 index 0000000..940ffaa --- /dev/null +++ b/docs/plans/2026-02-14-bishop-state-rebranding-plan.md @@ -0,0 +1,895 @@ +# Bishop State Rebranding Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace all KCTCS references with Bishop State Community College, migrate from MariaDB/AWS to Supabase Postgres, generate synthetic data, retrain ML models, and deploy. + +**Architecture:** Sequential pipeline: synthetic data generation → database setup (Supabase Postgres) → Python DB driver migration → ML pipeline retraining → Node.js DB driver migration → frontend rebranding → documentation updates → deployment verification. + +**Tech Stack:** Python 3.8+ (pandas, psycopg2, XGBoost, scikit-learn), Next.js 16/React 19/TypeScript, node-postgres (`pg`), Supabase (Postgres), Vercel + +**Design doc:** `docs/plans/2026-02-14-kctcs-to-bishop-state-rebranding-design.md` + +--- + +## Task 1: Set Up Local Supabase (via CLI + Docker) + +**Prerequisites:** Docker Desktop running, Node.js installed. + +**Step 1: Install Supabase CLI (if not already installed)** + +```bash +brew install supabase/tap/supabase +``` + +**Step 2: Initialize Supabase in the project** + +```bash +cd /Users/william-meroxa/Development/codebenders-datathon +supabase init +``` + +This creates a `supabase/` directory with config files. + +**Step 3: Start local Supabase** + +```bash +supabase start +``` + +This spins up local Postgres (+ Auth, Storage, etc.) via Docker. On first run it pulls images (~5 min). + +Expected output includes: +``` +API URL: http://127.0.0.1:54321 +DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres +Studio URL: http://127.0.0.1:54323 +``` + +**Step 4: Note local connection details** + +The local Supabase Postgres defaults: +``` +Host: 127.0.0.1 +Port: 54322 +Database: postgres +User: postgres +Password: postgres +``` + +**Step 5: Update environment files for local dev** + +Update `codebenders-dashboard/.env.local`: +``` +DB_HOST=127.0.0.1 +DB_PORT=54322 +DB_NAME=postgres +DB_USER=postgres +DB_PASSWORD=postgres +``` + +Update `operations/` Python config to also read from env vars (done in Task 4). + +**Step 6: Verify Supabase Studio** + +Open http://127.0.0.1:54323 — you should see the Supabase Studio dashboard with an empty `postgres` database. + +**Step 7: Commit Supabase config (not credentials)** + +```bash +git add supabase/ +git commit -m "feat: initialize local Supabase for Postgres development" +``` + +**Note:** When ready to deploy, we'll create a hosted Supabase project (Task 14) and update env vars in Vercel. The local setup uses the same Postgres interface, so no code changes needed. + +--- + +## Task 2: Generate Synthetic Bishop State Student Data + +**Files:** +- Create: `ai_model/generate_bishop_state_data.py` +- Output: `data/bishop_state_cohorts_with_zip.csv`, `data/ar_bscc_with_zip.csv`, `data/bishop_state_courses.csv` + +**Step 1: Write the data generation script** + +Create `ai_model/generate_bishop_state_data.py` with the following structure: + +```python +""" +Synthetic Data Generator for Bishop State Community College +============================================================ +Generates PDP-compliant (Postsecondary Data Partnership) synthetic student data +matching Bishop State Community College demographics. + +Output files: +- data/bishop_state_cohorts_with_zip.csv (~4,000 students) +- data/ar_bscc_with_zip.csv (~4,000 students) +- data/bishop_state_courses.csv (~100,000 course records) +""" + +import pandas as pd +import numpy as np +from datetime import datetime +import os +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_DIR = os.path.join(PROJECT_ROOT, 'data') +NUM_STUDENTS = 4000 + +# Read existing KCTCS cohort CSV header to match schema exactly +COHORT_HEADER_FILE = os.path.join(DATA_DIR, 'kctcs_cohorts_with_zip.csv') + +# ---- Bishop State Demographics ---- +DEMOGRAPHICS = { + 'race_dist': { + 'Black or African American': 0.59, + 'White': 0.28, + 'Two or More Races': 0.047, + 'Hispanic': 0.029, + 'Asian': 0.023, + 'American Indian or Alaska Native': 0.012, + 'Native Hawaiian or Other Pacific Islander': 0.001, + }, + 'gender_dist': {'F': 0.63, 'M': 0.37}, + 'enrollment_intensity': {'Full-Time': 0.32, 'Part-Time': 0.68}, + 'age_dist': {'20 and younger': 0.50, '>20 - 24': 0.26, 'Older than 24': 0.24}, + 'cohorts': ['2019-20', '2020-21', '2021-22', '2022-23', '2023-24'], + 'campuses': [102030], # Single IPEDS ID +} + +# Mobile, AL area zip codes +MOBILE_ZIPS = [ + '36601', '36602', '36603', '36604', '36605', '36606', '36607', '36608', + '36609', '36610', '36611', '36612', '36613', '36615', '36616', '36617', + '36618', '36619', '36688', '36693', '36695', +] +# Washington County, AL zips +WASHINGTON_ZIPS = ['36524', '36540', '36560', '36562', '36571', '36572', '36575', '36587'] + +# Bishop State programs mapped to CIP codes +PROGRAMS = { + '510000.0': 0.25, # Health Sciences + '520101.0': 0.15, # Business/Management + '110101.0': 0.10, # Computer Science/STEM + '240101.0': 0.20, # Liberal Arts + '480508.0': 0.10, # Welding Technology + '120401.0': 0.05, # Cosmetology + '430104.0': 0.05, # Criminal Justice + '150000.0': 0.05, # Engineering Technology + '460000.0': 0.05, # Construction +} + +TRANSFER_STATES = ['AL', 'MS', 'FL', 'GA', 'TN', 'LA'] + +# ... (implementation generates students matching schema from kctcs_cohorts_with_zip.csv header) +``` + +The script must: +1. Read the column headers from `kctcs_cohorts_with_zip.csv` to ensure schema match +2. Generate ~4,000 students with `Student_GUID` format `BSCC_STU{:05d}` +3. Set `Institution_ID` = 102030, `school` = "BSCC", `dataset_type` = "S" +4. Distribute demographics per Bishop State actuals +5. Generate realistic academic outcomes (GPA, credits, retention, persistence, gateway courses) +6. Generate AR file (`ar_bscc_with_zip.csv`) with NSC supplementary fields +7. Generate course-level data (`bishop_state_courses.csv`) with ~25 courses per student avg +8. Use Alabama zip codes from Mobile/Washington County + +**Step 2: Run the data generation** + +```bash +cd /Users/william-meroxa/Development/codebenders-datathon +python ai_model/generate_bishop_state_data.py +``` + +Expected output: +``` +Generated 4,000 students → data/bishop_state_cohorts_with_zip.csv +Generated 4,000 AR records → data/ar_bscc_with_zip.csv +Generated ~100,000 course records → data/bishop_state_courses.csv +``` + +**Step 3: Validate schema compliance** + +```bash +# Compare headers +head -1 data/kctcs_cohorts_with_zip.csv > /tmp/kctcs_header.txt +head -1 data/bishop_state_cohorts_with_zip.csv > /tmp/bscc_header.txt +diff /tmp/kctcs_header.txt /tmp/bscc_header.txt +``` + +Expected: Headers should be identical (same columns, same order). + +**Step 4: Commit** + +```bash +git add ai_model/generate_bishop_state_data.py data/bishop_state_cohorts_with_zip.csv data/ar_bscc_with_zip.csv data/bishop_state_courses.csv +git commit -m "feat: add synthetic Bishop State data generation script and data files" +``` + +--- + +## Task 3: Create Merged Student-Level File + +**Files:** +- Create: `ai_model/merge_bishop_state_data.py` (based on `ai_model/merge_kctcs_data.py`) +- Output: `data/bishop_state_student_level_with_zip.csv` + +**Step 1: Copy and adapt the merge script** + +Copy `ai_model/merge_kctcs_data.py` → `ai_model/merge_bishop_state_data.py`. + +Changes needed: +- Line 5: `"MERGING KCTCS DATA FILES"` → `"MERGING BISHOP STATE DATA FILES"` +- Line 9-10: `ar_kcts.csv` / `ar_kcts_with_zip.csv` → `ar_bscc_with_zip.csv` +- Line 14-15: `kctcs_cohorts.csv` / `kctcs_cohorts_with_zip.csv` → `bishop_state_cohorts_with_zip.csv` +- Line 19: `kctcs_courses.csv` → `bishop_state_courses.csv` +- Line 85: Output file `kctcs_merged_with_zip.csv` → `bishop_state_student_level_with_zip.csv` +- All print statements referencing KCTCS → Bishop State + +**Step 2: Run the merge** + +```bash +python ai_model/merge_bishop_state_data.py +``` + +Expected: Creates `data/bishop_state_student_level_with_zip.csv` with ~4,000 rows. + +**Step 3: Commit** + +```bash +git add ai_model/merge_bishop_state_data.py data/bishop_state_student_level_with_zip.csv +git commit -m "feat: add Bishop State data merge script and merged dataset" +``` + +--- + +## Task 4: Migrate Python DB Layer to Postgres/Supabase + +**Files:** +- Modify: `operations/db_config.py` +- Modify: `operations/db_utils.py` +- Modify: `operations/__init__.py` +- Modify: `requirements.txt` + +**Step 1: Update `operations/db_config.py`** + +Replace entire file content: +```python +""" +Database Configuration for Supabase Postgres +============================================= +Credentials for Bishop State Community College database +""" +import os + +# Supabase Postgres Connection Settings +DB_CONFIG = { + 'host': os.environ.get('DB_HOST', 'db..supabase.co'), + 'user': os.environ.get('DB_USER', 'postgres.'), + 'password': os.environ.get('DB_PASSWORD', ''), + 'database': os.environ.get('DB_NAME', 'postgres'), + 'port': int(os.environ.get('DB_PORT', '6543')), + 'sslmode': 'require' +} + +# Table names +TABLES = { + 'student_predictions': 'student_level_with_predictions', + 'course_predictions': 'course_predictions', + 'model_performance': 'ml_model_performance' +} +``` + +Note: The actual Supabase credentials should come from environment variables. Update the defaults after Task 1 is complete. + +**Step 2: Update `operations/db_utils.py`** + +Replace `pymysql` with `psycopg2`: +- Line 8: `import pymysql` → `import psycopg2` and `from psycopg2.extras import RealDictCursor` +- Lines 15-31: `get_connection()` — change `pymysql.connect(...)` to `psycopg2.connect(...)` with `cursor_factory=RealDictCursor` +- Lines 34-46: `get_sqlalchemy_engine()` — change connection string from `mysql+pymysql://` to `postgresql+psycopg2://`, add `?sslmode=require` +- Lines 120-156: `create_model_performance_table()` — change `pymysql` cursor usage to `psycopg2` cursor (use `conn.cursor()` pattern with `conn.commit()`) +- Lines 159-210: `save_model_performance()` — change `pymysql` cursor to `psycopg2` cursor, replace `%s` style with psycopg2 `%s` (same syntax, but use `conn.commit()`) +- Lines 213-235: `test_connection()` — replace `pymysql` connect with `psycopg2`, change `SHOW TABLES` to `SELECT table_name FROM information_schema.tables WHERE table_schema='public'` + +**Step 3: Update `requirements.txt`** + +Add `psycopg2-binary` if not present. The existing file already has `supabase` and `python-dotenv`. + +**Step 4: Test the connection** + +```bash +python -m operations.test_db_connection +``` + +Expected: `✓ Connected to database: postgres` and table listing. + +**Step 5: Commit** + +```bash +git add operations/db_config.py operations/db_utils.py requirements.txt +git commit -m "feat: migrate Python DB layer from pymysql/MariaDB to psycopg2/Supabase Postgres" +``` + +--- + +## Task 5: Update ML Pipeline for Bishop State + +**Files:** +- Modify: `ai_model/complete_ml_pipeline.py` +- Modify: `ai_model/complete_ml_pipeline_csv_only.py` +- Modify: `ai_model/__init__.py` + +**Step 1: Update `ai_model/complete_ml_pipeline.py`** + +Key changes: +- Line 2: Docstring `"KCTCS Student Success Prediction"` → `"Bishop State Student Success Prediction"` +- Line 74: `'kctcs_student_level_with_zip.csv'` → `'bishop_state_student_level_with_zip.csv'` +- All print statements referencing KCTCS → Bishop State + +**Step 2: Update `ai_model/complete_ml_pipeline_csv_only.py`** + +Same changes as above — update file references and KCTCS mentions. + +**Step 3: Update `ai_model/__init__.py`** + +Replace "Kentucky Community and Technical College System (KCTCS)" with "Bishop State Community College (BSCC)". + +**Step 4: Run the ML pipeline** + +```bash +cd /Users/william-meroxa/Development/codebenders-datathon +python ai_model/complete_ml_pipeline.py +``` + +This will: +1. Load `data/bishop_state_student_level_with_zip.csv` +2. Train all 5 models on ~4,000 students +3. Generate predictions +4. Save to Supabase Postgres (student_predictions, course_predictions, ml_model_performance tables) + +Expected runtime: 5-15 minutes depending on machine. + +**Step 5: Verify database population** + +```bash +python -c " +from operations.db_utils import get_connection +conn = get_connection() +cur = conn.cursor() +cur.execute('SELECT COUNT(*) as cnt FROM student_level_with_predictions') +print('Students:', cur.fetchone()) +cur.execute('SELECT COUNT(*) as cnt FROM course_predictions') +print('Courses:', cur.fetchone()) +cur.execute('SELECT COUNT(*) as cnt FROM ml_model_performance') +print('Models:', cur.fetchone()) +conn.close() +" +``` + +Expected: ~4,000 students, ~100K courses, 5 model records. + +**Step 6: Commit** + +```bash +git add ai_model/complete_ml_pipeline.py ai_model/complete_ml_pipeline_csv_only.py ai_model/__init__.py +git commit -m "feat: update ML pipeline for Bishop State data and Supabase Postgres" +``` + +--- + +## Task 6: Create Shared Postgres Pool for Next.js API Routes + +**Files:** +- Create: `codebenders-dashboard/lib/db.ts` +- Modify: `codebenders-dashboard/package.json` (add `pg`, `@types/pg`; remove `mysql2`) + +**Step 1: Install pg, remove mysql2** + +```bash +cd /Users/william-meroxa/Development/codebenders-datathon/codebenders-dashboard +npm install pg @types/pg +npm uninstall mysql2 +``` + +**Step 2: Create shared database pool module** + +Create `codebenders-dashboard/lib/db.ts`: +```typescript +import { Pool } from "pg" + +let pool: Pool | null = null + +export function getPool(): Pool { + if (!pool) { + pool = new Pool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + port: Number.parseInt(process.env.DB_PORT || "6543"), + database: process.env.DB_NAME || "postgres", + ssl: process.env.DB_SSL === "true" ? { rejectUnauthorized: false } : false, + max: 10, + }) + } + return pool +} +``` + +**Step 3: Commit** + +```bash +git add codebenders-dashboard/lib/db.ts codebenders-dashboard/package.json codebenders-dashboard/package-lock.json +git commit -m "feat: add shared Postgres pool module, swap mysql2 for pg" +``` + +--- + +## Task 7: Migrate Dashboard API Routes to Postgres + +**Files:** +- Modify: `codebenders-dashboard/app/api/dashboard/kpis/route.ts` +- Modify: `codebenders-dashboard/app/api/dashboard/risk-alerts/route.ts` +- Modify: `codebenders-dashboard/app/api/dashboard/retention-risk/route.ts` +- Modify: `codebenders-dashboard/app/api/dashboard/readiness/route.ts` + +**Step 1: Update `kpis/route.ts`** + +Replace the full file. Key changes: +- Remove `import mysql from "mysql2/promise"` and local pool setup +- Add `import { getPool } from "@/lib/db"` +- Change `pool.query(sql)` → `pool.query(sql)` (pg returns `{ rows }` not `[rows]`) +- Access results via `result.rows[0]` instead of destructuring `[rows]` + +```typescript +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" + +export async function GET(request: NextRequest) { + try { + const pool = getPool() + + const sql = ` + SELECT + AVG("Retention") * 100 as overall_retention_rate, + AVG(retention_probability) * 100 as avg_predicted_retention, + SUM(CASE WHEN at_risk_alert IN ('HIGH', 'URGENT') THEN 1 ELSE 0 END) as high_critical_risk_count, + AVG(course_completion_rate) * 100 as avg_course_completion_rate, + COUNT(*) as total_students + FROM student_predictions + LIMIT 1 + ` + + const result = await pool.query(sql) + const kpis = result.rows[0] + + if (!kpis) { + return NextResponse.json({ error: "No data found" }, { status: 404 }) + } + + return NextResponse.json({ + overallRetentionRate: Number(kpis.overall_retention_rate || 0).toFixed(1), + avgPredictedRetention: Number(kpis.avg_predicted_retention || 0).toFixed(1), + highCriticalRiskCount: Number(kpis.high_critical_risk_count || 0), + avgCourseCompletionRate: Number(kpis.avg_course_completion_rate || 0).toFixed(1), + totalStudents: Number(kpis.total_students || 0), + }) + } catch (error) { + console.error("KPI fetch error:", error) + return NextResponse.json( + { + error: "Failed to fetch KPIs", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ) + } +} +``` + +**Note on Postgres quoting:** If column names like `Retention` are mixed-case in Postgres, they need double-quote quoting (`"Retention"`). Check the actual table DDL after ML pipeline runs. If columns are all lowercase in Postgres, remove the quotes. + +**Step 2: Update `risk-alerts/route.ts`** + +Same pattern: remove mysql2 import, use `getPool()` from `@/lib/db`, access `result.rows`. + +**Step 3: Update `retention-risk/route.ts`** + +Same pattern. + +**Step 4: Update `readiness/route.ts`** + +This route uses `mysql.createConnection()` directly (not pool). Replace with pg Pool pattern. Also replace `?` parameter placeholders with `$1, $2, $3`. + +Example change for parameterized queries: +```typescript +// Before (mysql2): +conditions.push('Institution_ID = ?'); +const [rows] = await connection.execute(sql, params); + +// After (pg): +conditions.push(`"Institution_ID" = $${conditions.length + 1}`); +const result = await pool.query(sql, params); +const rows = result.rows; +``` + +**Step 5: Verify locally** + +```bash +cd /Users/william-meroxa/Development/codebenders-datathon/codebenders-dashboard +npm run dev +``` + +Open http://localhost:3000 — dashboard should load with Bishop State data from Supabase. + +**Step 6: Commit** + +```bash +git add codebenders-dashboard/app/api/dashboard/ +git commit -m "feat: migrate dashboard API routes from mysql2 to pg (Postgres)" +``` + +--- + +## Task 8: Migrate Query API Routes to Postgres + +**Files:** +- Modify: `codebenders-dashboard/app/api/analyze/route.ts` +- Modify: `codebenders-dashboard/app/api/execute-sql/route.ts` +- Modify: `codebenders-dashboard/lib/prompt-analyzer.ts` +- Modify: `codebenders-dashboard/lib/query-executor.ts` + +**Step 1: Update `analyze/route.ts`** + +Key changes in SCHEMA_INFO (lines 23-79): +- Replace `kctcs` key with `bscc` +- Update database name: `"Kentucky_Community_and_Technical_College_System"` → `"postgres"` (Supabase default) +- Remove or update `akron` entry's database name +- Update schema descriptions + +**Step 2: Update `execute-sql/route.ts`** + +Replace mysql2 pool with pg pool from `@/lib/db`. Change `pool.query(sql)` result access to `result.rows`. + +**Step 3: Update `lib/prompt-analyzer.ts`** + +Lines 4-28: Update `SCHEMA_CONFIG.institutionDbMap`: +```typescript +institutionDbMap: { + bscc: "postgres", + akron: "University_of_Akron", +} +``` + +Remove `kctcs` entry. + +**Step 4: Update `lib/query-executor.ts`** + +If it references institution codes or database names, update accordingly. + +**Step 5: Verify query interface** + +Open http://localhost:3000/query → select Bishop State → enter a query like "Show retention rates by cohort" → verify results. + +**Step 6: Commit** + +```bash +git add codebenders-dashboard/app/api/analyze/ codebenders-dashboard/app/api/execute-sql/ codebenders-dashboard/lib/prompt-analyzer.ts codebenders-dashboard/lib/query-executor.ts +git commit -m "feat: migrate query API routes to Postgres and rebrand to Bishop State" +``` + +--- + +## Task 9: Rebrand Frontend UI + +**Files:** +- Modify: `codebenders-dashboard/app/layout.tsx` +- Modify: `codebenders-dashboard/app/page.tsx` +- Modify: `codebenders-dashboard/app/query/page.tsx` +- Modify: `codebenders-dashboard/components/export-button.tsx` + +**Step 1: Update `layout.tsx`** + +- Line 16: `title: "KCTCS Student Success Dashboard"` → `title: "Bishop State Student Success Dashboard"` +- Line 17: `description: "AI-Powered Student Success Analytics & Predictive Models for KCTCS"` → `description: "AI-Powered Student Success Analytics & Predictive Models for Bishop State Community College"` + +**Step 2: Update `page.tsx`** + +Find the line with `"KCTCS Student Analytics & Predictive Models"` and replace with `"Bishop State Student Analytics & Predictive Models"`. + +**Step 3: Update `query/page.tsx`** + +Replace INSTITUTIONS array (lines 18-24): +```typescript +const INSTITUTIONS = [ + { name: "Bishop State", code: "bscc" }, + { name: "University of Akron", code: "akron" }, +] +``` + +Remove KCTCS, Cal State San Bernardino, and Thomas More entries. + +**Step 4: Update `export-button.tsx`** + +- Line 77: `institution: "KCTCS"` → `institution: "Bishop State"` +- Line 106: `**Institution:** KCTCS` → `**Institution:** Bishop State` + +**Step 5: Verify visually** + +- http://localhost:3000 — title should say "Bishop State", no KCTCS visible +- http://localhost:3000/query — dropdown should show Bishop State + Akron only +- Export a report in each format (CSV, JSON, Markdown) — verify "Bishop State" in output + +**Step 6: Commit** + +```bash +git add codebenders-dashboard/app/layout.tsx codebenders-dashboard/app/page.tsx codebenders-dashboard/app/query/page.tsx codebenders-dashboard/components/export-button.tsx +git commit -m "feat: rebrand frontend from KCTCS to Bishop State Community College" +``` + +--- + +## Task 10: Update Docker Configuration + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `.docker.env.example` + +**Step 1: Update `.docker.env.example`** + +Replace MariaDB config with Postgres: +```env +# Postgres Configuration for Docker Compose +# Copy this file to .docker.env and update with your values + +# Database name +POSTGRES_DB=postgres + +# Database user +POSTGRES_USER=postgres + +# Database password +POSTGRES_PASSWORD=devcolor2025 + +# Port mapping +POSTGRES_PORT=5432 + +# pgAdmin port (optional) +PGADMIN_PORT=8080 +``` + +**Step 2: Update `docker-compose.yml`** + +Replace MariaDB service with Postgres. Replace phpMyAdmin with pgAdmin (optional). Or simplify to just point at Supabase directly and remove the local DB service entirely (since we're using Supabase for the demo). + +**Step 3: Commit** + +```bash +git add docker-compose.yml .docker.env.example +git commit -m "feat: update Docker config from MariaDB to Postgres" +``` + +--- + +## Task 11: Update Documentation + +**Files:** ~15 markdown files (see design doc Section 5 for full list) + +**Step 1: Update CLAUDE.md** + +Key replacements: +- "KCTCS Student Success Prediction" → "Bishop State Student Success Prediction" +- "Kentucky Community and Technical College System (KCTCS)" → "Bishop State Community College (BSCC)" +- Database name references +- "MariaDB" → "Postgres/Supabase" +- "~20K students" → "~4,000 students" +- Table: `Kentucky_Community_and_Technical_College_System` → `Bishop_State_Community_College` (or `postgres`) +- File references: `kctcs_*.csv` → `bishop_state_*.csv` +- "MySQL2 driver" → "node-postgres (pg) driver" + +**Step 2: Update README.md** + +Full pass replacing KCTCS → Bishop State, updating tech stack (MariaDB → Postgres), data counts, institution descriptions. + +**Step 3: Update remaining docs** + +For each file, do a find-replace pass: +- QUICKSTART.md +- DATA_DICTIONARY.md (update file names, record counts, institution references, `school` column values) +- ML_MODELS_GUIDE.md +- DOCKER_SETUP.md (MariaDB → Postgres throughout) +- DASHBOARD_README.md +- operations/README.md +- AI_Powered_Student_Success_PRD.md +- DASHBOARD_VISUALIZATIONS.md +- docs/README_api.md +- docs/README_datageneration.md +- READINESS_ASSESSMENT_INTEGRATION.md +- DOCUMENTATION_ISSUES.md +- hackathon_github_issues.md + +**Step 4: Update schema file** + +Replace `kctcs_student_level_with_predictions_schema.json` with `bishop_state_student_level_with_predictions_schema.json`. Update title, description, and `school` field values inside. + +**Step 5: Commit** + +```bash +git add *.md docs/ operations/README.md codebenders-dashboard/*.md bishop_state_student_level_with_predictions_schema.json +git commit -m "docs: rebrand all documentation from KCTCS to Bishop State Community College" +``` + +--- + +## Task 12: Clean Up Old KCTCS Files + +**Files:** +- Delete: `data/kctcs_*.csv` (all KCTCS data files) +- Delete: `data/ar_kcts_with_zip.csv` +- Delete: `database_dumps/dump-Kentucky_*.sql` +- Delete: `ai_model/merge_kctcs_data.py` +- Delete: `kctcs_student_level_with_predictions_schema.json` + +**Step 1: Remove old files** + +```bash +rm data/kctcs_*.csv +rm data/ar_kcts_with_zip.csv +rm database_dumps/dump-Kentucky_Community_and_Technical_College_System-*.sql +rm ai_model/merge_kctcs_data.py +rm kctcs_student_level_with_predictions_schema.json +``` + +**Step 2: Verify no broken references** + +```bash +grep -ri "kctcs\|kentucky\|ar_kcts\|kcts" --include="*.py" --include="*.ts" --include="*.tsx" --include="*.json" . | grep -v node_modules | grep -v .git | grep -v ".csv" +``` + +Expected: No matches in code files. Some matches in docs are OK if they're historical context. + +**Step 3: Commit** + +```bash +git add -A +git commit -m "chore: remove old KCTCS data files and scripts" +``` + +--- + +## Task 13: Final Verification Sweep + +**Step 1: Full grep for remaining KCTCS references** + +```bash +grep -ri "kctcs\|kentucky_community\|kentucky community" --include="*.py" --include="*.ts" --include="*.tsx" --include="*.json" --include="*.md" --include="*.yml" --include="*.env*" . | grep -v node_modules | grep -v .git +``` + +Fix any remaining references found. + +**Step 2: Build the Next.js app** + +```bash +cd codebenders-dashboard && npm run build +``` + +Expected: Build succeeds with no errors. + +**Step 3: Run the app locally and smoke test** + +```bash +npm run dev +``` + +Test: +- [ ] Dashboard loads at http://localhost:3000 +- [ ] KPI cards show data (not zeros or errors) +- [ ] Risk alert chart renders +- [ ] Retention risk chart renders +- [ ] No "KCTCS" text visible anywhere on the page +- [ ] Query page at /query shows Bishop State + Akron in dropdown +- [ ] A sample query executes and returns results +- [ ] Export button produces correct output with "Bishop State" + +**Step 4: Commit any final fixes** + +```bash +git add -A +git commit -m "fix: resolve remaining KCTCS references and verify build" +``` + +--- + +## Task 14: Create Hosted Supabase & Deploy to Vercel + +**Step 1: Create hosted Supabase project (manual — user does this in browser)** + +Go to https://supabase.com/dashboard → New Project: +- Project name: `bishop-state-analytics` +- Database password: (save securely) +- Region: US East (closest to Vercel iad1) + +Note connection details from Settings → Database: +``` +Host: db..supabase.co +Port: 6543 (connection pooler — use this for serverless) +Database: postgres +User: postgres. +Password: +``` + +**Step 2: Populate hosted database** + +Update `operations/db_config.py` env var defaults (or set env vars) to point at hosted Supabase, then re-run the ML pipeline: + +```bash +export DB_HOST=db..supabase.co +export DB_PORT=6543 +export DB_NAME=postgres +export DB_USER=postgres. +export DB_PASSWORD= + +cd /Users/william-meroxa/Development/codebenders-datathon +python ai_model/complete_ml_pipeline.py +``` + +Verify data landed in hosted Supabase via Studio dashboard. + +**Step 3: Update Vercel environment variables** + +In Vercel dashboard (https://vercel.com/william-hills-projects/codebenders-dashboard/settings/environment-variables): + +``` +DB_HOST=db..supabase.co +DB_PORT=6543 +DB_NAME=postgres +DB_USER=postgres. +DB_PASSWORD= +OPENAI_API_KEY= +NEXT_PUBLIC_ENABLE_LLM=1 +``` + +**Step 4: Push to main** + +```bash +git checkout main +git merge docs-update +git push origin main +``` + +Vercel auto-deploys on push to main. + +**Step 5: Verify live deployment** + +Wait for Vercel build to complete, then test the production URL: +- [ ] Dashboard loads with Bishop State branding +- [ ] KPIs populate from hosted Supabase +- [ ] Query interface works +- [ ] No console errors referencing KCTCS or MySQL + +**Step 6: Final confirmation** + +View page source / network tab to confirm no KCTCS strings in HTML or API responses. + +--- + +## Summary of Commits + +| Task | Commit Message | +|------|---------------| +| 1 | `feat: initialize local Supabase for Postgres development` | +| 2 | `feat: add synthetic Bishop State data generation script and data files` | +| 3 | `feat: add Bishop State data merge script and merged dataset` | +| 4 | `feat: migrate Python DB layer from pymysql/MariaDB to psycopg2/Supabase Postgres` | +| 5 | `feat: update ML pipeline for Bishop State data and Supabase Postgres` | +| 6 | `feat: add shared Postgres pool module, swap mysql2 for pg` | +| 7 | `feat: migrate dashboard API routes from mysql2 to pg (Postgres)` | +| 8 | `feat: migrate query API routes to Postgres and rebrand to Bishop State` | +| 9 | `feat: rebrand frontend from KCTCS to Bishop State Community College` | +| 10 | `feat: update Docker config from MariaDB to Postgres` | +| 11 | `docs: rebrand all documentation from KCTCS to Bishop State Community College` | +| 12 | `chore: remove old KCTCS data files and scripts` | +| 13 | `fix: resolve remaining KCTCS references and verify build` | +| 14 | Create hosted Supabase + Deploy (no code commit) | diff --git a/docs/plans/2026-02-14-kctcs-to-bishop-state-rebranding-design.md b/docs/plans/2026-02-14-kctcs-to-bishop-state-rebranding-design.md new file mode 100644 index 0000000..09fc9e5 --- /dev/null +++ b/docs/plans/2026-02-14-kctcs-to-bishop-state-rebranding-design.md @@ -0,0 +1,241 @@ +# Design: KCTCS → Bishop State Community College Rebranding + +**Date:** 2026-02-14 +**Status:** Approved +**Approach:** Sequential (Data → DB → ML → Frontend → Docs → Deploy) + +## Context + +The application was built to demo KCTCS (Kentucky Community and Technical College System). Due to permissions issues, we're rebranding to Bishop State Community College (BSCC). University of Akron remains as a secondary institution. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Data source | Generate new synthetic data | No real Bishop State data available | +| Data standard | PDP/AR file compliant | Same column schema as existing KCTCS data; standard NSC PDP field names | +| Database name | `Bishop_State_Community_College` | Mirror the institution-specific naming pattern | +| Database hosting | Supabase (Postgres) | Simpler than AWS RDS, free tier, user prefers Postgres | +| DB driver migration | mysql2 → pg (Node), pymysql → psycopg2 (Python) | Required for Postgres switch | +| Institution structure | Single IPEDS ID (102030) | Bishop State reports as one institution to IPEDS | +| Campus attribute | Main, Southwest, Carver, Baker-Gaines Central | Reflects actual Bishop State campus structure | +| Data scale | ~4,000 students | Realistic for Bishop State enrollment (~3,568 in 2023-24) | +| Demographics | 59% Black, 28% White, 63% female, 32% full-time | Matches Bishop State actual demographics | +| Frontend institutions | Bishop State (default) + University of Akron | These two were in the cohort | +| Frontend approach | Clone V0 code and modify locally | Full control, simpler workflow | +| Deployment | Same Vercel project | No need for new project | + +--- + +## Section 1: Synthetic Data Generation + +Generate ~4,000 PDP-compliant synthetic students matching Bishop State Community College demographics. + +### PDP/AR Schema Compliance + +The synthetic data will use the **exact same column schema** as the existing KCTCS data files, which follow the National Student Clearinghouse Postsecondary Data Partnership (PDP) Analysis-Ready file format: + +- **Cohort file columns:** `id`, `Institution_ID`, `Cohort`, `Student_GUID`, `Cohort_Term`, `Student_Age`, `Enrollment_Type`, `Enrollment_Intensity_First_Term`, `Math_Placement`, `English_Placement`, `Reading_Placement`, demographics, gateway course fields, retention/persistence outcomes, credential tracking, transfer institution data, `school`, `dataset_type`, `created_at`, `zip_code` +- **AR file columns:** `id`, `student_id`, `years_to_bachelors_cohort`, `years_to_bachelor_other`, NASPA first-gen, transfer institution state/Carnegie/locale fields, `school`, `created_at`, `zip_code` +- **Course file columns:** Standard PDP course-level AR file format + +### Bishop State Parameters + +- **IPEDS ID:** 102030 +- **Campuses:** Main (351 N Broad St), Southwest (925 Dauphin Island Pkwy), Carver (414 Stanton Rd), Baker-Gaines Central (1365 MLK Jr Ave) +- **Demographics:** + - Race: 59% Black/African American, 28% White, 4.7% Two+ races, 2.9% Hispanic, 2.3% Asian, 1.2% Native American, 0.1% Pacific Islander + - Gender: 63% female, 37% male + - Enrollment: 32% full-time, 68% part-time + - Age: 76% under 25, 24% over 25 +- **Student GUIDs:** `BSCC_STU00001` format +- **Zip codes:** Mobile, AL area (36601-36695) and Washington County, AL +- **Cohorts:** 2019-20 through 2023-24 +- **Programs:** Health Sciences, Business/Management, STEM, Liberal Arts, Welding/Manufacturing, Cosmetology (aligned to Bishop State's actual programs, mapped to CIP codes) +- **`school` column:** `"BSCC"` (replacing `"KCTCS"`) +- **Transfer states:** AL, MS, FL, GA (Southeast region, replacing KY/OH/MI) + +### Output Files + +| File | Records | Description | +|------|---------|-------------| +| `data/bishop_state_cohorts_with_zip.csv` | ~4,000 | PDP cohort-level AR file | +| `data/ar_bscc_with_zip.csv` | ~4,000 | NSC Analysis-Ready supplementary data | +| `data/bishop_state_courses.csv` | ~100,000 | PDP course-level data | +| `data/bishop_state_student_level_with_zip.csv` | ~4,000 | Merged student-level file (ML pipeline input) | + +--- + +## Section 2: Database Migration (MariaDB/AWS RDS → Supabase Postgres) + +### New Infrastructure + +- Create new Supabase project +- Database: `Bishop_State_Community_College` (or use Supabase's default `postgres` DB with tables directly) +- Same table structure: `student_predictions`, `course_predictions`, `ml_model_performance` + +### Connection Config Changes + +| File | Change | +|------|--------| +| `operations/db_config.py` | Postgres connection params: Supabase host, port 6543 (pooler) or 5432, `sslmode=require` | +| `operations/db_utils.py` | Replace `pymysql` with `psycopg2`, SQLAlchemy URI → `postgresql://` | +| `.env.local` | Update all DB_* vars for Supabase | +| `.docker.env.example` | Update to Postgres config | +| `docker-compose.yml` | Replace MariaDB with Postgres (for local dev) or remove if using Supabase directly | + +### SQL Syntax Changes + +| MySQL/MariaDB | Postgres Equivalent | +|---------------|-------------------| +| Backtick quoting `` ` `` | Double quotes `"` or unquoted | +| `SHOW TABLES` | `SELECT table_name FROM information_schema.tables WHERE table_schema='public'` | +| `?` parameter placeholders | `$1, $2, $3` (node-postgres) | +| `SELECT VERSION()` | `SELECT version()` | + +### Node.js Driver Migration + +| Change | Details | +|--------|---------| +| Package swap | `mysql2` → `pg` in package.json | +| Connection | `mysql.createConnection()` → `new Pool()` from `pg` | +| Queries | `connection.execute(sql, [params])` → `pool.query(sql, [params])` with `$1` placeholders | +| All 6 API routes | Update connection and query patterns | + +--- + +## Section 3: ML Pipeline Updates + +### File Changes + +| File | Change | +|------|--------| +| `ai_model/complete_ml_pipeline.py` | Update data file path, docstring, comments | +| `ai_model/complete_ml_pipeline_csv_only.py` | Same updates | +| `ai_model/merge_kctcs_data.py` | Rename to `merge_bishop_state_data.py`, update all file references | +| `ai_model/__init__.py` | Update module docstring | +| `requirements.txt` | Add `psycopg2-binary`, keep or remove `pymysql` | + +### Model Retraining + +Retrain all 5 models on ~4,000 Bishop State students: + +1. **Retention Prediction** - XGBoost Binary Classification +2. **Early Warning System** - XGBoost Binary Classification +3. **Time-to-Credential** - Regression (XGBoost/Random Forest) +4. **Credential Type** - Multi-class Classification (Random Forest) +5. **Course Success/GPA** - Regression (Random Forest) + +Note: With ~4K students (vs 20K), model performance may differ. We may need to adjust hyperparameters or use more aggressive regularization to avoid overfitting on the smaller dataset. + +--- + +## Section 4: Frontend Rebranding + +### Text/Branding Changes + +| File | Current | New | +|------|---------|-----| +| `app/layout.tsx` | "KCTCS Student Success Dashboard" | "Bishop State Student Success Dashboard" | +| `app/layout.tsx` | "AI-Powered...for KCTCS" | "AI-Powered...for Bishop State Community College" | +| `app/page.tsx` | "KCTCS Student Analytics..." | "Bishop State Student Analytics & Predictive Models" | +| `components/export-button.tsx` | `institution: "KCTCS"` | `institution: "Bishop State"` | + +### Institution Configuration + +**`app/query/page.tsx`** - Update INSTITUTIONS array: +```typescript +const INSTITUTIONS = [ + { name: "Bishop State", code: "bscc" }, + { name: "University of Akron", code: "akron" }, +] +``` + +### API Route Updates (6 routes) + +All routes under `app/api/` need: +1. DB driver swap (mysql2 → pg) +2. Query parameter syntax (`?` → `$1`) +3. Default database name update +4. Connection pool setup change + +Key routes: +- `app/api/analyze/route.ts` - Replace `kctcs` schema key with `bscc`, update DB name +- `app/api/execute-sql/route.ts` - Update DB connection +- `app/api/dashboard/kpis/route.ts` - Update DB connection +- `app/api/dashboard/risk-alerts/route.ts` - Update DB connection +- `app/api/dashboard/retention-risk/route.ts` - Update DB connection +- `app/api/dashboard/readiness/route.ts` - Update DB connection + +### Library Updates + +- `lib/prompt-analyzer.ts` - Update `institutionDbMap` (remove kctcs, add bscc) +- `lib/query-executor.ts` - Update if institution codes changed + +--- + +## Section 5: Documentation Updates + +~15+ files need updating. Replace all references: + +| Find | Replace With | +|------|-------------| +| "KCTCS" | "BSCC" or "Bishop State" (context-dependent) | +| "Kentucky Community and Technical College System" | "Bishop State Community College" | +| "Kentucky_Community_and_Technical_College_System" | "Bishop_State_Community_College" | +| "kctcs_*.csv" file references | "bishop_state_*.csv" or "ar_bscc_*.csv" | +| "~20K students" / "32,800 students" | "~4,000 students" | +| "16 colleges/institutions" | "4 campuses" | +| "MariaDB" references | "Postgres" / "Supabase" | +| Kentucky zip codes | Alabama zip codes | + +### Key Files + +CLAUDE.md, README.md, QUICKSTART.md, DATA_DICTIONARY.md, ML_MODELS_GUIDE.md, DOCKER_SETUP.md, DASHBOARD_README.md, operations/README.md, AI_Powered_Student_Success_PRD.md, DASHBOARD_VISUALIZATIONS.md, docs/README_api.md, docs/README_datageneration.md, READINESS_ASSESSMENT_INTEGRATION.md, hackathon_github_issues.md + +--- + +## Section 6: Deployment + +### Steps + +1. **Create Supabase project** - Note connection credentials (host, port, password, pooler URL) +2. **Run ML pipeline** - Generate predictions, populate Supabase database +3. **Update Vercel env vars** - DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD (in Vercel dashboard) +4. **Push to main** - Vercel auto-deploys +5. **Smoke test** - Dashboard loads, KPIs populate, query interface works, exports work +6. **Final KCTCS grep** - Verify no KCTCS references remain in the live UI + +### Vercel Environment Variables to Update + +``` +DB_HOST=.supabase.co +DB_PORT=6543 +DB_NAME=postgres +DB_USER=postgres. +DB_PASSWORD= +``` + +--- + +## Files to Delete + +| File | Reason | +|------|--------| +| `data/kctcs_*.csv` (all) | Replaced by bishop_state_*.csv | +| `data/ar_kcts_with_zip.csv` | Replaced by ar_bscc_with_zip.csv | +| `database_dumps/dump-Kentucky_*.sql` | Old MariaDB dump, no longer needed | +| `ai_model/merge_kctcs_data.py` | Replaced by merge_bishop_state_data.py | +| `kctcs_student_level_with_predictions_schema.json` | Replace with bishop_state version | + +--- + +## Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Postgres SQL incompatibilities | Test all API queries against Supabase before deploying | +| ML model performance on smaller dataset (~4K vs ~20K) | Validate metrics; adjust regularization if overfitting | +| Missed KCTCS references | Final `grep -ri "kctcs\|kentucky" .` sweep before deployment | +| Supabase free tier limits | ~4K students + ~100K courses well within 500MB free tier | +| Node-postgres parameter syntax | Systematic replacement of `?` with `$1, $2, $3` in all queries | diff --git a/docs/plans/2026-02-20-readiness-pdp-alignment-and-methodology.md b/docs/plans/2026-02-20-readiness-pdp-alignment-and-methodology.md new file mode 100644 index 0000000..0b73a23 --- /dev/null +++ b/docs/plans/2026-02-20-readiness-pdp-alignment-and-methodology.md @@ -0,0 +1,919 @@ +# Readiness Scoring: PDP Alignment, Methodology Docs & LLM Enrichment + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Align the readiness rule engine with PDP momentum metrics, document the methodology with proper research citations, add a frontend methodology page, and implement optional LLM-based recommendation enrichment using OpenAI. + +**Architecture:** The rule engine (Option C) remains the authoritative scoring source — deterministic, FERPA-safe, fully traceable. LLM enrichment is an optional post-processing step that replaces only the `rationale` and `suggested_actions` text fields for medium/low readiness students, never the numeric score. A new `codebenders-dashboard/app/methodology/page.tsx` provides a human-readable explanation of the approach with research citations. + +**Tech Stack:** Python 3.8+ (rule engine), OpenAI Python SDK (enrichment), Next.js 16 / React 19 / Tailwind CSS (methodology page), PostgreSQL (Supabase), shadcn/ui components. + +--- + +## Context: Why These Changes + +### PDP Alignment Gaps (from research audit) +The Postsecondary Data Partnership uses **momentum metrics** as leading indicators of student success. Two PDP metrics are absent from the current rule engine: + +1. **12-credit Year 1 milestone** — completing ≥12 credits in the first year is the single strongest PDP leading indicator. We have `Number_of_Credits_Earned_Year_1` but don't score it. +2. **Math placement as a direct input** — our XGBoost model found `Math_Placement` has 35.1% feature importance (highest of all features), yet the rule engine only captures gateway completion (a downstream outcome), not placement itself. `Math_Placement` values in the data: `C` (college-level), `R` (remedial), `N` (none/unknown). + +### LLM Feasibility Conclusion +Using an LLM to *score* readiness is not recommended — scores must be deterministic, auditable, and FERPA-safe. Using an LLM to *narrate* personalized intervention recommendations is **feasible and well-suited**: we send only the FERPA-safe profile (no GUID/PII) + the rule engine score to an LLM, receive enriched natural language, and store it in the existing `rationale` and `suggested_actions` fields. The numeric score never changes. This maps directly to how Civitas Learning and similar platforms use generative AI in higher ed. **LiteLLM** is used as the provider interface so the model (OpenAI, Ollama, Anthropic, Azure, etc.) is a runtime flag — no code changes needed to switch providers. + +--- + +## Task 1: Create `docs/READINESS_METHODOLOGY.md` + +**Files:** +- Create: `docs/READINESS_METHODOLOGY.md` + +**Step 1: Create the file with the following content** + +```markdown +# Student Readiness Assessment Methodology + +**Version:** rules_v1 +**Last Updated:** 2026-02-20 +**Script:** `ai_model/generate_readiness_scores.py` +**Table:** `llm_recommendations` + +--- + +## Overview + +The Bishop State Student Readiness Assessment scores each student on a 0.0–1.0 scale using a weighted combination of three evidence-based sub-scores. The methodology is aligned with the **Postsecondary Data Partnership (PDP)** momentum metrics framework and validated by Community College Research Center (CCRC) research on multiple measures assessment. + +Every score is fully traceable to its inputs via the `input_features` JSONB column. No personally identifiable information (PII) is stored in scoring inputs (FERPA §99.31). + +--- + +## Research Foundation + +### Postsecondary Data Partnership (PDP) — National Student Clearinghouse +The PDP defines **leading indicators** (early momentum metrics) and **lagging indicators** (outcomes) for community college student success. Our scoring directly incorporates the PDP's four core momentum metrics: + +| PDP Metric | Our Feature | Sub-Score | +|---|---|---| +| Gateway math completion Year 1 | `CompletedGatewayMathYear1` | Academic (gateway component) | +| Gateway English completion Year 1 | `CompletedGatewayEnglishYear1` | Academic (gateway component) | +| Credit completion ratio | `course_completion_rate` | Academic | +| Credit accumulation (≥12 credits Year 1) | `Number_of_Credits_Earned_Year_1` | Academic (momentum component) | +| Enrollment intensity | `Enrollment_Intensity_First_Term` | Engagement | + +> Source: [Postsecondary Data Partnership Metrics, National Student Clearinghouse](https://www.studentclearinghouse.org/academy/courses/postsecondary-data-partnership-an-introduction/lessons/the-postsecondary-data-partnership-metrics/) + +### Multiple Measures Assessment — CCRC / CAPR +Research by the Community College Research Center (CCRC) and Center for the Analysis of Postsecondary Readiness (CAPR) demonstrates that combining multiple academic indicators — GPA, placement level, course completion, and gateway outcomes — produces more accurate and equitable student assessments than any single metric. + +> Source: [Modernizing College Course Placement by Using Multiple Measures, CCRC](https://ccrc.tc.columbia.edu/publications/modernizing-college-course-placement-multiple-measures.html) +> Source: [Lessons From Two Experimental Studies of Multiple Measures Assessment, CCRC/CAPR](https://ccrc.tc.columbia.edu/publications/multiple-measures-assessment-lessons-capr.html) + +### Transparency in Predictive Analytics +Bird, Castleman, Mabel & Song (2021) found that advisors distrusted and underused opaque machine learning predictions in higher education settings. Transparent, rule-based scoring with human-readable explanations improves adoption and intervention rates. + +> Source: [Bringing Transparency to Predictive Analytics, Bird et al. (2021), AERA Open](https://journals.sagepub.com/doi/full/10.1177/23328584211037630) + +### Math Placement as a Predictor +Our own XGBoost retention model found `Math_Placement` to be the single most important feature (35.1% of model importance). This aligns with extensive research on math placement as a gateway to college-level coursework and long-term credential completion. + +--- + +## Scoring Formula + +``` +readiness_score = (academic_score × 0.40) + + (engagement_score × 0.30) + + (ml_score × 0.30) +``` + +### Readiness Levels +| Score | Level | +|---|---| +| ≥ 0.65 | high | +| ≥ 0.40 | medium | +| < 0.40 | low | + +--- + +## Sub-Scores + +### Academic Score (weight: 0.40) +Average of five equally-weighted components: + +| Component | Source Field | Calculation | +|---|---|---| +| GPA | `GPA_Group_Year_1` | `min(gpa / 4.0, 1.0)` — null → 0.5 | +| Course completion | `course_completion_rate` | direct — null → 0.5 | +| Passing rate | `passing_rate` | direct — null → 0.5 | +| Gateway completion | `CompletedGatewayMathYear1`, `CompletedGatewayEnglishYear1` | 0.5 + 0.25 per gateway completed | +| Credit momentum | `Number_of_Credits_Earned_Year_1` | ≥12 → 1.0, ≥6 → 0.6, <6 → 0.3, null → 0.5 | + +The credit momentum component directly implements the PDP's 12-credit Year 1 milestone. + +### Engagement Score (weight: 0.30) +Average of three components: + +| Component | Source Field | Calculation | +|---|---|---| +| Enrollment intensity | `Enrollment_Intensity_First_Term` | FT → 1.0, PT/LE → 0.5, unknown → 0.3 | +| Courses enrolled | `total_courses_enrolled` | `min(courses / 10.0, 1.0)` — null → 0.5 | +| Math placement | `Math_Placement` | C → 1.0, N → 0.5, R → 0.2 | + +Math placement is included here because it reflects incoming academic preparation (an engagement/readiness predictor), not a gateway outcome. It mirrors the research finding that pre-enrollment placement level is among the strongest early indicators. + +### ML Score (weight: 0.30) +Inverts ML-predicted risk into a readiness signal: + +| Component | Source Field | Calculation | +|---|---|---| +| Retention probability | `retention_probability` | direct (higher = more ready) — null → 0.5 | +| At-risk alert | `at_risk_alert` | URGENT→0.1, HIGH→0.3, MODERATE→0.6, LOW→0.9 — unknown → 0.5 | + +--- + +## FERPA Compliance + +The `input_features` JSONB column stores a stripped profile containing no PII: +- **Excluded:** `Student_GUID`, zip code, name, date of birth, address +- **Included:** Aggregate behavioral metrics (GPA group, completion rate, placement level, enrollment type) + +This satisfies FERPA §99.31(a)(1) for legitimate educational interest use. No student-level data is transmitted to external services in the rule engine path. + +--- + +## LLM Recommendation Enrichment (Optional) + +The numeric readiness score is always computed by the rule engine. Optionally, personalized narrative recommendations can be generated using OpenAI's API: + +``` +Rule engine score (deterministic) → FERPA-safe profile + score → OpenAI API + → enriched rationale + → enriched suggested_actions +``` + +**What changes:** Only the `rationale` and `suggested_actions` text fields. +**What never changes:** `readiness_score`, `readiness_level`, `source`, `model_version`, `input_features`. + +Run with enrichment: +```bash +venv/bin/python ai_model/generate_readiness_scores.py --enrich-with-llm +``` + +The enrichment targets only medium and low readiness students (those most likely to benefit from a personalized intervention narrative). High readiness students retain rule-generated text. + +--- + +## Limitations + +1. **No behavioral engagement data.** CCSSE/SENSE research identifies help-seeking behavior, faculty interaction, and first-week engagement as strong predictors — none of which are captured in administrative records. +2. **Weights are not empirically learned.** The 0.40/0.30/0.30 sub-score weights and component weights within each sub-score reflect the PDP's emphasis on academic indicators but have not been validated against Bishop State outcome data. An ML-trained readiness model (Option B) could learn optimal weights from historical data. +3. **Static thresholds.** The high/medium/low thresholds (0.65, 0.40) are heuristic. Institutions implementing PDP dashboards typically calibrate thresholds to their own cohort distributions. + +--- + +## Upgrade Path + +| Option | Description | Schema changes | +|---|---|---| +| Option C (current) | Rule engine, deterministic | — | +| Option C+ | Rule engine + OpenAI narrative enrichment | None | +| Option A | Ollama local LLM scoring (replaces score) | None — same table, `source='ollama'` | +| Option B | ML-trained readiness model (learned weights) | None — same table, `source='ml_model'` | +``` + +**Step 2: Verify file was created** + +```bash +ls -la docs/READINESS_METHODOLOGY.md +``` + +Expected: file exists, non-zero size. + +**Step 3: Commit** + +```bash +git add docs/READINESS_METHODOLOGY.md +git commit -m "docs: add readiness methodology with PDP citations and LLM feasibility" +``` + +--- + +## Task 2: Update `ML_MODELS_GUIDE.md` to reference readiness methodology + +**Files:** +- Modify: `ML_MODELS_GUIDE.md` + +**Step 1: Add a Readiness Scoring section to the Summary Table** + +Find the Summary Table (around line 22) and add a row: + +```markdown +| **9. Readiness Score** | How prepared is this student for success? | Rule-based | Advisor prioritization & intervention planning | +``` + +**Step 2: Add a new section after the 8-model descriptions** + +At the end of the models section, add: + +```markdown +--- + +## 📐 Model 9: Student Readiness Score (Rule-Based) + +**Type:** Weighted rule engine (not ML) +**Output:** `readiness_score` (0.0–1.0), `readiness_level` (high/medium/low) +**Table:** `llm_recommendations` +**Script:** `ai_model/generate_readiness_scores.py` + +Unlike the 8 ML models above, the readiness score is a **deterministic rule-based system** aligned with Postsecondary Data Partnership (PDP) momentum metrics. It combines: + +- **Academic sub-score (40%):** GPA, course completion rate, passing rate, gateway course completion, and Year 1 credit momentum (≥12 credits) +- **Engagement sub-score (30%):** Enrollment intensity, total courses enrolled, math placement level +- **ML risk sub-score (30%):** Retention probability and at-risk alert from Models 1 & 2 (inverted — higher retention probability = higher readiness) + +See [`docs/READINESS_METHODOLOGY.md`](docs/READINESS_METHODOLOGY.md) for full formula, research citations, and upgrade path. + +To regenerate scores: +```bash +venv/bin/python ai_model/generate_readiness_scores.py +``` +``` + +**Step 3: Commit** + +```bash +git add ML_MODELS_GUIDE.md +git commit -m "docs: add readiness score section to ML models guide" +``` + +--- + +## Task 3: Update rule engine with PDP-aligned improvements + +**Files:** +- Modify: `ai_model/generate_readiness_scores.py` + +This task makes three targeted changes to `compute_readiness()`, `build_safe_profile()`, and `build_risk_factors()`: + +### Change 1: Add `credit_momentum_component` to academic sub-score + +In `compute_readiness()`, find the academic sub-score section and replace: + +**Before:** +```python + gateway_component = 0.5 + (0.25 if math_done else 0.0) + (0.25 if english_done else 0.0) + + academic_score = np.mean([gpa_component, completion_component, passing_component, gateway_component]) +``` + +**After:** +```python + gateway_component = 0.5 + (0.25 if math_done else 0.0) + (0.25 if english_done else 0.0) + + credits_y1 = _safe_float(row.get("Number_of_Credits_Earned_Year_1")) + if credits_y1 is None: + credit_momentum_component = 0.5 + elif credits_y1 >= 12: + credit_momentum_component = 1.0 # PDP 12-credit momentum milestone + elif credits_y1 >= 6: + credit_momentum_component = 0.6 + else: + credit_momentum_component = 0.3 + + academic_score = np.mean([gpa_component, completion_component, passing_component, + gateway_component, credit_momentum_component]) +``` + +### Change 2: Add `math_placement_component` to engagement sub-score + +In `compute_readiness()`, find the engagement sub-score section and replace: + +**Before:** +```python + total_courses = _safe_float(row.get("total_courses_enrolled")) + courses_score = min(total_courses / 10.0, 1.0) if total_courses is not None else 0.5 + + engagement_score = np.mean([intensity_score, courses_score]) +``` + +**After:** +```python + total_courses = _safe_float(row.get("total_courses_enrolled")) + courses_score = min(total_courses / 10.0, 1.0) if total_courses is not None else 0.5 + + math_placement = str(row.get("Math_Placement", "")).strip().upper() + math_placement_score = {"C": 1.0, "R": 0.2, "N": 0.5}.get(math_placement, 0.5) + + engagement_score = np.mean([intensity_score, courses_score, math_placement_score]) +``` + +### Change 3: Add credit momentum to safe profile + +In `build_safe_profile()`, add to the returned dict: +```python + "credits_earned_y1": safe_float(row.get("Number_of_Credits_Earned_Year_1")), +``` +(This line already exists in the current safe_profile — verify it's there, no change needed.) + +### Change 4: Add credit momentum risk factor + +In `build_risk_factors()`, add after the gateway English check: +```python + credits_y1 = _safe_float(row.get("Number_of_Credits_Earned_Year_1")) + if credits_y1 is not None and credits_y1 < 12: + factors.append(f"Below 12-credit Year 1 milestone ({int(credits_y1)} credits earned)") +``` + +### Change 5: Add suggested action for credit momentum + +In `build_suggested_actions()`, add to the factor_text checks: +```python + if "12-credit year 1 milestone" in factor_text: + actions.append("Increase credit load to reach 12-credit first-year milestone") +``` + +**Step 1: Apply all five changes above to `ai_model/generate_readiness_scores.py`** + +**Step 2: Re-run the script and verify output** + +```bash +venv/bin/python ai_model/generate_readiness_scores.py +``` + +Expected output: +``` +✓ Scored 4,000 students (0 errors) +``` + +**Step 3: Spot-check that credit momentum appears in risk factors** + +```bash +PGPASSWORD=postgres psql -h 127.0.0.1 -p 54332 -U postgres -d postgres \ + -c "SELECT risk_factors FROM llm_recommendations WHERE risk_factors LIKE '%12-credit%' LIMIT 3;" +``` + +Expected: rows returned with credit milestone text. + +**Step 4: Verify score distribution shifted slightly (PDP metrics added weight)** + +```bash +PGPASSWORD=postgres psql -h 127.0.0.1 -p 54332 -U postgres -d postgres \ + -c "SELECT readiness_level, COUNT(*), ROUND(AVG(readiness_score)::numeric,4) FROM llm_recommendations GROUP BY readiness_level;" +``` + +**Step 5: Commit** + +```bash +git add ai_model/generate_readiness_scores.py +git commit -m "feat: add PDP credit momentum and math placement to readiness rule engine" +``` + +--- + +## Task 4: Implement optional LLM recommendation enrichment (via LiteLLM) + +**Files:** +- Modify: `ai_model/generate_readiness_scores.py` +- Modify: `requirements.txt` + +LiteLLM provides a single unified interface to 100+ LLM providers. Swapping providers is a runtime flag — no code changes needed. The enrichment is additive: it only replaces narrative text (`rationale`, `suggested_actions`) for medium/low readiness students. The numeric score is never changed. + +Supported providers (examples): +| `--llm-model` value | Provider | Credentials needed | +|---|---|---| +| `gpt-4o-mini` | OpenAI | `OPENAI_API_KEY` | +| `ollama/llama3.2:3b` | Local Ollama | None (free) | +| `claude-haiku-4-5-20251001` | Anthropic | `ANTHROPIC_API_KEY` | +| `azure/gpt-4o` | Azure OpenAI | `AZURE_API_KEY` + `AZURE_API_BASE` | + +### Step 1: Add `litellm` to `requirements.txt` + +Open `requirements.txt` and add: +``` +litellm>=1.40.0 +``` + +Then install it: +```bash +venv/bin/pip install litellm>=1.40.0 +``` + +Expected: installs without error. + +### Step 2: Add argparse and litellm imports at the top of the script + +After the existing imports, add: +```python +import argparse +import litellm +from litellm import completion as llm_completion + +litellm.telemetry = False # opt out of LiteLLM usage telemetry +``` + +### Step 3: Add the `enrich_with_llm()` function + +Add this function before `main()`: + +```python +def enrich_with_llm(record: dict, model: str) -> dict: + """ + Replace rationale and suggested_actions with LLM-generated content. + Only called for medium/low readiness students. + Input is the FERPA-safe profile — no PII sent to any external service. + Returns the record with enriched text fields (score unchanged). + + Provider is determined by the model string: + "gpt-4o-mini" → OpenAI (requires OPENAI_API_KEY) + "ollama/llama3.2:3b" → local Ollama (no key needed) + "claude-haiku-4-5-20251001" → Anthropic (requires ANTHROPIC_API_KEY) + """ + profile = json.loads(record["input_features"]) if isinstance(record["input_features"], str) else record["input_features"] + risk_factors = json.loads(record["risk_factors"]) if isinstance(record["risk_factors"], str) else [] + + prompt = f"""You are an academic advisor assistant at Bishop State Community College. +A student has a readiness score of {record['readiness_score']:.2f} ({record['readiness_level']} readiness). + +Student profile (no PII): +- Enrollment: {profile.get('enrollment_type')} / {profile.get('enrollment_intensity')} +- First-year GPA: {profile.get('gpa_year1')} +- Course completion rate: {profile.get('course_completion_rate')} +- Gateway math completed: {profile.get('gateway_math_completed')} +- Gateway English completed: {profile.get('gateway_english_completed')} +- Credits earned Year 1: {profile.get('credits_earned_y1')} +- Math placement: {profile.get('math_placement')} +- At-risk alert: {profile.get('at_risk_alert')} +- Retention probability: {profile.get('retention_probability')} + +Identified risk factors: +{chr(10).join(f'- {f}' for f in risk_factors)} + +Write two things: +1. RATIONALE: A 2-sentence explanation of this student's readiness score for an advisor. +2. ACTIONS: A JSON array of 3-5 specific, actionable intervention recommendations (strings only). + +Format your response exactly as: +RATIONALE: +ACTIONS: """ + + try: + response = llm_completion( + model=model, + messages=[{"role": "user", "content": prompt}], + max_tokens=400, + temperature=0.3, + ) + text = response.choices[0].message.content.strip() + + rationale_line = next((l for l in text.split("\n") if l.startswith("RATIONALE:")), None) + actions_line = next((l for l in text.split("\n") if l.startswith("ACTIONS:")), None) + + if rationale_line: + record["rationale"] = rationale_line.replace("RATIONALE:", "").strip() + if actions_line: + record["suggested_actions"] = actions_line.replace("ACTIONS:", "").strip() + + except Exception as e: + print(f" ⚠ LLM enrichment failed for {record['Student_GUID']}: {e}") + # Falls back silently to rule-generated text + + return record +``` + +### Step 4: Update `main()` to accept `--enrich-with-llm` and `--llm-model` flags + +Replace the `def main():` line and opening with: + +```python +def main(): + parser = argparse.ArgumentParser(description="Generate student readiness scores") + parser.add_argument( + "--enrich-with-llm", + action="store_true", + help="Enrich rationale and suggested_actions for medium/low students via LiteLLM", + ) + parser.add_argument( + "--llm-model", + default="gpt-4o-mini", + help=( + "LiteLLM model string (default: gpt-4o-mini). Examples: " + "'ollama/llama3.2:3b', 'claude-haiku-4-5-20251001'. " + "Credentials resolved automatically from environment variables." + ), + ) + args = parser.parse_args() + + if args.enrich_with_llm: + print(f"✓ LLM enrichment enabled — model: {args.llm_model}") + print(" (medium/low readiness students only; score is never changed)") +``` + +### Step 5: Add enrichment call in the scoring loop + +In `main()`, after `record["run_id"] = run_id` and before `records.append(record)`, add: + +```python + if args.enrich_with_llm and record["readiness_level"] in ("medium", "low"): + record = enrich_with_llm(record, args.llm_model) +``` + +### Step 6: Verify script runs normally without the flag + +```bash +venv/bin/python ai_model/generate_readiness_scores.py +``` + +Expected: runs normally, identical behavior to before. + +### Step 7: (Optional) Test enrichment with a local Ollama model (no API key needed) + +If Ollama is running locally with llama3.2:3b pulled: +```bash +venv/bin/python ai_model/generate_readiness_scores.py \ + --enrich-with-llm --llm-model ollama/llama3.2:3b +``` + +Or with OpenAI: +```bash +OPENAI_API_KEY= venv/bin/python ai_model/generate_readiness_scores.py \ + --enrich-with-llm --llm-model gpt-4o-mini +``` + +Expected: medium/low students have longer, more personalized rationale text in the DB. + +### Step 8: Commit + +```bash +git add ai_model/generate_readiness_scores.py requirements.txt +git commit -m "feat: add optional LiteLLM narrative enrichment for medium/low readiness students" +``` + +--- + +## Task 5: Create `codebenders-dashboard/app/methodology/page.tsx` + +**Files:** +- Create: `codebenders-dashboard/app/methodology/page.tsx` + +This is a static page (no data fetching needed). It uses only shadcn/ui Card, Badge, and standard Tailwind layout — no new dependencies. + +**Step 1: Create the file** + +```tsx +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import Link from "next/link" +import { ArrowLeft, BookOpen, Database, FlaskConical, ShieldCheck } from "lucide-react" + +export const metadata = { + title: "Readiness Methodology — Bishop State Student Success Dashboard", +} + +const CITATIONS = [ + { + id: 1, + authors: "National Student Clearinghouse", + title: "Postsecondary Data Partnership Metrics", + url: "https://www.studentclearinghouse.org/academy/courses/postsecondary-data-partnership-an-introduction/lessons/the-postsecondary-data-partnership-metrics/", + year: "2024", + }, + { + id: 2, + authors: "Community College Research Center (CCRC)", + title: "Modernizing College Course Placement by Using Multiple Measures", + url: "https://ccrc.tc.columbia.edu/publications/modernizing-college-course-placement-multiple-measures.html", + year: "2023", + }, + { + id: 3, + authors: "CCRC / Center for the Analysis of Postsecondary Readiness (CAPR)", + title: "Lessons From Two Experimental Studies of Multiple Measures Assessment", + url: "https://ccrc.tc.columbia.edu/publications/multiple-measures-assessment-lessons-capr.html", + year: "2022", + }, + { + id: 4, + authors: "Bird, Castleman, Mabel & Song", + title: "Bringing Transparency to Predictive Analytics: A Systematic Comparison of Predictive Modeling Methods in Higher Education", + url: "https://journals.sagepub.com/doi/full/10.1177/23328584211037630", + year: "2021", + }, + { + id: 5, + authors: "Achieving the Dream", + title: "Postsecondary Data Partnership (PDP)", + url: "https://achievingthedream.org/innovation/postsecondary-data-partnership-pdp/", + year: "2024", + }, +] + +export default function MethodologyPage() { + return ( +
    +
    + {/* Header */} +
    + + + Back to Dashboard + +

    + Readiness Assessment Methodology +

    +

    + How student readiness scores are calculated, the research behind the approach, and its alignment with the Postsecondary Data Partnership (PDP) framework. +

    +
    + Version: rules_v1 + Script: generate_readiness_scores.py + Table: llm_recommendations +
    +
    + + {/* Research Foundation */} +
    +
    + +

    Research Foundation

    +
    +

    + The scoring methodology is grounded in three bodies of research from leading higher education institutions: +

    +
    + + + Postsecondary Data Partnership (PDP) + National Student Clearinghouse [1][5] + + + The PDP defines leading indicators (early momentum metrics) and lagging indicators (outcomes) for community college student success. + Our academic sub-score directly incorporates the PDP's four core momentum metrics: gateway math and English completion, credit completion ratio, and the 12-credit Year 1 milestone. + The PDP framework itself uses explicit metric thresholds — validating a rule-based approach over black-box ML for institutional reporting contexts. + + + + + Multiple Measures Assessment + Community College Research Center (CCRC) / CAPR [2][3] + + + CCRC and CAPR experimental studies found that combining multiple academic indicators — GPA, placement level, course completion, and gateway outcomes — + significantly outperforms single-measure assessment. Students placed via multiple measures pass gateway courses at equal or higher rates, + and the effects persist for 3+ semesters. Our scoring is explicitly a multiple-measures system. + + + + + Transparency in Predictive Analytics + Bird, Castleman, Mabel & Song (2021) [4] + + + This study found that advisors distrusted and underused opaque machine learning predictions in higher education settings. + Transparent, rule-based scoring with human-readable explanations improves advisor adoption and student intervention rates. + Every readiness score in this system is fully traceable to its inputs via the input_features column. + + +
    +
    + + {/* Scoring Formula */} +
    +
    + +

    Scoring Formula

    +
    + + +

    + readiness_score = (academic_score × 0.40)
    +                  + (engagement_score × 0.30)
    +                  + (ml_score × 0.30) +

    +
    +
    +
    ≥ 0.65
    +
    High Readiness
    +
    +
    +
    0.40 – 0.64
    +
    Medium Readiness
    +
    +
    +
    {'<'} 0.40
    +
    Low Readiness
    +
    +
    +
    +
    + + {/* Sub-scores */} +
    + + +
    + Academic Sub-Score + Weight: 40% +
    + Average of 5 equally-weighted components (PDP-aligned) +
    + + + + + + + + + + +
    ComponentSource FieldCalculation
    GPAGPA_Group_Year_1min(gpa / 4.0, 1.0)
    Course completioncourse_completion_ratedirect (0.0–1.0)
    Passing ratepassing_ratedirect (0.0–1.0)
    Gateway completionCompletedGateway*Year10.5 + 0.25 per gateway
    Credit momentum PDPCredits_Earned_Year_1≥12→1.0, ≥6→0.6, {'<'}6→0.3
    +
    +
    + + + +
    + Engagement Sub-Score + Weight: 30% +
    + Average of 3 equally-weighted components +
    + + + + + + + + +
    ComponentSource FieldCalculation
    Enrollment intensityEnrollment_Intensity_First_TermFT→1.0, PT→0.5, unknown→0.3
    Courses enrolledtotal_courses_enrolledmin(courses / 10, 1.0)
    Math placementMath_PlacementC→1.0, N→0.5, R→0.2
    +
    +
    + + + +
    + ML Risk Sub-Score + Weight: 30% +
    + Inverted ML risk signal — higher retention probability = higher readiness +
    + + + + + + + +
    ComponentSource FieldCalculation
    Retention probabilityretention_probabilitydirect (Model 1 output)
    At-risk alertat_risk_alertURGENT→0.1, HIGH→0.3, MODERATE→0.6, LOW→0.9
    +
    +
    +
    +
    + + {/* FERPA */} +
    +
    + +

    FERPA Compliance

    +
    + + +

    + The input_features column stores a stripped profile with no PII: Student_GUID and zip code are excluded before storage. + Only aggregate behavioral metrics (GPA group, completion rate, placement level, enrollment type) are retained. +

    +

    + When LLM narrative enrichment is enabled, only the FERPA-safe profile is transmitted to the OpenAI API — never the Student_GUID, name, date of birth, or address. + This satisfies FERPA §99.31(a)(1) for legitimate educational interest use. +

    +
    +
    +
    + + {/* Data Source */} +
    +
    + +

    Data Source

    +
    + + +

    Input table: student_level_with_predictions (~4,000 students)

    +

    Output table: llm_recommendations

    +

    Scoring script: ai_model/generate_readiness_scores.py

    +

    Re-run command: venv/bin/python ai_model/generate_readiness_scores.py

    +

    Re-running the script upserts scores — no duplicates are created. Each run is logged in readiness_generation_runs.

    +
    +
    +
    + + {/* Citations */} +
    +

    References

    +
      + {CITATIONS.map((c) => ( +
    1. + [{c.id}]{" "} + {c.authors} ({c.year}).{" "} + + {c.title} + +
    2. + ))} +
    +
    +
    +
    + ) +} +``` + +**Step 2: Verify the file was created** + +```bash +ls codebenders-dashboard/app/methodology/page.tsx +``` + +**Step 3: Commit** + +```bash +git add codebenders-dashboard/app/methodology/page.tsx +git commit -m "feat: add methodology page with PDP citations and scoring breakdown" +``` + +--- + +## Task 6: Add Methodology nav link to the dashboard header + +**Files:** +- Modify: `codebenders-dashboard/app/page.tsx:146-151` + +**Step 1: Add import for the icon** (at the top, `BookOpen` is already imported on line 10 — no change needed) + +**Step 2: Add Methodology link alongside SQL Query Interface button** + +Find the existing SQL Query link block: +```tsx + + + +``` + +Replace with: +```tsx + + + + + + +``` + +**Step 3: Verify the dashboard still loads (dev server should be running)** + +```bash +curl -s http://localhost:3001 | grep -o "Methodology" | head -1 +``` + +Expected: `Methodology` + +**Step 4: Commit** + +```bash +git add codebenders-dashboard/app/page.tsx +git commit -m "feat: add Methodology nav link to dashboard header" +``` + +--- + +## Verification Checklist + +After all tasks complete: + +- [ ] `docs/READINESS_METHODOLOGY.md` exists with citations and formula +- [ ] `ML_MODELS_GUIDE.md` references readiness as Model 9 +- [ ] `generate_readiness_scores.py` scores 4,000 students with 0 errors after PDP changes +- [ ] Credit momentum risk factor appears in DB for students with <12 credits earned +- [ ] `--enrich-with-llm` and `--llm-model` flags accepted without error (no API key needed to run normally) +- [ ] `litellm` added to `requirements.txt` and installed in venv +- [ ] `/methodology` page loads and renders all sections +- [ ] Dashboard header shows "Methodology" button linking to `/methodology` diff --git a/migrations/001_user_roles.sql b/migrations/001_user_roles.sql new file mode 100644 index 0000000..cf4aac5 --- /dev/null +++ b/migrations/001_user_roles.sql @@ -0,0 +1,20 @@ +-- Run this in the Supabase SQL editor (or via supabase db push) + +-- Role lookup table — one row per user; references auth.users +CREATE TABLE IF NOT EXISTS public.user_roles ( + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('admin', 'advisor', 'ir', 'faculty', 'leadership')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id) +); + +-- Row-Level Security: authenticated users may read their own role only +ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users can read own role" ON public.user_roles; +CREATE POLICY "Users can read own role" + ON public.user_roles + FOR SELECT + USING (auth.uid() = user_id); + +-- Service role (used by seed script) bypasses RLS automatically diff --git a/scripts/seed-demo-users.ts b/scripts/seed-demo-users.ts new file mode 100644 index 0000000..00d263f --- /dev/null +++ b/scripts/seed-demo-users.ts @@ -0,0 +1,93 @@ +/** + * Seed five demo users (one per role) into Supabase Auth and the user_roles table. + * + * Usage: + * SUPABASE_URL=https://.supabase.co \ + * SUPABASE_SERVICE_ROLE_KEY= \ + * npx tsx scripts/seed-demo-users.ts + * + * The service role key is available in Supabase → Project Settings → API. + * Never commit it to source control. + */ + +import { createClient } from "@supabase/supabase-js" + +const SUPABASE_URL = + process.env.NEXT_PUBLIC_SUPABASE_URL ?? + process.env.SUPABASE_URL + +const SUPABASE_SERVICE_ROLE_KEY = + process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY ?? + process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + console.error( + "Missing required env vars. Set NEXT_PUBLIC_SUPABASE_URL + NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY " + + "(or SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY)" + ) + process.exit(1) +} + +const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { autoRefreshToken: false, persistSession: false }, +}) + +const DEMO_USERS = [ + { email: "admin@bscc.edu", password: "BishopState2025!", role: "admin" }, + { email: "advisor@bscc.edu", password: "BishopState2025!", role: "advisor" }, + { email: "ir@bscc.edu", password: "BishopState2025!", role: "ir" }, + { email: "faculty@bscc.edu", password: "BishopState2025!", role: "faculty" }, + { email: "leadership@bscc.edu", password: "BishopState2025!", role: "leadership" }, +] + +async function seed() { + console.log("Seeding demo users…\n") + + for (const demo of DEMO_USERS) { + // Create or look up user + const { data: createData, error: createError } = + await supabase.auth.admin.createUser({ + email: demo.email, + password: demo.password, + email_confirm: true, + }) + + if (createError && !createError.message.includes("already been registered")) { + console.error(` ✗ ${demo.email}: ${createError.message}`) + continue + } + + // If user already existed, fetch their ID + let userId = createData?.user?.id + if (!userId) { + const { data: listData } = await supabase.auth.admin.listUsers() + const existing = listData?.users?.find(u => u.email === demo.email) + userId = existing?.id + } + + if (!userId) { + console.error(` ✗ ${demo.email}: could not resolve user ID`) + continue + } + + // Upsert role + const { error: roleError } = await supabase + .from("user_roles") + .upsert({ user_id: userId, role: demo.role }, { onConflict: "user_id" }) + + if (roleError) { + console.error(` ✗ ${demo.email} role: ${roleError.message}`) + } else { + console.log(` ✓ ${demo.email} → ${demo.role}`) + } + } + + console.log("\nDone. Demo credentials:") + console.log(" Password for all accounts: BishopState2025!") + DEMO_USERS.forEach(u => console.log(` ${u.role.padEnd(12)} ${u.email}`)) +} + +seed().catch(err => { + console.error(err) + process.exit(1) +}) From f644db486060f74e8253cf23f10c788d921645ec Mon Sep 17 00:00:00 2001 From: William-Hill Date: Sun, 22 Feb 2026 21:26:10 -0600 Subject: [PATCH 2/8] chore: add GitHub Actions CI/CD workflows (#83) (#84) --- .github/workflows/ci-dashboard.yml | 53 +++++++++++++++++++++++ .github/workflows/ci-python.yml | 44 +++++++++++++++++++ .github/workflows/deploy-preview.yml | 57 +++++++++++++++++++++++++ .github/workflows/deploy-production.yml | 47 ++++++++++++++++++++ .github/workflows/security-audit.yml | 50 ++++++++++++++++++++++ ruff.toml | 23 ++++++++++ 6 files changed, 274 insertions(+) create mode 100644 .github/workflows/ci-dashboard.yml create mode 100644 .github/workflows/ci-python.yml create mode 100644 .github/workflows/deploy-preview.yml create mode 100644 .github/workflows/deploy-production.yml create mode 100644 .github/workflows/security-audit.yml create mode 100644 ruff.toml diff --git a/.github/workflows/ci-dashboard.yml b/.github/workflows/ci-dashboard.yml new file mode 100644 index 0000000..dfcd6d3 --- /dev/null +++ b/.github/workflows/ci-dashboard.yml @@ -0,0 +1,53 @@ +name: Dashboard CI + +on: + push: + branches: [main, rebranding/bishop-state] + paths: + - "codebenders-dashboard/**" + - ".github/workflows/ci-dashboard.yml" + pull_request: + branches: [main, rebranding/bishop-state] + paths: + - "codebenders-dashboard/**" + - ".github/workflows/ci-dashboard.yml" + +defaults: + run: + working-directory: codebenders-dashboard + +jobs: + ci: + name: Type check · Lint · Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: codebenders-dashboard/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: TypeScript type check + run: npx tsc --noEmit + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + env: + # Provide placeholder values so the build doesn't fail on missing env assertions. + # API routes and Supabase calls are opt-in at runtime; they are not executed during build. + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: postgres diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml new file mode 100644 index 0000000..f5defab --- /dev/null +++ b/.github/workflows/ci-python.yml @@ -0,0 +1,44 @@ +name: Python CI + +on: + push: + branches: [main, rebranding/bishop-state] + paths: + - "ai_model/**" + - "operations/**" + - "requirements.txt" + - ".github/workflows/ci-python.yml" + pull_request: + branches: [main, rebranding/bishop-state] + paths: + - "ai_model/**" + - "operations/**" + - "requirements.txt" + - ".github/workflows/ci-python.yml" + +jobs: + ci: + name: Lint · Deps · Syntax check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install ruff + run: pip install ruff + + - name: Lint (ruff) + run: ruff check ai_model/ operations/ + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Syntax check — entry points + run: | + python -m py_compile ai_model/complete_ml_pipeline.py + python -m py_compile operations/db_config.py diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml new file mode 100644 index 0000000..eb7674e --- /dev/null +++ b/.github/workflows/deploy-preview.yml @@ -0,0 +1,57 @@ +name: Deploy Preview + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + deploy: + name: Vercel preview deployment + runs-on: ubuntu-latest + # Only run when Vercel secrets are configured (skips forks / contributors without access) + if: ${{ vars.VERCEL_PROJECT_ID != '' }} + + permissions: + pull-requests: write # needed to post the preview URL comment + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Pull Vercel environment (preview) + run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Build + run: vercel build --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Deploy + id: deploy + run: | + url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}) + echo "url=$url" >> "$GITHUB_OUTPUT" + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Post preview URL to PR + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## Preview deployment\n\n🚀 **${{ steps.deploy.outputs.url }}**\n\nDeployed from ${context.sha.slice(0, 7)}.`, + }) diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml new file mode 100644 index 0000000..8f0c491 --- /dev/null +++ b/.github/workflows/deploy-production.yml @@ -0,0 +1,47 @@ +name: Deploy Production + +on: + push: + branches: [main] + +jobs: + deploy: + name: Vercel production deployment + runs-on: ubuntu-latest + # Only run when Vercel secrets are configured + if: ${{ vars.VERCEL_PROJECT_ID != '' }} + + environment: + name: production + url: ${{ steps.deploy.outputs.url }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Pull Vercel environment (production) + run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Build + run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Deploy + id: deploy + run: | + url=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}) + echo "url=$url" >> "$GITHUB_OUTPUT" + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..9684534 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,50 @@ +name: Security Audit + +on: + schedule: + - cron: "0 9 * * 1" # Every Monday at 09:00 UTC + push: + branches: [main] + workflow_dispatch: + +jobs: + audit-npm: + name: npm audit (dashboard) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: codebenders-dashboard + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: codebenders-dashboard/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Audit + run: npm audit --audit-level=high + + audit-python: + name: pip-audit (ML pipeline) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install pip-audit + run: pip install pip-audit + + - name: Audit + run: pip-audit -r requirements.txt diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..edfbc47 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,23 @@ +# Ruff configuration for the ML pipeline and operations code. +# +# Rule selection is intentionally narrow to give a green CI baseline +# from the existing codebase. Expand select/remove ignores incrementally +# as the code is cleaned up. +# +# Selected rules: +# E9xx — syntax errors (always fatal) +# F — pyflakes: undefined names, bad imports, redefinitions +# +# Ignored rules (existing code baseline): +# F401 — imported but unused (common in ML notebooks-style scripts) +# F541 — f-string without placeholders (style, not a bug) +# F841 — local variable assigned but never used (acceptable in data scripts) + +[lint] +select = ["E9", "F"] + +ignore = [ + "F401", + "F541", + "F841", +] From 9247784aa918eea77cd1c050791bc92ca2c41b00 Mon Sep 17 00:00:00 2001 From: William Hill Date: Mon, 23 Feb 2026 15:24:38 -0500 Subject: [PATCH 3/8] fix: drop npm lockfile cache since package-lock.json is gitignored npm ci requires a lockfile; switch to npm install in ci-dashboard and security-audit workflows to avoid cache resolution failures. --- .github/workflows/ci-dashboard.yml | 4 +--- .github/workflows/security-audit.yml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-dashboard.yml b/.github/workflows/ci-dashboard.yml index dfcd6d3..56a0f4f 100644 --- a/.github/workflows/ci-dashboard.yml +++ b/.github/workflows/ci-dashboard.yml @@ -27,11 +27,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" - cache: "npm" - cache-dependency-path: codebenders-dashboard/package-lock.json - name: Install dependencies - run: npm ci + run: npm install - name: TypeScript type check run: npx tsc --noEmit diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 9684534..480af77 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -22,11 +22,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" - cache: "npm" - cache-dependency-path: codebenders-dashboard/package-lock.json - name: Install dependencies - run: npm ci + run: npm install - name: Audit run: npm audit --audit-level=high From 65c5d216045ded5a6371ec5d639a78b0bc2e3c4a Mon Sep 17 00:00:00 2001 From: William-Hill Date: Mon, 23 Feb 2026 14:44:48 -0600 Subject: [PATCH 4/8] feat: course sequencing insights and DFWI analysis (#85) (#87) * feat: add course_enrollments migration and data ingestion script (#85) * fix: transaction safety and validation in course enrollment ingestion (#85) * feat: add course DFWI, gateway funnel, and sequence API routes (#85) * fix: sequence join granularity, gateway funnel clarity, DFWI result cap (#85) * feat: add /courses page with DFWI table, gateway funnel, and co-enrollment pairs (#85) * fix: percentage display and component cleanup in /courses page (#85) * fix: gateway type label values (M/E) and add RBAC to gateway-funnel route (#85) * feat: sortable column headers, info popovers for DFWI/pass rate, pairings table sort (#85) * feat: tabbed courses page with AI-powered co-enrollment explainability - Redesign /courses page with 3 tabs: DFWI Rates, Gateway Funnel, Co-enrollment Insights - Add POST /api/courses/explain-pairing route: queries per-pair stats (individual DFWI/pass rates, breakdown by delivery method and instructor type) then calls gpt-4o-mini to generate an advisor-friendly narrative - Co-enrollment Insights tab shows sortable pairings table with per-row Explain button that fetches and renders stats chips + LLM analysis inline - Tab state is client-side (no Radix Tabs dependency needed) * ci: trigger re-run after workflow fix * fix: update ESLint for Next.js 16 (next lint removed) - Replace next lint with direct eslint . in package.json lint script - Rewrite eslint.config.mjs to use eslint-config-next flat config exports directly instead of deprecated FlatCompat bridge - Add eslint and eslint-config-next as devDependencies - Suppress pre-existing rule violations (no-explicit-any, no-unescaped-entities, set-state-in-effect) to avoid CI failures on legacy code --- .../app/api/courses/dfwi/route.ts | 93 +++ .../app/api/courses/explain-pairing/route.ts | 209 +++++ .../app/api/courses/gateway-funnel/route.ts | 55 ++ .../app/api/courses/sequences/route.ts | 56 ++ codebenders-dashboard/app/courses/page.tsx | 732 ++++++++++++++++++ .../components/nav-header.tsx | 30 + codebenders-dashboard/eslint.config.mjs | 28 +- codebenders-dashboard/lib/roles.ts | 2 + codebenders-dashboard/package.json | 4 +- migrations/002_course_enrollments.sql | 38 + scripts/load-course-enrollments.ts | 265 +++++++ 11 files changed, 1500 insertions(+), 12 deletions(-) create mode 100644 codebenders-dashboard/app/api/courses/dfwi/route.ts create mode 100644 codebenders-dashboard/app/api/courses/explain-pairing/route.ts create mode 100644 codebenders-dashboard/app/api/courses/gateway-funnel/route.ts create mode 100644 codebenders-dashboard/app/api/courses/sequences/route.ts create mode 100644 codebenders-dashboard/app/courses/page.tsx create mode 100644 migrations/002_course_enrollments.sql create mode 100644 scripts/load-course-enrollments.ts diff --git a/codebenders-dashboard/app/api/courses/dfwi/route.ts b/codebenders-dashboard/app/api/courses/dfwi/route.ts new file mode 100644 index 0000000..b077d75 --- /dev/null +++ b/codebenders-dashboard/app/api/courses/dfwi/route.ts @@ -0,0 +1,93 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/dfwi", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + + const gatewayOnly = searchParams.get("gatewayOnly") === "true" + const minEnrollments = Math.max(1, Number(searchParams.get("minEnrollments") || 10)) + const cohort = searchParams.get("cohort") || "" + const term = searchParams.get("term") || "" + const sortBy = searchParams.get("sortBy") || "dfwi_rate" + const sortDir = searchParams.get("sortDir") === "asc" ? "ASC" : "DESC" + + // Whitelist sort columns to prevent injection + const SORT_COLS: Record = { + dfwi_rate: "dfwi_rate", + enrollments: "enrollments", + } + const orderExpr = SORT_COLS[sortBy] ?? "dfwi_rate" + + const conditions: string[] = [] + const params: unknown[] = [] + + if (gatewayOnly) { + conditions.push("gateway_type IN ('M', 'E')") + } + + if (cohort) { + params.push(cohort) + conditions.push(`cohort = $${params.length}`) + } + + if (term) { + params.push(term) + conditions.push(`academic_term = $${params.length}`) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + // minEnrollments goes into HAVING — bind as a param + params.push(minEnrollments) + const minEnrollmentsParam = `$${params.length}` + + const sql = ` + SELECT + course_prefix, + course_number, + MAX(course_name) AS course_name, + MAX(gateway_type) AS gateway_type, + COUNT(*) AS enrollments, + COUNT(*) FILTER (WHERE grade IN ('D', 'F', 'W', 'I')) AS dfwi_count, + ROUND( + COUNT(*) FILTER (WHERE grade IN ('D', 'F', 'W', 'I')) * 100.0 / NULLIF(COUNT(*), 0), + 1 + ) AS dfwi_rate, + ROUND( + COUNT(*) FILTER ( + WHERE grade NOT IN ('D', 'F', 'W', 'I') + AND grade IS NOT NULL + AND grade != '' + ) * 100.0 / NULLIF(COUNT(*), 0), + 1 + ) AS pass_rate + FROM course_enrollments + ${where} + GROUP BY course_prefix, course_number + HAVING COUNT(*) >= ${minEnrollmentsParam} + ORDER BY ${orderExpr} ${sortDir} + LIMIT 200 -- capped at 200 rows; add pagination if needed + ` + + try { + const pool = getPool() + const result = await pool.query(sql, params) + + return NextResponse.json({ + courses: result.rows, + total: result.rows.length, + }) + } catch (error) { + console.error("DFWI fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch DFWI data", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/api/courses/explain-pairing/route.ts b/codebenders-dashboard/app/api/courses/explain-pairing/route.ts new file mode 100644 index 0000000..fc560c0 --- /dev/null +++ b/codebenders-dashboard/app/api/courses/explain-pairing/route.ts @@ -0,0 +1,209 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" +import { generateText } from "ai" +import { createOpenAI } from "@ai-sdk/openai" + +const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY || "" }) + +const DELIVERY_LABELS: Record = { + F: "Face-to-Face", + O: "Online", + H: "Hybrid", +} + +export async function POST(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/explain-pairing", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + if (!process.env.OPENAI_API_KEY) { + return NextResponse.json({ error: "OpenAI API key not configured" }, { status: 500 }) + } + + const body = await request.json() + const { prefix_a, number_a, name_a, prefix_b, number_b, name_b } = body + + if (!prefix_a || !number_a || !prefix_b || !number_b) { + return NextResponse.json({ error: "Missing course identifiers" }, { status: 400 }) + } + + const pool = getPool() + + try { + // Query 1: Individual DFWI + pass rates for each course + const [indivRes, deliveryRes, instrRes] = await Promise.all([ + pool.query( + `SELECT + course_prefix, + course_number, + COUNT(*) AS enrollments, + ROUND( + COUNT(*) FILTER (WHERE grade IN ('D','F','W','I') AND grade IS NOT NULL AND grade <> '') + * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS dfwi_rate, + ROUND( + COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') AND grade IS NOT NULL AND grade <> '') + * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS pass_rate + FROM course_enrollments + WHERE (course_prefix = $1 AND course_number = $2) + OR (course_prefix = $3 AND course_number = $4) + GROUP BY course_prefix, course_number`, + [prefix_a, number_a, prefix_b, number_b], + ), + + // Query 2: Co-enrollment stats by delivery method + pool.query( + `SELECT + a.delivery_method, + COUNT(*) AS co_count, + ROUND( + COUNT(*) FILTER ( + WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> '' + AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> '' + ) * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS both_pass_rate + FROM course_enrollments a + JOIN course_enrollments b + ON a.student_guid = b.student_guid + AND a.academic_year = b.academic_year + AND a.academic_term = b.academic_term + WHERE a.course_prefix = $1 AND a.course_number = $2 + AND b.course_prefix = $3 AND b.course_number = $4 + GROUP BY a.delivery_method + ORDER BY co_count DESC`, + [prefix_a, number_a, prefix_b, number_b], + ), + + // Query 3: Co-enrollment stats by instructor status + pool.query( + `SELECT + a.instructor_status, + COUNT(*) AS co_count, + ROUND( + COUNT(*) FILTER ( + WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> '' + AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> '' + ) * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS both_pass_rate + FROM course_enrollments a + JOIN course_enrollments b + ON a.student_guid = b.student_guid + AND a.academic_year = b.academic_year + AND a.academic_term = b.academic_term + WHERE a.course_prefix = $1 AND a.course_number = $2 + AND b.course_prefix = $3 AND b.course_number = $4 + GROUP BY a.instructor_status + ORDER BY co_count DESC`, + [prefix_a, number_a, prefix_b, number_b], + ), + ]) + + const courseA = indivRes.rows.find( + r => r.course_prefix === prefix_a && r.course_number === number_a, + ) + const courseB = indivRes.rows.find( + r => r.course_prefix === prefix_b && r.course_number === number_b, + ) + + const byDelivery = deliveryRes.rows.map(r => ({ + delivery_method: DELIVERY_LABELS[r.delivery_method] ?? r.delivery_method ?? "Unknown", + co_count: Number(r.co_count), + both_pass_rate: parseFloat(r.both_pass_rate), + })) + + const byInstructor = instrRes.rows.map(r => ({ + instructor_status: + r.instructor_status === "FT" + ? "Full-Time Instructor" + : r.instructor_status === "PT" + ? "Part-Time Instructor" + : (r.instructor_status ?? "Unknown"), + co_count: Number(r.co_count), + both_pass_rate: parseFloat(r.both_pass_rate), + })) + + const stats = { + courseA: courseA + ? { + dfwi_rate: parseFloat(courseA.dfwi_rate), + pass_rate: parseFloat(courseA.pass_rate), + enrollments: Number(courseA.enrollments), + } + : null, + courseB: courseB + ? { + dfwi_rate: parseFloat(courseB.dfwi_rate), + pass_rate: parseFloat(courseB.pass_rate), + enrollments: Number(courseB.enrollments), + } + : null, + byDelivery, + byInstructor, + } + + // Build prompt context + const labelA = `${prefix_a} ${number_a}${name_a ? ` (${name_a})` : ""}` + const labelB = `${prefix_b} ${number_b}${name_b ? ` (${name_b})` : ""}` + + const statsLineA = courseA + ? `${labelA}: ${courseA.dfwi_rate}% DFWI, ${courseA.pass_rate}% pass rate, ${Number(courseA.enrollments).toLocaleString()} total enrollments` + : `${labelA}: no individual stats available` + + const statsLineB = courseB + ? `${labelB}: ${courseB.dfwi_rate}% DFWI, ${courseB.pass_rate}% pass rate, ${Number(courseB.enrollments).toLocaleString()} total enrollments` + : `${labelB}: no individual stats available` + + const deliverySection = byDelivery.length + ? byDelivery + .map(d => ` ${d.delivery_method}: ${d.co_count} co-enrolled, ${d.both_pass_rate}% both passed`) + .join("\n") + : " No delivery breakdown available" + + const instrSection = byInstructor.length + ? byInstructor + .map(i => ` ${i.instructor_status}: ${i.co_count} co-enrolled, ${i.both_pass_rate}% both passed`) + .join("\n") + : " No instructor breakdown available" + + const llmPrompt = `You are an academic success analyst at a community college. An advisor is reviewing co-enrollment data for two courses students frequently take in the same term. + +INDIVIDUAL COURSE STATS: +- ${statsLineA} +- ${statsLineB} + +CO-ENROLLMENT BREAKDOWN (students taking both courses in the same term): + +By delivery method: +${deliverySection} + +By instructor type: +${instrSection} + +Write a concise analysis (3-4 sentences) that: +1. Explains why students might struggle when taking both courses together (consider workload, cognitive load, prerequisite overlap, or scheduling demands) +2. Highlights which conditions show better or worse outcomes based on the data +3. Ends with one specific, actionable recommendation for advisors + +Be practical and data-driven. Do not speculate beyond what the numbers show.` + + const result = await generateText({ + model: openai("gpt-4o-mini"), + prompt: llmPrompt, + maxOutputTokens: 320, + }) + + return NextResponse.json({ stats, explanation: result.text }) + } catch (error) { + console.error("[explain-pairing] Error:", error) + return NextResponse.json( + { + error: "Failed to generate explanation", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ) + } +} diff --git a/codebenders-dashboard/app/api/courses/gateway-funnel/route.ts b/codebenders-dashboard/app/api/courses/gateway-funnel/route.ts new file mode 100644 index 0000000..eae069c --- /dev/null +++ b/codebenders-dashboard/app/api/courses/gateway-funnel/route.ts @@ -0,0 +1,55 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/gateway-funnel", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const mathSql = ` + SELECT + cohort, + COUNT(*) AS attempted, + COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') + AND grade IS NOT NULL AND grade <> '') AS passed, + COUNT(*) FILTER (WHERE grade IN ('D','F','W','I')) AS dfwi + FROM course_enrollments + WHERE gateway_type = 'M' + GROUP BY cohort + ORDER BY cohort + ` + + const englishSql = ` + SELECT + cohort, + COUNT(*) AS attempted, + COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') + AND grade IS NOT NULL AND grade <> '') AS passed, + COUNT(*) FILTER (WHERE grade IN ('D','F','W','I')) AS dfwi + FROM course_enrollments + WHERE gateway_type = 'E' + GROUP BY cohort + ORDER BY cohort + ` + + try { + const pool = getPool() + const [mathResult, englishResult] = await Promise.all([ + pool.query(mathSql), + pool.query(englishSql), + ]) + + return NextResponse.json({ + math: mathResult.rows, + english: englishResult.rows, + }) + } catch (error) { + console.error("Gateway funnel fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch gateway funnel data", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/api/courses/sequences/route.ts b/codebenders-dashboard/app/api/courses/sequences/route.ts new file mode 100644 index 0000000..e9b418b --- /dev/null +++ b/codebenders-dashboard/app/api/courses/sequences/route.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/sequences", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const sql = ` + SELECT + a.course_prefix AS prefix_a, + a.course_number AS number_a, + b.course_prefix AS prefix_b, + b.course_number AS number_b, + MAX(a.course_name) AS name_a, + MAX(b.course_name) AS name_b, + COUNT(*) AS co_enrollment_count, + ROUND( + COUNT(*) FILTER ( + WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> '' + AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> '' + ) * 100.0 / NULLIF(COUNT(*), 0), + 1 + ) AS both_pass_rate + FROM course_enrollments a + JOIN course_enrollments b + ON a.student_guid = b.student_guid + AND a.academic_year = b.academic_year + AND a.academic_term = b.academic_term + AND ( + a.course_prefix < b.course_prefix + OR (a.course_prefix = b.course_prefix AND a.course_number < b.course_number) + ) + GROUP BY a.course_prefix, a.course_number, b.course_prefix, b.course_number + HAVING COUNT(*) >= 20 + ORDER BY co_enrollment_count DESC + LIMIT 20 + ` + + try { + const pool = getPool() + const result = await pool.query(sql) + + return NextResponse.json({ + pairs: result.rows, + }) + } catch (error) { + console.error("Course sequences fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch course sequences", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/courses/page.tsx b/codebenders-dashboard/app/courses/page.tsx new file mode 100644 index 0000000..42eea7f --- /dev/null +++ b/codebenders-dashboard/app/courses/page.tsx @@ -0,0 +1,732 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowDown, ArrowLeft, ArrowUp, ArrowUpDown, Loader2, Sparkles } from "lucide-react" +import { Button } from "@/components/ui/button" +import { InfoPopover } from "@/components/info-popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, +} from "recharts" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface CourseRow { + course_prefix: string + course_number: string + course_name: string + gateway_type: string | null + enrollments: number + dfwi_count: number + dfwi_rate: number + pass_rate: number +} + +interface CoursesResponse { + courses: CourseRow[] + total: number +} + +interface FunnelCohort { + cohort: string + attempted: number + passed: number + dfwi: number +} + +interface GatewayFunnelResponse { + math: FunnelCohort[] + english: FunnelCohort[] +} + +interface CoursePair { + prefix_a: string + number_a: string + name_a: string + prefix_b: string + number_b: string + name_b: string + co_enrollment_count: number + both_pass_rate: number +} + +interface SequencesResponse { + pairs: CoursePair[] +} + +interface PairStats { + courseA: { dfwi_rate: number; pass_rate: number; enrollments: number } | null + courseB: { dfwi_rate: number; pass_rate: number; enrollments: number } | null + byDelivery: { delivery_method: string; co_count: number; both_pass_rate: number }[] + byInstructor: { instructor_status: string; co_count: number; both_pass_rate: number }[] +} + +interface ExplainState { + loading: boolean + stats?: PairStats + explanation?: string + error?: string +} + +// ─── Color helpers ──────────────────────────────────────────────────────────── + +function DfwiRate({ value }: { value: number }) { + const v = parseFloat(String(value)) + const pct = v.toFixed(1) + let color = "text-green-600" + if (v >= 50) color = "text-red-600" + else if (v >= 30) color = "text-orange-600" + return {pct}% +} + +function PassRate({ value }: { value: number }) { + const v = parseFloat(String(value)) + const pct = v.toFixed(1) + let color = "text-red-600" + if (v >= 70) color = "text-green-600" + else if (v >= 50) color = "text-yellow-600" + return {pct}% +} + +function GatewayTypeLabel({ type }: { type: string | null }) { + if (!type) return + if (type === "M") return Math Gateway + if (type === "E") return English Gateway + return {type} +} + +// ─── Table header helpers ───────────────────────────────────────────────────── + +function Th({ label, right, info }: { label: string; right?: boolean; info?: React.ReactNode }) { + return ( + + + {label}{info} + + + ) +} + +function SortIcon({ active, dir }: { active: boolean; dir: "asc" | "desc" }) { + if (!active) return + return dir === "asc" + ? + : +} + +function ThSort({ + label, col, sortBy, sortDir, onSort, right, info, +}: { + label: string; col: T; sortBy: T; sortDir: "asc" | "desc" + onSort: (col: T) => void; right?: boolean; info?: React.ReactNode +}) { + return ( + + + + {info} + + + ) +} + +// ─── Stat chip ──────────────────────────────────────────────────────────────── + +function StatChip({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
    + {value} + {label} +
    + ) +} + +// ─── Tab helpers ────────────────────────────────────────────────────────────── + +type Tab = "dfwi" | "funnel" | "sequences" + +function TabButton({ id, label, active, onClick }: { id: Tab; label: string; active: boolean; onClick: (t: Tab) => void }) { + return ( + + ) +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function CoursesPage() { + // ── Tab state ── + const [activeTab, setActiveTab] = useState("dfwi") + + // ── DFWI table state ── + const [coursesData, setCoursesData] = useState(null) + const [coursesLoading, setCoursesLoading] = useState(true) + const [coursesError, setCoursesError] = useState(null) + + // ── Funnel state ── + const [funnelData, setFunnelData] = useState(null) + const [funnelLoading, setFunnelLoading] = useState(true) + const [funnelError, setFunnelError] = useState(null) + + // ── Sequences state ── + const [seqData, setSeqData] = useState(null) + const [seqLoading, setSeqLoading] = useState(true) + const [seqError, setSeqError] = useState(null) + + // ── Explain state (keyed by pairing key) ── + const [explainMap, setExplainMap] = useState>({}) + + // ── DFWI table filters + sort ── + const [gatewayOnly, setGatewayOnly] = useState(false) + const [minEnrollments, setMinEnrollments] = useState("10") + const [sortBy, setSortBy] = useState<"dfwi_rate" | "enrollments">("dfwi_rate") + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc") + + // ── Pairings client-side sort ── + const [pairSortBy, setPairSortBy] = useState<"co_enrollment_count" | "both_pass_rate">("co_enrollment_count") + const [pairSortDir, setPairSortDir] = useState<"asc" | "desc">("desc") + + // ── Fetch DFWI courses ── + useEffect(() => { + setCoursesLoading(true) + setCoursesError(null) + const p = new URLSearchParams() + p.set("gatewayOnly", String(gatewayOnly)) + p.set("minEnrollments", minEnrollments) + p.set("sortBy", sortBy) + p.set("sortDir", sortDir) + fetch(`/api/courses/dfwi?${p.toString()}`) + .then(r => r.json()) + .then(d => { setCoursesData(d); setCoursesLoading(false) }) + .catch(e => { setCoursesError(e.message); setCoursesLoading(false) }) + }, [gatewayOnly, minEnrollments, sortBy, sortDir]) + + // ── Fetch gateway funnel ── + useEffect(() => { + setFunnelLoading(true) + setFunnelError(null) + fetch("/api/courses/gateway-funnel") + .then(r => r.json()) + .then(d => { setFunnelData(d); setFunnelLoading(false) }) + .catch(e => { setFunnelError(e.message); setFunnelLoading(false) }) + }, []) + + // ── Fetch sequences ── + useEffect(() => { + setSeqLoading(true) + setSeqError(null) + fetch("/api/courses/sequences") + .then(r => r.json()) + .then(d => { setSeqData(d); setSeqLoading(false) }) + .catch(e => { setSeqError(e.message); setSeqLoading(false) }) + }, []) + + const courses = coursesData?.courses ?? [] + const total = coursesData?.total ?? 0 + const mathData = funnelData?.math ?? [] + const englishData = funnelData?.english ?? [] + + // ── Sort handlers ── + function handleCourseSort(col: "dfwi_rate" | "enrollments") { + if (col === sortBy) setSortDir(d => d === "asc" ? "desc" : "asc") + else { setSortBy(col); setSortDir("desc") } + } + + function handlePairSort(col: "co_enrollment_count" | "both_pass_rate") { + if (col === pairSortBy) setPairSortDir(d => d === "asc" ? "desc" : "asc") + else { setPairSortBy(col); setPairSortDir("desc") } + } + + const sortedPairs = useMemo(() => { + const raw = (seqData?.pairs ?? []).slice(0, 20) + return [...raw].sort((a, b) => { + const av = parseFloat(String(a[pairSortBy])) + const bv = parseFloat(String(b[pairSortBy])) + return pairSortDir === "desc" ? bv - av : av - bv + }) + }, [seqData, pairSortBy, pairSortDir]) + + // ── Explain pairing ── + function pairingKey(pair: CoursePair) { + return `${pair.prefix_a}-${pair.number_a}-${pair.prefix_b}-${pair.number_b}` + } + + async function explainPairing(pair: CoursePair) { + const key = pairingKey(pair) + // Toggle collapse if already loaded + if (explainMap[key] && !explainMap[key].loading) { + setExplainMap(prev => { + const next = { ...prev } + delete next[key] + return next + }) + return + } + setExplainMap(prev => ({ ...prev, [key]: { loading: true } })) + try { + const res = await fetch("/api/courses/explain-pairing", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prefix_a: pair.prefix_a, + number_a: pair.number_a, + name_a: pair.name_a || "", + prefix_b: pair.prefix_b, + number_b: pair.number_b, + name_b: pair.name_b || "", + }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || "Failed to fetch explanation") + setExplainMap(prev => ({ + ...prev, + [key]: { loading: false, stats: data.stats, explanation: data.explanation }, + })) + } catch (e) { + setExplainMap(prev => ({ + ...prev, + [key]: { loading: false, error: e instanceof Error ? e.message : String(e) }, + })) + } + } + + // ─── Render ─────────────────────────────────────────────────────────────── + + return ( +
    +
    + + {/* Header */} +
    + + + +
    +

    Course Analytics

    +

    + DFWI rates, gateway funnels, and course co-enrollment patterns +

    +
    +
    + + {/* Tab bar */} +
    + + + +
    + + {/* ── Tab: DFWI Rates ── */} + {activeTab === "dfwi" && ( +
    + {/* Filter bar */} +
    +
    + +
    + +
    +
    +
    + +

    + {coursesLoading ? "Loading…" : `${total.toLocaleString()} course${total !== 1 ? "s" : ""}`} +

    + + {coursesError && ( +
    + {coursesError} +
    + )} + +
    + + + + + + + {coursesLoading ? ( + Array.from({ length: 10 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((__, j) => ( + + ))} + + )) + ) : courses.length === 0 ? ( + + + + ) : ( + courses.map((c, idx) => ( + + + + + + + + + + )) + )} + +
    + + + + + +

    Percentage of enrolled students who received a D, F, W (Withdraw), or I (Incomplete) grade. Higher values indicate courses where students struggle most.

    + + } + /> + +

    Percentage of enrolled students who received a passing grade (A through C-). A pass rate below 50% signals a course where more than half of students are not succeeding.

    + + } + /> +
    +
    +
    + No courses match the current filters. +
    + {c.course_prefix} {c.course_number} + {c.course_name ?? "—"}{c.enrollments.toLocaleString()}{c.dfwi_count.toLocaleString()}
    +
    +
    + )} + + {/* ── Tab: Gateway Funnel ── */} + {activeTab === "funnel" && ( +
    +

    + Enrollment, pass, and DFWI counts for gateway courses, broken down by cohort. +

    + + {funnelError && ( +
    + {funnelError} +
    + )} + + {funnelLoading ? ( +
    + {[0, 1].map(i => ( +
    +
    +
    +
    + ))} +
    + ) : ( +
    +
    +

    Math Gateway

    + {mathData.length === 0 ? ( +

    No data available.

    + ) : ( + + + + + + + + + + + + )} +
    + +
    +

    English Gateway

    + {englishData.length === 0 ? ( +

    No data available.

    + ) : ( + + + + + + + + + + + + )} +
    +
    + )} +
    + )} + + {/* ── Tab: Co-enrollment Insights ── */} + {activeTab === "sequences" && ( +
    +

    + Course pairs most frequently taken in the same term. Click Explain on any row for an AI-powered analysis of why students struggle with the combination. +

    + + {seqError && ( +
    + {seqError} +
    + )} + +
    + + + + + + + {seqLoading ? ( + Array.from({ length: 10 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((__, j) => ( + + ))} + + )) + ) : sortedPairs.length === 0 ? ( + + + + ) : ( + sortedPairs.map(pair => { + const key = pairingKey(pair) + const explainState = explainMap[key] + const isExpanded = !!explainState && !explainState.loading + + return ( + <> + + + + + + + + + {/* Expanded explain panel */} + {isExpanded && ( + + + + )} + + ) + }) + )} + +
    + + + + +
    +
    +
    + No course pairing data available. +
    + {pair.prefix_a} {pair.number_a} + {pair.name_a && ( + — {pair.name_a} + )} + + {pair.prefix_b} {pair.number_b} + {pair.name_b && ( + — {pair.name_b} + )} + {pair.co_enrollment_count.toLocaleString()} + +
    + {explainState.error ? ( +

    {explainState.error}

    + ) : ( +
    + {/* Individual course stats */} +
    + {explainState.stats?.courseA && ( +
    +

    + {pair.prefix_a} {pair.number_a} — Individual +

    +
    + = 50 ? "text-red-600" : explainState.stats.courseA.dfwi_rate >= 30 ? "text-orange-600" : "text-green-600"} + /> + = 70 ? "text-green-600" : explainState.stats.courseA.pass_rate >= 50 ? "text-yellow-600" : "text-red-600"} + /> + +
    +
    + )} + {explainState.stats?.courseB && ( +
    +

    + {pair.prefix_b} {pair.number_b} — Individual +

    +
    + = 50 ? "text-red-600" : explainState.stats.courseB.dfwi_rate >= 30 ? "text-orange-600" : "text-green-600"} + /> + = 70 ? "text-green-600" : explainState.stats.courseB.pass_rate >= 50 ? "text-yellow-600" : "text-red-600"} + /> + +
    +
    + )} +
    + + {/* Delivery + instructor breakdown */} +
    + {explainState.stats?.byDelivery && explainState.stats.byDelivery.length > 0 && ( +
    +

    By Delivery Method

    +
    + {explainState.stats.byDelivery.map(d => ( +
    + {d.delivery_method} + {d.co_count.toLocaleString()} students + + both pass +
    + ))} +
    +
    + )} + {explainState.stats?.byInstructor && explainState.stats.byInstructor.length > 0 && ( +
    +

    By Instructor Type

    +
    + {explainState.stats.byInstructor.map(d => ( +
    + {d.instructor_status} + {d.co_count.toLocaleString()} students + + both pass +
    + ))} +
    +
    + )} +
    + + {/* LLM explanation */} + {explainState.explanation && ( +
    + +

    {explainState.explanation}

    +
    + )} +
    + )} +
    +
    +
    + )} + +
    +
    + ) +} diff --git a/codebenders-dashboard/components/nav-header.tsx b/codebenders-dashboard/components/nav-header.tsx index aa3ea14..834681e 100644 --- a/codebenders-dashboard/components/nav-header.tsx +++ b/codebenders-dashboard/components/nav-header.tsx @@ -1,5 +1,7 @@ "use client" +import Link from "next/link" +import { usePathname } from "next/navigation" import { GraduationCap, LogOut } from "lucide-react" import { Button } from "@/components/ui/button" import { signOut } from "@/app/actions/auth" @@ -10,7 +12,15 @@ interface NavHeaderProps { role: Role } +const NAV_LINKS = [ + { href: "/", label: "Dashboard" }, + { href: "/courses", label: "Courses" }, + { href: "/students", label: "Students" }, +] + export function NavHeader({ email, role }: NavHeaderProps) { + const pathname = usePathname() + return (
    @@ -21,6 +31,26 @@ export function NavHeader({ email, role }: NavHeaderProps) { Bishop State SSA
    + {/* Nav links */} + + {/* Right side: role badge + email + logout */}
    = [ { prefix: "/students", roles: ["admin", "advisor", "ir"] }, + { prefix: "/courses", roles: ["admin", "advisor", "ir", "faculty"] }, { prefix: "/query", roles: ["admin", "advisor", "ir", "faculty"] }, { prefix: "/api/students", roles: ["admin", "advisor", "ir"] }, + { prefix: "/api/courses", roles: ["admin", "advisor", "ir", "faculty"] }, { prefix: "/api/query-history/export", roles: ["admin", "ir"] }, ] diff --git a/codebenders-dashboard/package.json b/codebenders-dashboard/package.json index 9628cab..c9fdbf5 100644 --- a/codebenders-dashboard/package.json +++ b/codebenders-dashboard/package.json @@ -3,7 +3,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint ." }, "dependencies": { "@ai-sdk/openai": "^2.0.56", @@ -34,6 +34,8 @@ "@types/pg": "^8.16.0", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", + "eslint": "^9.39.3", + "eslint-config-next": "^16.1.6", "postcss": "8.5.6", "tailwindcss": "4.1.16", "tsx": "^4.21.0", diff --git a/migrations/002_course_enrollments.sql b/migrations/002_course_enrollments.sql new file mode 100644 index 0000000..e93ebb9 --- /dev/null +++ b/migrations/002_course_enrollments.sql @@ -0,0 +1,38 @@ +-- Migration 002: course_enrollments table +-- Stores one row per course enrollment from bishop_state_courses.csv + +CREATE TABLE IF NOT EXISTS public.course_enrollments ( + id BIGSERIAL PRIMARY KEY, + student_guid TEXT NOT NULL, + cohort TEXT, + cohort_term TEXT, + academic_year TEXT, + academic_term TEXT, + course_prefix TEXT, + course_number TEXT, + course_name TEXT, + course_cip TEXT, + course_type TEXT, -- CU (credit unit) / CC (co-req) + gateway_type TEXT, -- M (math) / E (English) / N (neither) + is_co_requisite BOOLEAN, + is_core_course BOOLEAN, + core_course_type TEXT, + delivery_method TEXT, -- F (face-to-face) / O (online) / H (hybrid) + grade TEXT, + credits_attempted NUMERIC, + credits_earned NUMERIC, + instructor_status TEXT -- FT / PT +); + +-- Indexes for common query patterns +CREATE INDEX IF NOT EXISTS idx_course_enrollments_student_guid + ON public.course_enrollments (student_guid); + +CREATE INDEX IF NOT EXISTS idx_course_enrollments_course + ON public.course_enrollments (course_prefix, course_number); + +CREATE INDEX IF NOT EXISTS idx_course_enrollments_gateway_type + ON public.course_enrollments (gateway_type); + +CREATE INDEX IF NOT EXISTS idx_course_enrollments_term + ON public.course_enrollments (academic_year, academic_term); diff --git a/scripts/load-course-enrollments.ts b/scripts/load-course-enrollments.ts new file mode 100644 index 0000000..06cc7ed --- /dev/null +++ b/scripts/load-course-enrollments.ts @@ -0,0 +1,265 @@ +/** + * Ingestion script: streams bishop_state_courses.csv and bulk-inserts rows + * into public.course_enrollments in batches of 500. + * + * Usage (from project root): + * NODE_PATH=codebenders-dashboard/node_modules \ + * DB_HOST=127.0.0.1 DB_PORT=54332 DB_USER=postgres DB_PASSWORD=postgres DB_NAME=postgres \ + * codebenders-dashboard/node_modules/.bin/tsx scripts/load-course-enrollments.ts + */ + +import fs from "fs" +import path from "path" +import readline from "readline" +import { Pool } from "pg" + +// --------------------------------------------------------------------------- +// Load .env.local from codebenders-dashboard if it exists +// --------------------------------------------------------------------------- +const envLocalPath = path.resolve(__dirname, "../codebenders-dashboard/.env.local") +if (fs.existsSync(envLocalPath)) { + const lines = fs.readFileSync(envLocalPath, "utf8").split("\n") + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eqIdx = trimmed.indexOf("=") + if (eqIdx === -1) continue + const key = trimmed.slice(0, eqIdx).trim() + const value = trimmed.slice(eqIdx + 1).trim() + if (key && !(key in process.env)) { + process.env[key] = value + } + } + console.log(`Loaded env from ${envLocalPath}`) +} + +// --------------------------------------------------------------------------- +// DB connection +// --------------------------------------------------------------------------- +const pool = new Pool({ + host: process.env.DB_HOST ?? "127.0.0.1", + port: parseInt(process.env.DB_PORT ?? "54332", 10), + user: process.env.DB_USER ?? "postgres", + password: process.env.DB_PASSWORD ?? "postgres", + database: process.env.DB_NAME ?? "postgres", +}) + +// --------------------------------------------------------------------------- +// CSV helpers +// --------------------------------------------------------------------------- +const CSV_PATH = path.resolve(__dirname, "../data/bishop_state_courses.csv") + +/** + * Parse a single CSV line, respecting double-quoted fields. + * Returns an array of raw string values (empty string for missing cells). + */ +function parseCsvLine(line: string): string[] { + const fields: string[] = [] + let current = "" + let inQuotes = false + + for (let i = 0; i < line.length; i++) { + const ch = line[i] + if (ch === '"') { + if (inQuotes && line[i + 1] === '"') { + // Escaped double-quote inside a quoted field + current += '"' + i++ + } else { + inQuotes = !inQuotes + } + } else if (ch === "," && !inQuotes) { + fields.push(current) + current = "" + } else { + current += ch + } + } + fields.push(current) + return fields +} + +/** Convert "Y"/"N" CSV values to boolean (null when neither). */ +function toBoolean(val: string): boolean | null { + if (val === "Y") return true + if (val === "N") return false + return null +} + +/** Convert a string to a numeric value, returning null for blank/non-numeric. */ +function toNumeric(val: string): number | null { + const trimmed = val.trim() + if (trimmed === "" || trimmed === "null" || trimmed === "NULL") return null + const n = parseFloat(trimmed) + return isNaN(n) ? null : n +} + +// --------------------------------------------------------------------------- +// Batch insert +// --------------------------------------------------------------------------- +const BATCH_SIZE = 500 +const LOG_EVERY = 10_000 + +interface Row { + student_guid: string + cohort: string | null + cohort_term: string | null + academic_year: string | null + academic_term: string | null + course_prefix: string | null + course_number: string | null + course_name: string | null + course_cip: string | null + course_type: string | null + gateway_type: string | null + is_co_requisite: boolean | null + is_core_course: boolean | null + core_course_type: string | null + delivery_method: string | null + grade: string | null + credits_attempted: number | null + credits_earned: number | null + instructor_status: string | null +} + +async function insertBatch(client: import("pg").PoolClient, batch: Row[]): Promise { + if (batch.length === 0) return + + // Build parameterized multi-value INSERT + const COLS = [ + "student_guid", "cohort", "cohort_term", "academic_year", "academic_term", + "course_prefix", "course_number", "course_name", "course_cip", "course_type", + "gateway_type", "is_co_requisite", "is_core_course", "core_course_type", + "delivery_method", "grade", "credits_attempted", "credits_earned", "instructor_status", + ] as const + + const numCols = COLS.length + const valuePlaceholders: string[] = [] + const params: unknown[] = [] + + batch.forEach((row, rowIdx) => { + const placeholders = COLS.map( + (_, colIdx) => `$${rowIdx * numCols + colIdx + 1}` + ).join(", ") + valuePlaceholders.push(`(${placeholders})`) + COLS.forEach(col => params.push(row[col])) + }) + + const sql = ` + INSERT INTO public.course_enrollments (${COLS.join(", ")}) + VALUES ${valuePlaceholders.join(", ")} + ` + await client.query(sql, params) +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +async function main(): Promise { + console.log(`Loading course enrollments from: ${CSV_PATH}`) + + const client = await pool.connect() + try { + await client.query("BEGIN") + + // Truncate for idempotent re-runs + console.log("Truncating course_enrollments…") + await client.query("TRUNCATE TABLE public.course_enrollments RESTART IDENTITY") + + const rl = readline.createInterface({ + input: fs.createReadStream(CSV_PATH, { encoding: "utf8" }), + crlfDelay: Infinity, + }) + + let headers: string[] = [] + let batch: Row[] = [] + let totalRows = 0 + let lineNum = 0 + + for await (const rawLine of rl) { + lineNum++ + + // First line is the header + if (lineNum === 1) { + headers = parseCsvLine(rawLine) + continue + } + + const cols = parseCsvLine(rawLine) + + // Helper to get a column value by header name (empty string → null) + const get = (name: string): string | null => { + const idx = headers.indexOf(name) + if (idx === -1) return null + const v = cols[idx]?.trim() ?? "" + return v === "" ? null : v + } + + // C2: validate student_guid; skip row if missing + const student_guid = get("Student_GUID") + if (!student_guid) { + console.warn(`Row ${lineNum}: missing Student_GUID, skipping`) + continue + } + + const row: Row = { + student_guid, + cohort: get("Cohort"), + cohort_term: get("Cohort_Term"), + academic_year: get("Academic_Year"), + academic_term: get("Academic_Term"), + course_prefix: get("Course_Prefix"), + course_number: get("Course_Number"), + course_name: get("Course_Name"), + course_cip: get("Course_CIP"), + course_type: get("Course_Type"), + gateway_type: get("Math_or_English_Gateway"), + is_co_requisite: toBoolean(get("Co_requisite_Course") ?? ""), + is_core_course: toBoolean(get("Core_Course") ?? ""), + core_course_type: get("Core_Course_Type"), + delivery_method: get("Delivery_Method"), + grade: get("Grade"), + credits_attempted: toNumeric(get("Number_of_Credits_Attempted") ?? ""), + credits_earned: toNumeric(get("Number_of_Credits_Earned") ?? ""), + instructor_status: get("Course_Instructor_Employment_Status"), + } + + batch.push(row) + totalRows++ + + if (batch.length >= BATCH_SIZE) { + await insertBatch(client, batch) + batch = [] + } + + if (totalRows % LOG_EVERY === 0) { + console.log(` ...${totalRows.toLocaleString()} rows inserted`) + } + } + + // Flush remaining rows + if (batch.length > 0) { + await insertBatch(client, batch) + } + + await client.query("COMMIT") + + // Final count + const { rows } = await client.query<{ count: string }>( + "SELECT COUNT(*) AS count FROM public.course_enrollments" + ) + console.log(`\nDone. Total rows in DB: ${parseInt(rows[0].count, 10).toLocaleString()}`) + console.log(`CSV rows processed: ${totalRows.toLocaleString()}`) + } catch (err) { + await client.query("ROLLBACK") + throw err + } finally { + client.release() + await pool.end() + } +} + +main().catch(err => { + console.error("Fatal error:", err) + process.exit(1) +}) From 17b4a30a5d429c370b962f3646cc46b250999e28 Mon Sep 17 00:00:00 2001 From: William-Hill Date: Mon, 23 Feb 2026 16:16:00 -0600 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20NQL=20interface=20redesign=20?= =?UTF-8?q?=E2=80=94=20nav=20link,=20sidebar=20history,=20LLM=20Summarize?= =?UTF-8?q?=20(#88)=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: NQL interface redesign design doc (#88) * docs: NQL redesign implementation plan (#88) * feat: add Query link to global nav header (#88) * feat: add POST /api/query-summary LLM result narration (#88) * fix: harden query-summary route input validation (#88) * refactor: adapt QueryHistoryPanel for sidebar layout (#88) * fix: restore institution label, fix text size, restore truncation threshold (#88) * feat: sidebar layout + LLM Summarize button on query page (#88) --- .../app/api/query-summary/route.ts | 63 +++ codebenders-dashboard/app/query/page.tsx | 277 ++++++++---- .../components/nav-header.tsx | 1 + .../components/query-history-panel.tsx | 118 +++-- codebenders-dashboard/lib/roles.ts | 1 + docs/plans/2026-02-23-nql-redesign-design.md | 114 +++++ docs/plans/2026-02-23-nql-redesign.md | 419 ++++++++++++++++++ 7 files changed, 849 insertions(+), 144 deletions(-) create mode 100644 codebenders-dashboard/app/api/query-summary/route.ts create mode 100644 docs/plans/2026-02-23-nql-redesign-design.md create mode 100644 docs/plans/2026-02-23-nql-redesign.md diff --git a/codebenders-dashboard/app/api/query-summary/route.ts b/codebenders-dashboard/app/api/query-summary/route.ts new file mode 100644 index 0000000..6fa563d --- /dev/null +++ b/codebenders-dashboard/app/api/query-summary/route.ts @@ -0,0 +1,63 @@ +import { type NextRequest, NextResponse } from "next/server" +import { canAccess, type Role } from "@/lib/roles" +import { generateText } from "ai" +import { createOpenAI } from "@ai-sdk/openai" + +const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY || "" }) + +export async function POST(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/query-summary", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + if (!process.env.OPENAI_API_KEY) { + return NextResponse.json({ error: "OpenAI API key not configured" }, { status: 500 }) + } + + let prompt: string + let data: unknown[] + let rowCount: number + let vizType: string + + try { + const body = await request.json() + prompt = body.prompt + data = body.data + rowCount = body.rowCount ?? 0 + vizType = body.vizType ?? "unknown" + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }) + } + + if (!prompt || !Array.isArray(data)) { + return NextResponse.json({ error: "prompt and data are required" }, { status: 400 }) + } + + // Cap rows sent to LLM to avoid token overflow + const sampleRows = data.slice(0, 50) + + const llmPrompt = `You are a student success analyst at a community college. An advisor ran the following query and got these results. + +QUERY: "${prompt.slice(0, 2000)}" +RESULT: ${rowCount} rows, visualization type: ${vizType} +DATA SAMPLE: +${JSON.stringify(sampleRows, null, 2)} + +Write a 2-3 sentence plain-English summary of what these results show. Be specific about the numbers. Do not speculate beyond the data. Address the advisor directly.` + + try { + const result = await generateText({ + model: openai("gpt-4o-mini"), + prompt: llmPrompt, + maxOutputTokens: 200, + }) + return NextResponse.json({ summary: result.text }) + } catch (error) { + console.error("[query-summary] Error:", error) + return NextResponse.json( + { error: "Failed to generate summary", details: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ) + } +} diff --git a/codebenders-dashboard/app/query/page.tsx b/codebenders-dashboard/app/query/page.tsx index f770675..a0ab4fe 100644 --- a/codebenders-dashboard/app/query/page.tsx +++ b/codebenders-dashboard/app/query/page.tsx @@ -1,9 +1,7 @@ "use client" import { useState } from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { Label } from "@/components/ui/label" @@ -13,8 +11,7 @@ import { QueryHistoryPanel } from "@/components/query-history-panel" import { analyzePrompt } from "@/lib/prompt-analyzer" import { executeQuery } from "@/lib/query-executor" import type { QueryPlan, QueryResult, HistoryEntry } from "@/lib/types" -import Link from "next/link" -import { ArrowLeft } from "lucide-react" +import { Loader2, Sparkles, PanelLeft } from "lucide-react" const INSTITUTIONS = [ { name: "Bishop State", code: "bscc" }, @@ -30,6 +27,10 @@ export default function QueryPage() { const [queryPlan, setQueryPlan] = useState(null) const [queryResult, setQueryResult] = useState(null) const [useDirectDB, setUseDirectDB] = useState(true) + const [sidebarOpen, setSidebarOpen] = useState(false) + const [summary, setSummary] = useState(null) + const [summaryLoading, setSummaryLoading] = useState(false) + const [summaryError, setSummaryError] = useState(null) const [history, setHistory] = useState(() => { // Read from localStorage on mount (client-only) if (typeof window === "undefined") return [] @@ -44,6 +45,9 @@ export default function QueryPage() { overridePrompt?: string, overrideInstitution?: string, ) => { + setSummary(null) + setSummaryError(null) + const activePrompt = overridePrompt ?? prompt const activeInstitution = overrideInstitution ?? institution @@ -125,100 +129,205 @@ export default function QueryPage() { localStorage.removeItem("bishop_query_history") } + const handleSummarize = async () => { + if (!queryResult || !queryPlan) return + setSummaryLoading(true) + setSummaryError(null) + try { + const res = await fetch("/api/query-summary", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt, + data: queryResult.data, + rowCount: queryResult.rowCount, + vizType: queryPlan.vizType, + }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json.error || "Failed") + setSummary(json.summary) + } catch (e) { + setSummaryError(e instanceof Error ? e.message : String(e)) + } finally { + setSummaryLoading(false) + } + } + return ( -
    -
    -
    - - - Back to Dashboard - -

    SQL Query Interface

    -

    Analyze student performance data with natural language queries

    -
    - - - - Query Controls - Select an institution and enter your analysis prompt - - -
    +
    + {/* Slim page-level header bar */} +
    + {/* Mobile sidebar toggle */} + + + {/* Title group */} + + Query Interface + +
    + + {/* Body: sidebar + main */} +
    + {/* Desktop sidebar */} + + + {/* Mobile sidebar overlay */} + {sidebarOpen && ( +
    +
    setSidebarOpen(false)} + /> + +
    + )} + + {/* Main content */} +
    + {/* Query controls */} +
    + {/* DB mode toggle row */} +
    -
    -
    -
    - - -
    + {/* Institution selector */} +
    + + +
    -
    - - setPrompt(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleAnalyze() - } - }} - /> -
    + {/* Query textarea */} +
    + +