"""Chat transcripts, one file per thread, in the per-student dataset storage. Layout, alongside the profile that's already there: students/{student_id}/chats/index.json — the thread list students/{student_id}/chats/{thread_id}.json — one conversation Every write is a git commit in the dataset repo (`server/storage.py`), so the budget is **one write per completed turn** — never per token. The index is only rewritten when the sidebar's ordering would actually change, which for a normal conversation means once, when it starts. This is also the shape the planned "My Story" feature needs: a durable, timestamped record of what a student asked and what they were pointed toward. """ from __future__ import annotations import logging import re import secrets from datetime import datetime, timezone from .. import storage log = logging.getLogger("foresight.agent") MAX_THREADS_LISTED = 50 def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def new_thread_id() -> str: return "t_" + secrets.token_hex(6) def _valid(thread_id: str) -> bool: """Thread ids become path segments — never let one traverse.""" return bool(re.fullmatch(r"t_[0-9a-f]{12}", thread_id or "")) def _path(student_id: str, name: str) -> str: return storage.student_path(student_id, f"chats/{name}") def title_from(question: str) -> str: """A thread title from the first question. Cheap on purpose — a second model call to name a conversation isn't worth the latency or the tokens.""" words = (question or "").strip().split() title = " ".join(words[:7]) if len(words) > 7: title += "…" return title[:80] or "New conversation" # --- read ------------------------------------------------------------------- def load(student_id: str, thread_id: str) -> dict | None: """The transcript, or None if there isn't one to show. A deleted thread reads back as its tombstone (see `delete`), which is not a conversation — returning it would 200 an empty transcript to the client and let a new turn resurrect the thread the student threw away. """ if not _valid(thread_id): return None data = storage.read_json(_path(student_id, f"{thread_id}.json")) if not data or data.get("deleted_at"): return None return data def index(student_id: str) -> list[dict]: data = storage.read_json(_path(student_id, "index.json")) or {} threads = data.get("threads") or [] threads.sort(key=lambda t: t.get("updated_at") or "", reverse=True) return threads[:MAX_THREADS_LISTED] def most_recent_id(student_id: str) -> str | None: """The conversation to fall back to when a session doesn't know where it was — a fresh sign-in, or the same student on a second device.""" listed = index(student_id) return listed[0].get("thread_id") if listed else None # --- write ------------------------------------------------------------------ def append_turn(student_id: str, thread_id: str | None, question: str, answer: dict, partial: bool = False) -> dict: """Persist one exchange and return the saved thread. `answer` is the runner's final event: text, sources, suggestion, tools. `partial` marks an answer the student walked out on — they reloaded or closed the tab while it was still streaming, so what we have is however far it got. It's saved rather than dropped: the exchange was on their screen, and finding it missing when they come back is worse than finding it cut short. """ created = False if not thread_id or not _valid(thread_id): thread_id, created = new_thread_id(), True thread = load(student_id, thread_id) if not created else None if thread is None: thread = {"thread_id": thread_id, "created_at": _now(), "title": title_from(question), "messages": []} created = True stamp = _now() thread["messages"].append({"role": "user", "text": question, "at": stamp}) reply = { "role": "assistant", "text": answer.get("text") or "", "at": stamp, "sources": answer.get("sources") or [], "suggestion": answer.get("suggestion"), "tools": answer.get("tools") or [], } if partial: reply["partial"] = True thread["messages"].append(reply) thread["updated_at"] = stamp storage.write_json(_path(student_id, f"{thread_id}.json"), thread, message=f"chat: turn in {thread_id}") _touch_index(student_id, thread) return thread def _touch_index(student_id: str, thread: dict) -> None: """Keep the index ordered by last activity, for as few commits as possible. An index write is a second commit per turn, so we only pay for one when the ordering would actually change: a brand-new thread, or a turn in a thread that isn't already at the top. Continuing the conversation you're already in — the common case by far — still costs one commit per turn, which is what the write budget here has always been. The consequence is that an entry's `updated_at` is not "last message"; it's "last time this thread moved to the top", which is exactly what ordering needs and nothing more. Don't render it as a last-activity time. `created_at` is carried alongside it precisely so the sidebar has an honest stamp to show ("Started …"), and `load()` always has the real per-message timestamps. """ data = storage.read_json(_path(student_id, "index.json")) or {"threads": []} threads = data.get("threads") or [] if threads and threads[0].get("thread_id") == thread["thread_id"]: return # already first; a rewrite would change nothing # `preview` is the opening question and `answer` the first reply — both are # the *first* of their kind, never the latest, so an entry stays true however # long the conversation runs and however rarely this function rewrites it. # The title is the question too, so a sidebar showing both lines wants the # answer on the second one; `preview` stays for entries written before that. preview = answer = "" for m in thread.get("messages", []): if not preview and m.get("role") == "user": preview = (m.get("text") or "")[:120] elif not answer and m.get("role") == "assistant": answer = " ".join((m.get("text") or "").split())[:140] if preview and answer: break rest = [t for t in threads if t.get("thread_id") != thread["thread_id"]] rest.insert(0, {"thread_id": thread["thread_id"], "title": thread["title"], "created_at": thread.get("created_at"), "updated_at": thread.get("updated_at"), "preview": preview, "answer": answer}) data["threads"] = rest[:MAX_THREADS_LISTED] try: storage.write_json(_path(student_id, "index.json"), data, message=f"chat: index {thread['thread_id']}") except Exception: # The transcript is already saved; a missing index entry is recoverable # and shouldn't fail the student's turn. log.exception("chat: failed to update the thread index") def delete(student_id: str, thread_id: str) -> bool: """Remove a thread from the index and blank its transcript. `storage` has no delete — it's an upload-only wrapper over the dataset repo — so the transcript is overwritten with a tombstone rather than removed. The repo's history keeps the old content either way; this is "gone from the app", not "erased from git". """ if not _valid(thread_id): return False data = storage.read_json(_path(student_id, "index.json")) or {"threads": []} before = len(data.get("threads", [])) data["threads"] = [t for t in data.get("threads", []) if t.get("thread_id") != thread_id] storage.write_json(_path(student_id, "index.json"), data, message=f"chat: delete {thread_id}") storage.write_json(_path(student_id, f"{thread_id}.json"), {"thread_id": thread_id, "deleted_at": _now(), "messages": []}, message=f"chat: tombstone {thread_id}") return len(data["threads"]) < before