File size: 35,656 Bytes
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d615e74
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d615e74
 
 
 
 
 
 
 
 
 
 
 
 
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d615e74
 
2c310c1
 
d615e74
 
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d615e74
 
2c310c1
 
 
d615e74
 
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d615e74
 
2c310c1
 
 
d615e74
 
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3129c85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7cf1d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7cf1d24
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7cf1d24
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3129c85
7cf1d24
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
"""Turn every collector's output into `Doc`s.

One adapter per source file. The adapters are the only place in the runtime that
knows a collector's field names β€” everything downstream sees `Doc`.

Three jobs beyond renaming fields:

1. **Cross-source event dedupe.** AnchorLink and the LiveWhale campus feed are the
   same platform underneath and overlap heavily (82 of AnchorLink's 92 events are
   also in the campus feed). `collectors/events/README.md` states plainly that the
   runtime has to dedupe at read time, and this is that.
2. **Occurrence collapse.** The campus feed publishes one row per *occurrence* β€”
   633 rows for 534 events, one exhibition repeating 26 times. That's correct data
   and the wrong search result, so one `Doc` per event carries its occurrence dates
   in `extra["occurrences"]`.
3. **Resource attachment.** `resources.json` files hold the "how to engage" layer,
   but `study-abroad/resources.json` is 134 near-identical budget-sheet links, one
   per program. Those attach to their program; only genuinely distinct resources
   (portals, advising, forms) become documents of their own.

Deduplication that already happened *at build time* is trusted, not redone:
duplicate directory records, duplicate map pins, and academic-calendar term
duplication were all settled by the collectors. Their merge artifacts (`alt_ids`,
`aliases`) are folded into the searchable text so "Jacobs Hall" still reaches
Featheringill.
"""
from __future__ import annotations

import json
import logging
import re
from dataclasses import replace
from pathlib import Path

from . import topics
from .doc import Doc

log = logging.getLogger("foresight.kb")

# Files that exist but must never be indexed.
#   *_needs_review.json β€” a human queue of unresolved records, not student-facing facts
#   structure.json      β€” the catalog PDF's section tree; wayfinding for the collector
#   collection_meta     β€” provenance
#   collection_meta.json β€” provenance, not content
#   structure.json       β€” the catalog PDF's section tree; collector wayfinding
#   needs_review.json    β€” NOT a list of records to exclude. It annotates the 447
#     directory entities whose *location* couldn't be confidently resolved; every
#     one of them is also in directory.json with `building_slug: null` and a real
#     phone, email and website. Dropping them would cost the companion 447 real
#     offices. They're indexed, and flagged `location_unknown` so an answer can
#     give the contact without inventing a room.
NEVER_INDEXED = ("collection_meta.json", "structure.json", "needs_review.json")


def _load(root: Path, rel: str):
    f = root / rel
    if not f.exists():
        log.warning("kb: %s is missing β€” skipping", rel)
        return []
    try:
        return json.loads(f.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as err:
        log.error("kb: %s failed to parse (%s) β€” skipping", rel, err)
        return []


def _join(*parts) -> str:
    """Searchable blob from a mixed bag of strings, lists and Nones."""
    out: list[str] = []
    for p in parts:
        if not p:
            continue
        if isinstance(p, (list, tuple)):
            out.extend(str(x) for x in p if x)
        elif isinstance(p, dict):
            out.extend(f"{k} {v}" for k, v in p.items() if v)
        else:
            out.append(str(p))
    return " ".join(out)


def _domains(rec) -> tuple[str, ...]:
    return tuple(rec.get("domains") or ())


# `extra["audience"]` means three different things depending on `kind`, because three
# collectors independently chose the obvious word. Anything reading it must scope by
# kind β€” `topics.is_for_students` is only ever handed an event's:
#
#   event   list[str] from `topics.AUDIENCES` β€” who may/should attend
#           ("undergraduate", "graduate", "faculty-staff", "alumni", "public")
#   office  list[str] of who an office serves ("faculty", "staff", "student",
#           "admissions", "academics", "parents")
#   process a bare string naming a cohort ("first_year", "all", "returning")
#
# Only the event one is a controlled vocabulary with a runtime filter attached.


def _norm_title(t: str | None) -> str:
    return re.sub(r"[^a-z0-9]+", "", (t or "").lower())


# --- AnchorLink -------------------------------------------------------------
def anchorlink_orgs(root: Path) -> list[Doc]:
    docs = []
    for r in _load(root, "anchorlink/organizations.json"):
        if r.get("status") and r["status"] != "Active":
            continue
        docs.append(Doc(
            id=f"anchorlink:org:{r['id']}",
            source="anchorlink", kind="organization",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("short_name"), r.get("summary"),
                       r.get("description"), r.get("categories"),
                       topics.labels(r.get("topics"))),
            url=r.get("url"), domains=_domains(r),
            extra={"categories": r.get("categories") or [],
                   "topics": r.get("topics") or [],
                   "audience": r.get("audience") or [],
                   "short_name": r.get("short_name")},
        ))
    return docs


