File size: 36,803 Bytes
6b7e4ba
 
 
 
 
 
 
 
 
2c310c1
 
6b7e4ba
 
 
 
 
 
 
2c310c1
 
6b7e4ba
 
 
2c310c1
6b7e4ba
 
5a759a9
6b7e4ba
 
fec93ae
2c310c1
6b7e4ba
464ae11
6b7e4ba
 
5a759a9
f73802e
acb3189
09b85ba
5a759a9
6b7e4ba
 
 
 
 
 
 
82d1975
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b7e4ba
 
 
 
 
 
82d1975
 
6b7e4ba
 
 
 
 
 
82d1975
 
6b7e4ba
 
 
 
 
2c310c1
 
 
 
 
 
6b7e4ba
 
 
 
 
 
 
 
 
 
 
25928a4
 
 
 
 
 
 
6b7e4ba
25928a4
6b7e4ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25928a4
 
 
6b7e4ba
 
 
25928a4
 
 
 
 
6b7e4ba
 
 
f4fa8d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b7e4ba
 
 
 
 
 
 
 
 
 
 
82a1b70
 
 
 
 
 
6b7e4ba
 
f4fa8d3
 
 
 
d615e74
 
 
 
 
 
6b7e4ba
 
 
 
 
 
 
 
82a1b70
 
 
 
 
 
09b85ba
 
 
 
 
 
 
 
 
 
 
 
 
 
afd6875
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82a1b70
 
 
 
09b85ba
82a1b70
 
 
 
 
 
 
 
 
 
 
 
 
 
f4fa8d3
 
 
 
afd6875
 
 
 
82a1b70
 
 
fec93ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
acb3189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a759a9
 
 
 
cdd85a5
 
 
 
5a759a9
 
 
cdd85a5
5a759a9
 
 
 
 
 
 
 
 
 
 
 
cdd85a5
5a759a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fec93ae
 
 
 
 
 
 
 
 
 
 
 
 
d615e74
 
 
 
 
 
 
 
 
 
 
 
 
 
09b85ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f73802e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c310c1
464ae11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c310c1
 
 
 
 
464ae11
 
2c310c1
 
 
 
 
464ae11
 
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464ae11
 
 
2c310c1
 
 
 
 
 
464ae11
 
 
 
 
2c310c1
 
 
464ae11
 
 
 
 
 
 
 
 
2c310c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464ae11
2c310c1
464ae11
2c310c1
464ae11
 
 
 
 
 
 
2c310c1
 
 
464ae11
 
 
 
 
 
 
2c310c1
 
 
 
 
 
 
 
464ae11
2c310c1
464ae11
 
 
 
2c310c1
 
464ae11
2c310c1
 
 
464ae11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c310c1
464ae11
 
2c310c1
 
 
 
 
 
 
 
6b7e4ba
 
82d1975
 
 
 
2c310c1
 
82d1975
6b7e4ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
"""Foresight server β€” serves the app, the knowledge base, and per-student data.

Small on purpose. Three jobs:

1. **Serve the frontend** (`app/`) and the **knowledge base** (`knowledge-base/`
   under `/kb/`). Serving the KB from a fixed URL prefix is what lets the
   calendar and map screens use one path that works locally and in the container.
2. **Sign-up / sign-in and sessions** (see `auth.py`).
3. **Read/write per-student JSON** in a private HF Dataset repo (see `storage.py`).
4. **Ask Foresight** β€” the streaming chat endpoint over the LangGraph agent
   (see `agent/`), grounded in the in-memory knowledge-base index (see `kb/`).

Run locally:

    pip install -r requirements.txt
    uvicorn server.app:app --reload --port 7860

Then open http://localhost:7860 and create an account. With no HF_TOKEN set,
accounts and saves land in a git-ignored `.data/` directory. With no
OPENAI_API_KEY set, everything works except Ask Foresight, which says so.
"""
from __future__ import annotations

import json
import logging
import os
from datetime import date, timedelta
from pathlib import Path

from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from starlette.background import BackgroundTask
from starlette.middleware.sessions import SessionMiddleware

from . import agent, auth, kb, key_dates, storage, survey, syllabus
from . import crew as crew_screen
from . import schedule as class_schedule
from . import today as today_screen
from .kb import offerings, queries, topics

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("foresight")

