kiosk_vanilla / backend /tools /office_hours.py
Monish Vijay Kumar
Deploy Northwestern CS Kiosk Docker Space with checkpoint and inference runtime
7056be6
Raw
History Blame
7.63 kB
"""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."],
)