# --- Events (two sources, one index) ----------------------------------------
def events(root: Path) -> list[Doc]:
    """AnchorLink events + the LiveWhale campus feed, collapsed and deduped."""
    docs: list[Doc] = []
    seen_urls: set[str] = set()
    seen_titles: set[tuple[str, str]] = set()

    # AnchorLink first β€” it carries host and org linkage, so it wins a collision.
    for r in _load(root, "anchorlink/events.json"):
        if r.get("is_canceled"):
            continue
        url, day = r.get("url"), r.get("start_date") or ""
        docs.append(Doc(
            id=f"anchorlink:event:{r['id']}",
            source="anchorlink", kind="event",
            title=r.get("title") or "",
            text=_join(r.get("title"), r.get("description"), r.get("categories"),
                       r.get("hosts"), r.get("location"),
                       topics.labels(r.get("topics"))),
            url=url, domains=_domains(r),
            start=r.get("start"), end=r.get("end"),
            extra={"location": r.get("location"), "hosts": r.get("hosts") or [],
                   "topics": r.get("topics") or [],
                   "audience": r.get("audience") or [],
                   "contact_email": r.get("author_email"),
                   "occurrences": [r.get("start")] if r.get("start") else []},
        ))
        if url:
            seen_urls.add(url)
        seen_titles.add((_norm_title(r.get("title")), day))

    # LiveWhale: group occurrences by event id before deduping against AnchorLink.
    by_event: dict = {}
    for r in _load(root, "events/events.json"):
        if r.get("is_canceled"):
            continue
        by_event.setdefault(r["id"], []).append(r)

    dropped = 0
    for event_id, rows in by_event.items():
        rows.sort(key=lambda r: r.get("date_iso") or "")
        first = rows[0]
        url = first.get("url")
        day = (first.get("date_iso") or "")[:10]
        if (url and url in seen_urls) or (_norm_title(first.get("title")), day) in seen_titles:
            dropped += 1
            continue
        docs.append(Doc(
            id=f"events:event:{event_id}",
            source="events", kind="event",
            title=first.get("title") or "",
            text=_join(first.get("title"), first.get("description"), first.get("tags"),
                       first.get("group_title"), first.get("location"),
                       topics.labels(first.get("topics"))),
            url=url, domains=_domains(first),
            start=first.get("date_iso"), end=rows[-1].get("end_iso") or rows[-1].get("date_iso"),
            extra={"location": first.get("location"),
                   "topics": first.get("topics") or [],
                   "audience": first.get("audience") or [],
                   # The contact published *for the event*, not the listing owner β€”
                   # they disagree on 437 of 447 records and this is the one to show.
                   "contact_email": first.get("contact_email"),
                   "contact_info": first.get("contact_info"),
                   "cost": first.get("cost"),
                   "has_registration": first.get("has_registration"),
                   "occurrences": [r.get("date_iso") for r in rows if r.get("date_iso")]},
        ))
    log.info("kb: events β€” %d docs (%d LiveWhale events deduped against AnchorLink)",
             len(docs), dropped)
    return docs


