"""What the Today dashboard shows — derived, never authored. Today is the landing screen, so it is the first thing a student believes about Foresight. Everything here therefore comes from two places only: the student's own reviewed syllabi, and the knowledge base. Nothing on this screen is written by a model and nothing is a placeholder. Three sections: * **classes** — today's meetings and office hours, from syllabi the student has confirmed. Suppressed outside the class period, because "Today's classes" with an empty list during fall break reads as broken rather than as accurate. * **week** — exams and assignments in the next seven days. Deliberately *only* coursework: campus deadlines like add/drop live in the feed, and listing them in both places was the duplication that made the old mock feel padded. * **feed** — one ranked list mixing events from groups the student has joined, academic key dates, and events matching their stated interests. This replaces both the three hardcoded "because you're into…" cards and the "nudge or two" section: two of those three nudges were feed items in a different card style, and the third ("you haven't been to office hours yet") needed attendance data that does not exist and could only ever have been invented. `payload()` is a pure function of (profile, syllabi, index, today) so the ranking is testable without storage, a session, or a clock. """ from __future__ import annotations import re from datetime import date, datetime, timedelta from . import key_dates from .kb import queries, topics from .kb.index import tokenize from .syllabus import schema as syllabus_schema from .syllabus import terms as syllabus_terms # --- feed weights ----------------------------------------------------------- # Deliberately small and readable rather than learned: with one cohort of testers # there is no signal to fit, and a student asking "why am I being shown this" # deserves an answer we can actually give. Every term maps to a `why` string. FROM_YOUR_ORG = 3.0 # an explicit membership the student typed in URGENT_KEY_DATE = 2.0 # an academic deadline inside a week # A tag hit: the student's interests and the event's subject agree on a word from the # same fixed vocabulary. Ranked above any lexical match because it *is* an agreement # rather than a coincidence of wording — see `server/kb/topics.py`. TOPIC_MATCH = 1.6 KEY_DATE = 0.6 # one further out — worth knowing, not worth the top slot # A match on the student's own words is the point of the section, so a weak-but-real # one still outranks a distant deadline. Split into a flat part and a scaled part # because BM25's tail is long: normalizing on the best match alone pushed the # second-best interest hit down to 0.2 and buried every one of them under the # fifteen add/drop variants the registrar publishes in a single week. INTEREST_BASE = 1.2 INTEREST_SCALED = 0.8 DAY_DECAY = 0.02 # mild preference for sooner, not a hard sort URGENT_DAYS = 7 # a key date this close counts as urgent WEEK_AHEAD = 7 # how far "this week" looks WEEK_URGENT_DAYS = 2 # coursework this close gets the "soon" badge FEED_LIMIT = 20 FEED_KEY_DATE_LIMIT = 5 # they inform the feed; they must not become the feed MAX_SAME_KIND_RUN = 2 # no more than this many key dates back to back FEED_WINDOW_DAYS = queries.EVENT_WINDOW_DAYS # --- key dates that aren't for everyone ------------------------------------- # The cohort filter lives in server/key_dates.py, shared with /api/key-dates # (the My VU page's full term list). Kept under their old private names so # nothing in this module — or in a test monkeypatching it — moves. _COHORT_KEY_DATES = key_dates.COHORT_KEY_DATES _ELC = key_dates.ELC _INTERNAL_KEY_DATES = key_dates.INTERNAL_KEY_DATES # An empty-query date scan has to be wide enough to see every event in the window, # since org membership and interests are filtered in Python afterwards. _SCAN_LIMIT = 500 _INTEREST_LIMIT = 12 _MAX_PHRASES = 8 # majors plus interest fragments _KEY_DATE_LIMIT = 40 _SPLIT = re.compile(r"[,;/]|\band\b|\n") # --- helpers ---------------------------------------------------------------- def _iso_day(value: str | None) -> str: return (value or "")[:10] def _parse_day(value: str | None) -> date | None: day = _iso_day(value) try: return date.fromisoformat(day) except ValueError: return None def _time(hour: int, minute: int) -> str: """"3 PM" / "9:10 AM". Deliberately the same format as `fmtTime` in `app/calendar.js`: both screens show the same campus events, and a student comparing them should not have to translate between two clock styles. Edit one, edit the other. """ display = hour % 12 or 12 meridiem = "AM" if hour < 12 else "PM" return f"{display} {meridiem}" if minute == 0 else f"{display}:{minute:02d} {meridiem}" def _clock(iso: str | None) -> str | None: """The time in an ISO timestamp, or None when it carries none. LiveWhale encodes all-day as midnight (`2026-07-28T00:00:00-05:00`) and sets a flag the normalizer doesn't carry, so midnight is the signal available here. No real campus event starts at 12:00am, and treating one as all-day would only hide a time nobody published. The offset is never parsed — the string is already campus-local, which is the same rule `app/calendar.js` follows to stop a 5pm event becoming 5pm elsewhere. """ if not iso or len(iso) < 16: return None try: hour, minute = int(iso[11:13]), int(iso[14:16]) except ValueError: return None return None if hour == 0 and minute == 0 else _time(hour, minute) def _hhmm(value: str | None) -> str | None: """"09:10", the 24-hour form syllabi store, to "9:10 AM".""" if not value or ":" not in value: return None try: hour, minute = (int(p) for p in value.split(":", 1)[:2]) except ValueError: return None return _time(hour, minute) def _when(iso: str | None, today: date) -> str: """A short human date: "Today · 3:00p", "Thu · 7:30p", "Sep 21".""" day = _parse_day(iso) time = _clock(iso) if day is None: return time or "" delta = (day - today).days if delta == 0: label = "Today" elif delta == 1: label = "Tomorrow" elif 0 < delta < WEEK_AHEAD: label = day.strftime("%a") else: label = _short_date(day) return f"{label} · {time}" if time else label def _short_date(day: date) -> str: """"Sep 4" — no zero padding, without assuming a platform's strftime.""" return f"{day.strftime('%b')} {day.day}" def _long_date(day: date) -> str: """"Wednesday, August 26".""" return f"{day.strftime('%A, %B')} {day.day}" _norm_name = key_dates.norm_name def _covers(doc, tokens: list[str]) -> bool: """Whether a match hits the whole phrase rather than one common word in it. BM25 will happily return a polymer-chemistry conference for "brain science" on the strength of "science" alone — 12% of events contain it. That is the kind of wrong answer that costs a first-week student's trust, so a multi-word phrase has to match all of its content words. """ if len(tokens) < 2: return True have = set(tokenize(f"{doc.title} {doc.text}")) return all(t in have for t in tokens) def _rarest(index, tokens: list[str]) -> str | None: """The most discriminating word in a phrase. The fallback when a whole phrase matches nothing. Retrying on the *rarest* word rather than letting BM25 weigh them all is what makes "public health" find the public-health congress instead of all 51 events that mention health, and what keeps "brain science" returning nothing rather than polymer chemistry — "brain" appears in no event, so there is correctly no answer. Frequency alone cannot rescue every case: "science" is in 12.2% of events and "music" in 15.0%, so no threshold both keeps "music production" → Wind Symphony and drops "brain science" → polymer science. Sparse and right beats full and wrong on the landing screen. The real fixes are embeddings, or matching interests against organization descriptions — far richer than event titles — and surfacing those organizations' events. """ if not tokens: return None return min(tokens, key=lambda t: index.doc_frequency(t, kinds=queries.EVENT_KINDS)) def _where(row: dict) -> str | None: """The most specific location a syllabus row actually carries.""" room = (row.get("room") or "").strip() raw = (row.get("location_raw") or "").strip() return raw or room or None def _orgs(profile: dict) -> list: """The stored `orgs` list, or empty. `profile.json` has no schema and `PUT /api/profile` accepts any JSON object, so a client bug can put a bare string here — and iterating a string yields characters, which would turn one typo into a dozen phantom groups. """ value = profile.get("orgs") return value if isinstance(value, list) else [] # --- classes and coursework ------------------------------------------------- def _term_state(today: date) -> dict: """Which term we're in and whether classes are actually meeting.""" term = syllabus_terms.current_or_next() bounds = syllabus_terms.bounds(term or "") if term else {} state = syllabus_terms.classify_date(bounds, today.isoformat()) if bounds else "unknown" note = None if state == "before_term" and bounds.get("classes_begin"): begins = _parse_day(bounds["classes_begin"]) if begins: label = bounds.get("label") or "Classes" note = f"{label} classes begin {_long_date(begins)}." elif state == "in_break": for brk in bounds.get("breaks") or []: if (brk.get("start") or "") <= today.isoformat() <= (brk.get("end") or ""): note = f"No classes — {brk.get('title') or brk.get('name')}." break note = note or "No classes this week." elif state == "in_exams": note = "Exam period — check each course for its exam time." elif state == "after_term": note = f"{bounds.get('label') or 'The term'} is over." return {"term": term, "status": state, "note": note} def _classes(syllabi: list, today: date, term_state: dict) -> dict: """Today's meetings and office hours, from confirmed syllabi only.""" ready = syllabus_schema.ship_ready(syllabi) weekday = syllabus_schema.DAYS[today.weekday()] items: list[dict] = [] # Outside the class period there is nothing to list, and an empty card reads as # a bug. The note explains it instead. meeting_ok = term_state["status"] in ("in_classes", "unknown") for record in ready: code = record.get("course_code") or "" title = record.get("course_title") or "" if meeting_ok: for meeting in record.get("meetings") or []: if weekday not in (meeting.get("days") or []) or not meeting.get("start"): continue items.append({ "kind": "class", "start": meeting.get("start"), "time": _hhmm(meeting.get("start")), "end": _hhmm(meeting.get("end")), "course_code": code, "label": title or code, "where": _where(meeting), "building_slug": meeting.get("building_slug"), }) instructor = record.get("instructor") or {} for slot in instructor.get("office_hours_slots") or []: if weekday not in (slot.get("days") or []) or not slot.get("start"): continue who = (instructor.get("name") or "").strip() items.append({ "kind": "office_hours", "start": slot.get("start"), "time": _hhmm(slot.get("start")), "end": _hhmm(slot.get("end")), "course_code": code, "label": f"Office hours — {who}" if who else "Office hours", "where": _where(slot) or instructor.get("office"), "building_slug": slot.get("building_slug") or instructor.get("office_building_slug"), }) items.sort(key=lambda i: i["start"] or "") return {"status": term_state["status"], "term": term_state["term"], "note": term_state["note"], "items": items} def _week(syllabi: list, today: date) -> list[dict]: """Exams and assignments due in the next seven days. Coursework only. Campus deadlines are in the feed — the mock listed add/drop here *and* in the recommender strip, which is what made three cards feel like filler. """ horizon = today + timedelta(days=WEEK_AHEAD) out: list[dict] = [] for record in syllabus_schema.ship_ready(syllabi): code = record.get("course_code") or "" for exam in record.get("exams") or []: out.append(("exam", exam.get("date"), exam.get("title"), exam.get("weight"), code)) for work in record.get("assignments") or []: out.append(("assignment", work.get("due"), work.get("title"), work.get("weight"), code)) items: list[dict] = [] for kind, when, title, weight, code in out: day = _parse_day(when) if day is None or not (today <= day <= horizon): continue name = (title or "").strip() or ("Exam" if kind == "exam" else "Due") items.append({ "kind": kind, "date": day.isoformat(), "day": day.strftime("%a"), "course_code": code, "label": f"{code} — {name}" if code else name, "weight": weight, "urgent": (day - today).days <= WEEK_URGENT_DAYS, }) items.sort(key=lambda i: i["date"]) return items # --- the feed --------------------------------------------------------------- def _joined_orgs(profile: dict, index) -> dict[str, str]: """{normalized org name: display name} for the groups the student named. Stored as `[{id, name}]`. The id is what makes this survive a rename: the join onto an event's `hosts` is by name, so a group that changed its name between collector runs would silently stop matching if the stored string were all we had. Looking the id up in the index recovers the current name. """ out: dict[str, str] = {} for entry in _orgs(profile): if isinstance(entry, str): # tolerate a bare-name list entry = {"name": entry} if not isinstance(entry, dict): continue name = (entry.get("name") or "").strip() org_id = str(entry.get("id") or "").strip() if org_id and index is not None: doc = index.get(f"anchorlink:org:{org_id}") if doc is not None and doc.title: name = doc.title if name: out[_norm_name(name)] = name return out # Moved to server/key_dates.py (shared with /api/key-dates); aliased so this # module's call sites and any test monkeypatching keep working unchanged. _schools = key_dates.student_schools _key_date_for_student = key_dates.relevant_to_student def _phrases(profile: dict) -> list[tuple[str, str]]: """[(phrase, why)] to search the events feed with. Two sources, per the 2026-07-30 check-in — Today should surface "campus events and major deadlines relevant to the **major** and interests given at onboarding": * `interests` is prose ("music, public health, coding"), so it is split and each fragment is run as its own query with the best score per document kept. * `majors` is already structured, picked from the catalog, so each is used whole. A curated interest-to-category map would rank better than free-text BM25, but AnchorLink's taxonomy is 75 flat values mixing real topics with administrative buckets (`POM Level 1 Compliance`, two dozen `Advised by …`), so it needs a hand-built table rather than the raw vocabulary. """ out: list[tuple[str, str]] = [] seen: set[str] = set() def push(phrase: str, why: str) -> None: phrase = " ".join(phrase.split()).strip(" .!-") key = phrase.casefold() if len(phrase) < 3 or key in seen: return seen.add(key) out.append((phrase, why)) for major in profile.get("majors") or []: if isinstance(major, str): push(major, f"Relevant to your {major.strip()} major") raw = profile.get("interests") if isinstance(raw, str): for part in _SPLIT.split(raw): cleaned = " ".join(part.split()).strip(" .!-") if cleaned: push(cleaned, f"Matches your interest in {cleaned}") return out[:_MAX_PHRASES] def _feed(profile: dict, index, today: date) -> list[dict]: if index is None: return [] # `today` is passed in rather than read from the clock so the ranking is # testable against the committed knowledge base at a fixed date. start = today.isoformat() end = (today + timedelta(days=FEED_WINDOW_DAYS)).isoformat() orgs = _joined_orgs(profile, index) # {doc id: [doc, when, score, why parts]} picked: dict[str, dict] = {} def add(hit, score: float, why: str, *, from_org: bool = False, host: str | None = None, urgent: bool = False) -> None: # One audience gate for every pool, so a new pool can't forget it. Topic tagging # worked and immediately exposed this: 170 events came back tagged `research` and # 112 were administrative — IRB office hours, grants-management training — all # genuinely about research and none of it for a first-year. # # Two deliberate exemptions. An unknown audience passes (`is_for_students`), and # so does anything hosted by a group the student *told us* they joined: # membership is a fact they stated, the audience tag is our inference, and the # fact wins. Scoped to events because `extra["audience"]` means something # different on an office or a housing process — see `normalize.py`. if (hit.doc.kind == "event" and not from_org and not topics.is_for_students(hit.doc.extra.get("audience"))): return row = picked.get(hit.doc.id) if row is None: row = {"hit": hit, "score": 0.0, "why": why, "from_org": False, "host": None, "urgent": False} picked[hit.doc.id] = row row["score"] += score row["urgent"] = row["urgent"] or urgent if from_org: # Membership is the strongest thing we know, so it owns the label. row["from_org"] = True row["why"] = why row["host"] = host # 1 & 2. One scan over the window serves both the membership check and the topic # intersection — neither is expressible as an index filter, and scanning # twice for two Python-side predicates would be silly. student_topics = set(topics.canonical(profile.get("interest_topics") or [])) for hit in (queries.events_between(index, start, end, limit=_SCAN_LIMIT) if (orgs or student_topics) else ()): # Events hosted by a group the student joined — included regardless of any # interest match, because they said they are in it. hosts = hit.doc.extra.get("hosts") or [] match = next((orgs[_norm_name(h)] for h in hosts if _norm_name(h) in orgs), None) if match: add(hit, FROM_YOUR_ORG, f"Hosted by {match} — a group you're in", from_org=True, host=match) # A tag agreement. Runs before the BM25 pool below so that when an event # matches both, the clearer explanation is the one the student reads. shared = student_topics & set(hit.doc.extra.get("topics") or []) if shared: add(hit, TOPIC_MATCH, f"Matches your interest in {topics.label(topics.canonical(shared)[0])}") # 3. Academic key dates — add/drop, withdrawal, registration, payment, # housing. The only date-sortable deadlines in the knowledge base: award # deadlines are month/day strings with no year. # # Capped, because the registrar publishes many variants of the same week's # deadlines: an uncapped September window returned fifteen, which buried # every event and turned a section called "based on your interests" into a # list of enrollment paperwork. schools = _schools(profile, index) background = profile.get("background") if isinstance( profile.get("background"), list) else [] kept = 0 for hit in queries.key_dates_between(index, start, end, limit=_KEY_DATE_LIMIT): if not _key_date_for_student(hit.doc.title, schools, background): continue if kept >= FEED_KEY_DATE_LIMIT: break kept += 1 day = _parse_day(hit.when) away = (day - today).days if day else 999 urgent = away <= URGENT_DAYS add(hit, URGENT_KEY_DATE if urgent else KEY_DATE, "On the academic calendar", urgent=urgent) # 4. Major and interest matches by wording — the recall net under the tags, for # phrasings the vocabulary doesn't carry. def matches(query: str, tokens: list[str]) -> list: return [h for h in queries.events_between(index, start, end, query=query, limit=_INTEREST_LIMIT) if h.score > 0 and _covers(h.doc, tokens)] raw: dict[str, tuple] = {} for phrase, why in _phrases(profile): tokens = tokenize(phrase) found = matches(phrase, tokens) if not found and len(tokens) > 1: # Nothing matched the whole phrase — retry on its most distinctive word # rather than abandoning the interest entirely. rare = _rarest(index, tokens) if rare: found = matches(rare, [rare]) for hit in found: best = raw.get(hit.doc.id) if best is None or hit.score > best[0]: raw[hit.doc.id] = (hit.score, why, hit) # BM25 scores are unbounded, so they're scaled against the best match in this # request — a relative ranking among the student's own phrases, which is all # the weight is meant to express. top = max((score for score, _, _ in raw.values()), default=0.0) for score, why, hit in raw.values(): scaled = INTEREST_SCALED * ((score / top) if top else 0.0) add(hit, INTEREST_BASE + scaled, why) out: list[dict] = [] for row in picked.values(): hit = row["hit"] day = _parse_day(hit.when) away = max((day - today).days, 0) if day else 0 score = row["score"] - DAY_DECAY * away domains = list(hit.doc.domains) out.append({ "id": hit.doc.id, "title": hit.doc.title, "url": hit.doc.url, "kind": hit.doc.kind, "date": _iso_day(hit.when), "when": _when(hit.when, today), "where": hit.doc.extra.get("location"), "domain": domains[0] if domains else None, "host": row["host"], "from_your_org": row["from_org"], "urgent": row["urgent"], "why": row["why"], "repeats": len(queries.upcoming_occurrences(hit.doc, start)) or None, "_score": round(score, 4), }) out.sort(key=lambda i: (-i["_score"], i["date"])) return _interleave(_collapse_repeats(out))[:FEED_LIMIT] def _collapse_repeats(rows: list[dict]) -> list[dict]: """One card per distinct thing, with a count when it runs more than once. `normalize.py` collapses a *campus* event's occurrences and dedupes campus rows against their AnchorLink originals, but AnchorLink publishes a recurring series as separate events with separate ids — "Academic Wellbeing Drop-In" is three records. Ranking by topic surfaces all of them at once, so three identical cards land next to each other and the feed looks broken. Keep the best-scoring, count the rest. Keyed on title plus host, so two genuinely different groups running an event of the same name ("Regular Meeting") stay separate. """ best: dict[tuple[str, str], dict] = {} for row in rows: # already in score order key = (re.sub(r"[^a-z0-9]+", "", (row["title"] or "").lower()), row.get("host") or "") kept = best.get(key) if kept is None: best[key] = row continue kept["repeats"] = max(kept.get("repeats") or 1, 1) + 1 # The soonest date is the useful one to show for a series. if row["date"] and (not kept["date"] or row["date"] < kept["date"]): kept["date"], kept["when"] = row["date"], row["when"] return list(best.values()) def _interleave(rows: list[dict], max_run: int = MAX_SAME_KIND_RUN) -> list[dict]: """Score order, but never more than `max_run` key dates back to back. An urgent add/drop deadline genuinely outranks a conference three weeks out, so score order is right. But the carousel shows four cards at a time, and in the first week of September the registrar publishes enough deadline variants to fill every one of them — an accurate and useless first page for a section called "Based on your interests". This pulls an event forward once two deadlines have run, without reordering anything else. The guarantee holds only while both kinds remain: once the events are spent, the remaining key dates run on in score order, which is the right tail behaviour — there is nothing left to break them up with. """ dates = [r for r in rows if r["kind"] == "key_date"] events = [r for r in rows if r["kind"] != "key_date"] out: list[dict] = [] run = 0 while dates or events: take_event = bool(events) and (run >= max_run or not dates or events[0]["_score"] > dates[0]["_score"]) if take_event: out.append(events.pop(0)) run = 0 else: out.append(dates.pop(0)) run += 1 return out # --- payload ---------------------------------------------------------------- def payload(profile: dict | None, syllabi: list | None, index, today: date | None = None) -> dict: """Everything the Today screen renders, derived from real data. `index` may be None when the knowledge base is still warming: the schedule half still works, and the caller reports `ready: false` for the feed. """ profile = profile if isinstance(profile, dict) else {} syllabi = syllabi if isinstance(syllabi, list) else [] today = today or queries.campus_today() term_state = _term_state(today) classes = _classes(syllabi, today, term_state) week = _week(syllabi, today) feed = _feed(profile, index, today) interests = profile.get("interests") has_interests = bool(isinstance(interests, str) and interests.strip()) return { "ready": index is not None, "day": today.isoformat(), "classes": classes, "week": week, "feed": feed, # What the screen should ask for, computed here so the frontend holds no # profile logic. `orgs` is "has none and hasn't dismissed the prompt". "needs": { "syllabi": not syllabus_schema.ship_ready(syllabi), "interests": not has_interests, "orgs": not _orgs(profile) and not profile.get("orgs_prompt_dismissed"), }, }