"""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, }