# --- Academics --------------------------------------------------------------
def courses(root: Path) -> list[Doc]:
    """Catalog course details, topped up with Kuali-only courses."""
    docs, seen = [], set()
    for r in _load(root, "academics/catalog/course_details.json"):
        cid = r.get("id")
        if not cid:
            continue
        seen.add(cid)
        tags = r.get("tags") or {}
        docs.append(Doc(
            id=f"academics:course:{cid}",
            source="academics", kind="course",
            title=f"{cid} β€” {r.get('title') or ''}".strip(" β€”"),
            text=_join(cid, r.get("title"), r.get("subject_name"), r.get("description"),
                       tags.get("axle"), tags.get("core"), tags.get("le")),
            url=(r.get("source") or {}).get("url"), domains=_domains(r),
            extra={"course_id": cid, "subject": r.get("subject"),
                   "subject_name": r.get("subject_name"),
                   "credit_hours": r.get("credit_hours"),
                   "axle": tags.get("axle") or [], "core": tags.get("core") or [],
                   "relations": r.get("relations") or {},
                   "description": r.get("description")},
        ))
    # Courses in Kuali but not in the printed catalog β€” identity only, no description.
    for r in _load(root, "academics/courses.json"):
        cid = r.get("course_id")
        if not cid or cid in seen:
            continue
        seen.add(cid)
        docs.append(Doc(
            id=f"academics:course:{cid}",
            source="academics", kind="course",
            title=f"{cid} β€” {r.get('title') or ''}".strip(" β€”"),
            text=_join(cid, r.get("title"), r.get("subject_name")),
            url=None, domains=_domains(r),
            extra={"course_id": cid, "subject": r.get("subject"),
                   "subject_name": r.get("subject_name")},
        ))
    return docs


def programs(root: Path) -> list[Doc]:
    """Kuali program list + the catalog's requirement trees, merged by title."""
    docs = []
    reqs_by_title = {}
    for r in _load(root, "academics/catalog/program_requirements.json"):
        reqs_by_title[_norm_title(r.get("title"))] = r

    used = set()
    for r in _load(root, "academics/programs.json"):
        key = _norm_title(r.get("title"))
        req = reqs_by_title.get(key)
        if req:
            used.add(key)
        rule_text = _join(*[g.get("rule_text") for g in (req or {}).get("requirement_groups", [])])
        docs.append(Doc(
            id=f"academics:program:{r.get('code') or key}",
            source="academics", kind="program",
            title=r.get("title") or "",
            text=_join(r.get("title"), r.get("kind"), r.get("schools"), rule_text),
            url=r.get("api_url"), domains=_domains(r),
            extra={"program_kind": r.get("kind"), "schools": r.get("schools") or [],
                   "requirements": (req or {}).get("requirement_groups"),
                   "total_hours": (req or {}).get("total_hours")},
        ))

    # Requirement trees with no Kuali counterpart (combined/joint degrees, mostly).
    for key, req in reqs_by_title.items():
        if key in used:
            continue
        rule_text = _join(*[g.get("rule_text") for g in req.get("requirement_groups", [])])
        docs.append(Doc(
            id=f"academics:program:{req.get('id')}",
            source="academics", kind="program",
            title=req.get("title") or "",
            text=_join(req.get("title"), req.get("kind"), req.get("school"), rule_text),
            url=None, domains=("vu",),
            extra={"program_kind": req.get("kind"),
                   "schools": [req.get("school")] if req.get("school") else [],
                   "requirements": req.get("requirement_groups"),
                   "total_hours": req.get("total_hours")},
        ))
    return docs


