LFED / app.py
Kasualdad's picture
rebrand: Kasualdad LFED → Local First Education Data Framework (LFED)
d9cd3f3
Raw
History Blame Contribute Delete
43.7 kB
"""
app.py — Local First Education Data Framework (LFED).
Thin Gradio controller. All logic lives in:
- prompts.py (system prompt, schema docs, few-shot examples)
- model_inference.py (transformers + LoRA wrapper, SQL generation + streaming)
- data_engine.py (DuckDB lifecycle, schema seeding, execution guard)
- ui_strings.py (user-facing copy: domains, errors, rephrasings)
"""
from __future__ import annotations
import os
import tempfile
from dataclasses import dataclass, field
import gradio as gr
# spaces.GPU is only available on HF Spaces — use a no-op locally
try:
import spaces
_gpu_decorator = spaces.GPU(duration=30)
except ImportError:
_gpu_decorator = lambda fn: fn # no-op for local dev
from model_inference import load_model, generate_sql_streaming
from data_engine import create_session, execute_safe, QueryTimeoutError
from ui_strings import (
APP_TITLE,
APP_TAGLINE,
FIRST_VISIT_NUDGE,
PRIVACY_EXPLAINER,
HOW_IT_WORKS,
WHAT_THIS_IS,
WHAT_THIS_IS_NOT,
WHAT_THIS_IS_ONE_LINER,
WHAT_THIS_IS_NOT_ONE_LINER,
DOMAIN_SECTIONS,
INPUT_HELPER,
INPUT_PROMPT_EMPTY,
SUMMARY_TEMPLATES,
ERROR_REPHRASINGS,
SQL_DISCLOSURE_LABEL,
PREVIOUS_RIBBON_TEMPLATE,
ABOUT_MODAL_TITLE,
ABOUT_MODAL_INTRO,
ABOUT_MODAL_HOW_IT_WORKS,
ABOUT_MODAL_PRIVACY,
ABOUT_MODAL_WHAT_IT_IS_BULLETS,
ABOUT_MODAL_WHAT_IT_ISNT_BULLETS,
ABOUT_MODAL_FAQ,
ABOUT_MODAL_CLOSE,
ABOUT_MODAL_HINT,
)
# ── Startup ───────────────────────────────────────────────────────────
print("🚀 Starting Local First Education Data Framework...")
from pathlib import Path
_parquet_dirs = [Path("/data"), Path(__file__).parent / "data"]
_pq_files = [
"enrollment.parquet", "attendance.parquet", "students.parquet",
"discipline.parquet", "grades.parquet",
]
_pq_found = any(
all((base / f).exists() for f in _pq_files)
for base in _parquet_dirs
)
if not _pq_found:
print("📦 Generating seed Parquet files (first boot)...")
from data.export_parquet import export_parquet
_pq_out = _parquet_dirs[0] if _parquet_dirs[0].exists() else _parquet_dirs[1]
export_parquet(_pq_out)
# Load the model at startup. On ZeroGPU, bitsandbytes/transformers go through
# PyTorch, so the model loads onto the emulated CUDA device here and runs on
# the real GPU inside @spaces.GPU. (llama.cpp could not do this.)
print("🤖 Loading model (Qwen2.5-Coder-14B bnb-4bit + LoRA)...")
llm = load_model()
print("✅ Ready.")
# ── Result + session state ────────────────────────────────────────────
@dataclass
class PriorAnswer:
"""One-step memory: the most recent successful answer, kept so the next
question can refer to it without the user having to remember."""
question: str
summary: str
sql: str
columns: list[str] = field(default_factory=list)
rows: list[list] = field(default_factory=list)
row_count: int = 0
def empty_prior() -> dict | None:
return None
def format_result_df(df):
"""Round floats to 2 decimal places. If a column name suggests a
percentage (contains 'percent', '%', or ends with '_pct' / '_rate' /
'_ratio'), format values as a percentage with 2dp + '%' suffix.
Rate/ratio columns may already be returned as percentages (e.g. 10.0)
or as 0-1 proportions (e.g. 0.10). We scale only when the value is
clearly a proportion (0 <= value <= 1). Values outside that range are
treated as already-scaled percentages.
Preserves integer dtype for integer columns."""
if df is None:
return None
import pandas as pd
df = df.copy()
for col in df.columns:
col_lower = str(col).lower()
is_pct = (
"percent" in col_lower
or "%" in col_lower
or col_lower.endswith("_pct")
or col_lower.endswith("_rate")
or col_lower.endswith("_ratio")
)
if not pd.api.types.is_numeric_dtype(df[col]):
continue
if is_pct:
def _fmt(v):
if pd.isna(v):
return v
val = float(v)
# Scale only if the value looks like a 0-1 proportion.
if 0 <= val <= 1:
val *= 100
return f"{round(val, 2):.2f}%"
df[col] = df[col].apply(_fmt)
elif pd.api.types.is_integer_dtype(df[col]):
pass # keep integers as integers
elif pd.api.types.is_float_dtype(df[col]):
df[col] = df[col].apply(
lambda v: round(float(v), 2) if pd.notna(v) else v
)
return df
def build_summary(question: str, df, row_count: int) -> str:
"""One-sentence plain-English summary of a result.
Best-effort heuristic — falls back to a neutral 'N rows returned' line
when the shape doesn't match any pattern.
"""
if df is None or row_count == 0:
return "I didn't find anything that matches that question."
cols = list(df.columns)
if row_count == 1 and len(cols) == 1:
val = df.iloc[0, 0]
return SUMMARY_TEMPLATES["single_value"].format(value=val)
if row_count == 1 and len(cols) == 2:
label = df.iloc[0, 0]
val = df.iloc[0, 1]
return SUMMARY_TEMPLATES["single_pair"].format(label=label, value=val)
if len(cols) == 2 and "school_name" in cols:
return SUMMARY_TEMPLATES["by_school"].format(n=row_count)
return SUMMARY_TEMPLATES["generic"].format(n=row_count)
def rephrase_error(raw_error: str, raw_sql: str) -> str:
"""Map a model/validation/timeout error to a plain-English message that
suggests a starter question. Always ends with 'Try:' + suggestion."""
raw = (raw_error or "").lower()
for marker, template in ERROR_REPHRASINGS.items():
if marker in raw:
suggestion = DOMAIN_SECTIONS[0]["questions"][0]
return f"{template}\n\n**Try:** _{suggestion}_"
suggestion = DOMAIN_SECTIONS[0]["questions"][0]
return f"Something didn't work. Try rephrasing — for example: _{suggestion}_"
# ── Query handler ─────────────────────────────────────────────────────
@_gpu_decorator
def handle_query(user_question: str, prior_state: dict | None):
"""
Process an admin's question end-to-end, streaming SQL as it generates.
Updates prior_state with the new answer (for the ribbon on the next ask).
"""
prior = _state_to_prior(prior_state)
if not user_question or not user_question.strip():
yield prior, "", None, "🤖", INPUT_PROMPT_EMPTY, prior_state
return
raw_output = ""
try:
yield prior, "", None, "🤖", "Finding your answer…", prior_state
for accumulated in generate_sql_streaming(user_question, llm=llm, max_tokens=192):
raw_output = accumulated
yield prior, raw_output, None, "🤖", "Finding your answer…", prior_state
except Exception as e:
yield prior, raw_output, None, "❌", rephrase_error(str(e), ""), prior_state
return
try:
yield prior, raw_output, None, "🦆", "Looking it up…", prior_state
conn = create_session()
clean_sql, df = execute_safe(conn, raw_output, timeout_sec=30)
conn.close()
row_count = len(df)
df = format_result_df(df)
summary = build_summary(user_question, df, row_count)
new_prior = PriorAnswer(
question=user_question,
summary=summary,
sql=clean_sql,
columns=list(df.columns),
rows=df.values.tolist(),
row_count=row_count,
)
new_state = _prior_to_state(new_prior)
yield new_prior, clean_sql, df, "✅", summary, new_state
except ValueError as e:
yield prior, raw_output, None, "⚠️", rephrase_error(str(e), raw_output), prior_state
except QueryTimeoutError as e:
yield prior, raw_output, None, "⏱️", rephrase_error(str(e), raw_output), prior_state
except Exception as e:
yield prior, raw_output, None, "❌", rephrase_error(str(e), raw_output), prior_state
def _prior_to_state(p: PriorAnswer | None) -> dict | None:
if p is None:
return None
return {
"question": p.question,
"summary": p.summary,
"sql": p.sql,
"columns": p.columns,
"rows": p.rows,
"row_count": p.row_count,
}
def _state_to_prior(s: dict | None) -> PriorAnswer | None:
if s is None:
return None
return PriorAnswer(
question=s.get("question", ""),
summary=s.get("summary", ""),
sql=s.get("sql", ""),
columns=s.get("columns", []),
rows=s.get("rows", []),
row_count=s.get("row_count", 0),
)
def bring_back_prior(prior_state: dict | None) -> tuple:
"""When the user clicks 'bring back' on the previous-answer ribbon,
re-render the previous answer in the result panels and hide the
bring-back button (since the prior is now the active result)."""
prior = _state_to_prior(prior_state)
if prior is None:
return (
"", # ribbon text (empty)
None, # sql
None, # df
"", # summary html
prior_state,
gr.update(visible=False), # bring-back button
)
# prior.rows are stored as list[list]; rebuild the formatted display
import pandas as pd
df = pd.DataFrame(prior.rows, columns=prior.columns) if prior.columns else None
df = format_result_df(df)
return (
"", # clear ribbon text
prior.sql,
df,
f'<p class="result-summary">{prior.summary}</p>',
prior_state,
gr.update(visible=False), # hide the bring-back button
)
# ── UI Theme & Styles ──────────────────────────────────────────────────
# Phase 1: Native Gradio Theme Definition
# This replaces the massive :root variable override block you previously had in CUSTOM_CSS.
# It anchors the app in a clean, low-cognitive-load neutral palette.
catalyst_theme = gr.themes.Base(
primary_hue="zinc",
neutral_hue="zinc",
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
).set(
body_background_fill="#F3F4F6",
body_text_color="#111827",
background_fill_primary="#FFFFFF",
background_fill_secondary="#F9FAFB",
# Input styling
input_background_fill="#FFFFFF",
input_border_color="rgba(17, 24, 39, 0.10)",
input_border_color_focus="#111827", # Catalyst sharp focus
# Button styling
button_primary_background_fill="#111827",
button_primary_text_color="#FFFFFF",
# Structural borders
border_color_primary="rgba(17, 24, 39, 0.10)",
block_border_width="0px", # Stripping default bulky borders
)
# Phase 2: Targeted CSS for Layout and Catalyst Components
CUSTOM_CSS = """\\
/* ── Layout ────────────────────────────────────────────────────────── */
body, #root, .gradio-container, .gradio-container > .main, .gradio-container .app {
max-width: 960px !important;
width: 100% !important;
margin-left: auto !important;
margin-right: auto !important;
box-sizing: border-box !important;
}
.gradio-container {
max-width: 960px !important;
width: 100% !important;
margin: 0 auto !important;
padding: 0 1.5rem !important;
font-size: 14px !important;
box-sizing: border-box !important;
}
@media (max-width: 720px) {
.gradio-container { padding: 0 1rem !important; }
}
/* ── Force light mode (kill Gradio auto-dark) ──────────────────────── */
html, html.dark, html[data-theme="dark"], .dark, .gradio-container {
color-scheme: light !important;
}
html.dark body, .dark body, html.dark .gradio-container, .dark .gradio-container {
background: #F3F4F6 !important;
color: #111827 !important;
}
html.dark, html.dark .gradio-container, .dark, .dark .gradio-container {
--body-background-fill: #F3F4F6 !important;
--body-text-color: #111827 !important;
--background-fill-primary: #FFFFFF !important;
--background-fill-secondary: #F9FAFB !important;
--input-background-fill: #FFFFFF !important;
--input-border-color: rgba(17, 24, 39, 0.10) !important;
--input-border-color-focus: #111827 !important;
--button-primary-background-fill: #111827 !important;
--button-primary-text-color: #FFFFFF !important;
--border-color-primary: rgba(17, 24, 39, 0.10) !important;
--block-border-width: 0px !important;
--input-border-width: 1px !important;
--input-shadow-focus: none !important;
--input-shadow: none !important;
--input-shadow-focus-dark: none !important;
--input-shadow-dark: none !important;
--block-shadow: none !important;
--block-shadow-dark: none !important;
}
/* Remove bulky default Gradio borders from wrappers */
.gradio-container,
.gradio-container .form,
.gradio-container .block,
.gradio-container .gr-block,
.gradio-container .gr-form,
.gradio-container .panel,
.gradio-container .tabitem,
.gradio-container .tab-content,
.gradio-container .row,
.gradio-container .column,
.gradio-container .gr-row,
.gradio-container .gr-column,
.gradio-container .wrap,
.gradio-container .input-text,
.gradio-container .gr-textbox,
.gradio-container .gr-dropdown,
.gradio-container .gr-button {
border: none !important;
outline: none !important;
}
/* Add back borders only on elements that actually need them */
.gradio-container input,
.gradio-container textarea,
.gradio-container select,
.domain-dropdown .dropdown-menu,
.domain-dropdown .option {
border: 1px solid var(--input-border-color) !important;
}
.domain-dropdown .option {
border-bottom: 1px solid rgba(17, 24, 39, 0.05) !important;
}
/* Keep the previous-ribbon bottom separator */
.previous-ribbon-row {
border-bottom: 1px solid rgba(17, 24, 39, 0.05) !important;
}
/* Force wrapper backgrounds to transparent so Gradio dark theme doesn't leak.
Gradio's .form uses background: var(--border-color-primary) as a gap color,
so we must explicitly override it. */
.gradio-container,
.gradio-container .form,
.gradio-container .form.svelte-d5xbca,
.gradio-container .block,
.gradio-container .block.svelte-1plpy97,
.gradio-container .gr-block,
.gradio-container .gr-form,
.gradio-container .panel,
.gradio-container .tabitem,
.gradio-container .tab-content,
.gradio-container .row,
.gradio-container .column,
.gradio-container .gr-row,
.gradio-container .gr-column,
.gradio-container .wrap,
.gradio-container .input-text,
.gradio-container .gr-textbox,
.gradio-container .gr-dropdown,
.gradio-container .gr-button,
.ask-block,
.ask-block .gr-textbox,
.ask-block .wrap,
.domain-dropdown,
.domain-dropdown .gr-dropdown,
.domain-dropdown .wrap {
background: transparent !important;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* Keep actual form controls white */
.gradio-container input,
.gradio-container textarea,
.gradio-container select,
.gradio-container .gr-dropdown .wrap,
.gradio-container .gr-dropdown input {
background: #FFFFFF !important;
background-color: #FFFFFF !important;
}
/* Domain dropdowns: no dark surrounding box, input field white */
.domain-dropdown .wrap,
.domain-dropdown input {
border: 1px solid rgba(17, 24, 39, 0.10) !important;
border-radius: 0.5rem !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
background: #FFFFFF !important;
color: #111827 !important;
}
.domain-dropdown .dropdown-menu {
background: #FFFFFF !important;
}
.domain-dropdown .option {
color: #111827 !important;
}
/* Make the ask-block label dark so it's visible on light bg.
Gradio renders the label text in a scoped span inside label. */
.ask-block label > span,
.ask-block .block-label,
.ask-block .gr-textbox > label,
.ask-block .gr-textbox > label span,
.ask-block [data-testid="block-info"] {
color: #111827 !important;
}
/* Hide Gradio chrome */
.progress-bar, .progress-text, .generating,
.eta-bar, #loading, [data-testid="progress-bar"] {
display: none !important;
}
/* ── Typography and Form Resets */
input::placeholder,
textarea::placeholder {
color: #6B7280 !important;
-webkit-text-fill-color: #6B7280 !important;
opacity: 1 !important;
}
/* ── The Catalyst Input & Focus Ring ─────────────────────────────── */
.gradio-container input, .gradio-container textarea {
border-radius: 0.5rem !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
transition: all 0.15s ease-in-out;
}
.gradio-container input:focus, .gradio-container textarea:focus {
outline: none !important;
box-shadow: 0 0 0 1px var(--input-border-color-focus) !important;
}
/* ── Your question textarea ───── */
.ask-block .gr-textbox textarea {
padding: 8px 12px !important;
min-height: 2.75rem !important;
line-height: 1.5 !important;
resize: vertical !important;
}
/* The label above the textarea */
.ask-block .block-label,
.ask-block .gr-textbox > label {
font-size: 14px !important;
font-weight: 500 !important;
margin: 0 0 0.375rem 0 !important;
text-transform: none !important;
letter-spacing: normal !important;
display: block !important;
}
/* ── Primary CTA ────────────────────────── */
.primary-cta {
font-weight: 600 !important;
padding: 8px 16px !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
border-radius: 0.5rem !important;
margin-top: 0.75rem !important;
}
.primary-cta:hover {
background: #000000 !important;
}
/* ── Domain row: equal-height columns, horizontally aligned ───────── */
.domain-row {
display: flex !important;
flex-direction: row !important;
margin-top: 1.25rem !important;
gap: 1rem !important;
align-items: stretch !important;
}
.domain-row > .gr-column,
.domain-row > .gr-row > .gr-column,
.domain-row > div {
flex: 1 1 0 !important;
min-width: 0 !important;
}
/* ── Domain card ──────────────── */
.domain-col {
background: #FFFFFF !important;
border: 1px solid rgba(17, 24, 39, 0.05) !important;
border-radius: 0.5rem !important;
padding: 0.875rem 1rem 0.75rem 1rem !important;
display: flex !important;
flex-direction: column !important;
gap: 0.5rem !important;
height: 100% !important;
min-height: 7.5rem !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
justify-content: flex-start !important;
}
.domain-col-title {
font-weight: 500 !important;
margin: 0 0 0.25rem 0 !important;
line-height: 1.4 !important;
min-height: 2.8rem !important;
}
/* Push dropdown to bottom of card */
.domain-col .domain-dropdown {
margin-top: auto !important;
flex: 0 0 auto !important;
}
/* ── Domain dropdowns ─────────────── */
.domain-dropdown label { display: none !important; }
.domain-dropdown .wrap,
.domain-dropdown input {
border: 1px solid rgba(17, 24, 39, 0.10) !important;
border-radius: 0.5rem !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
background: #FFFFFF !important;
color: #111827 !important;
}
.domain-dropdown .dropdown-menu {
background: #FFFFFF !important;
border-radius: 0.5rem !important;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.10), 0 4px 6px -4px rgba(0, 0, 0, 0.10) !important;
z-index: 1000 !important;
}
.domain-dropdown .option {
color: #111827 !important;
padding: 8px 12px !important;
border-bottom: 1px solid rgba(17, 24, 39, 0.05) !important;
}
.domain-dropdown .option:hover {
background: #E5E7EB !important;
}
/* ── Header ────────────────────────────────────────────────────────── */
.app-header-wrap { padding: 1.5rem 0 0.5rem 0; }
.app-header h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
letter-spacing: -0.01em;
}
.app-header .tagline {
color: #6B7280;
margin: 0;
line-height: 1.5;
max-width: 60ch;
}
/* ── Status line ───────────────────────────────────────────────────── */
.status-line {
color: #6B7280 !important;
}
/* ── Previous-answer ribbon ────────────────────────────────────────── */
.previous-ribbon-row {
border-bottom: 1px solid rgba(17, 24, 39, 0.05) !important;
padding: 0.5rem 0 !important;
margin: 0 0 0.75rem 0 !important;
align-items: center !important;
}
.previous-ribbon-text {
color: #6B7280 !important;
}
/* ── Link-style buttons ────────────────────────────────────────────── */
button.gr-button.link-button {
background: transparent !important;
border: none !important;
color: #6B7280 !important;
text-decoration: underline;
text-underline-offset: 3px;
font-weight: 400 !important;
padding: 0 !important;
box-shadow: none !important;
width: auto !important;
}
button.gr-button.link-button:hover {
color: #111827 !important;
}
button.gr-button.footer-help { margin-top: 0.5rem; }
/* ── Result region ─────────────────────────────────────────────────── */
.result-summary {
font-weight: 600;
padding: 1rem 0 0.5rem 0;
margin: 0;
border-top: 1px solid rgba(17, 24, 39, 0.05);
}
/* ── SQL code block ────────────────────────────────────────────────── */
.sql-disclosure {
margin-top: 0.75rem;
border-top: 1px solid rgba(17, 24, 39, 0.05);
padding-top: 0.75rem;
}
.sql-disclosure summary,
.sql-disclosure summary span,
.sql-disclosure summary mark,
.sql-disclosure summary *,
.gradio-container .block:has(.codemirror-wrapper) label,
.gradio-container .block:has(.cm-editor) label,
.gradio-container .gr-code label,
.gradio-container .block label {
color: #111827 !important;
cursor: pointer;
font-weight: 400;
}
.sql-disclosure summary::-webkit-details-marker { display: none; }
.sql-disclosure pre {
background: #F9FAFB !important;
border: 1px solid rgba(17, 24, 39, 0.05) !important;
border-radius: 0.5rem !important;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace !important;
}
.sql-disclosure pre code {
color: #111827 !important;
}
/* ── Data tables ───────────────────────────────────────────────────── */
/* Override Gradio's table CSS variables so the dataframe renders light.
Gradio uses scoped Svelte classes, so generic class selectors lose to
component styles. Setting the variables at the container level wins. */
.gradio-container {
--table-even-background-fill: #FFFFFF !important;
--table-odd-background-fill: #F9FAFB !important;
--table-row-focus: #E5E7EB !important;
--table-text-color: #111827 !important;
--table-border-color: rgba(17, 24, 39, 0.10) !important;
--table-radius: 0.5rem !important;
}
.gr-dataframe,
[data-testid="dataframe"],
.table-wrap,
.gradio-container table {
background: #FFFFFF !important;
color: #111827 !important;
border-radius: 0.5rem !important;
border: 1px solid rgba(17, 24, 39, 0.05) !important;
box-shadow: none !important;
}
.gradio-container table th,
.gradio-container table td,
.gradio-container .table th,
.gradio-container .table td,
.gradio-container .cell,
.gradio-container .head {
color: #111827 !important;
background: transparent !important;
}
.gradio-container table th,
.gradio-container .table th,
.gradio-container .head {
font-weight: 500 !important;
color: #6B7280 !important;
border-bottom: 1px solid rgba(17, 24, 39, 0.10) !important;
padding: 8px 16px 8px 4px !important;
}
.gradio-container table td,
.gradio-container .table td,
.gradio-container .cell {
border-bottom: 1px solid rgba(17, 24, 39, 0.05) !important;
padding: 16px 16px 16px 4px !important;
}
.gradio-container table tr:nth-child(even) td,
.gradio-container .table tr:nth-child(even) td,
.gradio-container .cell:nth-child(even) {
background: #F9FAFB !important;
}
.gradio-container .index {
color: #6B7280 !important;
background: #F9FAFB !important;
}
.gradio-container .paginate {
color: #111827 !important;
}
.gradio-container table input,
.gradio-container .table input {
color: #111827 !important;
background: #FFFFFF !important;
}
/* ── First-visit nudge ─────────────────────────────────────────────── */
.first-visit-nudge {
color: #6B7280;
padding: 0.5rem 0.75rem;
background: #FFFFFF;
border: 1px solid rgba(17, 24, 39, 0.05);
border-radius: 0.5rem;
margin: 0.75rem 0;
}
.first-visit-nudge.hidden { display: none; }
.first-visit-link {
color: #111827 !important;
text-decoration: underline !important;
cursor: pointer;
}
/* ── CSV download link ──────────────────────────────────────────────── */
.download-csv {
margin-top: 0.5rem !important;
}
.download-csv .file-preview,
.download-csv .file-size,
.download-csv .file-name { color: #111827 !important; }
.download-csv a { color: #111827 !important; }
.download-csv:empty { display: none; }
/* ── Footer ────────────────────────────────────────────────────────── */
.app-footer {
margin-top: 2.5rem;
padding: 1rem 0 1.25rem 0;
border-top: 1px solid rgba(17, 24, 39, 0.05);
color: #6B7280;
line-height: 1.6;
}
.app-footer h4 {
font-weight: 600;
margin: 0 0 0.25rem 0;
}
.app-footer .footer-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.app-footer .footer-cols p { margin: 0; }
.app-footer .footer-explainer-hint {
margin: 0.75rem 0 0 0 !important;
}
@media (max-width: 720px) {
.app-footer .footer-cols { grid-template-columns: 1fr; gap: 1.25rem; }
}
/* ── Explainer ─────────────────────────────────────────────────────── */
.explainer,
.explainer-content,
.block.explainer-content,
.explainer-wrap .block,
.explainer-wrap .gr-group,
.explainer-content > div,
.explainer-wrap [class*="styler"] {
background: #FFFFFF !important;
border: none !important;
border-radius: 0.5rem !important;
padding: 0 !important;
margin: 0.75rem 0 !important;
box-shadow: none !important;
}
.explainer-wrap {
background: #FFFFFF !important;
border: none !important;
border-radius: 0.5rem !important;
box-shadow: none !important;
padding: 1rem 1.25rem !important;
margin: 0.75rem 0 !important;
}
.explainer h3,
.explainer-content h3,
.explainer-wrap h3 {
margin: 0 0 0.25rem 0;
font-weight: 600;
}
.explainer p, .explainer li,
.explainer-content p, .explainer-content li,
.explainer-wrap p, .explainer-wrap li {
line-height: 1.6;
}
.explainer-wrap p { margin: 0 0 0.75rem 0; }
/* ── Accessibility ─────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
*:focus-visible {
outline: 2px solid #111827;
outline-offset: 2px;
}
/* ── About / first-time modal ──────────────────────────────────────── */
#about-modal {
position: fixed;
inset: 0;
background: rgba(17, 24, 39, 0.45);
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
padding: 1rem;
}
#about-modal.visible { display: flex !important; }
.about-modal-card {
background: #FFFFFF;
border-radius: 0.75rem;
max-width: 640px;
width: 100%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.10), 0 8px 10px -6px rgba(0, 0, 0, 0.10);
padding: 1.5rem;
color: #111827;
}
.about-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.about-modal-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.about-modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6B7280;
line-height: 1;
padding: 0.25rem;
}
.about-modal-close:hover { color: #111827; }
.about-modal-card h3 {
font-size: 1rem;
font-weight: 600;
margin: 1.25rem 0 0.5rem 0;
}
.about-modal-card p,
.about-modal-card li {
font-size: 0.95rem;
line-height: 1.6;
color: #374151;
}
.about-modal-card ul {
margin: 0 0 0.75rem 0;
padding-left: 1.25rem;
}
.about-modal-card details {
margin-bottom: 0.75rem;
border-bottom: 1px solid rgba(17, 24, 39, 0.05);
padding-bottom: 0.75rem;
}
.about-modal-card summary {
font-weight: 500;
cursor: pointer;
color: #111827;
list-style: none;
}
.about-modal-card summary::-webkit-details-marker { display: none; }
.about-modal-card details[open] summary {
margin-bottom: 0.25rem;
}
.about-modal-hint {
background: #F9FAFB;
border: 1px solid rgba(17, 24, 39, 0.05);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin-top: 1rem;
font-size: 0.9rem;
}
@media (max-width: 640px) {
.about-modal-card { padding: 1rem; }
}
"""
HEAD_HTML = """
<script>
function openAboutModal() {
const modal = document.getElementById('about-modal');
if (modal) {
modal.classList.add('visible');
document.body.style.overflow = 'hidden';
}
}
function closeAboutModal() {
const modal = document.getElementById('about-modal');
if (modal) {
modal.classList.remove('visible');
document.body.style.overflow = '';
}
}
document.addEventListener('click', function (e) {
const modal = document.getElementById('about-modal');
if (modal && modal.classList.contains('visible') && e.target === modal) {
closeAboutModal();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAboutModal();
});
</script>
"""
# ── Component builders ────────────────────────────────────────────────
def build_explainer_html() -> str:
return (
f'<div class="explainer">'
f'<h3>How this works</h3>'
f'<p>{HOW_IT_WORKS}</p>'
f'<h3 style="margin-top:1.5rem;">Your privacy</h3>'
f'<p>{PRIVACY_EXPLAINER}</p>'
f'</div>'
)
def build_footer_html() -> str:
return (
f'<div class="app-footer">'
f'<div class="footer-cols">'
f'<div><h4>What this is</h4>'
f'<p>{WHAT_THIS_IS_ONE_LINER}</p></div>'
f'<div><h4>What this isn’t</h4>'
f'<p>{WHAT_THIS_IS_NOT_ONE_LINER}</p></div>'
f'</div>'
f'<p class="footer-explainer-hint">'
f'For more, read the full explainer below.'
f'</p>'
f'</div>'
)
def build_about_modal_html() -> str:
what_is_items = "\n".join(f"<li>{item}</li>" for item in ABOUT_MODAL_WHAT_IT_IS_BULLETS)
what_isnt_items = "\n".join(f"<li>{item}</li>" for item in ABOUT_MODAL_WHAT_IT_ISNT_BULLETS)
faq_items = "\n".join(
f'<details><summary>{item["q"]}</summary><p>{item["a"]}</p></details>'
for item in ABOUT_MODAL_FAQ
)
return (
f'<div id="about-modal" role="dialog" aria-modal="true" aria-labelledby="about-modal-title">'
f'<div class="about-modal-card">'
f'<div class="about-modal-header">'
f'<h2 id="about-modal-title">{ABOUT_MODAL_TITLE}</h2>'
f'<button class="about-modal-close" onclick="closeAboutModal()" aria-label="Close">×</button>'
f'</div>'
f'<p>{ABOUT_MODAL_INTRO}</p>'
f'<h3>How it works</h3>'
f'<p>{ABOUT_MODAL_HOW_IT_WORKS}</p>'
f'<h3>What you can do</h3>'
f'<ul>{what_is_items}</ul>'
f'<h3>What it isn’t</h3>'
f'<ul>{what_isnt_items}</ul>'
f'<h3>FAQ</h3>'
f'{faq_items}'
f'<h3>Privacy</h3>'
f'<p>{ABOUT_MODAL_PRIVACY}</p>'
f'<div class="about-modal-hint">{ABOUT_MODAL_HINT}</div>'
f'</div>'
f'</div>'
)
# Ensure the theme parameter is passed to gr.Blocks here:
with gr.Blocks(title="Local First Education Data Framework", theme=catalyst_theme, css=CUSTOM_CSS) as demo:
# Hidden state for one-step memory (most recent prior answer)
prior_state = gr.State(value=None)
has_asked = gr.State(value=False)
# ── About / first-time modal (rendered hidden, toggled via JS) ─────
gr.HTML(build_about_modal_html())
# ── Header (compact) ─────────────────────────────────────────────
with gr.Column(elem_classes="app-header-wrap"):
gr.HTML(
f"""
<div class="app-header">
<h1>{APP_TITLE}</h1>
<p class="tagline">{APP_TAGLINE}</p>
</div>
"""
)
# ── Input + primary CTA ──────────────────────────────────────────
with gr.Column(elem_classes="ask-block"):
user_input = gr.Textbox(
label="Your question",
placeholder="Ask in plain English…",
lines=2,
)
submit_btn = gr.Button(
"Get answer",
elem_classes="primary-cta",
variant="primary",
)
# ── Domain sections (below the input) ────────────────────────────
# Three equal-height columns. Each column has a single plain-text title
# and a dropdown of starter questions. Selecting an entry fills the
# input (no auto-submit).
starter_buttons: list[tuple[gr.components.Component, str]] = []
with gr.Row(elem_classes="domain-row"):
for section in DOMAIN_SECTIONS:
with gr.Column(elem_classes="domain-col", scale=1):
gr.HTML(
f'<div class="domain-col-title">{section["title"]}</div>'
)
dropdown = gr.Dropdown(
choices=section["questions"],
value=None,
label=None,
show_label=False,
elem_classes="domain-dropdown",
)
starter_buttons.append((dropdown, section["questions"]))
# ── First-visit nudge (fades after first question) ──────────────
first_visit = gr.HTML(
f'<div class="first-visit-nudge">{FIRST_VISIT_NUDGE}</div>',
visible=True,
)
# ── Result region ────────────────────────────────────────────────
# Always-visible ribbon row. The Markdown text and the button are
# always present; the button starts hidden (visible=False) so the
# row collapses to just a thin label, and toggles to visible when
# a prior answer is available. We can't toggle the Row's visibility
# from a callback (layout primitives are not valid Gradio outputs).
with gr.Row(elem_classes="previous-ribbon-row") as previous_ribbon_row:
previous_ribbon = gr.Markdown("", elem_classes="previous-ribbon-text")
bring_back_btn = gr.Button(
"Bring it back",
elem_classes="link-button",
size="sm",
visible=False,
)
result_summary = gr.HTML(visible=False)
data_output = gr.Dataframe(visible=False, wrap=True)
download_output = gr.File(
label="Download results as CSV",
visible=False,
elem_classes="download-csv",
interactive=False,
)
sql_output = gr.Code(visible=False, language="sql", label=SQL_DISCLOSURE_LABEL)
status = gr.Markdown("", elem_classes="status-line")
# ── Footer (compact) ─────────────────────────────────────────────
gr.HTML(build_footer_html())
footer_help_btn = gr.Button(
"Read the full explainer",
elem_classes="link-button footer-help",
size="sm",
)
# ── Explainer (at the very bottom) ───────────────────────────────
with gr.Group(elem_classes="explainer-wrap") as explainer_panel:
explainer_content = gr.HTML(value="", elem_classes="explainer-content")
explainer_close_btn = gr.Button(
"Close",
elem_classes="link-button explainer-close",
size="sm",
visible=False,
)
# ── Wiring ───────────────────────────────────────────────────────
def fill_input(q: str) -> str:
return q or ""
def clear_dropdowns():
return tuple(gr.update(value=None) for _ in starter_buttons)
def on_submit(question, prior, first_time):
has_asked = True
# Hide first-visit nudge once user submits anything
if not question or not question.strip():
yield (
"", # ribbon text (empty)
gr.update(visible=False), # summary
gr.update(visible=False), # dataframe
None, # csv download path
gr.update(visible=False), # sql
INPUT_PROMPT_EMPTY, # status
gr.update(visible=not first_time), # nudge visibility
prior,
has_asked,
gr.update(visible=False), # bring-back button hidden
*clear_dropdowns(), # reset all 3 domain dropdowns
)
return
# Live result handlers stream updates via handle_query directly
for prior_obj, sql_text, df, emoji, status_text, new_state in handle_query(
question, prior
):
ribbon_visible = prior_obj is not None
ribbon_text = (
PREVIOUS_RIBBON_TEMPLATE.format(summary=prior_obj.summary)
if prior_obj is not None else ""
)
summary_visible = bool(status_text and emoji in ("✅",))
has_data = df is not None
# Write CSV to a temp file whenever we have data.
csv_path = None
if has_data:
import tempfile, datetime as _dt
csv_path = os.path.join(
tempfile.gettempdir(),
f"kasualdad_result_{_dt.datetime.now(_dt.timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv",
)
df.to_csv(csv_path, index=False)
yield (
ribbon_text, # ribbon markdown
gr.update(
value=(
f'<p class="result-summary">{status_text}</p>'
if summary_visible else ""
),
visible=summary_visible,
),
gr.update(value=df, visible=has_data),
gr.update(value=csv_path, visible=has_data),
gr.update(value=sql_text, visible=bool(sql_text)),
f"{emoji} {status_text}",
gr.update(visible=False), # hide first-visit nudge
new_state,
has_asked,
gr.update(visible=False), # bring-back button remains hidden
*clear_dropdowns(), # reset all 3 domain dropdowns
)
# Dropdown selection: fill the input (no auto-submit)
for dropdown, _questions in starter_buttons:
dropdown.change(
fn=fill_input,
inputs=[dropdown],
outputs=user_input,
)
# Footer help button: open the explainer (set HTML to full content,
# make close button visible)
footer_help_btn.click(
fn=lambda: (build_explainer_html(), gr.update(visible=True)),
inputs=None,
outputs=[explainer_content, explainer_close_btn],
)
# Explainer close button: clear the HTML and hide the close button
explainer_close_btn.click(
fn=lambda: ("", gr.update(visible=False)),
inputs=None,
outputs=[explainer_content, explainer_close_btn],
)
# Bring-back button: restore the prior answer into the result panels
bring_back_btn.click(
fn=bring_back_prior,
inputs=[prior_state],
outputs=[
previous_ribbon,
sql_output,
data_output,
result_summary,
prior_state,
bring_back_btn,
],
)
submit_btn.click(
fn=on_submit,
inputs=[user_input, prior_state, has_asked],
outputs=[
previous_ribbon,
result_summary,
data_output,
download_output,
sql_output,
status,
first_visit,
prior_state,
has_asked,
bring_back_btn,
*[dd for dd, _ in starter_buttons], # clear all 3 domain dropdowns
],
)
user_input.submit(
fn=on_submit,
inputs=[user_input, prior_state, has_asked],
outputs=[
previous_ribbon,
result_summary,
data_output,
download_output,
sql_output,
status,
first_visit,
prior_state,
has_asked,
bring_back_btn,
*[dd for dd, _ in starter_buttons], # clear all 3 domain dropdowns
],
)
if __name__ == "__main__":
demo.launch(css=CUSTOM_CSS, head=HEAD_HTML)