""" 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'
{prior.summary}
', 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 = """ """ # ── Component builders ──────────────────────────────────────────────── def build_explainer_html() -> str: return ( f'{HOW_IT_WORKS}
' f'{PRIVACY_EXPLAINER}
' f'{item["a"]}
{ABOUT_MODAL_INTRO}
' f'{ABOUT_MODAL_HOW_IT_WORKS}
' f'{ABOUT_MODAL_PRIVACY}
' f'{APP_TAGLINE}
{status_text}
' 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)