def academic_misc(root: Path) -> list[Doc]:
    docs = []
    for r in _load(root, "academics/catalog/policies.json"):
        docs.append(Doc(
            id=f"academics:policy:{r.get('id')}",
            source="academics", kind="policy",
            title=r.get("title") or "",
            text=_join(r.get("title"), r.get("summary"), r.get("content")),
            url=None, domains=_domains(r) or ("vu",),
            extra={"policy_type": r.get("type"), "rules": r.get("rules"),
                   "summary": r.get("summary"), "entry_year": r.get("entry_year")},
        ))
    for r in _load(root, "academics/calendar.json"):
        docs.append(Doc(
            id=f"academics:calendar:{r.get('occurrence_id') or r.get('id')}",
            source="academics", kind="key_date",
            title=r.get("title") or "",
            text=_join(r.get("title"), r.get("description"), r.get("terms"), r.get("tags")),
            url=r.get("url"), domains=_domains(r) or ("vu",),
            start=r.get("date_iso"), end=r.get("end_iso"),
            extra={"terms": r.get("terms") or [], "date": r.get("date")},
        ))
    for r in _load(root, "academics/catalog/liberal_ed_index.json"):
        docs.append(Doc(
            id=f"academics:liberal_ed:{r.get('curriculum')}:{r.get('category')}",
            source="academics", kind="liberal_ed",
            title=f"{r.get('curriculum')} β€” {r.get('category_name')}",
            # The 685 course ids stay out of the searchable text and live in extra;
            # indexing them would let one category match almost any course query.
            text=_join(r.get("curriculum"), r.get("curriculum_name"),
                       r.get("category"), r.get("category_name")),
            url=None, domains=("vu",),
            extra={"curriculum": r.get("curriculum"), "category": r.get("category"),
                   "count": r.get("count"), "courses": r.get("courses") or []},
        ))
    for r in _load(root, "academics/campuses.json"):
        docs.append(Doc(
            id=f"academics:campus:{r.get('slug')}",
            source="academics", kind="page",
            title=r.get("title") or "",
            text=_join(r.get("title"), r.get("summary"), r.get("content")),
            url=r.get("url"), domains=_domains(r),
            extra={"campus": r.get("campus")},
        ))
    return docs


# --- YES class schedule (per-term offerings) ---------------------------------
def yes_offerings(root: Path) -> list[Doc]:
    """What is actually offered each term, from the public YES class search.

    One Doc per course per term β€” sections stay inline in `extra` rather than
    becoming documents, or MATH 1200's twenty-five sections would fill every
    result list. The term name is in the *title* deliberately: "fall" in a query
    then title-boosts the right term's offering.

    Distinct from `academics` courses: that source is the catalog of everything
    that *exists*; this one is what actually *runs* in a term, with meeting
    times, instructors, and an enrollment snapshot (`fetched_at` says how old).
    """
    yes_root = root / "yes"
    if not yes_root.is_dir():
        return []
    docs = []
    for f in sorted(yes_root.glob("*/*.json")):
        for r in _load(root, f.relative_to(root).as_posix()):
            sections = r.get("sections") or []
            instructors = sorted({i for s in sections
                                  for i in (s.get("instructors") or [])})
            docs.append(Doc(
                id=f"yes:offering:{r.get('term_code')}:"
                   f"{(r.get('course_id') or '').replace(' ', '')}",
                source="yes", kind="class_offering",
                title=f"{r.get('course_id')} β€” {r.get('title') or ''} "
                      f"({r.get('term_name')})",
                text=_join(r.get("course_id"), r.get("title"),
                           r.get("subject_name"), r.get("term_name"),
                           r.get("career"), r.get("school"), r.get("description"),
                           r.get("attributes"), instructors),
                url=r.get("url"), domains=_domains(r),
                extra={"course_id": r.get("course_id"),
                       "subject": r.get("subject"),
                       "subject_name": r.get("subject_name"),
                       "term_code": r.get("term_code"),
                       "term_name": r.get("term_name"),
                       "career": r.get("career"), "school": r.get("school"),
                       "credit_hours": r.get("credit_hours"),
                       "session": r.get("session"),
                       "session_start": r.get("session_start"),
                       "session_end": r.get("session_end"),
                       "description": r.get("description"),
                       "requirements": r.get("requirements"),
                       "attributes": r.get("attributes") or [],
                       "notes": r.get("notes") or [],
                       "sections": sections,
                       "fetched_at": r.get("fetched_at")},
            ))
    return docs


