Skip to content
63 changes: 63 additions & 0 deletions codebenders-dashboard/app/api/query-summary/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
}
}
277 changes: 193 additions & 84 deletions codebenders-dashboard/app/query/page.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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" },
Expand All @@ -30,6 +27,10 @@ export default function QueryPage() {
const [queryPlan, setQueryPlan] = useState<QueryPlan | null>(null)
const [queryResult, setQueryResult] = useState<QueryResult | null>(null)
const [useDirectDB, setUseDirectDB] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(false)
const [summary, setSummary] = useState<string | null>(null)
const [summaryLoading, setSummaryLoading] = useState(false)
const [summaryError, setSummaryError] = useState<string | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>(() => {
// Read from localStorage on mount (client-only)
if (typeof window === "undefined") return []
Expand All @@ -44,6 +45,9 @@ export default function QueryPage() {
overridePrompt?: string,
overrideInstitution?: string,
) => {
setSummary(null)
setSummaryError(null)

const activePrompt = overridePrompt ?? prompt
const activeInstitution = overrideInstitution ?? institution

Expand Down Expand Up @@ -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 (
<div className="min-h-screen bg-background">
<div className="container mx-auto p-6 space-y-6">
<div className="border-b border-border pb-6">
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Dashboard
</Link>
<h1 className="text-3xl font-bold tracking-tight text-foreground">SQL Query Interface</h1>
<p className="text-muted-foreground mt-2">Analyze student performance data with natural language queries</p>
</div>

<Card>
<CardHeader>
<CardTitle>Query Controls</CardTitle>
<CardDescription>Select an institution and enter your analysis prompt</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2 pb-4 border-b border-border">
<div className="min-h-screen bg-background flex flex-col">
{/* Slim page-level header bar */}
<header className="h-12 flex items-center gap-3 px-4 border-b border-border/60 shrink-0">
{/* Mobile sidebar toggle */}
<button
onClick={() => setSidebarOpen(true)}
className="md:hidden p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
aria-label="Open query history"
>
<PanelLeft className="h-4 w-4" />
</button>

{/* Title group */}
<span className="text-sm font-semibold tracking-widest uppercase text-foreground">
Query Interface
</span>
<span className="hidden sm:block h-4 w-px bg-border/60" aria-hidden="true" />
<span className="hidden sm:block text-xs text-muted-foreground font-mono">
Natural Language Analytics
</span>
</header>

{/* Body: sidebar + main */}
<div className="flex flex-1 overflow-hidden">
{/* Desktop sidebar */}
<aside className="hidden md:flex w-[260px] border-r border-border/60 flex-col shrink-0 bg-muted/20">
<QueryHistoryPanel entries={history} onRerun={handleRerun} onClear={handleClear} />
</aside>

{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-50 md:hidden">
<div
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
onClick={() => setSidebarOpen(false)}
/>
<aside className="absolute left-0 top-0 bottom-0 w-[260px] bg-background border-r border-border/60 flex flex-col">
<QueryHistoryPanel entries={history} onRerun={handleRerun} onClear={handleClear} />
</aside>
</div>
)}

{/* Main content */}
<main className="flex-1 overflow-auto p-6 space-y-6">
{/* Query controls */}
<div className="border border-border/60 rounded-lg p-5 space-y-4">
{/* DB mode toggle row */}
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
<Switch id="db-mode" checked={useDirectDB} onCheckedChange={setUseDirectDB} />
<Label htmlFor="db-mode" className="text-sm font-medium">
<Label htmlFor="db-mode" className="text-sm font-medium cursor-pointer">
{useDirectDB ? "Direct Database" : "API Mode"}
</Label>
<span className="text-xs text-muted-foreground">
{useDirectDB ? "(Execute SQL directly)" : "(Fetch from API endpoints)"}
<span className="text-xs text-muted-foreground font-mono">
{useDirectDB ? "(execute SQL directly)" : "(fetch from API endpoints)"}
</span>
</div>

<div className="grid gap-4 md:grid-cols-[200px_1fr_auto]">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Institution</label>
<Select value={institution} onValueChange={setInstitution}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INSTITUTIONS.map((inst) => (
<SelectItem key={inst.code} value={inst.code}>
{inst.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Institution selector */}
<div className="space-y-1.5">
<label className="text-[10px] font-semibold tracking-widest uppercase text-muted-foreground/70">
Institution
</label>
<Select value={institution} onValueChange={setInstitution}>
<SelectTrigger className="border-border/60 bg-background text-sm w-full md:w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INSTITUTIONS.map((inst) => (
<SelectItem key={inst.code} value={inst.code}>
{inst.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Analysis Prompt</label>
<Input
placeholder="e.g., retention by cohort for last two terms"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleAnalyze()
}
}}
/>
</div>
{/* Query textarea */}
<div className="space-y-1.5">
<label className="text-[10px] font-semibold tracking-widest uppercase text-muted-foreground/70">
Natural Language Query
</label>
<textarea
placeholder="e.g., retention by cohort for last two terms"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleAnalyze()
}
}}
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"
/>
</div>

{/* Run button row */}
<div className="flex justify-end">
<Button
onClick={() => handleAnalyze()}
disabled={isAnalyzing || !prompt.trim()}
className="bg-foreground text-background hover:bg-foreground/90 text-xs font-semibold tracking-wide uppercase px-5"
>
{isAnalyzing ? (
<>
<Loader2 className="h-3 w-3 animate-spin mr-1.5" />
Analyzing…
</>
) : (
"Run Analysis"
)}
</Button>
</div>
</div>

<div className="flex items-end">
<Button onClick={() => handleAnalyze()} disabled={isAnalyzing || !prompt.trim()} className="w-full md:w-auto">
{isAnalyzing ? "Analyzing..." : "Analyze"}
</Button>
{/* Empty state */}
{!queryResult && (
<div className="border border-dashed border-border/50 rounded-lg py-12 flex items-center justify-center">
<div className="text-center space-y-1.5">
<p className="text-sm text-muted-foreground">Enter a query above and run analysis to see results</p>
<p className="text-xs text-muted-foreground font-mono">
Try: &ldquo;Show me all cohorts&rdquo; or &ldquo;Count students by term&rdquo;
</p>
</div>
</div>
</CardContent>
</Card>

{history.length > 0 && (
<QueryHistoryPanel
entries={history}
onRerun={handleRerun}
onClear={handleClear}
/>
)}
)}

{queryResult && queryPlan && (
<div className="grid gap-6 lg:grid-cols-[1fr_400px]">
<AnalysisResult result={queryResult} plan={queryPlan} />
<QueryPlanPanel plan={queryPlan} />
</div>
)}
{/* Results section */}
{queryResult && queryPlan && (
<div className="space-y-3">
{/* Results header row */}
<div className="flex items-center justify-between">
<h2 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Results
</h2>
{!summary && (
<button
onClick={handleSummarize}
disabled={summaryLoading}
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"
>
{summaryLoading ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
Generating…
</>
) : (
<>
<Sparkles className="h-3 w-3" />
Summarize
</>
)}
</button>
)}
</div>

{!queryResult && (
<Card className="border-dashed">
<CardContent className="flex items-center justify-center py-12">
<div className="text-center space-y-2">
<p className="text-muted-foreground">Enter a prompt and click Analyze to see results</p>
<p className="text-sm text-muted-foreground">Try: "Show me all cohorts" or "Count students by term"</p>
{/* AI summary block */}
{summary && (
<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">
<Sparkles className="h-4 w-4 text-amber-500/70 mt-0.5 shrink-0" />
<p className="text-sm text-foreground/90 leading-relaxed">{summary}</p>
</div>
)}
{summaryError && (
<p className="text-xs text-destructive">{summaryError}</p>
)}

<div className="grid gap-6 lg:grid-cols-[1fr_400px]">
<AnalysisResult result={queryResult} plan={queryPlan} />
<QueryPlanPanel plan={queryPlan} />
</div>
</CardContent>
</Card>
)}
</div>
)}
</main>
</div>
</div>
)
Expand Down
1 change: 1 addition & 0 deletions codebenders-dashboard/components/nav-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const NAV_LINKS = [
{ href: "/", label: "Dashboard" },
{ href: "/courses", label: "Courses" },
{ href: "/students", label: "Students" },
{ href: "/query", label: "Query" },
]

export function NavHeader({ email, role }: NavHeaderProps) {
Expand Down
Loading
Loading