Spaces:
Running
Running
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")
|