ROOT = Path(__file__).resolve().parent.parent
APP_DIR = ROOT / "app"
KB_DIR = ROOT / "knowledge-base"
SESSION_MAX_AGE = 60 * 60 * 24 * 30  # 30 days β€” students shouldn't sign in weekly


def _truthy(name: str) -> bool:
    return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")


def cookie_policy() -> tuple[str, bool]:
    """(same_site, https_only) for the session cookie.

    Hugging Face serves a Space inside a **cross-site iframe** (huggingface.co
    framing *.hf.space). Browsers do not send a `SameSite=Lax` cookie in that
    context, so the sign-in POST succeeds and then the very next request arrives
    with no session β€” an endless bounce back to /login. `SameSite=None` is
    required there, and browsers only accept it when the cookie is also `Secure`.

    Spaces sets SPACE_ID, so the right policy is detected rather than configured.
    Locally we stay on Lax over plain HTTP, where Secure cookies wouldn't be sent
    at all. Both are overridable.
    """
    on_spaces = bool(os.environ.get("SPACE_ID"))
    same_site = (os.environ.get("FORESIGHT_COOKIE_SAMESITE") or
                 ("none" if on_spaces else "lax")).strip().lower()
    https_only = _truthy("FORESIGHT_HTTPS_ONLY") or on_spaces
    if same_site == "none":
        https_only = True   # a SameSite=None cookie without Secure is dropped
    return same_site, https_only


SAME_SITE, HTTPS_ONLY = cookie_policy()

app = FastAPI(title="Foresight", docs_url=None, redoc_url=None)
app.add_middleware(
    SessionMiddleware,
    secret_key=auth.session_secret(),
    max_age=SESSION_MAX_AGE,
    same_site=SAME_SITE,
    https_only=HTTPS_ONLY,
)


@app.on_event("startup")
def _log_config() -> None:
    log.info("storage: %s", storage.describe())
    log.info("session cookie: samesite=%s secure=%s (SPACE_ID=%s)",
             SAME_SITE, HTTPS_ONLY, os.environ.get("SPACE_ID") or "-")
    if not storage.using_hub():
        log.warning("No HF_TOKEN set β€” accounts and student data go to the local "
                    "fallback directory, which does NOT survive a container restart.")
    if not os.environ.get("FORESIGHT_SESSION_SECRET"):
        log.warning("No FORESIGHT_SESSION_SECRET set β€” sessions reset on every restart.")
    if not agent.enabled():
        log.warning("No OPENAI_API_KEY set β€” Ask Foresight is disabled. Everything "
                    "else works; the chat surface reports itself as unconfigured.")
    # Indexing the knowledge base takes a couple of seconds. Do it on a background
    # thread so pages and sign-in are available immediately β€” only chat waits.
    kb.start_warmup()


# --- auth ------------------------------------------------------------------
def _require_student(request: Request) -> str:
    student_id = auth.current_student(request)
    if not student_id:
        raise HTTPException(status_code=401, detail="not signed in")
    return student_id


@app.post("/api/signup")
async def signup(
    request: Request,
    username: str = Form(...),
    password: str = Form(...),
    first_name: str = Form(""),
    last_name: str = Form(""),
):
    try:
        record = auth.sign_up(username, password, first_name, last_name)
    except auth.AuthError as err:
        return JSONResponse({"error": str(err)}, status_code=400)
    auth.start_session(request, record)
    log.info("signup: %s", record["username"])
    return {"username": record["username"]}


@app.post("/api/login")
async def login(request: Request, username: str = Form(...), password: str = Form(...)):
    try:
        record = auth.sign_in(username, password)
    except auth.AuthError as err:
        return JSONResponse({"error": str(err)}, status_code=401)
    auth.start_session(request, record)
    log.info("login: %s", record["username"])
    return {"username": record["username"]}


@app.post("/api/logout")
async def logout(request: Request):
    request.session.clear()
    return {"ok": True}


@app.get("/api/me")
async def me(request: Request):
    student_id = auth.current_student(request)
    if not student_id:
        return JSONResponse({"signed_in": False}, status_code=401)
    first = request.session.get("first_name") or ""
    last = request.session.get("last_name") or ""
    username = request.session.get("username") or ""
    return {
        "signed_in": True,
        "student_id": student_id,
        "username": username,
        "first_name": first,
        "last_name": last,
        "display_name": (f"{first} {last}".strip() or username),
        "initials": auth.initials(first, last, username),
    }


