monish563/NU-KIOSK-API
0
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 