NU-KIOSK-API / backend /mcp /context_resolver.py
Monish BV
update context and anthropic model
53def98
"""Context resolution layer: enriches user input before planning.
Handles day abbreviations (e.g. 'F' -> 'friday') and topic-switch detection.
When the user asks a standalone new question, we strip topic/subject so the
planner routes on the new intent instead of carrying over old context.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, replace
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo
from .actions import PlannerContext
CHICAGO_TZ = ZoneInfo("America/Chicago")
DAY_ABBREVIATIONS: Dict[str, str] = {
"m": "monday",
"mon": "monday",
"monday": "monday",
"t": "tuesday",
"tu": "tuesday",
"tue": "tuesday",
"tues": "tuesday",
"tuesday": "tuesday",
"w": "wednesday",
"wed": "wednesday",
"wednesday": "wednesday",
"r": "thursday",
"th": "thursday",
"thu": "thursday",
"thur": "thursday",
"thurs": "thursday",
"thursday": "thursday",
"f": "friday",
"fri": "friday",
"friday": "friday",
"sa": "saturday",
"sat": "saturday",
"saturday": "saturday",
"su": "sunday",
"sun": "sunday",
"sunday": "sunday",
}
AFFIRMATIONS = frozenset({
"yes", "yes please", "yes, please", "yeah", "yep", "sure", "ok", "okay",
"please", "that would be great", "sounds good", "go ahead", "do it",
"please do", "absolutely", "certainly",
})
def is_affirmation(text: str) -> bool:
if not text or not isinstance(text, str):
return False
normalized = " ".join(text.strip().lower().split())
return normalized in AFFIRMATIONS
@dataclass
class ResolvedInput:
"""Output of context resolution: possibly rewritten question + optional day."""
question: str
resolved_day: Optional[str] = None
def expand_day_abbreviation(day: str) -> Optional[str]:
"""Map day abbreviation to full day name. Returns None if not a day token."""
if not day or not isinstance(day, str):
return None
key = day.strip().lower()
return DAY_ABBREVIATIONS.get(key)
def resolve_relative_day(day: str) -> Optional[str]:
"""Resolve 'today' or 'tomorrow' to the actual weekday name."""
if not day or not isinstance(day, str):
return None
key = day.strip().lower()
if key == "today":
return datetime.now(CHICAGO_TZ).strftime("%A").lower()
if key == "tomorrow":
tomorrow = (datetime.now(CHICAGO_TZ).date() + timedelta(days=1))
return tomorrow.strftime("%A").lower()
return None
def resolve_day_in_question(question: str) -> tuple[str, Optional[str]]:
"""
If the question contains a standalone day token (e.g. 'F', 'Friday', 'Mon', 'today', 'tomorrow'),
expand it and return (rewritten_question, full_day_name).
Otherwise return (question, None).
"""
words = question.strip().lower().split()
for i, w in enumerate(words):
expanded = DAY_ABBREVIATIONS.get(w)
if expanded:
words[i] = expanded
return (" ".join(words), expanded)
resolved = resolve_relative_day(w)
if resolved:
words[i] = resolved
return (" ".join(words), resolved)
return (question, None)
def resolve(question: str, context: Any = None) -> ResolvedInput:
"""
Run context resolution on the user question.
Returns ResolvedInput with possibly rewritten question and flags.
"""
q = (question or "").strip()
if not q:
return ResolvedInput(question=q)
resolved_day = None
new_q, resolved_day = resolve_day_in_question(q)
return ResolvedInput(question=new_q, resolved_day=resolved_day)
_COURSE_PATTERN = re.compile(r"\b(?:cs|CS)\s*\d{3}[a-zA-Z]?(?:\s*[/-]\s*\d{3})?\b", re.IGNORECASE)
_OFFICE_HOURS_PATTERN = re.compile(
r"\b(?:office\s*hours?|office\s*hrs?|oh)\b",
re.IGNORECASE,
)
_PERSON_QUESTION_PATTERN = re.compile(
r"\b(?:what\s+does?|who\s+is|tell\s+me\s+about|find|where\s+is)\s+",
re.IGNORECASE,
)
_PRONOUN_FOLLOWUP = re.compile(
r"\b(his|her|their|him|he|she|them|any\s+other|more\s+about|what\s+about)\b",
re.IGNORECASE,
)
def strip_context_on_topic_switch(
question: str, context: PlannerContext
) -> PlannerContext:
"""If the question is a standalone new question, return stripped context; else return original."""
if not context or not getattr(context, "topic", None):
return context
q = (question or "").strip().lower()
if len(q) < 4:
return context
topic = getattr(context, "topic", None)
if _PRONOUN_FOLLOWUP.search(q):
return context
has_office_hours = bool(_OFFICE_HOURS_PATTERN.search(q))
has_course = bool(_COURSE_PATTERN.search(q))
has_day = any(
d in q
for d in ("today", "tomorrow", "monday", "tuesday", "wednesday", "thursday", "friday")
)
if has_office_hours and (has_course or has_day) and topic in ("professor", "student"):
return replace(
context,
topic=None,
subject=None,
last_class=None,
last_subject=None,
)
if _PERSON_QUESTION_PATTERN.search(q) and topic == "office_hours":
return replace(
context,
topic=None,
subject=None,
last_class=None,
last_subject=None,
)
return context