File size: 11,604 Bytes
f73802e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73669f9
 
 
 
 
 
f73802e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73669f9
f73802e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
"""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,
    }