Spaces:
Running
Running
| """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, | |
| ) | |
| 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 | |
| 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"]} | |
| 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"]} | |
| async def logout(request: Request): | |
| request.session.clear() | |
| return {"ok": True} | |
| 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"])} | |
| 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) | |
| 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) | |
| 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") | |
| 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 ------------------------------------------------------ | |
| 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 {} | |
| 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") | |
| 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} | |
| 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}", | |
| ) | |
| 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(), | |
| } | |
| 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)} | |
| 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} | |
| 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 ------------------------------------------ | |
| 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) | |
| 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} | |
| 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())} | |
| 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 ------------------------------------------------- | |
| 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 ---------------------------------------------------- | |
| 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 ---------------------------------------------------------------- | |
| 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) | |
| 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, | |
| } | |
| async def chat_threads(request: Request): | |
| student_id = _require_student(request) | |
| return {"threads": agent.threads.index(student_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 | |
| 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)} | |
| 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} | |
| 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", | |
| }) | |
| 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 ----------------------------------------------------------------- | |
| 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") | |
| async def login_page(request: Request): | |
| if auth.current_student(request): | |
| return RedirectResponse("/app") | |
| return FileResponse(APP_DIR / "login.html") | |
| 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") | |