Skip to content

Commit 89ae696

Browse files
committed
fix: NQL rate formatting and course_enrollments routing (#90)
- Add isRateColumn helper and formatCellValue to render 0–1 probabilities as percentages in table cells, chart axes, tooltips, and KPI display - Fix KPI suffix to only append % when value is actually in 0–1 range - Add course_enrollments table to LLM prompt with schema, routing rules, DFWI/pass rate SQL patterns, FERPA guardrails, and worked example - Add SchemaEntry interface; remove as-any cast on courseColumns - SQL patterns return 0–1 scale; display layer handles multiplication
1 parent 17b4a30 commit 89ae696

2 files changed

Lines changed: 124 additions & 17 deletions

File tree

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

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,17 @@ const queryPlanSchema = z.object({
2121
// IMPORTANT: Column names listed here are the EXACT case-sensitive names in PostgreSQL.
2222
// Mixed-case columns (e.g. "Cohort", "Retention") must be double-quoted in generated SQL.
2323
// All-lowercase columns (e.g. retention_probability) do not require quoting.
24-
const SCHEMA_INFO = {
24+
interface SchemaEntry {
25+
database: string
26+
mainTable: string
27+
description?: string
28+
columns: Record<string, string>
29+
courseTable?: string
30+
courseColumns?: Record<string, string>
31+
ferpaExcluded?: string[]
32+
}
33+
34+
const SCHEMA_INFO: Record<string, SchemaEntry> = {
2535
bscc: {
2636
database: "postgres",
2737
mainTable: "student_level_with_predictions",
@@ -68,6 +78,22 @@ const SCHEMA_INFO = {
6878
course_completion_rate: "Course completion rate (0-1)",
6979
passing_rate: "Course passing rate (0-1)",
7080
},
81+
courseTable: "course_enrollments",
82+
courseColumns: {
83+
course_prefix: "Course dept code ('MAT','ENG','NUR','CIS', etc.) — lowercase, no quoting",
84+
course_number: "Course number ('100','201', etc.) — lowercase, no quoting",
85+
course_name: "Full course name — lowercase, no quoting",
86+
grade: "Student grade: 'A','B','C','D','F','W','I','AU','P' — lowercase, no quoting",
87+
delivery_method: "Delivery: 'F'=face-to-face, 'O'=online, 'H'=hybrid — lowercase, no quoting",
88+
instructor_status: "Instructor type: 'FT'=full-time, 'PT'=part-time — lowercase, no quoting",
89+
gateway_type: "Gateway: 'M'=math gateway, 'E'=English gateway, 'N'=not a gateway — lowercase",
90+
credits_attempted: "Credits attempted (numeric)",
91+
credits_earned: "Credits earned (numeric)",
92+
cohort: "Cohort year as text — lowercase, no quoting",
93+
academic_year: "Academic year e.g. '2021-22' — lowercase, no quoting",
94+
academic_term: "Term e.g. 'FALL','SPRING','SUMMER' — lowercase, no quoting",
95+
},
96+
ferpaExcluded: ["Student_GUID", "student_guid"],
7197
},
7298
akron: {
7399
database: "University_of_Akron",
@@ -104,14 +130,21 @@ export async function POST(request: NextRequest) {
104130
schema: queryPlanSchema,
105131
prompt: `You are a SQL query generator for student success analytics using PostgreSQL.
106132
107-
DATABASE SCHEMA:
108-
- Main Table: ${schemaInfo.mainTable}
109-
- Description: ${schemaInfo.description}
133+
AVAILABLE TABLES:
134+
135+
1. ${schemaInfo.mainTable} — student-level analytics
136+
USE FOR: retention rates, persistence, GPA, demographics, risk scores, credential predictions, enrollment counts
137+
COLUMNS:
138+
${Object.entries(schemaInfo.columns).map(([col, desc]) => ` - ${col}: ${desc}`).join("\n")}
110139
111-
KEY COLUMNS:
112-
${Object.entries(schemaInfo.columns).map(([col, desc]) => `- ${col}: ${desc}`).join("\n")}
140+
2. ${schemaInfo.courseTable ?? "course_enrollments"} — individual course enrollment records
141+
USE FOR: DFW/DFWI rates by course, pass rates by course, gateway course outcomes, delivery method analysis, instructor type analysis
142+
COLUMNS:
143+
${Object.entries(schemaInfo.courseColumns ?? {}).map(([col, desc]) => ` - ${col}: ${desc}`).join("\n")}
113144
114-
CRITICAL SCHEMA NOTES:
145+
TABLE SELECTION RULE: If the question mentions "courses", "DFW", "DFWI", "withdrawal rate", "pass rate by course", "gateway course", "failing courses", "course outcomes" → use ${schemaInfo.courseTable ?? "course_enrollments"}. Otherwise use ${schemaInfo.mainTable}.
146+
147+
CRITICAL SCHEMA NOTES (for ${schemaInfo.mainTable}):
115148
- Column names with uppercase letters MUST be double-quoted in PostgreSQL SQL or the query will fail.
116149
CORRECT: WHERE "Cohort" = 2023 AND "Cohort_Term" = 'Fall'
117150
INCORRECT: WHERE cohort = 2023 AND cohort_term = 'Fall'
@@ -122,6 +155,16 @@ CRITICAL SCHEMA NOTES:
122155
- Lowercase ML columns (retention_probability, at_risk_alert, etc.) do NOT need quoting.
123156
- Use standard PostgreSQL syntax — no backtick quoting, no cross-database references
124157
158+
COMPUTING DFWI RATE from course_enrollments (returns 0–1, display layer multiplies by 100):
159+
ROUND(COUNT(*) FILTER (WHERE grade IN ('D','F','W','I'))::numeric / NULLIF(COUNT(*), 0), 4) AS dfwi_rate
160+
161+
COMPUTING PASS RATE from course_enrollments (returns 0–1, display layer multiplies by 100):
162+
ROUND(COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') AND grade IS NOT NULL AND grade != '')::numeric / NULLIF(COUNT(*), 0), 4) AS pass_rate
163+
164+
FERPA COMPLIANCE — NEVER include these in SELECT output:
165+
Student_GUID, student_guid
166+
Do not expose individual student identifiers in query results.
167+
125168
IMPORTANT QUERY INTERPRETATION RULES:
126169
127170
1. METRIC SELECTION:
@@ -151,7 +194,7 @@ IMPORTANT QUERY INTERPRETATION RULES:
151194
- Age filters: use numeric comparisons directly (e.g., "Student_Age" >= 25)
152195
153196
4. VISUALIZATION:
154-
- Comparing groups (age, gender, race) → "bar"
197+
- Comparing groups (age, gender, race, courses) → "bar"
155198
- Time series (cohort, term over time) → "line"
156199
- Single number → "kpi"
157200
- Percentages/shares → "pie"
@@ -166,7 +209,7 @@ Generate a query plan with:
166209
- filters: any filters to apply [OPTIONAL]
167210
- timeHint: human-readable time description [OPTIONAL]
168211
- vizType: appropriate visualization [REQUIRED]
169-
- sql: VALID executable PostgreSQL query against table "${schemaInfo.mainTable}" [REQUIRED]
212+
- sql: VALID executable PostgreSQL query against the appropriate table (${schemaInfo.mainTable} or course_enrollments) [REQUIRED]
170213
- queryString: empty string [OPTIONAL]
171214
172215
EXAMPLE for "segment students over 25 and under 25 in 2023 cohort":
@@ -176,6 +219,13 @@ EXAMPLE for "segment students over 25 and under 25 in 2023 cohort":
176219
"queryString": ""
177220
}
178221
222+
EXAMPLE for "top 5 courses with highest DFW rates":
223+
{
224+
"vizType": "bar",
225+
"sql": "SELECT course_prefix || ' ' || course_number AS course, MAX(course_name) AS course_name, COUNT(*) AS enrollments, ROUND(COUNT(*) FILTER (WHERE grade IN ('D','F','W','I'))::numeric / NULLIF(COUNT(*), 0), 4) AS dfwi_rate FROM course_enrollments GROUP BY course_prefix, course_number HAVING COUNT(*) >= 10 ORDER BY dfwi_rate DESC LIMIT 5",
226+
"queryString": ""
227+
}
228+
179229
Make sure the SQL is valid PostgreSQL and addresses exactly what the user asked for!`,
180230
})
181231

codebenders-dashboard/components/analysis-result.tsx

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ const TOOLTIP_STYLE = {
3737
color: "var(--popover-foreground)",
3838
}
3939

40+
function isRateColumn(col: string): boolean {
41+
const lower = col.toLowerCase()
42+
return (
43+
lower.endsWith("_rate") ||
44+
lower.endsWith("_probability") ||
45+
lower.endsWith("_pct") ||
46+
lower.endsWith("_percent") ||
47+
lower.endsWith("_percentage")
48+
)
49+
}
50+
51+
function formatCellValue(col: string, val: unknown): string {
52+
if (typeof val !== "number") return val == null ? "" : String(val)
53+
if (isRateColumn(col) && val >= 0 && val <= 1) return (val * 100).toFixed(1) + "%"
54+
return Number.isInteger(val) ? String(val) : val.toFixed(2)
55+
}
56+
4057
export function AnalysisResult({ result, plan }: AnalysisResultProps) {
4158
const renderDataTable = () => {
4259
if (!result.data || result.data.length === 0) return null
@@ -56,7 +73,7 @@ export function AnalysisResult({ result, plan }: AnalysisResultProps) {
5673
{result.data.map((row, idx) => (
5774
<TableRow key={idx}>
5875
{columns.map((col) => (
59-
<TableCell key={col}>{typeof row[col] === "number" ? row[col].toFixed(2) : row[col]}</TableCell>
76+
<TableCell key={col}>{formatCellValue(col, row[col])}</TableCell>
6077
))}
6178
</TableRow>
6279
))}
@@ -89,10 +106,22 @@ export function AnalysisResult({ result, plan }: AnalysisResultProps) {
89106
<LineChart data={result.data}>
90107
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
91108
<XAxis dataKey={groupByKey} stroke="var(--muted-foreground)" />
92-
<YAxis stroke="var(--muted-foreground)" />
109+
<YAxis
110+
stroke="var(--muted-foreground)"
111+
// No upper-bound guard: axis ticks may slightly exceed 1.0 (chart padding),
112+
// and should still render as % to stay visually consistent with data labels.
113+
tickFormatter={(v: number) =>
114+
isRateColumn(metricKey) && v >= 0 ? `${(v * 100).toFixed(0)}%` : String(v)
115+
}
116+
/>
93117
<Tooltip
94118
wrapperStyle={{ zIndex: 10, overflow: 'visible' as const }}
95119
contentStyle={TOOLTIP_STYLE}
120+
formatter={(v: number) =>
121+
isRateColumn(metricKey) && v >= 0 && v <= 1
122+
? [`${(v * 100).toFixed(1)}%`, metricKey.replace(/_/g, " ")]
123+
: [v, metricKey.replace(/_/g, " ")]
124+
}
96125
/>
97126
<Legend />
98127
<Line
@@ -115,11 +144,23 @@ export function AnalysisResult({ result, plan }: AnalysisResultProps) {
115144
<BarChart data={result.data}>
116145
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
117146
<XAxis dataKey={groupByKey} stroke="var(--muted-foreground)" />
118-
<YAxis stroke="var(--muted-foreground)" />
147+
<YAxis
148+
stroke="var(--muted-foreground)"
149+
// No upper-bound guard: axis ticks may slightly exceed 1.0 (chart padding),
150+
// and should still render as % to stay visually consistent with data labels.
151+
tickFormatter={(v: number) =>
152+
isRateColumn(metricKey) && v >= 0 ? `${(v * 100).toFixed(0)}%` : String(v)
153+
}
154+
/>
119155
<Tooltip
120156
cursor={false}
121157
wrapperStyle={{ zIndex: 10, overflow: 'visible' as const }}
122158
contentStyle={TOOLTIP_STYLE}
159+
formatter={(v: number) =>
160+
isRateColumn(metricKey) && v >= 0 && v <= 1
161+
? [`${(v * 100).toFixed(1)}%`, metricKey.replace(/_/g, " ")]
162+
: [v, metricKey.replace(/_/g, " ")]
163+
}
123164
/>
124165
<Legend />
125166
<Bar
@@ -155,6 +196,11 @@ export function AnalysisResult({ result, plan }: AnalysisResultProps) {
155196
<Tooltip
156197
wrapperStyle={{ zIndex: 10, overflow: 'visible' as const }}
157198
contentStyle={TOOLTIP_STYLE}
199+
formatter={(v: number) =>
200+
isRateColumn(metricKey) && v >= 0 && v <= 1
201+
? [`${(v * 100).toFixed(1)}%`, metricKey.replace(/_/g, " ")]
202+
: [v, metricKey.replace(/_/g, " ")]
203+
}
158204
/>
159205
<Legend
160206
verticalAlign="bottom"
@@ -166,19 +212,30 @@ export function AnalysisResult({ result, plan }: AnalysisResultProps) {
166212
</div>
167213
)
168214

169-
case "kpi":
170-
const kpiValue = result.data[0]?.[metricKey] ?? 0
215+
case "kpi": {
216+
const kpiRaw = result.data[0]?.[metricKey] ?? 0
217+
const isRate = isRateColumn(metricKey)
218+
const shouldScale = isRate && typeof kpiRaw === "number" && kpiRaw >= 0 && kpiRaw <= 1
219+
const kpiDisplay =
220+
typeof kpiRaw === "number"
221+
? shouldScale
222+
? (kpiRaw * 100).toFixed(1)
223+
: kpiRaw.toFixed(1)
224+
: String(kpiRaw)
225+
const kpiSuffix = shouldScale ? "%" : ""
171226
return (
172227
<div className="flex items-center justify-center h-48">
173228
<div className="text-center space-y-4">
174229
<div className="text-6xl font-bold text-foreground">
175-
{typeof kpiValue === "number" ? kpiValue.toFixed(1) : kpiValue}
176-
{metricKey.includes("rate") || metricKey.includes("percentage") ? "%" : ""}
230+
{kpiDisplay}{kpiSuffix}
231+
</div>
232+
<div className="text-xl text-muted-foreground capitalize">
233+
{metricKey.replace(/_/g, " ")}
177234
</div>
178-
<div className="text-xl text-muted-foreground capitalize">{metricKey.replace(/_/g, " ")}</div>
179235
</div>
180236
</div>
181237
)
238+
}
182239

183240
case "table":
184241
return (

0 commit comments

Comments
 (0)