# --- changing an account ----------------------------------------------------
# Split by what each change costs rather than collapsed into one PATCH: the name is
# free, the username and the password are credentials and re-authenticate, and the
# delete is irreversible. One endpoint would have to re-derive that from which keys
# happened to be present.
def _account(request: Request) -> dict:
    """The signed-in student's user record, or 401."""
    _require_student(request)
    record = auth.load_user(request.session.get("username") or "")
    if record is None or record.get("disabled"):
        # The session outlived the account β€” a deleted account with a live cookie.
        request.session.clear()
        raise HTTPException(status_code=401, detail="not signed in")
    return record


async def _json_body(request: Request) -> dict:
    try:
        payload = await request.json()
    except (json.JSONDecodeError, UnicodeDecodeError):
        raise HTTPException(status_code=400, detail="expected a JSON object")
    if not isinstance(payload, dict):
        raise HTTPException(status_code=400, detail="expected a JSON object")
    return payload


def _account_ok(request: Request, record: dict) -> dict:
    """Re-issue the session so the header and avatar follow a change immediately."""
    auth.start_session(request, record)
    first = record.get("first_name") or ""
    last = record.get("last_name") or ""
    return {"ok": True, "username": record["username"],
            "first_name": first, "last_name": last,
            "display_name": (f"{first} {last}".strip() or record["username"]),
            "initials": auth.initials(first, last, record["username"])}


@app.post("/api/account/name")
async def account_name(request: Request):
    record = _account(request)
    body = await _json_body(request)
    try:
        updated = auth.update_names(record, body.get("first_name", ""),
                                    body.get("last_name", ""))
    except auth.AuthError as err:
        return JSONResponse({"error": str(err)}, status_code=400)
    return _account_ok(request, updated)


@app.post("/api/account/username")
async def account_username(request: Request):
    record = _account(request)
    body = await _json_body(request)
    try:
        updated = auth.change_username(record, body.get("username", ""),
                                       body.get("password", ""))
    except auth.AuthError as err:
        return JSONResponse({"error": str(err)}, status_code=400)
    log.info("account: %s -> %s", record["username"], updated["username"])
    return _account_ok(request, updated)


@app.post("/api/account/password")
async def account_password(request: Request):
    record = _account(request)
    body = await _json_body(request)
    try:
        updated = auth.change_password(record, body.get("current_password", ""),
                                       body.get("new_password", ""))
    except auth.AuthError as err:
        return JSONResponse({"error": str(err)}, status_code=400)
    return _account_ok(request, updated)


def _purge_student_data(student_id: str) -> None:
    """Blank everything under `students/{student_id}/` that this app wrote.

    Overwrites rather than removes, because `storage` cannot delete β€” so this is
    "gone from the app", not "erased from the dataset repo's history". The account
    tombstone means nobody can sign in to reach it either way, but don't describe it
    to a student as erasure.
    """
    for name in ("profile.json", SYLLABI_FILE):
        try:
            storage.write_json(storage.student_path(student_id, name), {},
                               message=f"account: purge {name}")
        except Exception:
            log.exception("account: failed to purge %s", name)
    for thread in agent.threads.index(student_id):
        try:
            agent.threads.delete(student_id, thread.get("thread_id") or "")
        except Exception:
            log.exception("account: failed to purge a chat thread")


@app.post("/api/account/delete")
async def account_delete(request: Request):
    """Close the account and blank its data. Irreversible from inside the app."""
    record = _account(request)
    student_id = record.get("student_id") or ""
    body = await _json_body(request)
    try:
        auth.delete_account(record, body.get("password", ""))
    except auth.AuthError as err:
        return JSONResponse({"error": str(err)}, status_code=400)
    # Only after the door is shut: if the purge fails halfway the account is still
    # closed, which is the half that matters.
    _purge_student_data(student_id)
    request.session.clear()
    log.info("account: deleted %s", record["username"])
    return {"ok": True}


# --- per-student data ------------------------------------------------------
@app.get("/api/profile")
async def get_profile(request: Request):
    student_id = _require_student(request)
    data = storage.read_json(storage.student_path(student_id, "profile.json"))
    return data or {}


