Spaces:
Sleeping
Sleeping
| """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 | |
| 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 | |