"""Responder classes that turn blueprint outputs into natural language replies.""" from __future__ import annotations import json from abc import ABC, abstractmethod from datetime import datetime, timedelta from typing import Any, Dict, Optional from zoneinfo import ZoneInfo from .providers import BaseLLM, ChatMessage, EchoProvider, LLMResponse, ProviderConfig from .tools.base import BlueprintResult # Northwestern is in Evanston, IL (America/Chicago) DEFAULT_TZ = ZoneInfo("America/Chicago") def _temporal_context() -> str: """Return current date/time context for temporal questions.""" now = datetime.now(DEFAULT_TZ) today = now.date() tomorrow = today + timedelta(days=1) return ( f"Current date and time (Chicago): {now.strftime('%A, %B %d, %Y at %I:%M %p')}. " f"Today is {today.strftime('%A, %B %d, %Y')}. " f"Tomorrow is {tomorrow.strftime('%A, %B %d, %Y')}." ) class Responder(ABC): """Converts blueprint outputs into natural language answers.""" def __init__(self) -> None: self._metadata: Dict[str, Any] = {} @abstractmethod def render(self, question: str, blueprint_name: str, result: BlueprintResult) -> str: raise NotImplementedError def _reset_metadata(self) -> None: self._metadata = {} def get_metadata(self) -> Dict[str, Any]: return dict(self._metadata) class LLMResponder(Responder): """Formats blueprint outputs and forwards them to an LLM client.""" def __init__( self, client: Optional[BaseLLM] = None, *, system_prompt: str = ( "You are an assistant for the Northwestern CS Kiosk. " "You may receive facts aggregated from one or more tool runs; synthesize all provided facts into a concise, grounded reply, and mention provenance when it helps the user." ), style_guidelines: Optional[str] = None, provider_id: Optional[str] = None, ) -> None: super().__init__() self.client = client or EchoProvider(ProviderConfig(api_key="local-echo", model="echo")) self.system_prompt = system_prompt.strip() self.style_guidelines = (style_guidelines or "").strip() if provider_id: self.provider_id = provider_id elif isinstance(self.client, EchoProvider): self.provider_id = "echo" else: self.provider_id = "unknown" def render(self, question: str, blueprint_name: str, result: BlueprintResult) -> str: self._reset_metadata() facts_payload = [ { "subject": fact.subject, "predicate": fact.predicate, "value": fact.value, "source": fact.source, } for fact in result.facts ] notes = result.notes or [] prompt_sections = [ f"QUESTION: {question}", f"CONTEXT: {_temporal_context()}", f"BLUEPRINT: {blueprint_name}", f"FACTS (JSON): {json.dumps(facts_payload, ensure_ascii=False, indent=2)}", ] if notes: prompt_sections.append(f"NOTES: {json.dumps(notes, ensure_ascii=False)}") if self.style_guidelines: prompt_sections.append(f"STYLE: {self.style_guidelines}") prompt_sections.append( "TASK: Compose a very brief, friendly reply grounded in the provided facts and notes. " "Keep responses to 1-2 sentences when possible. Write as if speaking aloud via text-to-speech: use natural conversational sentences, avoid bullet lists or markup, and do not include stage directions like *in a warm voice*. " "When many items exist (office hours, faculty list, etc.), mention only the first 2-3 and say how many more there are—do not list everything. " "For date or time questions (e.g. 'what time is it?', 'what day is tomorrow?'), use the CONTEXT section when facts are empty. " "If the facts are empty and the question is not about date/time, explain what information is missing instead of inventing details." ) user_prompt = "\n\n".join(prompt_sections) messages: list[ChatMessage] = [] if self.system_prompt: messages.append({"role": "system", "content": self.system_prompt}) messages.append({"role": "user", "content": user_prompt}) try: response: LLMResponse = self.client.generate(messages) model_name = getattr(getattr(self.client, "config", None), "model", "unknown") self._metadata = { "engine": "llm", "used_llm": True, "provider": self.provider_id, "model": model_name, "prompt_tokens": response.prompt_tokens, "response_tokens": response.response_tokens, "total_tokens": response.total_tokens, "fallback": False, } return response.text.strip() except Exception as exc: # pragma: no cover - network fallback path self._metadata = { "engine": "llm_error", "used_llm": False, "provider": self.provider_id, "prompt_tokens": 0, "response_tokens": 0, "error": str(exc), "fallback": True, } return "I'm having trouble reaching the language model right now. Please try again in a moment."