@app.put("/api/profile")
async def put_profile(request: Request):
    student_id = _require_student(request)
    try:
        payload = await request.json()
    except (json.JSONDecodeError, UnicodeDecodeError):
        # An empty or malformed body is the client's bug, not a server error β€” the
        # store retries on failure, so a 500 here would retry forever.
        raise HTTPException(status_code=400, detail="profile must be a JSON object")
    if not isinstance(payload, dict):
        raise HTTPException(status_code=400, detail="profile must be a JSON object")
    # The academic year a stated class year belongs to is stamped here, from the
    # server's clock and the profile already on disk β€” see `anchor_class_year`.
    stored = storage.read_json(storage.student_path(student_id, "profile.json")) or {}
    payload = survey.anchor_class_year(payload, stored)
    # And the interest text is mapped onto the shared topic vocabulary the Today feed
    # ranks on. Costs one small model call per *actual* change to the interests string,
    # never per save β€” see `stamp_interest_topics`. Best-effort by construction: it
    # falls back to an alias table, so no key and no network still leave a usable
    # profile rather than a failed save.
    payload = topics.stamp_interest_topics(payload, stored)
    storage.write_json(
        storage.student_path(student_id, "profile.json"),
        payload,
        message=f"profile: update {student_id}",
    )
    return {"ok": True}


# --- the intake / check-in survey -------------------------------------------
# The browser asks the server what to render rather than deciding for itself: which
# version of the instrument applies, whether the intake is actually complete, and
# whether a semester check-in is open. All three are date arithmetic over the profile
# (see `survey/schedule.py`), and having one implementation means the overlay can
# never disagree with what the agent prompt believes about the same student.
def _as_of(today: str | None) -> date | None:
    """`?today=` support, so date-driven surfaces can be exercised without waiting
    for the date: the May/December check-ins, and a Today screen mid-semester.

    Gated on an env var so it can never be used against a real deployment.
    """
    if not (today and _truthy("FORESIGHT_ALLOW_TIME_TRAVEL")):
        return None
    try:
        return date.fromisoformat(today)
    except ValueError:
        raise HTTPException(status_code=400, detail="today must be YYYY-MM-DD")


# Waving off the intake or the check-in lasts for the sign-in, not for the page.
# It rides in the session for the same reason the current chat thread does: this
# app keeps no client-side state, and "until they come back" is exactly the
# lifetime a session cookie already has.
SURVEY_DISMISSED_KEY = "survey_dismissed"
SURVEY_MODES = ("intake", "checkin")


@app.post("/api/survey/dismiss")
async def dismiss_survey(request: Request):
    """Not now β€” stop opening this prompt until the next sign-in.

    Distinct from opting out, which is a profile field and lasts forever. This is
    the softer "I'll do it later", and it has to outlive a refresh: a student who
    closes the wizard and hits reload should not be handed it again.
    """
    _require_student(request)
    payload = await request.json()
    mode = (payload or {}).get("mode")
    if mode not in SURVEY_MODES:
        raise HTTPException(status_code=400, detail="unknown prompt")
    waved = [m for m in request.session.get(SURVEY_DISMISSED_KEY, []) if m in SURVEY_MODES]
    if mode not in waved:
        waved.append(mode)
    request.session[SURVEY_DISMISSED_KEY] = waved
    return {"ok": True, "dismissed": waved}


@app.get("/api/survey")
async def get_survey(request: Request, today: str | None = None):
    student_id = _require_student(request)
    profile = storage.read_json(storage.student_path(student_id, "profile.json")) or {}
    as_of = _as_of(today)
    # The whole bank goes over the wire, flags and all, and the browser selects from
    # it β€” see `itemsFor` in app/app.js. The alternative was re-fetching every time a
    # student changed their graduation year or ticked "First View" mid-wizard, which
    # meant a save and a round trip before the next page could render. `intake_complete`
    # stays server-side because it is the gate, and the agent prompt reads the same
    # profile.
    return {
        "scale": survey.scale(),
        "items": survey.items(),
        "intake_wave": survey.schedule.INTAKE_WAVE,
        "intake_version": survey.intake_version(profile, as_of),
        "intake_complete": survey.intake_complete(profile, as_of),
        "checkin": survey.checkin_due(profile, as_of),
        "class_year": survey.class_year(profile, as_of),
        # The year "what year are you?" is asking about, so the wizard can say it out
        # loud. Over the summer that's the year about to start β€” see `standing_year`.
        "standing_year": survey.academic_year_label(
            survey.standing_year(as_of or date.today())),
        # Which prompts they've already waved off this sign-in, so a reload doesn't
        # reopen one they just closed.
        "dismissed": [m for m in request.session.get(SURVEY_DISMISSED_KEY, [])
                      if m in SURVEY_MODES],
    }