# --- Places: directory + buildings ------------------------------------------
def places(root: Path) -> list[Doc]:
    """Offices and buildings. Build-time dedupe is trusted; `alt_ids` and
    `aliases` are folded into the text so merged names stay findable."""
    docs = []
    for r in _load(root, "directory/directory.json"):
        docs.append(Doc(
            id=f"directory:office:{r.get('id')}",
            source="directory", kind="office",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("alt_ids"), r.get("kind"),
                       r.get("building_name"), r.get("audience"), r.get("evidence_quote")),
            url=r.get("website"), domains=_domains(r),
            extra={"building_slug": r.get("building_slug"),
                   "building_name": r.get("building_name"), "room": r.get("room"),
                   "address": r.get("address"), "phone": r.get("phone"),
                   "email": r.get("email"), "office_kind": r.get("kind"),
                   "audience": r.get("audience") or [], "verified": r.get("verified"),
                   # The collector refuses to guess a building rather than get one
                   # wrong. Carry that forward so an answer says "I have their
                   # email but not a room" instead of inventing one.
                   "location_unknown": not r.get("building_slug")},
        ))
    for r in _load(root, "buildings/pages.json"):
        docs.append(Doc(
            id=f"buildings:building:{r.get('slug')}",
            source="buildings", kind="building",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("aliases"), r.get("address"), r.get("description")),
            url=r.get("website") or r.get("map_url"), domains=(),
            extra={"slug": r.get("slug"), "address": r.get("address"),
                   "lat": r.get("lat"), "lng": r.get("lng"),
                   "map_url": r.get("map_url"), "aliases": r.get("aliases") or []},
        ))
    return docs


# --- Residential ------------------------------------------------------------
def residential(root: Path) -> list[Doc]:
    docs = []
    for r in _load(root, "residential/houses.json"):
        docs.append(Doc(
            id=f"residential:house:{_norm_title(r.get('name'))}",
            source="residential", kind="house",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("type"), r.get("description"),
                       r.get("namesake"), r.get("neighborhood"), r.get("class_years")),
            url=r.get("url"), domains=_domains(r),
            extra={"house_type": r.get("type"), "class_years": r.get("class_years") or [],
                   "room_types": r.get("room_types") or [], "location": r.get("location"),
                   "building_slug": r.get("building_slug")},
        ))
    for r in _load(root, "residential/contacts.json"):
        c = r.get("contact") or {}
        docs.append(Doc(
            id=f"residential:contact:{r.get('role')}",
            source="residential", kind="contact",
            title=(r.get("scenario") or r.get("role") or "").capitalize(),
            text=_join(r.get("role"), r.get("scenario"), c.get("office"), c.get("portal")),
            url=c.get("url"), domains=_domains(r),
            extra={"role": r.get("role"), "scenario": r.get("scenario"),
                   "phone": c.get("phone"), "email": c.get("email"),
                   "hours": c.get("hours"), "office": c.get("office")},
        ))
    for r in _load(root, "residential/processes.json"):
        docs.append(Doc(
            id=f"residential:process:{r.get('process')}",
            source="residential", kind="process",
            title=(r.get("process") or "").replace("_", " ").capitalize(),
            text=_join(r.get("process"), r.get("audience"), r.get("summary"),
                       *[s.get("description") for s in (r.get("steps") or [])]),
            url=r.get("source_url"), domains=_domains(r),
            extra={"audience": r.get("audience"), "summary": r.get("summary"),
                   "steps": r.get("steps") or [], "key_dates": r.get("key_dates")},
        ))
    for r in _load(root, "residential/policies.json"):
        docs.append(Doc(
            id=f"residential:policy:{_norm_title(r.get('title'))}",
            source="residential", kind="policy",
            title=r.get("title") or "",
            text=_join(r.get("title"), r.get("category"), r.get("summary"), r.get("detail")),
            url=r.get("source_url"), domains=_domains(r),
            extra={"category": r.get("category"), "summary": r.get("summary")},
        ))
    return docs


