The bare-tree layout fox flagged ("kinda sucks") showed one line per
node and a huge empty page. Each node is now a card with:
- bates + audit chip + branch/current pills in the header
- the question, full width
- cited-title pills row
- <details> "answer (N chars)" expandable to the full claim-lattice
pointer-line answer (monospace, scrollable, indented with a
cyan accent stripe)
- <details> "hashes / metadata" with cache_key (clickable to
/cache/<key>), node_hash, subtree_hash, created_at, parent_bates
cmd-panel-wide on the tree page so cards fill the screen instead of
leaving 70% empty.
New /cache/{cache_key} page lists every session node referencing a
providence cache_key — surfaces the "this answer reused across N
turns/sessions" structure. /api/cache-key/{cache_key} JSON twin.
/api/resolve/{hash} now dispatches a cache_key to /cache/<key> (was
/sessions/find?q=<hash> which FTS5 couldn't tokenize). Also
recognises a session node_hash or subtree_hash → /sessions/{sid}.
Routes: /cache/{cache_key}, /api/cache-key/{cache_key} (distinct
from the pre-existing /api/cache/{cache_key} which targets the
providence_cache row itself; the ticket-§13 surface stays unchanged).
CSS: cmd-node-card, cmd-answer-pre, cmd-meta-dl, cmd-current-pill,
cmd-branch-pill, cmd-panel-wide; dark-themed with status-coded
borders (cyan for current, amber for branch points).
205 lines
6.6 KiB
Python
205 lines
6.6 KiB
Python
"""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="cache_key_page", renderer="cache_key.jinja2")
|
|
def cache_key_page(request):
|
|
"""Show every session node referencing a given providence cache_key.
|
|
A cache hit reused across sessions/turns lights up here."""
|
|
sessions = request.sessions
|
|
cache_key = request.matchdict["cache_key"].strip().lower()
|
|
hits = (
|
|
sessions.find_by_cache_key(cache_key)
|
|
if (sessions and sessions.is_open) else []
|
|
)
|
|
ctx = _sessions_ctx(request)
|
|
ctx["title"] = f"cache_key {cache_key[:12]}… — Arborist VIZ"
|
|
ctx["cache_key"] = cache_key
|
|
ctx["hits"] = hits
|
|
return ctx
|
|
|
|
|
|
@view_config(route_name="api_session_cache_key", renderer="json")
|
|
def api_cache_key(request):
|
|
sessions = request.sessions
|
|
cache_key = request.matchdict["cache_key"].strip().lower()
|
|
hits = (
|
|
sessions.find_by_cache_key(cache_key)
|
|
if (sessions and sessions.is_open) else []
|
|
)
|
|
return {
|
|
"cache_key": cache_key,
|
|
"n_session_nodes": len(hits),
|
|
"nodes": [_node_to_json(n) for n in hits],
|
|
}
|
|
|
|
|
|
@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],
|
|
}
|