Skip to content

Commit dfa9ef0

Browse files
authored
feat: add cohort and enrollment intensity filters to student roster (#81) (#82)
* 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 <a href download> * 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)
1 parent 47253f6 commit dfa9ef0

File tree

34 files changed

+5057
-117
lines changed

34 files changed

+5057
-117
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ Desktop.ini
106106
*.csv
107107
*.xlsx
108108
*.xls
109+
*.pptx
109110
*.json
110111
!package.json
111112
!tsconfig.json
@@ -158,6 +159,12 @@ docker-compose.override.yml
158159
# Git worktrees
159160
.worktrees/
160161

162+
# Large presentation/doc files
163+
docs/AI-Powered-Student-Success-Analytics.pptx
164+
docs/Copy-of-AI-Powered-Student-Success-Analytics.pdf
165+
docs/CodeBenders-PRD_Student_Success_Analytics.pdf
166+
DOCUMENTATION_ISSUES.md
167+
161168
# Misc
162169
.cache/
163170
*.seed

ai_model/complete_ml_pipeline.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,17 @@ def assign_credential_type(row):
173173
else:
174174
return 2 # Default to Associate's (most common at community colleges)
175175

176-
# No credential completed
176+
# Priority 5: No completion data — fall back to credential type sought as proxy
177+
# (represents "what credential is this student on track for")
178+
credential_sought = str(row.get('Credential_Type_Sought_Year_1', ''))
179+
if credential_sought in ['01', '02', '03', 'C1', 'C2']:
180+
return 1 # Certificate-track
181+
elif credential_sought in ['A', '04', '05']:
182+
return 2 # Associate-track
183+
elif credential_sought in ['B', '06', '07', '08']:
184+
return 3 # Bachelor-track
185+
186+
# No credential completed or sought
177187
return 0 # No credential
178188

179189
df['target_credential_type'] = df.apply(assign_credential_type, axis=1)
@@ -646,6 +656,7 @@ def assign_alert_level(risk_score):
646656
n_estimators=50,
647657
max_depth=5,
648658
min_samples_split=30,
659+
class_weight='balanced',
649660
random_state=42,
650661
n_jobs=-1
651662
)
@@ -734,7 +745,7 @@ def assign_alert_level(risk_score):
734745
# Only include students who attempted gateway math (not NaN)
735746
gateway_math_raw = df['CompletedGatewayMathYear1']
736747
valid_idx = gateway_math_raw.notna()
737-
y_gateway_math = (gateway_math_raw[valid_idx] == 'C').astype(int)
748+
y_gateway_math = (gateway_math_raw[valid_idx] == 'Y').astype(int)
738749
X_gateway_math = X_gateway_math_clean[valid_idx]
739750

740751
print(f"\nDataset size: {len(X_gateway_math):,} students")
@@ -845,7 +856,7 @@ def assign_alert_level(risk_score):
845856
# Only include students who attempted gateway English (not NaN)
846857
gateway_english_raw = df['CompletedGatewayEnglishYear1']
847858
valid_idx = gateway_english_raw.notna()
848-
y_gateway_english = (gateway_english_raw[valid_idx] == 'C').astype(int)
859+
y_gateway_english = (gateway_english_raw[valid_idx] == 'Y').astype(int)
849860
X_gateway_english = X_gateway_english_clean[valid_idx]
850861

851862
print(f"\nDataset size: {len(X_gateway_english):,} students")
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"use server"
2+
3+
import { createClient } from "@/lib/supabase/server"
4+
import { redirect } from "next/navigation"
5+
6+
export async function signOut() {
7+
const supabase = await createClient()
8+
await supabase.auth.signOut()
9+
redirect("/login")
10+
}

codebenders-dashboard/app/api/dashboard/kpis/route.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,62 @@ import { getPool } from "@/lib/db"
33

