Skip to content

Commit 17b4a30

Browse files
authored
feat: NQL interface redesign — nav link, sidebar history, LLM Summarize (#88) (#89)
* 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)
1 parent 65c5d21 commit 17b4a30

File tree

7 files changed

+849
-144
lines changed

7 files changed

+849
-144
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { type NextRequest, NextResponse } from "next/server"
2+
import { canAccess, type Role } from "@/lib/roles"
3+
import { generateText } from "ai"
4+
import { createOpenAI } from "@ai-sdk/openai"
5+
6+
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY || "" })
7+
8+
export async function POST(request: NextRequest) {
9+
const role = request.headers.get("x-user-role") as Role | null
10+
if (!role || !canAccess("/api/query-summary", role)) {
11+
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
12+
}
13+
14+
if (!process.env.OPENAI_API_KEY) {
15+
return NextResponse.json({ error: "OpenAI API key not configured" }, { status: 500 })
16+
}
17+
18+
let prompt: string
19+
let data: unknown[]
20+
let rowCount: number
21+
let vizType: string
22+
23+
try {
24+
const body = await request.json()
25+
prompt = body.prompt
26+
data = body.data
27+
rowCount = body.rowCount ?? 0
28+
vizType = body.vizType ?? "unknown"
29+
} catch {
30+
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 })
31+
}
32+
33+
if (!prompt || !Array.isArray(data)) {
34+
return NextResponse.json({ error: "prompt and data are required" }, { status: 400 })
35+
}
36+
37+
// Cap rows sent to LLM to avoid token overflow
38+
const sampleRows = data.slice(0, 50)
39+
40+
const llmPrompt = `You are a student success analyst at a community college. An advisor ran the following query and got these results.
41+
42+
QUERY: "${prompt.slice(0, 2000)}"
43+
RESULT: ${rowCount} rows, visualization type: ${vizType}
44+
DATA SAMPLE:
45+
${JSON.stringify(sampleRows, null, 2)}
46+
47+
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.`
48+
49+
try {
50+
const result = await generateText({
51+
model: openai("gpt-4o-mini"),
52+
prompt: llmPrompt,
53+
maxOutputTokens: 200,
54+
})
55+
return NextResponse.json({ summary: result.text })
56+
} catch (error) {
57+
console.error("[query-summary] Error:", error)
58+
return NextResponse.json(
59+
{ error: "Failed to generate summary", details: error instanceof Error ? error.message : String(error) },
60+
{ status: 500 },
61+
)
62+
}
63+
}

codebenders-dashboard/app/query/page.tsx

Lines changed: 193 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
"use client"
22

33
import { useState } from "react"
4-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
54
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
6-
import { Input } from "@/components/ui/input"
75
import { Button } from "@/components/ui/button"
86
import { Switch } from "@/components/ui/switch"
97
import { Label } from "@/components/ui/label"
@@ -13,8 +11,7 @@ import { QueryHistoryPanel } from "@/components/query-history-panel"
1311
import { analyzePrompt } from "@/lib/prompt-analyzer"
1412
import { executeQuery } from "@/lib/query-executor"
1513
import type { QueryPlan, QueryResult, HistoryEntry } from "@/lib/types"
16-
import Link from "next/link"
17-
import { ArrowLeft } from "lucide-react"
14+
import { Loader2, Sparkles, PanelLeft } from "lucide-react"
1815

