"""Gradio chat bootstrap for the student-chat application. Composes the catalog, LLM clients, embedder, and orchestrator into a Gradio chat UI. Local mode opens localhost:7860. Hosted mode is sub-project #3's concern (it consumes the Gradio object returned by build_app). Visual layer follows the Duke Fuqua brand spec at content/duke-brand/fuqua-brand.json (colors, typography, lowercase main header, brand voice). """ from __future__ import annotations import argparse import base64 import json import os import sys from pathlib import Path import gradio as gr from scripts.lib.catalog_loader import load_catalog from scripts.lib.chat.conversation_store import InMemoryStore from scripts.lib.chat.llm_client import ClaudeClient, HaikuClient from scripts.lib.chat.orchestrator import ChatOrchestrator from scripts.lib.chat.query_planner import QueryPlanner from scripts.lib.chat.retrieval_tuning import load_tuning_state from scripts.lib.chat.safe_chatbot import TextOnlyChatbot from scripts.lib.chat.hardening import TOO_LONG_MESSAGE, scrub_display, too_long from scripts.lib.runtime_config import CHAT_CONCURRENCY_LIMIT, CHAT_QUEUE_MAX_SIZE, RECORDING_NOTICE from scripts.lib.embedder import Embedder, OpenAIEmbedder from scripts.lib.runtime_config import DERIVED_STUDENT_CHAT_DIR, DUKE_BRAND_DIR _BRAND_DIR = DUKE_BRAND_DIR _BRAND_SPEC_PATH = _BRAND_DIR / "fuqua-brand.json" _LOGO_PATH = _BRAND_DIR / "logo.png" def _default_catalog_dir() -> Path: env = os.environ.get("ME_HUB_CATALOG_DIR") if env: return Path(env) return DERIVED_STUDENT_CHAT_DIR def _logo_data_url() -> str: """Return the Fuqua wordmark as a base64 data URL, or "" if missing. Embedding inline avoids needing to whitelist a file path with Gradio's static-file server. """ if not _LOGO_PATH.is_file(): return "" encoded = base64.b64encode(_LOGO_PATH.read_bytes()).decode("ascii") return f"data:image/png;base64,{encoded}" def _brand_palette() -> dict[str, str]: """Pull the hex values we need from the brand spec. Falls back to defaults baked from the spec so the app still launches if the file is missing.""" defaults = { "fuquaBlue": "#012169", "skyBlue": "#198AC8", "charcoal": "#3D3D3D", "taupe": "#978B82", "darkSand": "#CFC7BD", "white": "#FFFFFF", } if not _BRAND_SPEC_PATH.is_file(): return defaults try: spec = json.loads(_BRAND_SPEC_PATH.read_text(encoding="utf-8")) primary = spec.get("colors", {}).get("primary", {}) secondary = spec.get("colors", {}).get("secondary", {}) return { "fuquaBlue": primary.get("fuquaBlue", {}).get("hex", defaults["fuquaBlue"]), "skyBlue": primary.get("skyBlue", {}).get("hex", defaults["skyBlue"]), "charcoal": primary.get("charcoal", {}).get("hex", defaults["charcoal"]), "taupe": secondary.get("taupe", {}).get("hex", defaults["taupe"]), "darkSand": secondary.get("darkSand", {}).get("hex", defaults["darkSand"]), "white": "#FFFFFF", } except (json.JSONDecodeError, OSError): return defaults def _build_theme(palette: dict[str, str]): """Compose a Gradio theme that maps Fuqua palette onto Gradio's tokens.""" return gr.themes.Soft( primary_hue=gr.themes.colors.blue, secondary_hue=gr.themes.colors.sky, neutral_hue=gr.themes.colors.slate, font=[gr.themes.GoogleFont("Inter"), "Arial", "Helvetica", "sans-serif"], ).set( body_text_color=palette["charcoal"], body_text_color_subdued=palette["taupe"], body_background_fill=palette["white"], background_fill_primary=palette["white"], background_fill_secondary="#F7F5F2", # very pale Dark Sand border_color_primary=palette["taupe"], button_primary_background_fill=palette["fuquaBlue"], button_primary_background_fill_hover=palette["skyBlue"], button_primary_text_color=palette["white"], button_secondary_background_fill=palette["white"], button_secondary_text_color=palette["fuquaBlue"], block_title_text_color=palette["fuquaBlue"], block_label_text_color=palette["fuquaBlue"], ) def _build_css(palette: dict[str, str]) -> str: """Custom CSS that the Gradio theme system doesn't reach: Domine for headers, Arial-stack for body, Charcoal text, lowercase main header, Fuqua-blue header bar.""" return f""" @import url('https://fonts.googleapis.com/css2?family=Domine:wght@400;500;600&display=swap'); body, .gradio-container, .gradio-container * {{ font-family: 'Arial', 'Helvetica', sans-serif; color: {palette["charcoal"]}; }} h1, h2, h3, h4, h5, .prose h1, .prose h2, .prose h3 {{ font-family: 'Domine', 'Georgia', 'Times New Roman', serif !important; color: {palette["fuquaBlue"]} !important; font-weight: 400 !important; }} .duke-fuqua-header {{ display: flex; align-items: center; gap: 18px; padding: 18px 24px; background: {palette["fuquaBlue"]}; color: {palette["white"]}; border-radius: 8px; margin-bottom: 12px; }} .duke-fuqua-header img {{ height: 56px; width: 56px; display: block; }} .duke-fuqua-header .header-title {{ font-family: 'Domine', 'Georgia', serif; font-size: 26px; font-weight: 400; color: {palette["white"]} !important; text-transform: lowercase; letter-spacing: 0.01em; }} .duke-fuqua-header .header-subtitle {{ font-family: 'Arial', 'Helvetica', sans-serif; font-size: 12px; color: {palette["white"]} !important; opacity: 0.85; letter-spacing: 0.04em; text-transform: uppercase; margin-top: 2px; }} .brand-description {{ color: {palette["charcoal"]}; padding: 8px 4px 16px 4px; display: flex; flex-direction: column; gap: 10px; }} .brand-description .brand-quote {{ font-family: 'Domine', 'Georgia', 'Times New Roman', serif; font-style: italic; font-size: 15px; line-height: 1.55; color: {palette["charcoal"]}; border-left: 3px solid {palette["skyBlue"]}; padding: 2px 0 2px 14px; }} .brand-description .brand-quote-lead {{ font-size: 17px; line-height: 1.5; color: {palette["fuquaBlue"]}; }} .brand-description .brand-attrib {{ font-family: 'Arial', 'Helvetica', sans-serif; font-style: normal; font-size: 12px; color: {palette["taupe"]}; letter-spacing: 0.02em; white-space: nowrap; }} /* Bordered card around the chat region (chatbot + input row) so the chat area, send button, and input feel like one unit instead of three floating pieces. */ .chat-card {{ border: 1px solid {palette["darkSand"]}; border-radius: 8px; padding: 12px; background: {palette["white"]}; }} /* Hide the third-party Gradio chrome at the bottom — "Use via API" and "Built with Gradio" attribution are off-brand for an institutional product. */ footer, .footer, .api-docs, [data-testid="footer"] {{ display: none !important; }} /* Hide the residual "Chatbot" pill label that Gradio renders even when show_label=False isn't fully respected by the wrapping ChatInterface. */ .chat-card .label-wrap, .chat-card .gr-block-label {{ display: none !important; }} """ def _load_faculty_notes_cache() -> dict[str, str]: """Best-effort fetch of each faculty login's notes from the HF Dataset. Returns an empty dict if the dataset env vars aren't set (local dev) or if any individual fetch fails. Never raises — a notes-fetch failure must not prevent the chat from starting.""" import logging as _logging _log = _logging.getLogger(__name__) repo_id = os.environ.get("ME_CHAT_HISTORY_DATASET_REPO_ID") token = os.environ.get("HF_TOKEN") if not (repo_id and token): _log.info("user_notes: HF Dataset env vars unset; cache empty.") return {} from scripts.lib.chat.prompt_builder import FACULTY_LOGINS from scripts.lib.chat_eval import notes_io cache: dict[str, str] = {} for login in sorted(FACULTY_LOGINS): try: body = notes_io.fetch_user_notes( login=login, repo_id=repo_id, token=token, ) except Exception as e: _log.warning("user_notes: fetch failed for %s: %s", login, e) continue if body: cache[login] = body _log.info("user_notes: loaded for logins=%s", sorted(cache.keys())) return cache def _make_handler(orch): """Build the per-turn handler closure used by gr.ChatInterface. Extracted to a top-level factory so unit tests can exercise the request -> user_login mapping without going through Gradio. """ def _handler(msg: str, history, request: gr.Request): # Gradio only injects a real Request when the parameter is annotated # `gr.Request`. Without the annotation it silently passes None, and # session_hash + username both fall back to local sentinels in # production — which is what bit us once the four-login auth went # live. See gradio issue #5297. session_id = getattr(request, "session_hash", None) or "local-default" user_login = getattr(request, "username", None) or "local" if too_long(msg): yield TOO_LONG_MESSAGE return accumulated = "" for chunk in orch.respond(session_id, msg, user_login=user_login): accumulated += chunk yield scrub_display(accumulated) return _handler def build_app( *, catalog_dir: Path | None = None, _embedder_factory=None, ): """Construct the branded Gradio chat. Reads ANTHROPIC_API_KEY at startup.""" cdir = catalog_dir or _default_catalog_dir() catalog = load_catalog(cdir) embedder_factory = _embedder_factory or (lambda: OpenAIEmbedder()) embedder: Embedder = embedder_factory() tuning_state = load_tuning_state() llm = ClaudeClient() haiku = HaikuClient() planner = QueryPlanner(haiku, term_names=list(catalog.term_to_docs.keys())) store = InMemoryStore() orch = ChatOrchestrator( catalog=catalog, llm=llm, store=store, embedder=embedder, planner=planner, score_penalties=tuning_state.get("penalties") or None, user_notes_cache=_load_faculty_notes_cache(), ) # Start the history pusher (no-op when ME_CHAT_HISTORY_DATASET_REPO_ID # / HF_TOKEN are unset — i.e. on local maintainer machines). from scripts.lib.chat import history_pusher history_pusher.start_pusher() _handler = _make_handler(orch) palette = _brand_palette() theme = _build_theme(palette) css = _build_css(palette) logo_url = _logo_data_url() # Header: wordmark on the left, lowercase course title + a non-redundant # subtitle ("MGRECON780 · Fall 1, 2025" — the wordmark already says # "Duke Fuqua School of Business" so we don't repeat it). header_html = ( '
' f'{"\"Duke" if logo_url else ""}' '
' '
managerial economics tutor
' '
MGRECON780 · Fall 1, 2025
' '
' '
' ) # Description: Vaidya's Day 1 framing of the whole course in one breath. # Verbatim from MGRECON780 lecture 2025-09-04, row 42. No attribution — # this reads as the chat's lead/header rather than a quoted teaching # moment. (Lopomo's invitation lives below as the chatbot placeholder.) description_html = ( '
' '
' '“Tell me the objective, tell me the constraints. ' 'Let’s figure out what choice emerges from those two things.”' '
' '
' ) chatbot_component = TextOnlyChatbot( type="messages", height=560, show_label=False, show_share_button=False, show_copy_button=True, bubble_full_width=False, # Empty-state placeholder: Lopomo's invitation, verbatim # from MGRECON780 lecture 2025-10-03, row 331. No # attribution — reads as a prompt to the student rather # than a quoted moment. placeholder=( "*“If you have questions, please ask because that " "benefits everybody.”*" ), ) def _on_clear(request: "gr.Request | None" = None) -> None: """Reset the orchestrator's per-session memory when the user clicks Start Over. Without this, the visible chat clears but the next turn still carries the prior conversation in InMemoryStore (and the on-disk dedupe state when CHAT_SESSION_CORE=1).""" session_id = ( getattr(request, "session_hash", None) if request is not None else None ) or "local-default" store.reset(session_id) if os.environ.get("CHAT_SESSION_CORE") == "1": session_file = ( DERIVED_STUDENT_CHAT_DIR / "sessions" / f"{session_id}.json" ) try: session_file.unlink(missing_ok=True) except OSError: pass with gr.Blocks(theme=theme, css=css, title="Managerial Economics Tutor", analytics_enabled=False) as demo: gr.HTML(header_html) gr.HTML(description_html) gr.Markdown(RECORDING_NOTICE, elem_classes="recording-notice") with gr.Group(elem_classes="chat-card"): chat_interface = gr.ChatInterface( fn=_handler, chatbot=chatbot_component, type="messages", # "Start Over" is unambiguous in a way "Clear" wasn't — # the prior worry was students reading "Clear" as # "delete account". Retry/undo stay suppressed to keep # the action row uncluttered. retry_btn=None, undo_btn=None, clear_btn="Start Over", submit_btn="Send", # Turns for different students are independent (the store # and turn log are locked), so a few can run at once. concurrency_limit=CHAT_CONCURRENCY_LIMIT, ) # Chatbot.clear() event was added in Gradio 5.x; on 4.44.1 we # have to attach to the ChatInterface's clear button directly. # Multiple click handlers compose, so the built-in clear (which # empties the visible transcript) still runs. chat_interface.clear_btn.click(_on_clear) demo.queue(max_size=CHAT_QUEUE_MAX_SIZE) return demo def main() -> int: parser = argparse.ArgumentParser( description="Run the student-chat application locally." ) parser.add_argument("--catalog-dir", type=Path, help="Path to derived/student-chat (overrides ME_HUB_CATALOG_DIR)") parser.add_argument("--port", type=int, default=7860) parser.add_argument("--share", action="store_true", help="Generate a public Gradio URL (FERPA-adjacent; gated)") args = parser.parse_args() if args.share: if os.environ.get("ME_HUB_ALLOW_PUBLIC_DEMO") != "1": print( "Refusing --share: set ME_HUB_ALLOW_PUBLIC_DEMO=1 to confirm " "you understand a public Gradio URL exposes live course " "content and paid API keys.", file=sys.stderr, ) return 2 print("WARNING: public Gradio URL exposes live course content + paid API keys.") print("Use only for short-lived colleague demos. URL auto-expires in 72h.") app = build_app(catalog_dir=args.catalog_dir) app.launch(share=args.share, server_port=args.port) return 0 if __name__ == "__main__": sys.exit(main())