| """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 |
| MAX_TURNS = 60 |
| 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 "") |
| |
| 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): |
| |
| 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 |
|
|
|
|
| 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() |
|
|