Spaces:
Running
Running
| """What the My Crew page needs the server for — the two things it can't do honestly | |
| in the browser. | |
| My Crew is mostly plain `/kb/` reads (the calendar's precedent): this week's events, | |
| the browsable catalog of all 1,014 organizations, recreation, the family block. Two | |
| sections are not, and they are the whole of `GET /api/crew`: | |
| * **Your orgs** — the memberships the student named in My Story, resolved to their | |
| *current* names through the index (a group renamed since it was saved is recovered | |
| by id, exactly as the Today feed does it — see `today._joined_orgs`) and joined to | |
| their next upcoming event by host name. | |
| * **Orgs for you** — suggestions ranked on the shared topic vocabulary: the student's | |
| `interest_topics` intersected with each org's `topics`. This is an *agreement* on a | |
| word both sides were tagged with, not a lexical coincidence, and the scoring already | |
| lives in Python (`server/kb/topics.py`); a second copy in JavaScript is drift waiting | |
| to happen. When the student has stated too little to rank on, the section changes | |
| shape rather than faking personalization — a deterministic, day-rotated sampler | |
| spread across topic areas, always including the first-gen / identity-community | |
| cluster this app is for. | |
| Everything here is derived from (profile, index, today) with no storage, session, or | |
| clock, so it is testable against the committed knowledge base at a pinned date — the | |
| same contract as `today.payload`. The endpoint returns only what needs the index | |
| (ids, the small index-side fields, the "why", the next-event join); the browser | |
| hydrates each card's summary and avatar from the organization catalog it already loads | |
| for the browse-all section, so nothing large is duplicated over the wire. | |
| """ | |
| from __future__ import annotations | |
| from datetime import date, timedelta | |
| from .kb import queries, topics | |
| from .today import _norm_name, _orgs, _when | |
| FOR_YOU_LIMIT = 8 | |
| # Below this many topic matches, an "Orgs for you" list is too thin to read as | |
| # personal — so the section becomes the cold-start sampler instead of a short, | |
| # lonely list pretending to be tailored. | |
| MIN_PERSONAL_MATCHES = 3 | |
| COLD_START_LIMIT = 8 | |
| # The cluster this app's audience belongs to. The cold-start sampler always seeds | |
| # from it, so a first-gen student who has said nothing yet still lands on community | |
| # before anything else. | |
| FIRST_GEN_CLUSTER = "culture-identity" | |
| # A generous scan for the next-event join: org membership is filtered in Python | |
| # afterwards, so the query has to see every event in the window first. | |
| _EVENT_SCAN_LIMIT = 500 | |
| def _org_docs(index) -> list: | |
| """Every active-organization document, in a stable order. | |
| Index order is insertion order (the catalog's own order); good enough as a base, | |
| because every consumer here re-sorts by name or by score.""" | |
| return [d for d in index.docs if d.kind == "organization"] | |
| def _org_id(doc) -> str: | |
| """The AnchorLink id out of an org document id (`anchorlink:org:64340` -> `64340`).""" | |
| return doc.id.rsplit(":", 1)[-1] | |
| def _card(doc, **extra) -> dict: | |
| """The small, index-only shape of an org card. | |
| Name, short name, link and topics come from the index so chips and the "View on | |
| AnchorLink" button work even if the browser's catalog fetch failed; the summary and | |
| avatar are hydrated client-side from the catalog it loads for browse-all.""" | |
| card = { | |
| "id": _org_id(doc), | |
| "name": doc.title, | |
| "short_name": doc.extra.get("short_name"), | |
| "url": doc.url, | |
| "topics": list(doc.extra.get("topics") or []), | |
| } | |
| card.update(extra) | |
| return card | |
| # --- your orgs -------------------------------------------------------------- | |
| def _resolved_orgs(profile: dict, index) -> list[dict]: | |
| """The student's memberships, each recovered to its current org document. | |
| Mirrors `today._joined_orgs`, but keeps the whole document rather than collapsing | |
| to a name map, because the card needs the id (to hydrate) and the link too. The id | |
| is what survives a rename: the stored name may be stale, so the doc's current title | |
| wins when the id still resolves. An id that no longer resolves (a deactivated org) | |
| keeps the stored name and simply has no card metadata to hydrate — shown, not | |
| dropped.""" | |
| out: list[dict] = [] | |
| for entry in _orgs(profile): | |
| if isinstance(entry, str): # tolerate a bare-name list | |
| entry = {"name": entry} | |
| if not isinstance(entry, dict): | |
| continue | |
| org_id = str(entry.get("id") or "").strip() | |
| name = (entry.get("name") or "").strip() | |
| doc = index.get(f"anchorlink:org:{org_id}") if org_id else None | |
| if doc is not None and doc.title: | |
| name = doc.title | |
| if not name and doc is None: | |
| continue | |
| out.append({"id": org_id, "name": name, "doc": doc}) | |
| return out | |
| def _next_event_by_host(index, today: date) -> dict[str, dict]: | |
| """{normalized host name: its soonest upcoming event card}. | |
| One scan over the event window, soonest-first, keeping the first event seen per | |
| host. Events carry `hosts` as a list of org *names* — the join is by name, which is | |
| why `_resolved_orgs` bothers to recover the current one.""" | |
| start = today.isoformat() | |
| end = (today + timedelta(days=queries.EVENT_WINDOW_DAYS)).isoformat() | |
| by_host: dict[str, dict] = {} | |
| for hit in queries.events_between(index, start, end, limit=_EVENT_SCAN_LIMIT): | |
| card = { | |
| "title": hit.doc.title, | |
| "when": _when(hit.when, today), | |
| "date": (hit.when or "")[:10], | |
| "url": hit.doc.url, | |
| "location": hit.doc.extra.get("location"), | |
| } | |
| for host in hit.doc.extra.get("hosts") or []: | |
| key = _norm_name(host) | |
| if key and key not in by_host: # soonest-first: first wins | |
| by_host[key] = card | |
| return by_host | |
| def _your_orgs(profile: dict, index, today: date) -> list[dict]: | |
| resolved = _resolved_orgs(profile, index) | |
| if not resolved: | |
| return [] | |
| by_host = _next_event_by_host(index, today) | |
| out: list[dict] = [] | |
| for item in resolved: | |
| doc = item["doc"] | |
| next_event = by_host.get(_norm_name(item["name"])) | |
| if doc is not None: | |
| out.append(_card(doc, next_event=next_event)) | |
| else: | |
| # Deactivated / unknown org: keep the student's own words, no metadata. | |
| out.append({"id": item["id"], "name": item["name"], "short_name": None, | |
| "url": None, "topics": [], "next_event": next_event}) | |
| return out | |
| # --- orgs for you ----------------------------------------------------------- | |
| def _personalized(org_docs: list, student_topics: set, joined_ids: set) -> list[dict]: | |
| """Orgs whose topics agree with the student's, best agreement first. | |
| Ranked by how many topics overlap, then by name for a stable order (no member | |
| counts exist to rank on, and inventing a popularity signal is exactly the kind of | |
| ornament the mock got wrong). The "why" names the first shared topic in vocabulary | |
| order, so it's stable and reads as the reason it does: *tagged music — like your | |
| interests.*""" | |
| scored: list[tuple[int, str, object]] = [] | |
| for doc in org_docs: | |
| if _org_id(doc) in joined_ids: | |
| continue | |
| shared = student_topics & set(doc.extra.get("topics") or []) | |
| if not shared: | |
| continue | |
| first = topics.canonical(shared)[0] | |
| scored.append((len(shared), doc.title.casefold(), doc, first)) | |
| scored.sort(key=lambda s: (-s[0], s[1])) | |
| return [_card(doc, why=f"Tagged {topics.label(first)} — like your interests.") | |
| for _, _, doc, first in scored[:FOR_YOU_LIMIT]] | |
| def _cold_start(org_docs: list, joined_ids: set, today: date) -> list[dict]: | |
| """A deterministic, day-rotated spread across topic areas — never the same eight | |
| forever, always seeded from the first-gen / identity cluster. | |
| One org per topic bucket so the sample reads as *campus*, not one corner of it. | |
| Rotation is by the day's ordinal — no clock beyond the date and no randomness, so | |
| it's reproducible in a test and identical for every student on a given day. Which | |
| org within a bucket also rotates, so a daily visitor sees movement. | |
| No `why` is attached: an org sits in several topic buckets, so labelling it by the | |
| bucket it happened to be drawn from produces nonsense ("VandyHacks — a way into | |
| visual arts"). These aren't interest matches anyway; the card shows the org's own | |
| summary and its real topic chips, which say what it is without inventing a reason.""" | |
| buckets: dict[str, list] = {} | |
| for doc in org_docs: | |
| if _org_id(doc) in joined_ids: | |
| continue | |
| for slug in doc.extra.get("topics") or []: | |
| buckets.setdefault(slug, []).append(doc) | |
| for slug in buckets: | |
| buckets[slug].sort(key=lambda d: d.title.casefold()) | |
| rot = today.toordinal() | |
| picked: list[dict] = [] | |
| seen_ids: set[str] = set() | |
| def take(slug: str) -> None: | |
| pool = buckets.get(slug) or [] | |
| if not pool: | |
| return | |
| # Rotate the within-bucket pick by the day so it isn't the same org forever. | |
| for offset in range(len(pool)): | |
| doc = pool[(rot + offset) % len(pool)] | |
| if _org_id(doc) not in seen_ids: | |
| seen_ids.add(_org_id(doc)) | |
| picked.append(_card(doc)) # no why — see the docstring | |
| return | |
| # Seed from the cluster this app is for, then spread across the rest of the | |
| # vocabulary in a day-rotated order so the mix changes but stays deterministic. | |
| take(FIRST_GEN_CLUSTER) | |
| slugs = [s for s in topics.SLUGS if s != FIRST_GEN_CLUSTER and buckets.get(s)] | |
| if slugs: | |
| start = rot % len(slugs) | |
| ordered = slugs[start:] + slugs[:start] | |
| for slug in ordered: | |
| if len(picked) >= COLD_START_LIMIT: | |
| break | |
| take(slug) | |
| return picked[:COLD_START_LIMIT] | |
| # --- payload ---------------------------------------------------------------- | |
| def payload(profile: dict | None, index, today: date | None = None) -> dict: | |
| """Everything `GET /api/crew` returns, derived from (profile, index, today). | |
| `index` may be None while the knowledge base is still warming — the browser retries, | |
| and the rest of the page (all `/kb/` reads) is unaffected. `today` is injectable so | |
| the ranking and the cold-start rotation are testable at a pinned date.""" | |
| profile = profile if isinstance(profile, dict) else {} | |
| today = today or queries.campus_today() | |
| if index is None: | |
| return {"ready": False, "your_orgs": [], "for_you": [], | |
| "cold_start": True, "needs_interests": True} | |
| org_docs = _org_docs(index) | |
| joined_ids = {str(o.get("id")) for o in _orgs(profile) | |
| if isinstance(o, dict) and o.get("id")} | |
| student_topics = set(topics.canonical(profile.get("interest_topics") or [])) | |
| your_orgs = _your_orgs(profile, index, today) | |
| matches = _personalized(org_docs, student_topics, joined_ids) | |
| if student_topics and len(matches) >= MIN_PERSONAL_MATCHES: | |
| for_you, cold_start = matches, False | |
| else: | |
| for_you, cold_start = _cold_start(org_docs, joined_ids, today), True | |
| return { | |
| "ready": True, | |
| "your_orgs": your_orgs, | |
| "for_you": for_you, | |
| "cold_start": cold_start, | |
| "needs_interests": not student_topics, | |
| } | |