# --- syllabi ----------------------------------------------------------------
# Parsing and saving are separate requests on purpose. A parse is unsaved until the
# student confirms it on the review screen, because `reviewed` gates every
# downstream surface and a silently mis-parsed exam date is worse than no exam date.
# One request per file, so several syllabi upload concurrently from the browser with
# genuinely independent status and failures β€” no job queue in a container that
# sleeps.
SYLLABI_FILE = "syllabi.json"
MAX_SYLLABI_PER_TERM = 8


def _read_syllabi(student_id: str) -> list:
    data = storage.read_json(storage.student_path(student_id, SYLLABI_FILE))
    if isinstance(data, dict):                      # tolerate an early wrapper shape
        data = data.get("syllabi")
    return data if isinstance(data, list) else []


def _write_syllabi(student_id: str, records: list, action: str) -> None:
    storage.write_json(
        storage.student_path(student_id, SYLLABI_FILE),
        records,
        message=f"syllabi: {action} {student_id}",
    )


@app.get("/api/syllabi")
async def get_syllabi(request: Request):
    student_id = _require_student(request)
    return {
        "syllabi": _read_syllabi(student_id),
        "term": syllabus.terms.current_or_next(),
        **syllabus.parse.describe(),
    }


@app.post("/api/syllabi/parse")
async def parse_syllabus(request: Request, file: UploadFile = File(...),
                         term: str = Form(None)):
    """Read one uploaded syllabus. Saves nothing β€” the student reviews first."""
    student_id = _require_student(request)
    if not syllabus.parse.configured():
        raise HTTPException(status_code=503,
                            detail="Syllabus reading isn't set up on this server yet.")
    data = await file.read()
    try:
        record = syllabus.parse.parse(data, file.filename or "syllabus",
                                      term=term or None)
    except syllabus.extract.Unsupported as err:
        # The student can fix this by uploading a different file, so it's a 400
        # with the reason shown as-is.
        raise HTTPException(status_code=400, detail=str(err)) from err
    except syllabus.parse.ParseFailed as err:
        raise HTTPException(status_code=502, detail=str(err)) from err
    return {"record": record,
            "duplicate_of": syllabus.schema.find_duplicate(_read_syllabi(student_id),
                                                           record)}


@app.put("/api/syllabi")
async def put_syllabus(request: Request):
    """Save one reviewed (or corrected) syllabus, replacing any earlier copy of the
    same course and term."""
    student_id = _require_student(request)
    payload = await request.json()
    if not isinstance(payload, dict):
        raise HTTPException(status_code=400, detail="expected a syllabus object")

    records = _read_syllabi(student_id)
    record = syllabus.schema.from_client(
        payload, bounds=syllabus.terms.bounds(payload.get("term") or ""))
    if not record["course_code"]:
        raise HTTPException(status_code=400, detail="A course code is required.")
    if len(records) >= MAX_SYLLABI_PER_TERM and not syllabus.schema.find_duplicate(
            records, record):
        raise HTTPException(
            status_code=400,
            detail=f"That's more than {MAX_SYLLABI_PER_TERM} syllabi β€” remove one "
                   f"you're no longer taking first.")

    records, replaced = syllabus.schema.upsert(records, record)
    _write_syllabi(student_id, records, "replace" if replaced else "add")
    return {"record": record, "replaced": replaced, "syllabi": records}


@app.get("/api/schedule")
async def get_schedule(request: Request):
    """The student's own classes as dated calendar occurrences β€” the Grand Calendar's
    *My classes* layer.

    Separate from `GET /api/syllabi`, which serves the upload and review screen: this
    is a few hundred expanded occurrences that screen has no use for, and the review
    screen is loaded far more often than the calendar.

    Derived, never stored (see `schedule.py`): the expansion depends on term anchors
    that move when the academic-calendar collector re-runs, so caching it in a
    student's file would go quietly stale.
    """
    student_id = _require_student(request)
    return class_schedule.payload(_read_syllabi(student_id))


