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