asa / scripts /lib /chat /app.py
defenestrate's picture
ASA Space: squashed history (instructions and settings live in the private pack)
526950e
Raw History Blame Contribute Delete
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 &middot; 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())