Spaces:
Sleeping
Sleeping
Download scripts/lib/chat/app.py from defenestrate/asa: direct link, hf CLI and curl.
- Browser
- Download file 16 kB
-
https://huggingface.co/spaces/defenestrate/asa/resolve/main/scripts/lib/chat/app.py
- Command line
-
hf download hf://spaces/defenestrate/asa/scripts/lib/chat/app.py
-
curl -L -o app.py https://huggingface.co/spaces/defenestrate/asa/resolve/main/scripts/lib/chat/app.py
16 kB
| """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 = ( | |
| '<div class="duke-fuqua-header">' | |
| f'{"<img src=\"" + logo_url + "\" alt=\"Duke Fuqua School of Business\" />" if logo_url else ""}' | |
| '<div>' | |
| '<div class="header-title">managerial economics tutor</div>' | |
| '<div class="header-subtitle">MGRECON780 · Fall 1, 2025</div>' | |
| '</div>' | |
| '</div>' | |
| ) | |
| # 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 = ( | |
| '<div class="brand-description">' | |
| '<div class="brand-quote brand-quote-lead">' | |
| '“Tell me the objective, tell me the constraints. ' | |
| 'Let’s figure out what choice emerges from those two things.”' | |
| '</div>' | |
| '</div>' | |
| ) | |
| 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()) | |