# --- My VU: key dates + class search ------------------------------------------
@app.get("/api/key-dates")
async def get_key_dates(request: Request, today: str | None = None,
                        to: str | None = None):
    """The full term-scoped academic calendar β€” everything events.vanderbilt.edu
    publishes under the academic-calendar flag, unfiltered (see
    `key_dates.payload` for why this surface doesn't cohort-filter while the
    Today feed does).

    Like Today, never blocks on a cold index: `ready: false` and the browser
    retries."""
    _require_student(request)
    if not kb.ready():
        return {"ready": False, "items": []}
    start = (_as_of(today) or queries.campus_today()).isoformat()
    if to:
        try:
            end = date.fromisoformat(to).isoformat()
        except ValueError:
            raise HTTPException(status_code=400, detail="to must be YYYY-MM-DD")
    else:
        # 180 days spans a semester's registrar records plus the next term's
        # registration season.
        end = (date.fromisoformat(start) + timedelta(days=180)).isoformat()
    return key_dates.payload(kb.get_index(), start, end)


@app.get("/api/classes")
async def search_classes(request: Request, query: str = "", subject: str = "",
                         term: str = "", level: str = "", limit: int = 6):
    """The My VU class search β€” the same selection logic as the agent's
    `find_offered_classes` tool (`kb/offerings.py`), with one difference: the
    "YYYY-YY Year" pseudo-term (School of Medicine year-long blocks) is excluded
    from this student-facing surface.

    The note carries the collection date and the registration-happens-in-YES
    reminder; the UI renders it verbatim."""
    _require_student(request)
    if not kb.ready():
        return {"ready": False, "results": [], "note": ""}
    items, _docs, note = offerings.search(
        kb.get_index(), query=query, subject=subject, term=term,
        level=level, limit=limit, exclude_year_terms=True)
    return {"ready": True, "results": items, "note": note}


@app.get("/api/classes/meta")
async def classes_meta(request: Request):
    """Terms and subjects on file for the class-search form (Year pseudo-terms
    excluded) β€” served separately so the form renders before the first query."""
    _require_student(request)
    if not kb.ready():
        return {"ready": False, "terms": [], "subjects": []}
    return {"ready": True, **offerings.meta(kb.get_index())}


@app.delete("/api/syllabi/{syllabus_id}")
async def delete_syllabus(request: Request, syllabus_id: str):
    """Removing a syllabus removes it from every downstream surface β€” the record is
    the only copy, since the uploaded file was never stored."""
    student_id = _require_student(request)
    records = _read_syllabi(student_id)
    kept = [r for r in records if r.get("id") != syllabus_id]
    if len(kept) == len(records):
        raise HTTPException(status_code=404, detail="No such syllabus.")
    _write_syllabi(student_id, kept, "remove")
    return {"syllabi": kept}


# --- the interest vocabulary -------------------------------------------------
@app.get("/api/topics")
async def get_topics():
    """The topic vocabulary, for the chips in My Story.

    Served rather than duplicated in JavaScript: the same 33 slugs are what the
    collectors tag events and organizations with, and a frontend copy that drifted by
    one slug would silently stop matching. No student data, so no session needed.
    """
    return {"topics": [{"slug": slug, "label": topics.label(slug)}
                       for slug in topics.SLUGS],
            **topics.describe()}


# --- the Today dashboard ----------------------------------------------------
@app.get("/api/today")
async def get_today(request: Request, today: str | None = None):
    """Everything the Today screen shows, derived from the student's own syllabi and
    the knowledge base. See `today.py` for the derivation and the feed ranking.

    Server-side rather than in the browser β€” unlike the calendar, which reads `/kb/`
    directly β€” because the feed ranks free-text interests with BM25 and gates the
    schedule on `syllabus.schema.ship_ready`. Reimplementing either in JS would mean
    two rankers to keep in step, and shipping 2 MB of JSON to rank in a tab.
    """
    student_id = _require_student(request)
    profile = storage.read_json(storage.student_path(student_id, "profile.json")) or {}
    # Today is the *landing* screen, so unlike chat it must not block on a cold
    # index. `ready: false` still carries the schedule, and the browser retries.
    index = kb.get_index() if kb.ready() else None
    return today_screen.payload(profile, _read_syllabi(student_id), index,
                                _as_of(today))


# --- My Crew ----------------------------------------------------------------
@app.get("/api/crew")
async def get_crew(request: Request, today: str | None = None):
    """The two My Crew sections that need the index: the student's own orgs (resolved
    to current names, joined to their next event) and ranked suggestions on the shared
    topic vocabulary. See `crew.py`. The rest of the page reads `/kb/` directly.

    Like Today and unlike chat, this must not block on a cold index: `ready: false`
    lets the browser render the `/kb/`-only sections and retry the ranked ones."""
    student_id = _require_student(request)
    profile = storage.read_json(storage.student_path(student_id, "profile.json")) or {}
    index = kb.get_index() if kb.ready() else None
    return crew_screen.payload(profile, index, _as_of(today))


