"""Blueprint for office hours: list slots by course, course+day, or person.""" from __future__ import annotations from typing import Any, Dict, List, Tuple import difflib from .base import AnalysisContext, Blueprint, BlueprintResult, Fact from ..data.utils import canonicalize_name from ..mcp.context_resolver import expand_day_abbreviation def _day_matches(row: Dict[str, Any], day: str) -> bool: """Check if row's Day matches the given day (handles W vs wednesday, etc.).""" val = (row.get("Day") or "").strip().lower() if not day or not val: return True return (day in val) or (val in day) def _resolve_day(day_raw: str) -> str: """Resolve day abbreviation or pass through (today/tomorrow resolved in executor).""" expanded = expand_day_abbreviation(day_raw) return (expanded or day_raw).strip().lower() def _find_course_rows(context: AnalysisContext, course: str) -> Tuple[List[Dict[str, Any]], List[str]]: """Return all office-hours rows matching the course. Returns (rows, notes).""" notes: List[str] = [] dataset = context.catalog.try_get("office_hours") if not dataset: return [], ["Office hours dataset not found."] canonical = canonicalize_name(course) matches: List[Dict[str, Any]] = [] seen: set[int] = set() for row in dataset.records: val = row.get("Course Name") if not val: continue val_canon = canonicalize_name(val) if canonical == val_canon or canonical in val_canon: if id(row) not in seen: matches.append(row) seen.add(id(row)) if not matches: names = [r.get("Course Name") for r in dataset.records if r.get("Course Name")] names = list(dict.fromkeys(names)) closest = difflib.get_close_matches(course, names, n=1, cutoff=0.6) if closest: for row in dataset.records: if row.get("Course Name") == closest[0] and id(row) not in seen: matches.append(row) seen.add(id(row)) notes.append(f"Showing results for '{closest[0]}' (closest match).") return matches, notes def _find_person_rows(context: AnalysisContext, person: str) -> Tuple[List[Dict[str, Any]], List[str]]: """Return all rows where the person appears in Course Name or Description of Use.""" notes: List[str] = [] dataset = context.catalog.try_get("office_hours") if not dataset: return [], ["Office hours dataset not found."] canonical = canonicalize_name(person) tokens = [canonicalize_name(t) for t in person.strip().split() if len(t) > 1] cols = ["Course Name", "Description of Use"] matches: List[Dict[str, Any]] = [] seen: set[int] = set() for row in dataset.records: if id(row) in seen: continue for col in cols: val = row.get(col) if not val or not isinstance(val, str): continue val_canon = canonicalize_name(val) if canonical in val_canon: matches.append(row) seen.add(id(row)) break for tok in tokens: if tok in val_canon: matches.append(row) seen.add(id(row)) break else: continue break if not matches and tokens: first = tokens[0] for row in dataset.records: if id(row) in seen: continue for col in cols: val = row.get(col) or "" if first in canonicalize_name(str(val)): matches.append(row) seen.add(id(row)) notes.append(f"Showing office hours matching '{person.strip().split()[0]}'.") break return matches, notes def _rows_to_facts(rows: List[Dict[str, Any]], subject: str, source: str | None) -> List[Fact]: """Convert office-hours rows to Fact objects.""" field_map = [ ("Course Name", "course_name"), ("Description of Use", "description"), ("Day", "day"), ("Time", "time"), ("Room", "room"), ("Capacity", "capacity"), ("Availability", "availability"), ] facts: List[Fact] = [] for row in rows: value: Dict[str, Any] = {} for col, key in field_map: if row.get(col): value[key] = row[col] facts.append( Fact( subject=subject, predicate="office_hours", value=value, source=source, confidence=0.9, ) ) return facts class OfficeHoursBlueprint(Blueprint): """ Office hours lookup: by course, course+day, or person. 1. course only: all office hours for that course (time + location) 2. course + day: slots for that course on that day 3. person only: all slots where the person holds office hours (course names + times) """ name = "office_hours" def run(self, context: AnalysisContext, **kwargs: Any) -> BlueprintResult: params = dict(kwargs) course = (params.get("course") or params.get("class_name") or "").strip() day_raw = (params.get("day") or "").strip() person = (params.get("person") or params.get("name") or "").strip() office = context.catalog.try_get("office_hours") source = office.origin if office else None # 3. Person only: "When are Prof Hammond's office hours?" if person and not course: rows, notes = _find_person_rows(context, person) if not rows: return BlueprintResult( self.name, params, facts=[], notes=[f"No office hours found for '{person}'."] + notes, ) if day_raw: day = _resolve_day(day_raw) rows = [r for r in rows if _day_matches(r, day)] if not rows: return BlueprintResult( self.name, params, facts=[], notes=[f"No office hours found for {person} on {day_raw}."], ) facts = _rows_to_facts(rows, person, source) return BlueprintResult(self.name, params, facts=facts, notes=notes) # 1 & 2. Course (with optional day) if course: rows, notes = _find_course_rows(context, course) if not rows: return BlueprintResult( self.name, params, facts=[], notes=[f"Course '{course}' not found."] + notes, ) if day_raw: day = _resolve_day(day_raw) rows = [r for r in rows if _day_matches(r, day)] if not rows: return BlueprintResult( self.name, params, facts=[], notes=[f"No office hours for '{course}' on {day_raw}."], ) facts = _rows_to_facts(rows, course, source) return BlueprintResult(self.name, params, facts=facts, notes=notes) return BlueprintResult( self.name, params, facts=[], notes=["Specify a course (e.g. CS 336) or person (e.g. Prof Hammond) to look up office hours."], )