foresight / server /schedule.py
umangchaudhry's picture
Deploy 13220d7 from GitHub
acb3189 verified
Raw
History Blame Contribute Delete
21.3 kB
"""The student's own classes, as dated calendar occurrences.
This is what the Grand Calendar's **My classes** layer draws: the one layer that
isn't collected campus data but the student's own courses, expanded out of the
syllabi they have confirmed — class meetings, office hours, exams and due dates.
`payload()` is a pure function of (syllabi, calendar), with no clock and no
storage, so the expansion is testable at a fixed date against a fixed calendar.
Four rules it inherits from the rest of the syllabus pipeline:
- **`reviewed` is the gate.** Only records the student confirmed are expanded
(`schema.ship_ready`). An unreviewed parse is counted, never drawn — a wrong exam
date a student plans around is worse than no exam date.
- **Never invent a recurrence.** A weekly meeting is only placed inside a term whose
anchors were actually derived from the academic calendar. A term the university
hasn't published class dates for (`spring-2027`, today) yields *no* meetings and an
entry in `unplaced` instead, so the calendar can say why rather than drawing a
plausible, wrong grid.
- **Exams and due dates are absolute.** They carry their own ISO date, so they are
placed as-is even when the term anchors are missing or the date sits outside them.
The student confirmed that date; the review screen already questioned the odd ones.
- **Nothing is dropped in silence.** A meeting with no days, an exam with no date, a
whole term with no anchors — each becomes an `unplaced` row the calendar can
explain, because "my midterm isn't on here" with no reason given is the failure
mode that costs trust.
**Why this is server-side** when `app/calendar.js` otherwise reads `/kb/` straight
from the browser: the recurrence needs the term anchors, and those are derived in
`syllabus/terms.py` from title text (`"Undergraduate examinations and reading days,
Dec. 11-19"`) by rules a JavaScript copy would drift from within one term. One
implementation, in the language the tests are in.
Deliberately separate from `today.py`, which also reads syllabi: that answers "what
is on *today*, what is due *this week*" and carries a dashboard's suppression and
urgency rules; this answers "place every occurrence on a grid". Both gate on
`ship_ready`, and both format times as `HH:MM` for their surface to render.
"""
from __future__ import annotations
import logging
from datetime import date, timedelta
from .syllabus import schema as syllabus_schema
from .syllabus import terms as syllabus_terms
log = logging.getLogger("foresight.schedule")
# The kinds of thing a syllabus puts on a calendar. `class` and `office_hours`
# recur weekly and need term anchors; `exam` and `assignment` are single dated
# points and don't.
CLASS = "class"
OFFICE_HOURS = "office_hours"
EXAM = "exam"
ASSIGNMENT = "assignment"
RECURRING = (CLASS, OFFICE_HOURS)
# Why something couldn't be placed. Codes, not sentences: the wording lives with the
# rest of the calendar's copy in `app/calendar.js`.
NO_TERM_DATES = "no_term_dates" # the university hasn't published the term yet
NO_DAYS = "no_days" # a recurring row with nothing to recur on
NO_DATE = "no_date" # an exam or assignment with no usable date
# Safety bounds, not product limits. Eight syllabi with a couple of meeting rows each
# over a 15-week term lands near 500 occurrences, so hitting either of these means a
# bad anchor (a `classes_end` years out) rather than a busy student — and a runaway
# loop is how a bad date turns into a hung request.
MAX_OCCURRENCES = 3000
MAX_TERM_DAYS = 220
# --- helpers ----------------------------------------------------------------
def _day(value) -> date | None:
try:
return date.fromisoformat(str(value)[:10])
except (TypeError, ValueError):
return None
def _rows(value) -> list:
"""A section's rows, or nothing — stored records come from client-editable JSON."""
return value if isinstance(value, list) else []
def _text(value) -> str | None:
value = value.strip() if isinstance(value, str) else None
return value or None
def _where(row: dict) -> str | None:
"""The most specific location a syllabus row actually carries.
Same rule as `today.py`: the verbatim string a student can read off their
syllabus beats the room we parsed out of it.
"""
return _text(row.get("location_raw")) or _text(row.get("room"))
def _labeled(code: str | None, name: str | None) -> str:
""""BSCI 1510 — Midterm 1", or whichever half exists."""
if code and name:
return f"{code}{name}"
return code or name or ""
def _meeting_span(bounds: dict) -> tuple[date | None, date | None]:
"""The first and last day a weekly meeting may be placed on, or (None, None).
Classes stop at the last day of classes: the exam period is not class time, and a
final has its own date on the syllabus. When the calendar published no last day of
classes, the day before exams begin is the next best truth and the end of the
semester the last resort — but a missing *start* is never guessed around, because
there is nothing to count a week from.
"""
begin = _day(bounds.get("classes_begin"))
end = _day(bounds.get("classes_end"))
if end is None:
exams = _day(bounds.get("exams_begin"))
end = exams - timedelta(days=1) if exams else _day(bounds.get("term_end"))
if begin is None or end is None or end < begin:
return None, None
if (end - begin).days > MAX_TERM_DAYS:
log.warning("schedule: %s spans %d days (%s → %s) — refusing to expand "
"weekly meetings against anchors that far apart",
bounds.get("term"), (end - begin).days, begin, end)
return None, None
return begin, end
def _break_ranges(bounds: dict) -> list[tuple[date, date]]:
"""Fall break, Thanksgiving, spring break — days classes don't meet."""
out = []
for brk in bounds.get("breaks") or []:
start, end = _day(brk.get("start")), _day(brk.get("end"))
if start and end and end >= start:
out.append((start, end))
return out
def _meeting_days(days, span: tuple[date, date],
breaks: list[tuple[date, date]]) -> list[date]:
"""Every day in the term this weekly slot actually meets."""
wanted = {syllabus_schema.DAYS.index(d) for d in _rows(days)
if d in syllabus_schema.DAYS}
if not wanted:
return []
out, day = [], span[0]
while day <= span[1]:
if day.weekday() in wanted and not any(s <= day <= e for s, e in breaks):
out.append(day)
day += timedelta(days=1)
return out
# --- occurrences ------------------------------------------------------------
def _course(record: dict) -> dict:
"""The fields every occurrence of a course carries, so a popover can name it."""
return {
"syllabus_id": record.get("id"),
"course_code": record.get("course_code"),
"course_title": _text(record.get("course_title")),
"term": record.get("term"),
}
def _occurrence(kind: str, series: str, day: date, record: dict, *, title: str,
start=None, end=None, location=None, building_slug=None,
room=None, weight=None, instructor=None, days=None,
through: date | None = None) -> dict:
"""One dated thing on the grid.
`date`/`start`/`end` stay split — a calendar day and a 24-hour `HH:MM` — rather
than joined into a timestamp, because `app/calendar.js` reads its dates and times
straight out of strings to keep a 9:10 class from drifting into the browser's
timezone. Handing it an offset it would have to strip is how that leaks.
"""
return {
"uid": f"{series}:{day.isoformat()}",
"series": series,
"kind": kind,
"title": title,
"date": day.isoformat(),
"start": start,
"end": end,
"location": location,
"building_slug": building_slug,
"room": room,
"weight": weight,
"instructor": instructor,
# Only on the recurring kinds, so a popover can say "meets Tue, Thu, through
# Dec 9" instead of describing one occurrence as if it were the whole course.
"days": list(days) if days else None,
"through": through.isoformat() if through else None,
**_course(record),
}
def _unplaced(record: dict, reason: str, what: str, label: str | None = None) -> dict:
return {
"course_code": record.get("course_code"),
"term": record.get("term"),
"term_label": label,
"reason": reason,
"what": what,
}
def _recurring(record: dict, span, breaks, term_label) -> tuple[list, list]:
"""Class meetings and office hours, expanded across the term."""
items, unplaced = [], []
instructor = record.get("instructor") if isinstance(record.get("instructor"), dict) else {}
who = _text(instructor.get("name"))
code = record.get("course_code")
# The chip says the course code — it has to fit in a month-view cell, and the
# title, instructor and room are one click away in the popover.
heading = code or _text(record.get("course_title")) or "Class"
slots = [(CLASS, i, row, heading)
for i, row in enumerate(_rows(record.get("meetings")))
if isinstance(row, dict)]
slots += [(OFFICE_HOURS, i, row,
f"{code} office hours" if code else "Office hours")
for i, row in enumerate(_rows(instructor.get("office_hours_slots")))
if isinstance(row, dict)]
for kind, i, row, title in slots:
what = "weekly meetings" if kind == CLASS else "office hours"
if not _rows(row.get("days")):
# Nothing to recur on. A slot the parser read a time but no days from is
# exactly what the review screen's `missing_days` flag is about, so point
# the student back at it rather than dropping the class off the calendar.
unplaced.append(_unplaced(record, NO_DAYS, what, term_label))
continue
if span[0] is None:
unplaced.append(_unplaced(record, NO_TERM_DATES, what, term_label))
continue
# A slot with days but no time is placed as an all-day chip: the student
# confirmed the days, and "Mon Wed Fri, time unknown" is true and useful.
# Office hours fall back to the instructor's office, as `schema.py` does.
location = _where(row) or (
_text(instructor.get("office")) if kind == OFFICE_HOURS else None)
building = row.get("building_slug") or (
instructor.get("office_building_slug") if kind == OFFICE_HOURS else None)
series = f"{kind}:{record.get('id')}:{i}"
for day in _meeting_days(row.get("days"), span, breaks):
items.append(_occurrence(
kind, series, day, record, title=title,
start=_text(row.get("start")), end=_text(row.get("end")),
location=location, building_slug=building,
room=_text(row.get("room")), instructor=who,
days=row.get("days"), through=span[1]))
return items, unplaced
def _coursework(record: dict, term_label) -> tuple[list, list]:
"""Exams and assignment due dates — dated points, placed as written."""
items, unplaced = [], []
code = record.get("course_code")
sections = (
(EXAM, "exams", "date", "Exam"),
(ASSIGNMENT, "assignments", "due", "Assignment"),
)
for kind, section, date_field, fallback in sections:
for i, row in enumerate(_rows(record.get(section))):
if not isinstance(row, dict):
continue
name = _text(row.get("title")) or fallback
day = _day(row.get(date_field))
if day is None:
unplaced.append(_unplaced(record, NO_DATE, name, term_label))
continue
items.append(_occurrence(
kind, f"{kind}:{record.get('id')}:{i}", day, record,
title=_labeled(code, name),
start=_text(row.get("start")), end=_text(row.get("end")),
location=_where(row), building_slug=row.get("building_slug"),
room=_text(row.get("room")), weight=_text(row.get("weight"))))
return items, unplaced
# --- where the classes are -------------------------------------------------
def _place_entry(kind: str, record: dict, row: dict, instructor: dict, room) -> dict:
"""One thing that happens in one building, for the campus map's pin card."""
return {
"kind": kind,
"course_code": record.get("course_code"),
"course_title": _text(record.get("course_title")),
"term": record.get("term"),
"days": [d for d in _rows(row.get("days")) if d in syllabus_schema.DAYS],
"start": _text(row.get("start")),
"end": _text(row.get("end")),
"room": room,
"instructor": _text(instructor.get("name")) if kind == OFFICE_HOURS else None,
}
def _places(ready: list[dict]) -> tuple[list[dict], list[dict]]:
"""Where the student's classes and office hours are, grouped by building.
The campus map's My classes layer: `places` are pins it can show, `unmapped` are
the locations it can't and has to say so about.
Three deliberate choices:
- **Derived from the records, not from `items`.** A room is a fact the syllabus
states; it doesn't depend on whether we could place the term's dates. A course in
a term the university hasn't published still has somewhere it meets.
- **Classes and office hours only.** An exam room is a place too, but this layer
answers "where do I go, week to week" — an exam hall is one date the calendar
already carries, and pinning it would put a room a student visits once beside the
three they visit weekly.
- **A location that didn't resolve is reported, never guessed.** `building_slug` is
null whenever the gazetteer wasn't confident (`schema.resolve_location`), and a
wrong pin is worse than a missing one — so the text goes to `unmapped` for the map
to show as words instead of a pin.
"""
by_slug: dict[str, dict] = {}
unmapped: list[dict] = []
for record in ready:
instructor = record.get("instructor") if isinstance(record.get("instructor"), dict) else {}
office = _text(instructor.get("office"))
rows = [(CLASS, row) for row in _rows(record.get("meetings"))
if isinstance(row, dict)]
rows += [(OFFICE_HOURS, row) for row in _rows(instructor.get("office_hours_slots"))
if isinstance(row, dict)]
for kind, row in rows:
raw, slug, room = _where(row), row.get("building_slug"), _text(row.get("room"))
if kind == OFFICE_HOURS and not raw:
# A slot with no room of its own happens in the office, exactly as the
# calendar places it.
raw, slug, room = (office, instructor.get("office_building_slug"),
_text(instructor.get("office_room")))
if not raw and not slug:
continue # a row with no location says nothing here
entry = _place_entry(kind, record, row, instructor, room)
if not slug:
unmapped.append({"course_code": record.get("course_code"),
"kind": kind, "location": raw,
"term": record.get("term")})
continue
place = by_slug.setdefault(slug, {"building_slug": slug, "location": raw,
"entries": []})
place["entries"].append(entry)
for place in by_slug.values():
place["entries"].sort(key=lambda e: (e["kind"] != CLASS, e["course_code"] or "",
e["start"] or ""))
return ([by_slug[s] for s in sorted(by_slug)], unmapped)
def _term_summary(bounds: dict, span) -> dict:
"""What the calendar may say about a term whose courses it is drawing."""
return {
"term": bounds.get("term"),
"label": bounds.get("label"),
"classes_begin": bounds.get("classes_begin"),
"classes_end": bounds.get("classes_end"),
"exams_begin": bounds.get("exams_begin"),
"exams_end": bounds.get("exams_end"),
"breaks": bounds.get("breaks") or [],
# Whether weekly meetings could be placed at all — the honest version of
# "why is my Spring 2027 class not on here".
"placeable": span[0] is not None,
}
def _course_summary(record: dict, span) -> dict:
"""One row for the calendar's course list, so the layer has a legend.
Times stay `HH:MM`; the frontend formats them with the same `fmtTime` it uses on
every other chip, so one calendar never shows two clock styles.
"""
instructor = record.get("instructor") if isinstance(record.get("instructor"), dict) else {}
meetings = [{"days": [d for d in _rows(row.get("days"))
if d in syllabus_schema.DAYS],
"start": _text(row.get("start")),
"end": _text(row.get("end")),
"location": _where(row)}
for row in _rows(record.get("meetings")) if isinstance(row, dict)]
return {
**_course(record),
"instructor": _text(instructor.get("name")),
"meetings": meetings,
"exams": len([r for r in _rows(record.get("exams")) if isinstance(r, dict)]),
"assignments": len([r for r in _rows(record.get("assignments"))
if isinstance(r, dict)]),
"placeable": span[0] is not None,
}
def payload(syllabi: list | None, calendar: list | None = None) -> dict:
"""Everything the My classes layer draws, from the student's confirmed syllabi.
Returns:
{"items": [occurrence, ...], # every dated thing, sorted
"courses": [{...}], # one row per confirmed course
"terms": [{...}], # the anchors each course was placed against
"places": [{...}], # buildings their classes are in (the map)
"unplaced": [{...}], # what couldn't be placed, and why
"unmapped": [{...}], # locations that resolved to no building
"unreviewed": <int>} # uploaded, saved, awaiting confirmation
Two surfaces read this: the calendar's grid takes `items`, the campus map's My
classes layer takes `places`. One endpoint rather than two because it is one
question — "where does this student have to be, and when" — and a second route
would re-read the same file and re-derive the same anchors.
Everything is returned for every term the student has a syllabus for, not a date
window: a term is a few hundred occurrences, and the calendar navigates freely in
both directions, so a window would mean a request per swipe of the month arrow.
"""
records = syllabi if isinstance(syllabi, list) else []
ready = syllabus_schema.ship_ready(records)
cal = calendar if calendar is not None else syllabus_terms.load_calendar()
anchors: dict[str, dict] = {}
spans: dict[str, tuple] = {}
def bounds_for(term: str) -> tuple[dict, tuple]:
if term not in anchors:
anchors[term] = syllabus_terms.bounds(term, cal)
spans[term] = _meeting_span(anchors[term])
return anchors[term], spans[term]
items: list[dict] = []
courses: list[dict] = []
unplaced: list[dict] = []
for record in ready:
bounds, span = bounds_for(record.get("term") or "")
courses.append(_course_summary(record, span))
rows, missed = _recurring(record, span, _break_ranges(bounds), bounds.get("label"))
items += rows
unplaced += missed
rows, missed = _coursework(record, bounds.get("label"))
items += rows
unplaced += missed
if len(items) > MAX_OCCURRENCES:
# Truncating in silence would read as "your term ends in October", so say it
# here and let the caller's log carry it.
log.warning("schedule: %d occurrences from %d syllabi — truncated to %d",
len(items), len(ready), MAX_OCCURRENCES)
items = items[:MAX_OCCURRENCES]
items.sort(key=lambda i: (i["date"], i["start"] or "", i["title"]))
places, unmapped = _places(ready)
return {
"items": items,
"courses": courses,
"terms": [_term_summary(anchors[t], spans[t]) for t in sorted(anchors) if t],
"places": places,
"unmapped": unmapped,
# Same thing said twice is noise: one line per (course, reason, what).
"unplaced": list({tuple(sorted(u.items())): u for u in unplaced}.values()),
# Saved but not confirmed. The calendar shows nothing from these — and a
# student who uploaded a syllabus and never finished the review deserves to be
# told that, rather than wondering where their classes went.
"unreviewed": len(records) - len(ready),
}