44
export async function GET(request: NextRequest) {
55
try {
6+
const { searchParams } = new URL(request.url)
7+
const cohort = searchParams.get("cohort") || ""
8+
const enrollmentType = searchParams.get("enrollmentType") || ""
9+
const credentialType = searchParams.get("credentialType") || ""
10+
611
const pool = getPool()
712

13+
const conditions: string[] = []
14+
const params: unknown[] = []
15+
16+
if (cohort) {
17+
params.push(cohort)
18+
conditions.push(`"Cohort" = $${params.length}`)
19+
}
20+
if (enrollmentType) {
21+
params.push(enrollmentType)
22+
conditions.push(`"Enrollment_Intensity_First_Term" = $${params.length}`)
23+
}
24+
if (credentialType) {
25+
params.push(credentialType)
26+
conditions.push(`predicted_credential_label = $${params.length}`)
27+
}
28+
29+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
30+
831
const sql = `
932
SELECT
10-
AVG("Retention") * 100 as overall_retention_rate,
11-
AVG(retention_probability) * 100 as avg_predicted_retention,
12-
SUM(CASE WHEN at_risk_alert IN ('HIGH', 'URGENT') THEN 1 ELSE 0 END) as high_critical_risk_count,
13-
AVG(course_completion_rate) * 100 as avg_course_completion_rate,
14-
COUNT(*) as total_students
33+
AVG("Retention") * 100 AS overall_retention_rate,
34+
AVG(retention_probability) * 100 AS avg_predicted_retention,
35+
SUM(CASE WHEN at_risk_alert IN ('HIGH', 'URGENT') THEN 1 ELSE 0 END) AS high_critical_risk_count,
36+
AVG(course_completion_rate) * 100 AS avg_course_completion_rate,
37+
COUNT(*) AS total_students
1538
FROM student_level_with_predictions
39+
${where}
1640
LIMIT 1
1741
`
1842

19-
const result = await pool.query(sql)
43+
const result = await pool.query(sql, params)
2044
const kpis = result.rows[0] ?? null
2145

2246
if (!kpis) {
2347
return NextResponse.json({ error: "No data found" }, { status: 404 })
2448
}
2549

2650
return NextResponse.json({
27-
overallRetentionRate: Number(kpis.overall_retention_rate || 0).toFixed(1),
28-
avgPredictedRetention: Number(kpis.avg_predicted_retention || 0).toFixed(1),
29-
highCriticalRiskCount: Number(kpis.high_critical_risk_count || 0),
51+
overallRetentionRate: Number(kpis.overall_retention_rate || 0).toFixed(1),
52+
avgPredictedRetention: Number(kpis.avg_predicted_retention || 0).toFixed(1),
53+
highCriticalRiskCount: Number(kpis.high_critical_risk_count || 0),
3054
avgCourseCompletionRate: Number(kpis.avg_course_completion_rate || 0).toFixed(1),
31-
totalStudents: Number(kpis.total_students || 0),
55+
totalStudents: Number(kpis.total_students || 0),
3256
})
3357
} catch (error) {
3458
console.error("KPI fetch error:", error)
3559
return NextResponse.json(
3660
{
37-
error: "Failed to fetch KPIs",
61+
error: "Failed to fetch KPIs",
3862
details: error instanceof Error ? error.message : String(error),
3963
},
4064
{ status: 500 }

codebenders-dashboard/app/api/dashboard/readiness/route.ts

Lines changed: 77 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,64 @@ import { getPool } from "@/lib/db"
44
export async function GET(request: Request) {
55
try {
66
const { searchParams } = new URL(request.url)
7-
const institution = searchParams.get("institution")
8-
const cohort = searchParams.get("cohort")
9-
const level = searchParams.get("level") // high, medium, low
7+
const institution = searchParams.get("institution")
8+
const cohort = searchParams.get("cohort")
9+
const level = searchParams.get("level") // high, medium, low
10+
const enrollmentType = searchParams.get("enrollmentType")
11+
const credentialType = searchParams.get("credentialType")
1012

1113
const pool = getPool()
1214

13-
// Build WHERE clause with $N Postgres placeholders
15+
// Build WHERE clause with $N Postgres placeholders.
16+
// llm_recommendations is aliased as lr; when enrollment/credential filters are
17+
// present we JOIN student_level_with_predictions as s.
1418
const conditions: string[] = []
15-
const params: any[] = []
19+
const params: unknown[] = []
1620

1721
if (institution) {
1822
params.push(institution)
19-
conditions.push(`"Institution_ID" = $${params.length}`)
23+
conditions.push(`lr."Institution_ID" = $${params.length}`)
2024
}
2125

2226
if (cohort) {
2327
params.push(cohort)
24-
conditions.push(`"Cohort" = $${params.length}`)
28+
conditions.push(`lr."Cohort" = $${params.length}`)
2529
}
2630

2731
if (level) {
2832
params.push(level)
29-
conditions.push(`readiness_level = $${params.length}`)
33+
conditions.push(`lr.readiness_level = $${params.length}`)
3034
}
3135

36+
if (enrollmentType) {
37+
params.push(enrollmentType)
38+
conditions.push(`s."Enrollment_Intensity_First_Term" = $${params.length}`)
39+
}
40+
41+
if (credentialType) {
42+
params.push(credentialType)
43+
conditions.push(`s.predicted_credential_label = $${params.length}`)
44+
}
45+
46+
const needsJoin = !!(enrollmentType || credentialType)
47+
const joinClause = needsJoin
48+
? `JOIN student_level_with_predictions s ON s."Student_GUID" = lr."Student_GUID"`
49+
: ""
3250
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
3351

3452
// Get overall statistics
3553
const statsResult = await pool.query(
3654
`
3755
SELECT
3856
COUNT(*) as total_students,
39-
AVG(readiness_score) as avg_score,
40-
MIN(readiness_score) as min_score,
41-
MAX(readiness_score) as max_score,
42-
SUM(CASE WHEN readiness_level = 'high' THEN 1 ELSE 0 END) as high_count,
43-
SUM(CASE WHEN readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count,
44-
SUM(CASE WHEN readiness_level = 'low' THEN 1 ELSE 0 END) as low_count
45-
FROM llm_recommendations
57+
AVG(lr.readiness_score) as avg_score,
58+
MIN(lr.readiness_score) as min_score,
59+
MAX(lr.readiness_score) as max_score,
60+
SUM(CASE WHEN lr.readiness_level = 'high' THEN 1 ELSE 0 END) as high_count,
61+
SUM(CASE WHEN lr.readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count,
62+
SUM(CASE WHEN lr.readiness_level = 'low' THEN 1 ELSE 0 END) as low_count
63+
FROM llm_recommendations lr
64+
${joinClause}
4665
${whereClause}
4766
`,
4867
params
@@ -58,19 +77,20 @@ export async function GET(request: Request) {
5877
const distributionResult = await pool.query(
5978
`
6079
SELECT
61-
readiness_level,
80+
lr.readiness_level,
6281
COUNT(*) as count,
63-
AVG(readiness_score) as avg_score,
64-
MIN(readiness_score) as min_score,
65-
MAX(readiness_score) as max_score
66-
FROM llm_recommendations
82+
AVG(lr.readiness_score) as avg_score,
83+
MIN(lr.readiness_score) as min_score,
84+
MAX(lr.readiness_score) as max_score
85+
FROM llm_recommendations lr
86+
${joinClause}
6787
${whereClause}
68-
GROUP BY readiness_level
88+
GROUP BY lr.readiness_level
6989
ORDER BY
70-
CASE readiness_level
71-
WHEN 'high' THEN 1
90+
CASE lr.readiness_level
91+
WHEN 'high' THEN 1
7292
WHEN 'medium' THEN 2
73-
WHEN 'low' THEN 3
93+
WHEN 'low' THEN 3
7494
END
7595
`,
7696
params
@@ -81,21 +101,22 @@ export async function GET(request: Request) {
81101
`
82102
SELECT
83103
CASE
84-
WHEN readiness_score >= 0.8 THEN '0.8-1.0'
85-
WHEN readiness_score >= 0.6 THEN '0.6-0.8'
86-
WHEN readiness_score >= 0.4 THEN '0.4-0.6'
87-
WHEN readiness_score >= 0.2 THEN '0.2-0.4'
104+
WHEN lr.readiness_score >= 0.8 THEN '0.8-1.0'
105+
WHEN lr.readiness_score >= 0.6 THEN '0.6-0.8'
106+
WHEN lr.readiness_score >= 0.4 THEN '0.4-0.6'
107+
WHEN lr.readiness_score >= 0.2 THEN '0.2-0.4'
88108
ELSE '0.0-0.2'
89109
END as score_range,
90110
COUNT(*) as count
91-
FROM llm_recommendations
111+
FROM llm_recommendations lr
112+
${joinClause}
92113
${whereClause}
93114
GROUP BY
94115
CASE
95-
WHEN readiness_score >= 0.8 THEN '0.8-1.0'
96-
WHEN readiness_score >= 0.6 THEN '0.6-0.8'
97-
WHEN readiness_score >= 0.4 THEN '0.4-0.6'
98-
WHEN readiness_score >= 0.2 THEN '0.2-0.4'
116+
WHEN lr.readiness_score >= 0.8 THEN '0.8-1.0'
117+
WHEN lr.readiness_score >= 0.6 THEN '0.6-0.8'
118+
WHEN lr.readiness_score >= 0.4 THEN '0.4-0.6'
119+
WHEN lr.readiness_score >= 0.2 THEN '0.2-0.4'
99120
ELSE '0.0-0.2'
100121
END
101122
ORDER BY score_range DESC
@@ -120,6 +141,7 @@ export async function GET(request: Request) {
120141
lr.generated_at,
121142
lr.model_name
122143
FROM llm_recommendations lr
144+
${joinClause}
123145
${whereClause}
124146
ORDER BY lr.generated_at DESC
125147
LIMIT 100
@@ -130,15 +152,16 @@ export async function GET(request: Request) {
130152
// Parse JSON fields in recent assessments
131153
const assessments = recentResult.rows.map((row) => ({
132154
...row,
133-
risk_factors: row.risk_factors ? JSON.parse(row.risk_factors) : [],
155+
risk_factors: row.risk_factors ? JSON.parse(row.risk_factors) : [],
134156
suggested_actions: row.suggested_actions ? JSON.parse(row.suggested_actions) : [],
135157
}))
136158

137159
// Get most common risk factors
138160
const riskFactorResult = await pool.query(
139161
`
140-
SELECT risk_factors
141-
FROM llm_recommendations
162+
SELECT lr.risk_factors
163+
FROM llm_recommendations lr
164+
${joinClause}
142165
${whereClause}
143166
`,
144167
params
@@ -172,16 +195,17 @@ export async function GET(request: Request) {
172195
const cohortResult = await pool.query(
173196
`
174197
SELECT
175-
"Cohort",
198+
lr."Cohort",
176199
COUNT(*) as total,
177-
AVG(readiness_score) as avg_score,
178-
SUM(CASE WHEN readiness_level = 'high' THEN 1 ELSE 0 END) as high_count,
179-
SUM(CASE WHEN readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count,
180-
SUM(CASE WHEN readiness_level = 'low' THEN 1 ELSE 0 END) as low_count
181-
FROM llm_recommendations
200+
AVG(lr.readiness_score) as avg_score,
201+
SUM(CASE WHEN lr.readiness_level = 'high' THEN 1 ELSE 0 END) as high_count,
202+
SUM(CASE WHEN lr.readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count,
203+
SUM(CASE WHEN lr.readiness_level = 'low' THEN 1 ELSE 0 END) as low_count
204+
FROM llm_recommendations lr
205+
${joinClause}
182206
${whereClause}
183-
GROUP BY "Cohort"
184-
ORDER BY "Cohort" DESC
207+
GROUP BY lr."Cohort"
208+
ORDER BY lr."Cohort" DESC
185209
`,
186210
params
187211
)
@@ -191,16 +215,16 @@ export async function GET(request: Request) {
191215
data: {
192216
summary: {
193217
total_students: stats.total_students,
194-
avg_score: parseFloat(stats.avg_score || 0).toFixed(4),
195-
min_score: parseFloat(stats.min_score || 0).toFixed(4),
196-
max_score: parseFloat(stats.max_score || 0).toFixed(4),
197-
high_count: stats.high_count,
198-
medium_count: stats.medium_count,
199-
low_count: stats.low_count,
218+
avg_score: parseFloat(stats.avg_score || 0).toFixed(4),
219+
min_score: parseFloat(stats.min_score || 0).toFixed(4),
220+
max_score: parseFloat(stats.max_score || 0).toFixed(4),
221+
high_count: stats.high_count,
222+
medium_count: stats.medium_count,
223+
low_count: stats.low_count,
200224
},
201-
distribution: distributionResult.rows,
225+
distribution: distributionResult.rows,
202226
score_distribution: scoreDistResult.rows,
203-
assessments: assessments,
227+
assessments: assessments,
204228
top_risk_factors: topRiskFactors,
205229
cohort_breakdown: cohortResult.rows,
206230
},
@@ -211,7 +235,7 @@ export async function GET(request: Request) {
211235
return NextResponse.json(
212236
{
213237
success: false,
214-
error: "Failed to fetch readiness assessment data",
238+
error: "Failed to fetch readiness assessment data",
215239
details: error instanceof Error ? error.message : "Unknown error",
216240
},
217241
{ status: 500 }

0 commit comments

Comments
 (0)