1916
const INSTITUTIONS = [
2017
{ name: "Bishop State", code: "bscc" },
@@ -30,6 +27,10 @@ export default function QueryPage() {
3027
const [queryPlan, setQueryPlan] = useState<QueryPlan | null>(null)
3128
const [queryResult, setQueryResult] = useState<QueryResult | null>(null)
3229
const [useDirectDB, setUseDirectDB] = useState(true)
30+
const [sidebarOpen, setSidebarOpen] = useState(false)
31+
const [summary, setSummary] = useState<string | null>(null)
32+
const [summaryLoading, setSummaryLoading] = useState(false)
33+
const [summaryError, setSummaryError] = useState<string | null>(null)
3334
const [history, setHistory] = useState<HistoryEntry[]>(() => {
3435
// Read from localStorage on mount (client-only)
3536
if (typeof window === "undefined") return []
@@ -44,6 +45,9 @@ export default function QueryPage() {
4445
overridePrompt?: string,
4546
overrideInstitution?: string,
4647
) => {
48+
setSummary(null)
49+
setSummaryError(null)
50+
4751
const activePrompt = overridePrompt ?? prompt
4852
const activeInstitution = overrideInstitution ?? institution
4953

@@ -125,100 +129,205 @@ export default function QueryPage() {
125129
localStorage.removeItem("bishop_query_history")
126130
}
127131

132+
const handleSummarize = async () => {
133+
if (!queryResult || !queryPlan) return
134+
setSummaryLoading(true)
135+
setSummaryError(null)
136+
try {
137+
const res = await fetch("/api/query-summary", {
138+
method: "POST",
139+
headers: { "Content-Type": "application/json" },
140+
body: JSON.stringify({
141+
prompt,
142+
data: queryResult.data,
143+
rowCount: queryResult.rowCount,
144+
vizType: queryPlan.vizType,
145+
}),
146+
})
147+
const json = await res.json()
148+
if (!res.ok) throw new Error(json.error || "Failed")
149+
setSummary(json.summary)
150+
} catch (e) {
151+
setSummaryError(e instanceof Error ? e.message : String(e))
152+
} finally {
153+
setSummaryLoading(false)
154+
}
155+
}
156+
128157
return (
129-
<div className="min-h-screen bg-background">
130-
<div className="container mx-auto p-6 space-y-6">
131-
<div className="border-b border-border pb-6">
132-
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4">
133-
<ArrowLeft className="h-4 w-4 mr-2" />
134-
Back to Dashboard
135-
</Link>
136-
<h1 className="text-3xl font-bold tracking-tight text-foreground">SQL Query Interface</h1>
137-
<p className="text-muted-foreground mt-2">Analyze student performance data with natural language queries</p>
138-
</div>
139-
140-
<Card>
141-
<CardHeader>
142-
<CardTitle>Query Controls</CardTitle>
143-
<CardDescription>Select an institution and enter your analysis prompt</CardDescription>
144-
</CardHeader>
145-
<CardContent className="space-y-4">
146-
<div className="flex items-center space-x-2 pb-4 border-b border-border">
158+
<div className="min-h-screen bg-background flex flex-col">
159+
{/* Slim page-level header bar */}
160+
<header className="h-12 flex items-center gap-3 px-4 border-b border-border/60 shrink-0">
161+
{/* Mobile sidebar toggle */}
162+
<button
163+
onClick={() => setSidebarOpen(true)}
164+
className="md:hidden p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
165+
aria-label="Open query history"
166+
>
167+
<PanelLeft className="h-4 w-4" />
168+
</button>
169+
170+
{/* Title group */}
171+
<span className="text-sm font-semibold tracking-widest uppercase text-foreground">
172+
Query Interface
173+
</span>
174+
<span className="hidden sm:block h-4 w-px bg-border/60" aria-hidden="true" />
175+
<span className="hidden sm:block text-xs text-muted-foreground font-mono">
176+
Natural Language Analytics
177+
</span>
178+
</header>
179+
180+
{/* Body: sidebar + main */}
181+
<div className="flex flex-1 overflow-hidden">
182+
{/* Desktop sidebar */}
183+
<aside className="hidden md:flex w-[260px] border-r border-border/60 flex-col shrink-0 bg-muted/20">
184+
<QueryHistoryPanel entries={history} onRerun={handleRerun} onClear={handleClear} />
185+
</aside>
186+
187+
{/* Mobile sidebar overlay */}
188+
{sidebarOpen && (
189+
<div className="fixed inset-0 z-50 md:hidden">
190+
<div
191+
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
192+
onClick={() => setSidebarOpen(false)}
193+
/>
194+
<aside className="absolute left-0 top-0 bottom-0 w-[260px] bg-background border-r border-border/60 flex flex-col">
195+
<QueryHistoryPanel entries={history} onRerun={handleRerun} onClear={handleClear} />
196+
</aside>
197+
</div>
198+
)}
199+
200+
{/* Main content */}
201+
<main className="flex-1 overflow-auto p-6 space-y-6">
202+
{/* Query controls */}
203+
<div className="border border-border/60 rounded-lg p-5 space-y-4">
204+
{/* DB mode toggle row */}
205+
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
147206
<Switch id="db-mode" checked={useDirectDB} onCheckedChange={setUseDirectDB} />
148-
<Label htmlFor="db-mode" className="text-sm font-medium">
207+
<Label htmlFor="db-mode" className="text-sm font-medium cursor-pointer">
149208
{useDirectDB ? "Direct Database" : "API Mode"}
150209
</Label>
151-
<span className="text-xs text-muted-foreground">
152-
{useDirectDB ? "(Execute SQL directly)" : "(Fetch from API endpoints)"}
210+
<span className="text-xs text-muted-foreground font-mono">
211+
{useDirectDB ? "(execute SQL directly)" : "(fetch from API endpoints)"}
153212
</span>
154213
</div>
155214

156-
<div className="grid gap-4 md:grid-cols-[200px_1fr_auto]">
157-
<div className="space-y-2">
158-
<label className="text-sm font-medium text-foreground">Institution</label>
159-
<Select value={institution} onValueChange={setInstitution}>
160-
<SelectTrigger>
161-
<SelectValue />
162-
</SelectTrigger>
163-
<SelectContent>
164-
{INSTITUTIONS.map((inst) => (
165-
<SelectItem key={inst.code} value={inst.code}>
166-
{inst.name}
167-
</SelectItem>
168-
))}
169-
</SelectContent>
170-
</Select>
171-
</div>
215+
{/* Institution selector */}
216+
<div className="space-y-1.5">
217+
<label className="text-[10px] font-semibold tracking-widest uppercase text-muted-foreground/70">
218+
Institution
219+
</label>
220+
<Select value={institution} onValueChange={setInstitution}>
221+
<SelectTrigger className="border-border/60 bg-background text-sm w-full md:w-[220px]">
222+
<SelectValue />
223+
</SelectTrigger>
224+
<SelectContent>
225+
{INSTITUTIONS.map((inst) => (
226+
<SelectItem key={inst.code} value={inst.code}>
227+
{inst.name}
228+
</SelectItem>
229+
))}
230+
</SelectContent>
231+
</Select>
232+
</div>
172233

173-
<div className="space-y-2">
174-
<label className="text-sm font-medium text-foreground">Analysis Prompt</label>
175-
<Input
176-
placeholder="e.g., retention by cohort for last two terms"
177-
value={prompt}
178-
onChange={(e) => setPrompt(e.target.value)}
179-
onKeyDown={(e) => {
180-
if (e.key === "Enter" && !e.shiftKey) {
181-
e.preventDefault()
182-
handleAnalyze()
183-
}
184-
}}
185-
/>
186-
</div>
234+
{/* Query textarea */}
235+
<div className="space-y-1.5">
236+
<label className="text-[10px] font-semibold tracking-widest uppercase text-muted-foreground/70">
237+
Natural Language Query
238+
</label>
239+
<textarea
240+
placeholder="e.g., retention by cohort for last two terms"
241+
value={prompt}
242+
onChange={(e) => setPrompt(e.target.value)}
243+
onKeyDown={(e) => {
244+
if (e.key === "Enter" && !e.shiftKey) {
245+
e.preventDefault()
246+
handleAnalyze()
247+
}
248+
}}
249+
className="flex w-full rounded-md border border-border/60 bg-muted/20 px-3 py-2 font-mono text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 min-h-[80px] resize-none"
250+
/>
251+
</div>
252+
253+
{/* Run button row */}
254+
<div className="flex justify-end">
255+
<Button
256+
onClick={() => handleAnalyze()}
257+
disabled={isAnalyzing || !prompt.trim()}
258+
className="bg-foreground text-background hover:bg-foreground/90 text-xs font-semibold tracking-wide uppercase px-5"
259+
>
260+
{isAnalyzing ? (
261+
<>
262+
<Loader2 className="h-3 w-3 animate-spin mr-1.5" />
263+
Analyzing…
264+
</>
265+
) : (
266+
"Run Analysis"
267+
)}
268+
</Button>
269+
</div>
270+
</div>
187271

188-
<div className="flex items-end">
189-
<Button onClick={() => handleAnalyze()} disabled={isAnalyzing || !prompt.trim()} className="w-full md:w-auto">
190-
{isAnalyzing ? "Analyzing..." : "Analyze"}
191-
</Button>
272+
{/* Empty state */}
273+
{!queryResult && (
274+
<div className="border border-dashed border-border/50 rounded-lg py-12 flex items-center justify-center">
275+
<div className="text-center space-y-1.5">
276+
<p className="text-sm text-muted-foreground">Enter a query above and run analysis to see results</p>
277+
<p className="text-xs text-muted-foreground font-mono">
278+
Try: &ldquo;Show me all cohorts&rdquo; or &ldquo;Count students by term&rdquo;
279+
</p>
192280
</div>
193281
</div>
194-
</CardContent>
195-
</Card>
196-
197-
{history.length > 0 && (
198-
<QueryHistoryPanel
199-
entries={history}
200-
onRerun={handleRerun}
201-
onClear={handleClear}
202-
/>
203-
)}
282+
)}
204283

205-
{queryResult && queryPlan && (
206-
<div className="grid gap-6 lg:grid-cols-[1fr_400px]">
207-
<AnalysisResult result={queryResult} plan={queryPlan} />
208-
<QueryPlanPanel plan={queryPlan} />
209-
</div>
210-
)}
284+
{/* Results section */}
285+
{queryResult && queryPlan && (
286+
<div className="space-y-3">
287+
{/* Results header row */}
288+
<div className="flex items-center justify-between">
289+
<h2 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
290+
Results
291+
</h2>
292+
{!summary && (
293+
<button
294+
onClick={handleSummarize}
295+
disabled={summaryLoading}
296+
className="inline-flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 disabled:opacity-50 transition-colors"
297+
>
298+
{summaryLoading ? (
299+
<>
300+
<Loader2 className="h-3 w-3 animate-spin" />
301+
Generating…
302+
</>
303+
) : (
304+
<>
305+
<Sparkles className="h-3 w-3" />
306+
Summarize
307+
</>
308+
)}
309+
</button>
310+
)}
311+
</div>
211312

212-
{!queryResult && (
213-
<Card className="border-dashed">
214-
<CardContent className="flex items-center justify-center py-12">
215-
<div className="text-center space-y-2">
216-
<p className="text-muted-foreground">Enter a prompt and click Analyze to see results</p>
217-
<p className="text-sm text-muted-foreground">Try: "Show me all cohorts" or "Count students by term"</p>
313+
{/* AI summary block */}
314+
{summary && (
315+
<div className="flex gap-2 px-4 py-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/60 dark:border-amber-800/40">
316+
<Sparkles className="h-4 w-4 text-amber-500/70 mt-0.5 shrink-0" />
317+
<p className="text-sm text-foreground/90 leading-relaxed">{summary}</p>
318+
</div>
319+
)}
320+
{summaryError && (
321+
<p className="text-xs text-destructive">{summaryError}</p>
322+
)}
323+
324+
<div className="grid gap-6 lg:grid-cols-[1fr_400px]">
325+
<AnalysisResult result={queryResult} plan={queryPlan} />
326+
<QueryPlanPanel plan={queryPlan} />
218327
</div>
219-
</CardContent>
220-
</Card>
221-
)}
328+
</div>
329+
)}
330+
</main>
222331
</div>
223332
</div>
224333
)

codebenders-dashboard/components/nav-header.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const NAV_LINKS = [
1616
{ href: "/", label: "Dashboard" },
1717
{ href: "/courses", label: "Courses" },
1818
{ href: "/students", label: "Students" },
19+
{ href: "/query", label: "Query" },
1920
]
2021

2122
export function NavHeader({ email, role }: NavHeaderProps) {

0 commit comments

Comments
 (0)