Spaces:
Running
Running
| """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"), | |
| }, | |
| } | |