# --- Recreation & sports ----------------------------------------------------
def recreation(root: Path) -> list[Doc]:
    """The Rec Center, intramurals, club sports, and varsity games.

    `how_to_participate` is deliberately first in the searchable text: the whole
    point of this source is that a student asking "how do I sign up for
    intramural basketball" gets procedure, not a phone number (issue #46).
    """
    docs = []
    for r in _load(root, "recreation/programs.json"):
        docs.append(Doc(
            id=f"recreation:program:{r.get('id')}",
            source="recreation", kind=r.get("type") or "program",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("how_to_participate"), r.get("summary"),
                       # the words students actually use ("gym", "free", "sign up"),
                       # which the source pages frequently don't contain
                       r.get("search_terms"),
                       r.get("eligibility"), r.get("exclusions"), r.get("cost_note"),
                       r.get("hours"), r.get("hours_text"), r.get("administered_by"),
                       *(r.get("sports") or []),
                       *(f"{lt.get('name')} {lt.get('description')}"
                         for lt in (r.get("league_types") or []))),
            url=r.get("url"), domains=_domains(r),
            extra={"category": r.get("category"),
                   "how_to_participate": r.get("how_to_participate"),
                   "requires_login": r.get("requires_login"),
                   "registration_url": r.get("registration_url"),
                   "cost": r.get("cost"), "eligibility": r.get("eligibility"),
                   "building_slug": r.get("building_slug"),
                   "contact": r.get("contact"), "hours_url": r.get("hours_url")},
        ))
    for r in _load(root, "recreation/club_sports.json"):
        docs.append(Doc(
            id=f"recreation:club:{r.get('id')}",
            source="recreation", kind="club_sport",
            title=f"{r.get('name')} (club sport)",
            text=_join(r.get("name"), "club sport team", r.get("summary"),
                       r.get("how_to_participate"), r.get("eligibility")),
            url=r.get("anchorlink_url") or r.get("url"), domains=_domains(r),
            extra={"category": r.get("category"),
                   "anchorlink_org_id": r.get("anchorlink_org_id"),
                   "how_to_participate": r.get("how_to_participate"),
                   "building_slug": r.get("building_slug"),
                   "contact": r.get("contact")},
        ))
    for r in _load(root, "recreation/games.json"):
        home = "home" if r.get("is_home") else "away"
        free = "free student tickets" if r.get("tickets_free_to_students") else ""
        docs.append(Doc(
            id=f"recreation:game:{r.get('id')}",
            source="recreation", kind="game",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("sport"), home, "game",
                       r.get("location"), r.get("time_note"), free, r.get("ticket_note")),
            url=r.get("url"), domains=_domains(r),
            start=r.get("date_iso"), end=r.get("date_iso"),
            extra={"category": r.get("category"), "sport": r.get("sport"),
                   "opponent": r.get("opponent"), "is_home": r.get("is_home"),
                   "start_time": r.get("start_time"), "time_note": r.get("time_note"),
                   "location": r.get("location"), "building_slug": r.get("building_slug"),
                   "tickets_free_to_students": r.get("tickets_free_to_students"),
                   "ticket_url": r.get("ticket_url"), "ticket_note": r.get("ticket_note")},
        ))
    for r in _load(root, "recreation/calendar.json"):
        docs.append(Doc(
            id=f"recreation:im:{r.get('id')}",
            source="recreation", kind="registration_window",
            title=f"{r.get('sport')} β€” intramural registration",
            text=_join(r.get("sport"), "intramural registration deadline league",
                       r.get("season"), r.get("how_to_participate")),
            url=r.get("url"), domains=_domains(r),
            start=r.get("registration_opens") or r.get("date_iso"),
            end=r.get("end_iso"),
            extra={"category": r.get("category"), "sport": r.get("sport"),
                   "registration_opens": r.get("registration_opens"),
                   "registration_closes": r.get("registration_closes"),
                   "season": r.get("season"),
                   # a deadline without the procedure is a date the student
                   # can't act on β€” carry the how-to onto the dated record too
                   "how_to_participate": r.get("how_to_participate"),
                   "requires_login": r.get("requires_login"),
                   "registration_url": r.get("registration_url")},
        ))
    for r in _load(root, "recreation/fusion_products.json"):
        docs.append(Doc(
            id=f"recreation:class:{_norm_title(r.get('name'))}",
            source="recreation", kind="class",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("classification"), r.get("day"),
                       r.get("time"), r.get("location")),
            url=r.get("url"), domains=_domains(r),
            extra={"classification": r.get("classification"), "day": r.get("day"),
                   "time": r.get("time"), "location": r.get("location"),
                   "cost": None, "requires_login": r.get("requires_login"),
                   "registration_url": r.get("registration_url")},
        ))
    return docs


