foresight / server /crew.py
umangchaudhry's picture
Deploy 649e0d3 from GitHub
73669f9 verified
Raw
History Blame Contribute Delete
11.6 kB
"""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,
}