From 60e8640d86b470681322bc4dceb96254dd9cd8c9 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 1 Jun 2026 17:41:24 -0400 Subject: [PATCH] sessions: list / tree / FTS5 search views via arborist.read.SessionsView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts the sessions shard (~/.arborist/sessions.db by default) as a read-only data source alongside the corpus shards. Imports stay behind the arborist.read seam — viz never touches the schema directly. Wire: - storage.py: get_sessions(path) — process-cached SessionsView, parallel structure to get_shards(paths). close_all() now closes both caches. - __init__.py: ARBORIST_VIZ_SESSIONS_DB env (or arborist.sessions_db ini) → request.sessions request property. Defaults to ~/.arborist/sessions.db. - routes.py: /sessions (list), /sessions/{sid} (tree), /sessions/find (FTS5 search) + matching /api/sessions/... JSON. - views/sessions.py: HTML + JSON handlers. - templates/sessions_{list,tree,find}.jinja2: minimal views — table for list, recursive tree macro for tree, table for find results. - static/css/viz.css: cmd-table / cmd-tree / cmd-audit-* styles. Endpoint summary: /sessions list every session /sessions/{sid} one session's tree (synthetic root collapsed, cross-session children show [from ] tag) /sessions/find?q=...&limit= FTS5 across all sessions /api/sessions JSON list /api/sessions/{sid} JSON tree /api/sessions/find?q=... JSON search Audit_mode is rendered as a colored chip (strict/hybrid/ungrounded/ canonical_projection); cited titles surface as inline pills. Privacy contract from base.jinja2 inherits unchanged — sessions shard is local-private per ticket #000069 §14. --- arborist_viz/__init__.py | 18 ++ arborist_viz/routes.py | 10 ++ arborist_viz/static/css/viz.css | 44 +++++ arborist_viz/storage.py | 35 ++++ arborist_viz/templates/sessions_find.jinja2 | 44 +++++ arborist_viz/templates/sessions_list.jinja2 | 52 ++++++ arborist_viz/templates/sessions_tree.jinja2 | 52 ++++++ arborist_viz/views/sessions.py | 173 ++++++++++++++++++++ 8 files changed, 428 insertions(+) create mode 100644 arborist_viz/templates/sessions_find.jinja2 create mode 100644 arborist_viz/templates/sessions_list.jinja2 create mode 100644 arborist_viz/templates/sessions_tree.jinja2 create mode 100644 arborist_viz/views/sessions.py diff --git a/arborist_viz/__init__.py b/arborist_viz/__init__.py index e38cdb2..0f40ebe 100644 --- a/arborist_viz/__init__.py +++ b/arborist_viz/__init__.py @@ -146,6 +146,24 @@ def main(global_config, **settings): return _storage.get_shards(req.arborist_shards) config.add_request_method(_shards, "shards", reify=True) + # Sessions shard — single-file conversation-forest journal. The + # corpus shards (Shards above) and the sessions shard are unrelated + # data layers, so they get separate request properties + separate + # process caches. Env var ARBORIST_VIZ_SESSIONS_DB overrides the + # .ini default ("arborist.sessions_db"); both default to + # ~/.arborist/sessions.db when unset. + sessions_db = ( + os.environ.get("ARBORIST_VIZ_SESSIONS_DB") + or settings.get("arborist.sessions_db", "") + or str(os.path.expanduser("~/.arborist/sessions.db")) + ) + config.add_request_method( + lambda r, _v=sessions_db: _v, "arborist_sessions_db", reify=True + ) + def _sessions(req): + return _storage.get_sessions(req.arborist_sessions_db) + config.add_request_method(_sessions, "sessions", reify=True) + config.include(".routes") config.scan("arborist_viz.views") diff --git a/arborist_viz/routes.py b/arborist_viz/routes.py index 2c0a1d0..10c1a32 100644 --- a/arborist_viz/routes.py +++ b/arborist_viz/routes.py @@ -53,6 +53,16 @@ def includeme(config): config.add_route("api_diff", "/api/diff/{root_a}/{root_b}") config.add_route("api_graph_snapshot", "/api/graph/snapshot") + # sessions — Merkle conversation forest (single-shard, per-user + # journal at ~/.arborist/sessions.db). Read-only consumer of + # arborist.read.SessionsView. + config.add_route("sessions_list", "/sessions") + config.add_route("sessions_find", "/sessions/find") + config.add_route("sessions_tree", "/sessions/{sid}") + config.add_route("api_sessions_list", "/api/sessions") + config.add_route("api_sessions_find", "/api/sessions/find") + config.add_route("api_sessions_tree", "/api/sessions/{sid}") + # SSE — §3-corrected replaces WS routes with text/event-stream. config.add_route("sse_audit_live", "/sse/audit/live") config.add_route("sse_run", "/sse/run/{run_id}") diff --git a/arborist_viz/static/css/viz.css b/arborist_viz/static/css/viz.css index 0332fcb..9a96e25 100644 --- a/arborist_viz/static/css/viz.css +++ b/arborist_viz/static/css/viz.css @@ -629,3 +629,47 @@ code, pre { font-family: var(--mono); font-size: 12px; } white-space: pre-wrap; word-break: keep-all; } + +/* ---- sessions views (ticket #000069 sessions surface) ---------------- */ + +.cmd-table { width: 100%; border-collapse: collapse; margin-top: 1em; } +.cmd-table th, .cmd-table td { + text-align: left; padding: 0.4em 0.8em; + border-bottom: 1px solid rgba(255,255,255,0.1); + font-size: 0.9em; +} +.cmd-table th { font-weight: 600; opacity: 0.8; } +.cmd-table code { font-size: 0.85em; } +.cmd-hash { word-break: break-all; } + +.cmd-inline-form { display: inline-flex; gap: 0.5em; margin-top: 0.5em; } +.cmd-inline-form input[type=text] { min-width: 24em; } + +.cmd-empty { opacity: 0.6; font-style: italic; padding: 2em 0; } + +.cmd-tree { list-style: none; padding-left: 0; } +.cmd-tree-children { list-style: none; padding-left: 1.5em; + border-left: 1px solid rgba(255,255,255,0.15); margin-left: 0.4em; } +.cmd-tree-node { margin: 0.4em 0; } +.cmd-tree-line { display: flex; gap: 0.6em; align-items: baseline; + flex-wrap: wrap; } +.cmd-tree-bates { font-size: 0.85em; } +.cmd-tree-audit { font-size: 0.75em; padding: 0.05em 0.4em; + border-radius: 0.2em; font-weight: 600; } +.cmd-audit-strict { background: rgba(80, 200, 120, 0.25); } +.cmd-audit-hybrid { background: rgba(220, 180, 80, 0.25); } +.cmd-audit-ungrounded { background: rgba(220, 100, 100, 0.25); } +.cmd-audit-canonical_projection { background: rgba(120, 160, 220, 0.25); } +.cmd-tree-from { font-size: 0.75em; opacity: 0.6; font-style: italic; } +.cmd-tree-label { font-size: 0.85em; opacity: 0.7; } +.cmd-tree-q { font-size: 0.95em; } +.cmd-tree-current > .cmd-tree-line .cmd-tree-bates { font-weight: 700; } +.cmd-tree-current > .cmd-tree-line::after { + content: '← current'; font-size: 0.8em; opacity: 0.7; +} +.cmd-tree-branch > .cmd-tree-line .cmd-tree-bates { color: #8af; } +.cmd-tree-cites { display: flex; gap: 0.3em; flex-wrap: wrap; + padding-left: 1.5em; margin-top: 0.2em; } +.cmd-cite { font-size: 0.75em; opacity: 0.7; + padding: 0.05em 0.4em; border-radius: 0.2em; + background: rgba(255,255,255,0.06); } diff --git a/arborist_viz/storage.py b/arborist_viz/storage.py index 5ee265f..a6ae70f 100644 --- a/arborist_viz/storage.py +++ b/arborist_viz/storage.py @@ -29,6 +29,14 @@ except Exception: # pragma: no cover — fixture mode only Shards = None # type: ignore open_shards = None # type: ignore +try: + from arborist.read import SessionsView, open_sessions + HAVE_ARBORIST_SESSIONS = True +except Exception: # pragma: no cover + HAVE_ARBORIST_SESSIONS = False + SessionsView = None # type: ignore + open_sessions = None # type: ignore + log = logging.getLogger(__name__) @@ -63,3 +71,30 @@ def close_all() -> None: except Exception: pass _HANDLES.clear() + for v in _SESSIONS_HANDLES.values(): + try: + v.close() + except Exception: + pass + _SESSIONS_HANDLES.clear() + + +# ---- sessions (conversation forest, single per-user shard) ---------- + +_SESSIONS_HANDLES: dict[str, "SessionsView"] = {} + + +def get_sessions(path: str | None) -> Optional["SessionsView"]: + """Return the cached :class:`arborist.read.SessionsView` for the + sessions shard at ``path``. When ``path`` is falsy or arborist + isn't importable, returns ``None`` — callers should render the + "no sessions shard" fallback state. + """ + if not HAVE_ARBORIST_SESSIONS or not path: + return None + v = _SESSIONS_HANDLES.get(path) + if v is None or not v.is_open: + v = open_sessions(path) + _SESSIONS_HANDLES[path] = v + log.info("arborist.read.sessions: opened %s", path) + return v diff --git a/arborist_viz/templates/sessions_find.jinja2 b/arborist_viz/templates/sessions_find.jinja2 new file mode 100644 index 0000000..e1ee4e7 --- /dev/null +++ b/arborist_viz/templates/sessions_find.jinja2 @@ -0,0 +1,44 @@ +{% extends "base.jinja2" %} +{% block body %} +
+
+

Search sessions

+
+ + + +
+

← all sessions

+
+ + {% if query %} +

{{ hits|length }} match(es) for {{ query }}

+ {% if hits %} + + + + + + + + + + + + {% for n in hits %} + + + + + + + + {% endfor %} + +
batessessionauditquestioncited titles
{{ n.bates }}{{ n.sid }}{{ n.audit_mode }}{{ (n.question or '')[:120] }}{% for t in n.cited_titles %}{{ t }}{% endfor %}
+ {% else %} +

no matches

+ {% endif %} + {% endif %} +
+{% endblock %} diff --git a/arborist_viz/templates/sessions_list.jinja2 b/arborist_viz/templates/sessions_list.jinja2 new file mode 100644 index 0000000..cbc8f49 --- /dev/null +++ b/arborist_viz/templates/sessions_list.jinja2 @@ -0,0 +1,52 @@ +{% extends "base.jinja2" %} +{% block body %} +
+
+

Sessions

+

+ Merkle conversation forest — single shard, + {% if sessions_stats.open %} + {{ sessions_stats.n_sessions }} session(s) · + {{ sessions_stats.n_nodes }} node(s) · + {{ sessions_stats.n_audit_events }} audit event(s) · + {{ sessions_stats.path }} + {% else %} + no sessions shard configured + {% endif %} +

+
+ + +
+
+ + {% if rows %} + + + + + + + + + + + + + {% for s in rows %} + + + + + + + + + {% endfor %} + +
sidnodessession_rootcurrent_batesupdatedlabel
{{ s.sid }}{{ s.n_nodes }}{{ s.session_root }}{{ s.current_bates }}{{ s.updated_at }}{{ s.label or '' }}
+ {% else %} +

No sessions yet. Run make session to start one.

+ {% endif %} +
+{% endblock %} diff --git a/arborist_viz/templates/sessions_tree.jinja2 b/arborist_viz/templates/sessions_tree.jinja2 new file mode 100644 index 0000000..110fb65 --- /dev/null +++ b/arborist_viz/templates/sessions_tree.jinja2 @@ -0,0 +1,52 @@ +{% extends "base.jinja2" %} + +{% macro render_node(node, kids_of, branch_set, current_bates, this_sid, depth=0) -%} +
  • +
    + {{ node.bates }} + {{ node.audit_mode }} + {% if node.sid != this_sid %}[from {{ node.sid }}]{% endif %} + {% if node.label %}{{ '{' + node.label + '}' }}{% endif %} + {{ (node.question or '')[:120] }} +
    + {% if node.cited_titles %} +
    + {% for t in node.cited_titles %}{{ t }}{% endfor %} +
    + {% endif %} + {% set kids = kids_of.get(node.bates, []) %} + {% if kids %} +
      + {% for c in kids %} + {{ render_node(c, kids_of, branch_set, current_bates, this_sid, depth + 1) }} + {% endfor %} +
    + {% endif %} +
  • +{%- endmacro %} + +{% block body %} +
    +
    +

    session {{ session.sid }}

    +

    + {{ session.n_nodes }} node(s) · {{ branches|length }} branch point(s) · + root={{ session.session_root }} · + current → {{ session.current_bates }} +

    +

    ← all sessions · JSON

    +
    + + {% set kids_of = children %} + {% set root_node = nodes[0] if nodes and nodes[0].seq == 0 else None %} + {% if root_node %} +
      + {% for child in kids_of.get(root_node.bates, []) %} + {{ render_node(child, kids_of, branches, session.current_bates, session.sid) }} + {% endfor %} +
    + {% else %} +

    empty session

    + {% endif %} +
    +{% endblock %} diff --git a/arborist_viz/views/sessions.py b/arborist_viz/views/sessions.py new file mode 100644 index 0000000..95555cb --- /dev/null +++ b/arborist_viz/views/sessions.py @@ -0,0 +1,173 @@ +"""Sessions — Merkle conversation forest dashboard. + +Read-only view of the sessions shard (one file per user, at +``~/.arborist/sessions.db`` by default; overridable via env or .ini). +Three surfaces: + + - /sessions list all sessions + - /sessions/{sid} one session's tree + - /sessions/find?q=... FTS5 search across every session + - /api/sessions JSON list + - /api/sessions/{sid} JSON tree + - /api/sessions/find?q=... JSON search + +All schema knowledge lives behind :mod:`arborist.read.SessionsView`. +""" +from __future__ import annotations + +from pyramid.httpexceptions import HTTPNotFound +from pyramid.view import view_config + +from ..views.home import _common_ctx + + +def _sessions_ctx(request) -> dict: + """Common template context with sessions stats.""" + sessions = request.sessions + if sessions is None or not sessions.is_open: + s_stats = {"n_sessions": 0, "n_nodes": 0, + "n_audit_events": 0, "open": False} + else: + c = sessions.counts() + s_stats = { + "n_sessions": c.n_sessions, + "n_nodes": c.n_nodes, + "n_audit_events": c.n_audit_events, + "open": True, + "path": c.shard_path, + } + ctx = _common_ctx(request) + ctx["sessions_stats"] = s_stats + return ctx + + +@view_config(route_name="sessions_list", renderer="sessions_list.jinja2") +def sessions_list(request): + sessions = request.sessions + rows = sessions.list_sessions() if sessions else [] + ctx = _sessions_ctx(request) + ctx["title"] = "Sessions — Arborist VIZ" + ctx["rows"] = rows + return ctx + + +@view_config(route_name="sessions_tree", renderer="sessions_tree.jinja2") +def sessions_tree(request): + sessions = request.sessions + if sessions is None: + raise HTTPNotFound("no sessions shard configured") + sid = request.matchdict["sid"] + meta = sessions.get_session(sid) + if meta is None: + raise HTTPNotFound(f"no such session: {sid}") + nodes = sessions.all_nodes_in_session(sid) + # Materialize a children-map per Bates so the template can render + # the tree without N round-trips into SessionsView. + children: dict[str, list] = {} + for n in nodes: + if n.parent_bates: + children.setdefault(n.parent_bates, []).append(n) + # Also include cross-session children whose parent_bates points + # into this session. + for parent_bates in list(children.keys()): + pass # already populated by the loop above + branches = sessions.branches_in_session(sid) + ctx = _sessions_ctx(request) + ctx["title"] = f"session {sid} — Arborist VIZ" + ctx["session"] = meta + ctx["nodes"] = nodes + ctx["children"] = children + ctx["branches"] = set(branches) + return ctx + + +@view_config(route_name="sessions_find", renderer="sessions_find.jinja2") +def sessions_find(request): + sessions = request.sessions + q = (request.params.get("q") or "").strip() + try: + limit = max(1, min(100, int(request.params.get("limit") or "20"))) + except ValueError: + limit = 20 + hits = sessions.find(q, limit=limit) if (sessions and q) else [] + ctx = _sessions_ctx(request) + ctx["title"] = f"search — Arborist VIZ" + (f" — {q}" if q else "") + ctx["query"] = q + ctx["limit"] = limit + ctx["hits"] = hits + return ctx + + +# ---- JSON APIs ------------------------------------------------------ + + +def _node_to_json(n) -> dict: + return { + "bates": n.bates, "sid": n.sid, "seq": n.seq, + "parent_bates": n.parent_bates, + "question": n.question, + "answer_text": n.answer_text, + "cache_key": n.cache_key, + "audit_mode": n.audit_mode, + "cited_titles": n.cited_titles, + "n_cited_sources": n.n_cited_sources, + "node_hash": n.node_hash, + "subtree_hash": n.subtree_hash, + "created_at": n.created_at, + "label": n.label, + } + + +@view_config(route_name="api_sessions_list", renderer="json") +def api_sessions_list(request): + sessions = request.sessions + if sessions is None: + return {"sessions": [], "open": False} + return { + "sessions": [ + { + "sid": s.sid, "root_bates": s.root_bates, + "current_bates": s.current_bates, + "label": s.label, "n_nodes": s.n_nodes, + "session_root": s.session_root, + "created_at": s.created_at, + "updated_at": s.updated_at, + } + for s in sessions.list_sessions() + ], + "open": True, + } + + +@view_config(route_name="api_sessions_tree", renderer="json") +def api_sessions_tree(request): + sessions = request.sessions + if sessions is None: + raise HTTPNotFound("no sessions shard") + sid = request.matchdict["sid"] + meta = sessions.get_session(sid) + if meta is None: + raise HTTPNotFound(f"no such session: {sid}") + nodes = sessions.all_nodes_in_session(sid) + return { + "sid": sid, + "session_root": meta.session_root, + "current_bates": meta.current_bates, + "branches": sessions.branches_in_session(sid), + "nodes": [_node_to_json(n) for n in nodes], + } + + +@view_config(route_name="api_sessions_find", renderer="json") +def api_sessions_find(request): + sessions = request.sessions + q = (request.params.get("q") or "").strip() + try: + limit = max(1, min(100, int(request.params.get("limit") or "20"))) + except ValueError: + limit = 20 + hits = sessions.find(q, limit=limit) if (sessions and q) else [] + return { + "query": q, "limit": limit, + "hits": [_node_to_json(n) for n in hits], + }