# --- Funding ----------------------------------------------------------------
def funding(root: Path) -> list[Doc]:
    docs = []
    for r in _load(root, "funding/awards.json"):
        # An award is listed once per applicant level β€” Lafayette appears for
        # postgraduates and for graduate students, with different deadlines. Those
        # are distinct records, so the level belongs in the id.
        level = r.get("applicant_level") or "any"
        docs.append(Doc(
            id=f"funding:award:{_norm_title(r.get('name'))}:{level}",
            source="funding", kind="award",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("description"), r.get("eligibility"),
                       r.get("level_label"), r.get("tier")),
            url=r.get("official_url") or r.get("source_url"), domains=_domains(r),
            extra={"eligibility": r.get("eligibility"), "tier": r.get("tier"),
                   "campus_deadline": r.get("campus_deadline"),
                   "priority_deadline": r.get("priority_deadline"),
                   "applicant_level": level, "level_label": r.get("level_label")},
        ))
    for r in _load(root, "funding/scholarships.json"):
        docs.append(Doc(
            id=f"funding:scholarship:{_norm_title(r.get('name'))}",
            source="funding", kind="award",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("description"), r.get("award"), r.get("type")),
            url=r.get("url") or r.get("source_url"), domains=_domains(r),
            extra={"award": r.get("award"), "campus_deadline": r.get("deadline"),
                   "scholarship_type": r.get("type")},
        ))
    for r in _load(root, "funding/research_programs.json"):
        docs.append(Doc(
            id=f"funding:research:{_norm_title(r.get('name'))}",
            source="funding", kind="research_program",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("description"), r.get("eligibility_group")),
            url=r.get("url"), domains=_domains(r),
            extra={"stipend": r.get("stipend"), "deadline": r.get("deadline"),
                   "eligibility_group": r.get("eligibility_group")},
        ))
    return docs


# --- Prose pages (immersion, study abroad, funding, residential) ------------
PAGE_FILES = [
    ("immersion/pages.json", "immersion"),
    ("study-abroad/pages.json", "study-abroad"),
    ("study-abroad/summer_pages.json", "study-abroad"),
    ("funding/pages.json", "funding"),
    ("residential/pages.json", "residential"),
    ("recreation/pages.json", "recreation"),
]


def pages(root: Path) -> list[Doc]:
    docs = []
    for rel, source in PAGE_FILES:
        for r in _load(root, rel):
            docs.append(Doc(
                id=f"{source}:page:{r.get('slug') or _norm_title(r.get('title'))}",
                source=source, kind="page",
                title=r.get("title") or "",
                text=_join(r.get("title"), r.get("summary"), r.get("content")),
                url=r.get("url"), domains=_domains(r),
                extra={"page_kind": r.get("kind"), "section": r.get("section"),
                       "summary": r.get("summary")},
            ))
    return docs


