loopable / platform /harness /chatlog.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
3.96 kB
"""harness/chatlog.py β€” durable Analyst conversations (2026-07-16).
Every Analyst conversation is a logged SESSION that survives reruns, page navigation and Space
restarts. Persistence rides the platform's only writable store (core/store.py β†’ the private HF
dataset), the SAME mechanism as users/notes β€” Odoo stays read-only.
Shape (store key 'analyst_sessions'): { username: [ session, ... ] } (newest first), each
session = {id, title, started_at, updated_at, ui:[[role,content,artifacts],...],
llm:[{role,content},...]}.
ui = the redisplay transcript; llm = the trimmed model-context transcript.
Write posture (per the design review): the LIVE turn stays in st.session_state for a snappy chat;
we persist in a BACKGROUND daemon thread (core/store.update is a full dataset commit β€” never block
the UI on it), and bound growth (MAX_SESSIONS per user, MAX_TURNS per session) so the JSON stays
small. A failed/absent store degrades to in-memory only (chat still works this session).
"""
import threading
import time
import uuid
import core.store as store
KEY = "analyst_sessions"
MAX_SESSIONS = 40 # keep the last N conversations per user (bounds the JSON)
MAX_TURNS = 60 # ui/llm entries kept per conversation
TITLE_LEN = 60
def new_id():
return uuid.uuid4().hex[:12]
def new_session(sid=None):
now = time.strftime("%Y-%m-%d %H:%M:%S")
return {"id": sid or new_id(), "title": "", "started_at": now, "updated_at": now,
"ui": [], "llm": []}
def _safe(obj):
"""JSON round-trip a value (chart/kpi artifacts) so only serializable content is stored;
anything that won't serialize is dropped rather than breaking the whole write."""
import json
try:
return json.loads(json.dumps(obj, default=str))
except Exception:
return None
def load_sessions(username):
"""The user's conversations, newest first (lenient read β€” never raises on a store blip)."""
if not store.available():
return []
try:
all_s = store.get(KEY) or {}
except Exception:
return []
return list(all_s.get(username, []))
def touch(session, question):
"""Stamp updated_at and derive a title from the first user question."""
session["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
if not session.get("title") and question:
t = " ".join(str(question).split())
session["title"] = t[:TITLE_LEN] + ("…" if len(t) > TITLE_LEN else "")
# bound in-memory transcript length (keep the most recent turns)
if len(session.get("ui", [])) > MAX_TURNS:
session["ui"] = session["ui"][-MAX_TURNS:]
if len(session.get("llm", [])) > MAX_TURNS:
session["llm"] = session["llm"][-MAX_TURNS:]
return session
def upsert(sessions, session):
"""Return the sessions list with `session` moved to the front (deduped by id), capped."""
out = [s for s in (sessions or []) if s.get("id") != session.get("id")]
out.insert(0, session)
return out[:MAX_SESSIONS]
def _persist(username, session):
# sanitize artifacts for storage (charts are plain dicts; drop any non-serializable stragglers)
stored = dict(session)
stored["ui"] = [[role, content, [a for a in (_safe(x) for x in (arts or [])) if a is not None]]
for (role, content, arts) in session.get("ui", [])]
def _fn(data):
data = data or {}
data[username] = upsert(data.get(username, []), stored)
return data
try:
store.update(KEY, _fn)
except Exception:
pass # store blip / no token β†’ in-memory only this run
def save_async(username, session):
"""Persist in a daemon thread so the chat never waits on a dataset commit."""
if not store.available():
return
threading.Thread(target=_persist, args=(username, dict(session)),
daemon=True, name="analyst-chatlog").start()