CoolFace
Apppublic

monish563/NU-KIOSK-API

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
main.py931 linesDownload Raw Back to backend
1"""FastAPI backend for Northwestern CS Kiosk - API only (no frontend)."""2 3from __future__ import annotations4import csv5import json6import logging7import os8import threading9import time10import warnings11 12try:13    from huggingface_hub import CommitScheduler, hf_hub_download14except ImportError:15    CommitScheduler = None  # type: ignore16    hf_hub_download = None  # type: ignore17from functools import lru_cache18from pathlib import Path19from typing import Any, Dict, List, Optional, Tuple20 21from fastapi import FastAPI, HTTPException, Query22from fastapi.middleware.cors import CORSMiddleware23from pydantic import BaseModel24 25from .data import load_default_catalog26from .tools import (27    AnalysisEngine,28    FacultyByTopicBlueprint,29    LocationBlueprint,30    CenterBlueprint,31    AdvisorshipBlueprint,32    PersonLookupBlueprint,33    StaffSupportBlueprint,34    UpcomingEventsBlueprint,35    OfficeHoursBlueprint,36    BlueprintResult,37)38from .responders import LLMResponder, Responder39from .providers import (40    BaseLLM,41    ProviderConfig,42    available_providers,43    get_provider,44    normalize_provider_name,45)46from .data.utils import canonicalize_name47from .mcp import (48    Action,49    PlannerContext,50    LLMActionPlanner,51)52from .mcp.tool_schemas import get_all_tool_schemas53from .mcp.tool_executor import ToolExecutor54from .mcp.context_resolver import (55    is_affirmation,56    resolve as resolve_context,57    strip_context_on_topic_switch,58)59 60BASE_DIR = Path(__file__).resolve().parent61ARCHIVE_DIR = BASE_DIR.parent / "Archive"62DATA_DIR = BASE_DIR / "storage"63DATA_DIR.mkdir(parents=True, exist_ok=True)64 65HISTORY_FILE = DATA_DIR / "chat_history.jsonl"66USAGE_FILE = DATA_DIR / "usage_metrics.jsonl"67 68DEFAULT_SESSION = "default"69 70app = FastAPI(71    title="Northwestern CS Kiosk API",72    description="REST API for the Northwestern CS Department Kiosk",73    version="1.0.0",74)75 76# Enable CORS for external integrations77app.add_middleware(78    CORSMiddleware,79    allow_origins=["*"],  # Configure as needed for your integration80    allow_credentials=True,81    allow_methods=["*"],82    allow_headers=["*"],83)84 85_orchestrator_lock = threading.Lock()86logger = logging.getLogger(__name__)87_hf_scheduler = None88_entity_names: List[str] = []89 90 91def _load_entity_names() -> None:92    """Scrape entity names from Archive folder at startup and store in memory."""93    global _entity_names94 95    def _extract_names_from_csv(filepath: Path) -> List[str]:96        names = []97        try:98            with open(filepath, "r", encoding="utf-8") as f:99                reader = csv.DictReader(f)100                if reader.fieldnames is None:101                    return names102                fieldnames = reader.fieldnames103                name_columns = []104                for field in fieldnames:105                    field_lower = field.lower()106                    if field_lower == "name" or field_lower == "assignee name":107                        name_columns = [field]108                        break109                    elif field_lower == "first name":110                        name_columns.append(field)111                    elif field_lower == "last name":112                        name_columns.insert(0, field)113                for row in reader:114                    if name_columns:115                        if len(name_columns) == 1 and row.get(name_columns[0]):116                            name = row[name_columns[0]].strip()117                            if name and name.upper() != "NA":118                                names.append(name)119                        elif len(name_columns) == 2:120                            last_name = row.get(name_columns[0], "").strip()121                            first_name = row.get(name_columns[1], "").strip()122                            if (last_name or first_name) and last_name.upper() != "NA" and first_name.upper() != "NA":123                                full_name = f"{first_name} {last_name}".strip() if (last_name and first_name) else (first_name or last_name)124                                if full_name:125                                    names.append(full_name)126        except Exception as e:127            logger.warning("Error reading CSV %s: %s", filepath, e)128        return names129 130    def _extract_names_from_text(filepath: Path) -> List[str]:131        names = []132        try:133            with open(filepath, "r", encoding="utf-8") as f:134                for line in f:135                    line = line.strip()136                    if line.startswith("Name:"):137                        name = line.replace("Name:", "").strip()138                        if name:139                            names.append(name)140        except Exception as e:141            logger.warning("Error reading text file %s: %s", filepath, e)142        return names143 144    try:145        archive_dir = ARCHIVE_DIR146        if not archive_dir.exists():147            logger.warning("Archive directory not found at %s", archive_dir)148            _entity_names = []149            return150        all_names: set = set()151        file_count = 0152        for filepath in sorted(archive_dir.iterdir()):153            if filepath.is_file():154                if filepath.suffix.lower() == ".csv":155                    names = _extract_names_from_csv(filepath)156                    all_names.update(names)157                    file_count += 1158                elif filepath.suffix.lower() == ".txt":159                    names = _extract_names_from_text(filepath)160                    all_names.update(names)161                    file_count += 1162        _entity_names = sorted(all_names)163        logger.info("Scraped %d unique entity names from %d files in Archive", len(_entity_names), file_count)164    except Exception as e:165        logger.error("Failed to scrape entity names from Archive: %s", e)166        _entity_names = []167 168 169_load_entity_names()170 171 172class QueryPayload(BaseModel):173    """Request payload for the /api/query endpoint."""174    question: str175    session_id: Optional[str] = None176    provider: Optional[str] = None177 178 179PROVIDER_ENV_SETTINGS: Dict[str, Dict[str, Optional[str]]] = {180    "claude": {181        "api_key": "ANTHROPIC_API_KEY",182        "model": "ANTHROPIC_MODEL",183        "base_url": "ANTHROPIC_BASE_URL",184        "default_model": "claude-haiku-4-5",185    },186    "gpt": {187        "api_key": "OPENAI_API_KEY",188        "model": "OPENAI_MODEL",189        "base_url": "OPENAI_BASE_URL",190        "default_model": "gpt-4.1-mini",191    },192    "gemini": {193        "api_key": "GEMINI_API_KEY",194        "model": "GEMINI_MODEL",195        "base_url": "GEMINI_BASE_URL",196        "default_model": "gemini-2.0-flash",197    },198    "echo": {199        "api_key": None,200        "model": None,201        "base_url": None,202        "default_model": "echo",203    },204}205 206 207def _load_env_once() -> None:208    """Load environment variables from .env exactly once."""209    if getattr(_load_env_once, "_loaded", False):210        return211 212    env_path = os.getenv("KIOSK_ENV_FILE")213    if not env_path:214        default_path = BASE_DIR / ".env"215        env_path = str(default_path) if default_path.exists() else ".env"216 217    try:218        from dotenv import load_dotenv219    except ImportError:220        _load_env_once._loaded = True221        return222 223    load_dotenv(env_path, override=False)224    _load_env_once._loaded = True225 226 227def _get_env_value(name: Optional[str]) -> str:228    """229    Read environment variables with an HF Spaces secret fallback.230    HF Secrets expose values as HF_<NAME>, so check both keys.231    """232    if not name:233        return ""234    direct = os.getenv(name, "").strip()235    if direct:236        return direct237    return os.getenv(f"HF_{name}", "").strip()238 239 240def _maybe_download_existing_metrics() -> None:241    """Download existing usage metrics from HF dataset on startup."""242    repo_id = os.getenv("KIOSK_HF_DATASET_REPO", "").strip()243    if not repo_id or hf_hub_download is None:244        return245    _load_env_once()246    token = _get_env_value("KIOSK_HF_TOKEN") or os.getenv("HF_TOKEN", "").strip()247    path_in_repo = os.getenv("KIOSK_HF_DATASET_PATH", "chat_history").strip()248    filename = f"{path_in_repo}/{USAGE_FILE.name}" if path_in_repo else USAGE_FILE.name249    try:250        import shutil251        downloaded = hf_hub_download(252            repo_id=repo_id, repo_type="dataset", filename=filename, token=token or None,253        )254        USAGE_FILE.parent.mkdir(parents=True, exist_ok=True)255        shutil.copy(downloaded, USAGE_FILE)256        logger.info("Downloaded usage metrics from HF: repo=%s file=%s", repo_id, filename)257    except Exception as exc:258        logger.info("No existing metrics to download (starting fresh): %s", exc)259 260 261def _maybe_download_existing_history() -> None:262    """Download existing chat history from HF dataset on startup."""263    repo_id = os.getenv("KIOSK_HF_DATASET_REPO", "").strip()264    if not repo_id or hf_hub_download is None:265        return266 267    _load_env_once()268    token = _get_env_value("KIOSK_HF_TOKEN") or os.getenv("HF_TOKEN", "").strip()269    path_in_repo = os.getenv("KIOSK_HF_DATASET_PATH", "chat_history").strip()270    filename = f"{path_in_repo}/{HISTORY_FILE.name}" if path_in_repo else HISTORY_FILE.name271 272    try:273        import shutil274 275        downloaded = hf_hub_download(276            repo_id=repo_id,277            repo_type="dataset",278            filename=filename,279            token=token or None,280        )281        HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)282        shutil.copy(downloaded, HISTORY_FILE)283        logger.info(284            "Downloaded chat history from HF dataset: repo=%s file=%s",285            repo_id,286            filename,287        )288    except Exception as exc:289        logger.info("No existing chat history to download (starting fresh): %s", exc)290 291 292def _maybe_start_hf_sync() -> None:293    """Start optional HF dataset syncing for chat history and usage metrics."""294    global _hf_scheduler295    if _hf_scheduler is not None:296        return297    repo_id = os.getenv("KIOSK_HF_DATASET_REPO", "").strip()298    if not repo_id or CommitScheduler is None:299        return300    _load_env_once()301    token = _get_env_value("KIOSK_HF_TOKEN") or os.getenv("HF_TOKEN", "").strip()302    path_in_repo = os.getenv("KIOSK_HF_DATASET_PATH", "chat_history").strip()303    interval_minutes = float(os.getenv("KIOSK_HF_SYNC_INTERVAL_MINUTES", "10"))304    try:305        _hf_scheduler = CommitScheduler(306            repo_id=repo_id,307            repo_type="dataset",308            folder_path=str(DATA_DIR),309            path_in_repo=path_in_repo,310            token=token or None,311            allow_patterns=[HISTORY_FILE.name, USAGE_FILE.name],312            every=interval_minutes,313        )314        logger.info(315            "Started HF CommitScheduler for chat_history and usage_metrics: repo=%s path=%s interval=%s",316            repo_id, path_in_repo or ".", interval_minutes,317        )318    except Exception as exc:319        warnings.warn(f"Unable to start HF sync: {exc}")320 321 322def _run_startup_tasks_in_background() -> None:323    """Run HF download and sync in a background thread so the server starts immediately."""324    def _run() -> None:325        try:326            _maybe_download_existing_metrics()327            _maybe_download_existing_history()328            _maybe_start_hf_sync()329        except Exception as exc:330            logger.warning("Background startup tasks failed: %s", exc)331 332    t = threading.Thread(target=_run, daemon=True)333    t.start()334 335 336_run_startup_tasks_in_background()337 338 339def _is_placeholder(value: Optional[str]) -> bool:340    if not value:341        return True342    lowered = value.strip().lower()343    return lowered.startswith("your-") or lowered in {"changeme", "placeholder"}344 345 346def _build_client_from_env(provider: str, model_override: Optional[str]) -> Optional[BaseLLM]:347    canonical = normalize_provider_name(provider)348    settings = PROVIDER_ENV_SETTINGS.get(canonical)349    if not settings:350        warnings.warn(f"Provider '{provider}' not recognized; falling back to echo responder.")351        return None352 353    timeout = int(os.getenv("KIOSK_LLM_TIMEOUT", "60"))354    max_tokens_raw = os.getenv("KIOSK_LLM_MAX_TOKENS", "").strip()355    max_tokens = int(max_tokens_raw) if max_tokens_raw.isdigit() else None356    api_env = settings.get("api_key")357    model_env = settings.get("model")358    base_url_env = settings.get("base_url")359    default_model = settings.get("default_model") or ""360 361    if api_env:362        api_key = _get_env_value(api_env)363        if not api_key or _is_placeholder(api_key):364            warnings.warn(f"{api_env} not set; falling back to echo responder.")365            return None366    else:367        api_key = "local-echo"368    model = model_override or (_get_env_value(model_env) if model_env else "") or default_model369    base_url = _get_env_value(base_url_env) if base_url_env else ""370 371    config = ProviderConfig(372        api_key=api_key,373        model=model,374        timeout_sec=timeout,375        base_url=base_url or None,376        max_tokens=max_tokens,377    )378 379    try:380        return get_provider(canonical, config)381    except ValueError as exc:382        warnings.warn(str(exc))383        return None384 385 386def _build_responder(387    provider: Optional[str],388    model_override: Optional[str],389) -> LLMResponder:390    _load_env_once()391    system_prompt = os.getenv(392        "KIOSK_LLM_SYSTEM_PROMPT",393        "You are a conversational receptionist for the Northwestern CS Kiosk whose responses are spoken aloud. Speak naturally and never include stage directions or annotations.",394    )395    style = os.getenv("KIOSK_LLM_STYLE", "Be very brief. One or two sentences max. No long listsโ€”summarize top 2-3 items only.")396 397    provider_name = provider or os.getenv("KIOSK_LLM_PROVIDER", "anthropic")398    model_override = model_override if provider else (model_override or os.getenv("KIOSK_LLM_MODEL"))399 400    client = _build_client_from_env(provider_name, model_override)401    canonical = normalize_provider_name(provider_name)402 403    if client:404        return LLMResponder(405            client=client,406            system_prompt=system_prompt,407            style_guidelines=style,408            provider_id=canonical,409        )410    warnings.warn("LLM provider not configured; using echo responder for kiosk responses.")411    return LLMResponder(412        system_prompt=system_prompt,413        style_guidelines=style,414        provider_id="echo",415    )416 417 418def _default_responder_from_env() -> Responder:419    try:420        return _build_responder(None, None)421    except RuntimeError as exc:422        warnings.warn(f"Failed to initialize LLM responder: {exc}")423        return LLMResponder(provider_id="echo")424 425 426def _create_planner() -> LLMActionPlanner:427    provider = os.getenv("KIOSK_PLANNER_PROVIDER") or os.getenv("KIOSK_LLM_PROVIDER", "anthropic")428    model_override = os.getenv("KIOSK_PLANNER_MODEL") or os.getenv("KIOSK_LLM_MODEL")429    client = _build_client_from_env(provider, model_override)430    if not client:431        raise RuntimeError("LLM planner requires a configured provider (set KIOSK_LLM_PROVIDER/KEY).")432    schemas = get_all_tool_schemas()433    return LLMActionPlanner(client, schemas=schemas, entity_names=_entity_names)434 435 436class ConversationOrchestrator:437    """Glue class that ties planner, executor, and responder together."""438 439    def __init__(self, engine: AnalysisEngine, responder: Optional[Responder] = None) -> None:440        _load_env_once()441        self.engine = engine442        self.responder = responder or _default_responder_from_env()443        self.executor = ToolExecutor(engine)444        self.planner = _create_planner()445        self.last_subject: Optional[str] = None446        self._faculty_lookup = self._build_name_lookup("faculty")447        self._student_lookup = self._build_name_lookup("students")448        self.provider_id = getattr(self.responder, "provider_id", None)449 450    def answer(451        self,452        question: str,453        context: Optional[PlannerContext] = None,454        resolved_input: Optional[Any] = None,455    ) -> Tuple[str, BlueprintResult, Action]:456        if context is None:457            context = PlannerContext(last_subject=self.last_subject)458 459        if is_affirmation(question) and context.last_subject:460            last_answer = ""461            if context.short_history:462                last_answer = (context.short_history[-1].get("answer") or "").lower()463            if any(464                x in last_answer465                for x in ("would you like", "look up", "find", "room number", "office")466            ):467                actions = [Action("lookup_location", {"use_last_subject": True})]468            else:469                actions = self.planner.plan(question, context)470        else:471            actions = self.planner.plan(question, context)472 473        if not actions:474            actions = [Action("noop", {"message": "I'm not sure how to help with that yet."})]475 476        # Inject resolved day (e.g. "F" โ†’ "friday", "today" โ†’ "wednesday") when planner returns lookup_office_hours without day477        if actions and resolved_input and getattr(resolved_input, "resolved_day", None):478            for act in actions:479                if act.type == "lookup_office_hours" and not act.arguments.get("day"):480                    act.arguments["day"] = resolved_input.resolved_day481 482        if len(actions) > 1:483            merged_facts: List = []484            merged_notes: List[str] = []485            ran: List[str] = []486            for act in actions:487                ran.append(act.type)488                sub_result = self.executor.execute(act, context)489                merged_facts.extend(sub_result.facts)490                for note in sub_result.notes:491                    if note not in merged_notes:492                        merged_notes.append(note)493            result = BlueprintResult("composite", {}, facts=merged_facts, notes=merged_notes)494            action = Action("composite", {"actions": [a.to_dict() for a in actions], "merged_actions": ran})495        else:496            action = actions[0]497            name_like = None498            if isinstance(action.arguments, dict):499                for key in ("name", "person", "student", "faculty"):500                    val = action.arguments.get(key)501                    if val:502                        name_like = val503                        break504 505            if name_like:506                result = self.executor.execute(action, context)507                if not result.facts:508                    canonical = canonicalize_name(name_like)509                    faculty_match = self._faculty_lookup.get(canonical)510                    student_match = self._student_lookup.get(canonical)511                    if faculty_match and not student_match:512                        action.arguments.pop("name", None)513                        action.arguments.pop("person", None)514                        action.arguments["faculty"] = faculty_match515                        result = self.executor.execute(action, context)516                    elif student_match and not faculty_match:517                        action.arguments.pop("name", None)518                        action.arguments.pop("person", None)519                        action.arguments["student"] = student_match520                        result = self.executor.execute(action, context)521                    elif faculty_match and student_match:522                        action = Action(523                            "noop",524                            {525                                "message": (526                                    f"I found both a faculty member and a student named {name_like}. "527                                    "Do you mean the faculty member or the student?"528                                )529                            },530                        )531                        result = BlueprintResult("noop", action.arguments, facts=[], notes=[action.arguments.get("message")])532            else:533                result = self.executor.execute(action, context)534        response_text = self.responder.render(question, result.name, result)535 536        subject = self._select_subject_from_result(result)537        if subject:538            self.last_subject = subject539        else:540            for key in ("name", "student", "faculty"):541                if key in action.arguments and action.arguments[key]:542                    self.last_subject = action.arguments[key]543                    break544        return response_text, result, action545 546    def ensure_responder(self, provider: Optional[str], model_override: Optional[str] = None) -> None:547        canonical = normalize_provider_name(provider) if provider else None548        if canonical and canonical == getattr(self.responder, "provider_id", None):549            return550        if not canonical and getattr(self.responder, "provider_id", None) != "unknown":551            return552        self.responder = _build_responder(provider, model_override)553        self.provider_id = getattr(self.responder, "provider_id", canonical)554 555    @staticmethod556    def _infer_subject(result: BlueprintResult) -> Optional[str]:557        if not result.facts:558            return None559        return result.facts[0].subject560 561    def _build_name_lookup(self, entity_name: str) -> Dict[str, str]:562        mapping: Dict[str, str] = {}563        entity = self.engine.catalog.try_get(entity_name)564        if not entity:565            return mapping566        for row in entity.records:567            name = row.get("Name")568            if not name:569                continue570            mapping[canonicalize_name(name)] = name571        return mapping572 573    def _select_subject_from_result(self, result: BlueprintResult) -> Optional[str]:574        candidates: List[str] = []575        for fact in result.facts:576            if isinstance(fact.subject, str):577                candidates.append(fact.subject)578            if isinstance(fact.value, str):579                candidates.append(fact.value)580        for candidate in candidates:581            canonical = canonicalize_name(candidate)582            if canonical in self._faculty_lookup:583                return self._faculty_lookup[canonical]584        for candidate in candidates:585            canonical = canonicalize_name(candidate)586            if canonical in self._student_lookup:587                return self._student_lookup[canonical]588        return self._infer_subject(result)589 590 591def _append_json_line(path: Path, payload: Dict[str, Any]) -> None:592    path.parent.mkdir(parents=True, exist_ok=True)593    with path.open("a", encoding="utf-8") as handle:594        handle.write(json.dumps(payload, ensure_ascii=False) + "\n")595 596 597def record_history(598    *,599    session_id: str,600    question: str,601    answer: str,602    blueprint: str,603    metadata: Dict[str, Any],604    facts: List[Dict[str, Any]],605    notes: List[str],606    action: Dict[str, Any],607) -> float:608    timestamp = time.time()609    payload = {610        "timestamp": timestamp,611        "session_id": session_id,612        "question": question,613        "answer": answer,614        "blueprint": blueprint,615        "facts": facts,616        "notes": notes,617        "usage": metadata,618        "action": action,619    }620    _append_json_line(HISTORY_FILE, payload)621    usage_entry = {622        "timestamp": timestamp,623        "session_id": session_id,624        "blueprint": blueprint,625        "question": question,626    }627    usage_entry.setdefault("action_type", action.get("type"))628    _append_json_line(USAGE_FILE, usage_entry)629    return timestamp630 631 632def load_history(session_id: str) -> List[Dict[str, Any]]:633    if not HISTORY_FILE.exists():634        return []635    rows: List[Dict[str, Any]] = []636    with HISTORY_FILE.open(encoding="utf-8") as handle:637        for line in handle:638            try:639                record = json.loads(line)640            except json.JSONDecodeError:641                continue642            if record.get("session_id") == session_id:643                rows.append(record)644    rows.sort(key=lambda row: row.get("timestamp", 0))645    return rows646 647 648def build_planner_context_from_history(history: List[Dict[str, Any]]) -> PlannerContext:649    """Build session context: full history, short window, and topic-aware follow-up state."""650    if not history:651        return PlannerContext()652 653    cap = 20654    full_history: List[Dict[str, Any]] = []655    for rec in history[-cap:]:656        full_history.append({657            "question": rec.get("question", ""),658            "answer": rec.get("answer", ""),659        })660 661    short_history: List[Dict[str, Any]] = []662    for rec in history[-4:]:663        short_history.append({664            "question": rec.get("question", ""),665            "answer": rec.get("answer", ""),666            "action": rec.get("action"),667        })668    short_history = short_history[-3:]669 670    last = history[-1]671    action = last.get("action") or {}672    args = action.get("arguments") or {}673    facts = last.get("facts") or []674    action_type = (action.get("type") or "").lower()675 676    topic: Optional[str] = None677    subject: Optional[str] = None678    last_class: Optional[str] = None679 680    if facts and isinstance(facts[0], dict) and facts[0].get("subject"):681        subject = facts[0]["subject"]682    if not subject:683        for key in ("name", "person", "student", "faculty", "class_name", "course"):684            if args.get(key):685                subject = args[key]686                break687 688    if action_type == "lookup_office_hours":689        topic = "office_hours"690        cls_val = args.get("class_name") or args.get("course") or ""691        if cls_val:692            last_class = cls_val693        elif subject and (any(c.isdigit() for c in subject) or subject.upper().startswith("CS")):694            last_class = subject695    elif action_type in (696        "lookup_person",697        "lookup_location",698        "lookup_center",699        "lookup_advisorship",700        "lookup_faculty_topic",701    ):702        if action_type == "lookup_advisorship" and args.get("student"):703            topic = "student"704        elif action_type == "lookup_faculty_topic" or action_type == "lookup_center":705            topic = "professor"706        else:707            topic = "professor"708 709    last_subject = subject710 711    return PlannerContext(712        full_history=full_history,713        short_history=short_history,714        topic=topic,715        subject=subject,716        last_class=last_class,717        last_subject=last_subject,718    )719 720 721def _display_name_from_timestamp(ts: float) -> str:722    from datetime import datetime723    dt = datetime.fromtimestamp(ts)724    return dt.strftime("Chat โ€“ %b %d, %I:%M %p")725 726 727def summarize_sessions() -> List[Dict[str, Any]]:728    if not HISTORY_FILE.exists():729        return []730    sessions: Dict[str, Dict[str, Any]] = {}731    with HISTORY_FILE.open(encoding="utf-8") as handle:732        for line in handle:733            try:734                record = json.loads(line)735            except json.JSONDecodeError:736                continue737            session_id = record.get("session_id")738            ts = record.get("timestamp")739            if not session_id or not ts:740                continue741            session = sessions.setdefault(742                session_id,743                {"session_id": session_id, "created_at": ts, "updated_at": ts},744            )745            session["created_at"] = min(session["created_at"], ts)746            session["updated_at"] = max(session["updated_at"], ts)747    for session in sessions.values():748        session["title"] = _display_name_from_timestamp(session["created_at"])749    ordered = sorted(sessions.values(), key=lambda item: item["updated_at"], reverse=True)750    return ordered751 752 753def get_session_summary(session_id: str) -> Optional[Dict[str, Any]]:754    for session in summarize_sessions():755        if session["session_id"] == session_id:756            return session757    return None758 759 760def describe_providers() -> Dict[str, Dict[str, Any]]:761    """Expose provider metadata and configuration status."""762    _load_env_once()763    inventory: Dict[str, Dict[str, Any]] = {}764    for name, meta in available_providers().items():765        entry = dict(meta)766        settings = PROVIDER_ENV_SETTINGS.get(name, {})767        api_env = settings.get("api_key")768        configured = True769        note = ""770        if api_env:771            value = os.getenv(api_env, "").strip()772            configured = bool(value) and not _is_placeholder(value)773            if not configured:774                note = f"Set {api_env} before using this provider."775        entry["configured"] = configured776        if note:777            entry["note"] = note778        entry.setdefault("default_model", settings.get("default_model"))779        inventory[name] = entry780    return inventory781 782 783@lru_cache(maxsize=1)784def get_orchestrator() -> ConversationOrchestrator:785    catalog = load_default_catalog(ARCHIVE_DIR)786    engine = AnalysisEngine(787        catalog,788        [789            FacultyByTopicBlueprint(),790            LocationBlueprint(),791            CenterBlueprint(),792            AdvisorshipBlueprint(),793            StaffSupportBlueprint(),794            UpcomingEventsBlueprint(),795            OfficeHoursBlueprint(),796            PersonLookupBlueprint(),797        ],798    )799    try:800        engine.refresh_events()801    except Exception:802        pass803    return ConversationOrchestrator(engine)804 805 806# =============================================================================807# API ENDPOINTS808# =============================================================================809 810@app.get("/")811def root() -> Dict[str, str]:812    """Health check endpoint."""813    return {"status": "ok", "service": "Northwestern CS Kiosk API"}814 815 816@app.get("/api/providers")817def providers_endpoint() -> Dict[str, Any]:818    """List available LLM providers and their configuration status."""819    inventory = describe_providers()820    default_provider = normalize_provider_name(os.getenv("KIOSK_LLM_PROVIDER", "anthropic"))821    return {"providers": inventory, "default_provider": default_provider}822 823 824@app.post("/api/query")825def query(payload: QueryPayload) -> Dict[str, Any]:826    """827    Main query endpoint - send a question and get an answer.828    829    This is the primary endpoint for speech-to-text integration:830    - Input: question (string from speech-to-text)831    - Output: answer (string for text-to-speech)832    """833    question = (payload.question or "").strip()834    if not question:835        raise HTTPException(status_code=400, detail="Question is required.")836 837    session_id = (payload.session_id or DEFAULT_SESSION).strip() or DEFAULT_SESSION838    requested_provider = (payload.provider or "").strip().lower() or None839    canonical_provider = normalize_provider_name(requested_provider) if requested_provider else None840 841    if canonical_provider:842        inventory = describe_providers()843        provider_meta = inventory.get(canonical_provider)844        if not provider_meta:845            raise HTTPException(status_code=400, detail=f"Unknown provider '{requested_provider}'.")846        if not provider_meta.get("configured", True):847            note = provider_meta.get("note") or f"The provider '{provider_meta.get('name', canonical_provider)}' is not configured."848            raise HTTPException(status_code=400, detail=note)849 850    history = load_history(session_id)851    planner_context = build_planner_context_from_history(history)852    planner_context = strip_context_on_topic_switch(question, planner_context)853    resolved = resolve_context(question, planner_context)854 855    orchestrator = get_orchestrator()856    with _orchestrator_lock:857        orchestrator.ensure_responder(canonical_provider)858        answer, result, action = orchestrator.answer(859            resolved.question,860            context=planner_context,861            resolved_input=resolved,862        )863        metadata = (864            orchestrator.responder.get_metadata()865            if hasattr(orchestrator, "responder") and hasattr(orchestrator.responder, "get_metadata")866            else {}867        )868        metadata.setdefault("planner_action", action.to_dict())869 870    facts_payload = [fact.__dict__ for fact in result.facts]871    record_history(872        session_id=session_id,873        question=question,874        answer=answer,875        blueprint=result.name,876        metadata=metadata,877        facts=facts_payload,878        notes=result.notes,879        action=action.to_dict(),880    )881    summary = get_session_summary(session_id) or {882        "session_id": session_id,883        "title": _display_name_from_timestamp(time.time()),884    }885 886    return {887        "session_id": session_id,888        "session_title": summary.get("title"),889        "question": question,890        "answer": answer,891        "blueprint": result.name,892        "facts": facts_payload,893        "notes": result.notes,894        "usage": metadata,895        "action": action.to_dict(),896    }897 898 899@app.get("/api/history")900def history(session_id: str = Query(DEFAULT_SESSION)) -> Dict[str, Any]:901    """Get conversation history for a session."""902    entries = load_history(session_id)903    summary = get_session_summary(session_id)904    title = summary.get("title") if summary else _display_name_from_timestamp(time.time())905    return {"session_id": session_id, "title": title, "history": entries}906 907 908@app.get("/api/sessions")909def sessions() -> Dict[str, Any]:910    """List all conversation sessions."""911    return {"sessions": summarize_sessions()}912 913 914def main() -> None:915    """Run the API server."""916    import uvicorn917    918    host = os.getenv("KIOSK_HOST", "0.0.0.0")919    port = int(os.getenv("KIOSK_PORT", "8000"))920    921    uvicorn.run(922        "backend.main:app",923        host=host,924        port=port,925        reload=False,926    )927 928 929if __name__ == "__main__":930    main()931