"""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")