foresight / server /agent /threads.py
umangchaudhry's picture
Deploy 275bfb4 from GitHub
464ae11 verified
Raw
History Blame Contribute Delete
8.33 kB
"""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