# --- Ask Foresight ----------------------------------------------------------
# Which conversation a student is in lives in the session, like their name and
# their id β€” this app keeps no client-side state, and a thread id in the signed
# cookie expires with the session the same way everything else does.
CHAT_THREAD_KEY = "chat_thread_id"


def _resume_thread_id(request: Request, student_id: str) -> str | None:
    """The conversation to reopen on boot.

    The session remembers where this browser was. A session that has never
    chatted β€” a fresh sign-in, a second device β€” falls back to the most recent
    thread on file, so "come back tomorrow and continue" survives a new cookie.

    An empty string is not a missing value here: it's "this student pressed New
    conversation", which has to outrank the fallback or the next reload would
    drop them right back into the thread they just left.

    Deliberately not verified against storage: `Chat.load()` already has to treat
    a 404 as "start a new conversation" (a thread deleted from another tab), and
    checking here would cost a storage read on every single page load.
    """
    chosen = request.session.get(CHAT_THREAD_KEY)
    if chosen is not None:
        return chosen or None
    return agent.threads.most_recent_id(student_id)


@app.get("/api/chat/config")
async def chat_config(request: Request):
    """What the chat UI needs to render itself β€” including whether it can work at
    all, so an unconfigured deployment shows an honest message instead of an error
    on the student's first question."""
    student_id = auth.current_student(request)
    first = request.session.get("first_name") or "" if student_id else ""
    return {
        **agent.describe(),
        "knowledge_base": kb.describe(),
        "greeting": agent.greeting(first),
        "suggested_prompts": agent.SUGGESTED_PROMPTS,
        # The client reopens this thread instead of booting into an empty box.
        "thread_id": _resume_thread_id(request, student_id) if student_id else None,
    }


@app.get("/api/chat/threads")
async def chat_threads(request: Request):
    student_id = _require_student(request)
    return {"threads": agent.threads.index(student_id)}


@app.get("/api/chat/threads/{thread_id}")
async def chat_thread(request: Request, thread_id: str):
    student_id = _require_student(request)
    thread = agent.threads.load(student_id, thread_id)
    if not thread:
        raise HTTPException(status_code=404, detail="no such conversation")
    # Opening a thread is what makes it the one you're in β€” so a reload after
    # clicking through the sidebar comes back here, not to wherever you were.
    request.session[CHAT_THREAD_KEY] = thread_id
    return thread


@app.delete("/api/chat/threads/{thread_id}")
async def chat_thread_delete(request: Request, thread_id: str):
    student_id = _require_student(request)
    if request.session.get(CHAT_THREAD_KEY) == thread_id:
        # Deleting the thread you're in leaves you in a new one β€” which is what
        # the screen now shows. Reloading into some other old thread would be a
        # surprise, so this is the same "" as pressing New conversation.
        request.session[CHAT_THREAD_KEY] = ""
    return {"ok": agent.threads.delete(student_id, thread_id)}


@app.post("/api/chat/new")
async def chat_new(request: Request):
    """Leave the current conversation. The only thing that starts a new one β€”
    reloading, navigating and closing the laptop all continue where you were."""
    _require_student(request)
    request.session[CHAT_THREAD_KEY] = ""
    return {"ok": True}