def study_abroad_programs(root: Path) -> list[Doc]:
    docs = []
    for r in _load(root, "study-abroad/programs.json"):
        stats = r.get("stats") or {}
        docs.append(Doc(
            id=f"study-abroad:program:{r.get('program_id')}",
            source="study-abroad", kind="program",
            title=r.get("name") or "",
            text=_join(r.get("name"), r.get("city"), r.get("country"),
                       r.get("overview"), stats),
            url=r.get("url"), domains=_domains(r) or ("crew", "future"),
            extra={"program_id": r.get("program_id"), "city": r.get("city"),
                   "country": r.get("country"), "stats": stats},
        ))
    return docs


# --- The "how to engage" layer ----------------------------------------------
RESOURCE_FILES = [
    ("immersion/resources.json", "immersion"),
    ("study-abroad/resources.json", "study-abroad"),
    ("funding/resources.json", "funding"),
    ("residential/resources.json", "residential"),
    ("academics/resources.json", "academics"),
    ("recreation/resources.json", "recreation"),
]


def resources(root: Path, by_id: dict[str, Doc]) -> list[Doc]:
    """Application portals, advising links, forms, class search.

    A resource whose every `found_on` is a specific program is an attachment to
    that program, not a document β€” `study-abroad/resources.json` is 134 copies of
    "See Budget Sheets", one per program, and indexing them would swamp search.
    """
    docs, seen_urls = [], set()
    attach: dict[str, list[dict]] = {}
    for rel, source in RESOURCE_FILES:
        for r in _load(root, rel):
            url, found_on = r.get("url"), r.get("found_on") or []
            entry = {"text": r.get("text"), "url": url, "kind": r.get("kind")}
            program_refs = [f for f in found_on if str(f).startswith("program:")]
            if program_refs and len(program_refs) == len(found_on):
                for ref in program_refs:
                    attach.setdefault(f"{source}:program:{ref.split(':', 1)[1]}", []).append(entry)
                continue
            if not url or url in seen_urls:
                continue
            seen_urls.add(url)
            docs.append(Doc(
                id=f"{source}:resource:{len(seen_urls)}",
                source=source, kind="resource",
                title=r.get("text") or url,
                text=_join(r.get("text"), r.get("kind"), found_on, source),
                url=url, domains=(),
                extra={"resource_kind": r.get("kind"), "found_on": found_on},
            ))

    # Fold program-specific resources onto their program (extras are mutable by design).
    for doc_id, entries in attach.items():
        target = by_id.get(doc_id)
        if target is not None:
            target.extra.setdefault("resources", []).extend(entries)
    return docs


# --- Entry point ------------------------------------------------------------
def load_all(root: Path) -> list[Doc]:
    docs: list[Doc] = []
    for fn in (anchorlink_orgs, events, courses, programs, academic_misc,
               yes_offerings, places, residential, recreation, funding, pages,
               study_abroad_programs):
        try:
            docs.extend(fn(root))
        except Exception:                       # one bad source shouldn't kill the app
            log.exception("kb: adapter %s failed β€” continuing without it", fn.__name__)

    by_id = {d.id: d for d in docs}
    try:
        docs.extend(resources(root, by_id))
    except Exception:
        log.exception("kb: resource adapter failed β€” continuing without it")

    # Ids must be unique β€” they're what a tool result cites back β€” but a collision
    # means the id scheme is too coarse, not that the record is redundant. Suffix
    # rather than drop, and say so, so a real duplicate shows up in the log instead
    # of silently costing the student an answer.
    seen: set[str] = set()
    out: list[Doc] = []
    collisions = 0
    for d in docs:
        if d.id in seen:
            collisions += 1
            n = 2
            while f"{d.id}#{n}" in seen:
                n += 1
            d = replace(d, id=f"{d.id}#{n}")
        seen.add(d.id)
        out.append(d)
    if collisions:
        log.warning("kb: %d documents needed an id suffix β€” the id scheme is too "
                    "coarse for those sources", collisions)
    return out