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