@app.post("/api/chat")
async def chat(request: Request):
    """One turn, streamed as Server-Sent Events.

    A grounded answer takes several seconds of tool calls, and a student watching a
    spinner assumes it's broken β€” so tokens and tool status go out as they happen.
    `EventSource` can't POST, so the client reads this with fetch + a stream reader.
    """
    student_id = _require_student(request)
    if not agent.enabled():
        raise HTTPException(status_code=503,
                            detail="Ask Foresight isn't configured on this server yet.")

    payload = await request.json()
    question = (payload or {}).get("message", "").strip()
    if not question:
        raise HTTPException(status_code=400, detail="message is required")
    if len(question) > 4000:
        raise HTTPException(status_code=400, detail="that message is too long")
    asked_for = (payload or {}).get("thread_id") or None

    thread = agent.threads.load(student_id, asked_for) if asked_for else None
    history = (thread or {}).get("messages", [])
    # Name the conversation before a byte goes out, not after the turn finishes.
    # A student who reloads while the first answer is still streaming can only
    # find their way back if the `start` frame already told them what it's called
    # β€” and an id minted at the end arrives in `done`, which they never see.
    # An id we were given but can't load is a thread deleted from another tab;
    # that turn starts a fresh one rather than resurrecting a tombstone.
    thread_id = thread["thread_id"] if thread else agent.threads.new_thread_id()
    profile = storage.read_json(storage.student_path(student_id, "profile.json")) or {}
    first_name = request.session.get("first_name") or ""

    # Written here rather than once the turn lands: SessionMiddleware can only
    # attach Set-Cookie to `http.response.start`, which for a streamed response
    # goes out before the generator below has run a single line.
    request.session[CHAT_THREAD_KEY] = thread_id

    turn: dict = {"text": "", "final": None, "failed": False}

    async def stream():
        def frame(event: dict) -> str:
            return f"data: {json.dumps(event, ensure_ascii=False)}\n\n"

        yield frame({"type": "start", "thread_id": thread_id})
        try:
            async for event in agent.run_turn(question, history, profile, first_name):
                if event.get("type") == "final":
                    turn["final"] = event
                    continue
                if event.get("type") == "token":
                    # Accumulated as it goes so `persist` has something to save
                    # even if this generator never reaches the line below.
                    turn["text"] += event.get("text") or ""
                yield frame(event)
        except Exception as err:
            turn["failed"] = True
            log.exception("chat: turn failed for %s", student_id)
            yield frame({"type": "error", "message": str(err)})
            return
        yield frame({"type": "done", "thread_id": thread_id})

    def persist() -> None:
        """Save the turn β€” including when the student walked out mid-answer.

        `StreamingResponse` runs `listen_for_disconnect` alongside the body and
        cancels the whole task group the moment the socket closes, so a generator
        suspended at a `yield` never resumes: anything written after the loop is
        simply skipped on a mid-answer reload, and the exchange the student was
        reading would be gone when they came back. A `background` task is awaited
        *after* that task group exits, so it still runs on a cancelled response.

        Run off the event loop by Starlette (it's a sync callable), which also
        suits `storage`'s blocking commit.
        """
        if turn["failed"]:
            return                  # the model blew up; there's no answer to keep
        final = turn["final"]
        if final is None and not turn["text"].strip():
            return                  # nothing was ever said
        answer = final or {"text": turn["text"], "sources": [],
                           "suggestion": None, "tools": []}
        try:
            agent.threads.append_turn(student_id, thread_id, question, answer,
                                      partial=final is None)
        except Exception:
            # The student has their answer on screen; losing the transcript is
            # worth a log line, not a failed turn.
            log.exception("chat: failed to persist the turn")

    return StreamingResponse(stream(), media_type="text/event-stream",
                             background=BackgroundTask(persist), headers={
        "Cache-Control": "no-cache, no-transform",
        "Connection": "keep-alive",
        # Spaces sits behind a proxy that will otherwise buffer the whole response
        # and defeat the point of streaming.
        "X-Accel-Buffering": "no",
    })


@app.get("/healthz")
async def healthz():
    return {
        "ok": True,
        "storage": storage.describe(),
        "cookie": {"same_site": SAME_SITE, "secure": HTTPS_ONLY},
        "chat": agent.describe(),
        "knowledge_base": kb.describe(),
    }


# --- pages -----------------------------------------------------------------
@app.get("/")
async def landing():
    """Public front page β€” explains Foresight and links to sign in / sign up.
    Always served, signed in or not; the page itself swaps its buttons for an
    'Open Foresight' link when a session exists."""
    return FileResponse(APP_DIR / "landing.html")


@app.get("/login")
async def login_page(request: Request):
    if auth.current_student(request):
        return RedirectResponse("/app")
    return FileResponse(APP_DIR / "login.html")


@app.get("/app")
async def student_app(request: Request):
    if not auth.current_student(request):
        return RedirectResponse("/login")
    return FileResponse(APP_DIR / "index.html")


# The knowledge base is public campus information and is read by the calendar
# and map screens. Mounted at a fixed prefix so one fetch path works both when
# running from the repo and from inside the container image.
app.mount("/kb", StaticFiles(directory=KB_DIR), name="kb")
# Static assets last: a real file wins, but it must not shadow the routes above.
app.mount("/", StaticFiles(directory=APP_DIR, html=False), name="app")