File size: 3,958 Bytes
c14ceee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | """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()
|