arborist-viz/arborist_viz/views/home.py
russell@unturf.com cb6cab0886
proof dispatch: every hash kind through /proof/{hash}, no kind-named URLs
Two fixes to fox feedback ("rarely like stuff collapsed · why does it
need to be a different screen than the other node types?"):

1. <details open> on answer + metadata panels in sessions_tree.jinja2.
   Expanded by default — fox rarely wants stuff collapsed; clicks to
   collapse cost nothing and discoverability of the field is what
   matters on first read.

2. /proof/{hash} is now THE dispatcher — drop ANY hash here and the
   page resolves the right view:

     - corpus document_root / leaf / merkle interior  → proof.jinja2
       (existing 3D Merkle lattice + inclusion proof + leaf-list)
     - providence cache_key with session references → cache_key.jinja2
       (every session node that hit this cache entry)
     - session node_hash / subtree_hash → sessions_tree.jinja2
       (the session's conversation tree)
     - session_root (= seq=0 node's subtree_hash) → sessions_tree.jinja2

   Aligns with ticket #000069 §13 discipline: "never mint new
   kind-named URLs." /sessions/{sid} and /cache/{cache_key} stay as
   working aliases — but the resolver, the metadata-panel links, and
   the hash-drop bar all route through /proof/{hash} now.

Sessions are still tree-shaped (parent/child) where corpus roots are
flat leaf lists, so the IN-PAGE view differs. That's a topology fact,
not a URL choice — sessions and documents live in the same hash
universe under one route. A future six.js tree widget could share
the proof.jinja2 3D stage; not in this commit.

Resolver:
- /api/resolve/{hash} → "url": "/proof/{hash}" for cache_keys and
  session node hashes (was /cache/<key> and /sessions/<sid>).

Templates:
- sessions_tree node-card cache_key + node_hash + subtree_hash are
  now clickable → each opens /proof/<hash>.
- <details> on answer + hash panels default-open.

Smoke verified all four dispatch paths:
  /proof/<cache_key>    → cache_key.jinja2
  /proof/<session_root> → sessions_tree.jinja2
  /proof/<node_hash>    → sessions_tree.jinja2
  /proof/<unknown>      → proof.jinja2 (corpus fallback)
2026-06-01 18:08:00 -04:00

217 lines
7.2 KiB
Python

"""Home + meta endpoints + dedicated proof/audit visualizer pages."""
from __future__ import annotations
import os
from pyramid.response import FileResponse, Response
from pyramid.view import view_config
from .. import GIT_COMMIT, SERVICE_NAME
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _shard_stats(request) -> dict:
"""Header pill counts. Single seam call into arborist.read."""
shards = request.shards
if shards is None:
return {"docs": 0, "audit_events": 0, "shard_open": False, "shard_count": 0}
counts = shards.counts()
return {
"docs": counts.documents,
"audit_events": counts.audit_events,
"shard_open": True,
"shard_count": counts.shard_count,
}
def _common_ctx(request) -> dict:
return {
"shards": request.arborist_shards,
"shard_stats": _shard_stats(request),
"git_commit": request.git_commit,
"reveal_private": request.reveal_private_leaves,
}
@view_config(route_name="home", renderer="dashboard.jinja2")
def home(request):
return {
"title": "Arborist VIZ — Merkle Command Center",
"dashboard_id": "default",
**_common_ctx(request),
}
@view_config(route_name="dashboard_page", renderer="dashboard.jinja2")
def dashboard_page(request):
return {
"title": "Arborist VIZ — {0}".format(request.matchdict["dashboard_id"]),
"dashboard_id": request.matchdict["dashboard_id"],
**_common_ctx(request),
}
@view_config(route_name="proof_page")
def proof_page(request):
"""Single-root visualizer. Dispatches by hash kind:
- corpus document_root / leaf / merkle interior → proof.jinja2
(the 3D Merkle lattice + inclusion proof + leaf-list view)
- session node_hash / subtree_hash → sessions_tree.jinja2
(the conversation-tree view, scrolled to the matching node)
- providence cache_key → cache_key.jinja2
(every session node referencing this cache hit)
All hashes drop through ONE URL — `/proof/{hash}` — per the
ticket's "never mint new kind-named URLs" discipline.
"""
from pyramid.renderers import render_to_response
root_hash = request.matchdict["root_hash"].strip().lower()
leaf_param = request.GET.get("leaf")
cache_key = request.GET.get("cache_key")
# Sessions dispatch — check before falling through to the corpus
# proof renderer. A session_root or session node_hash here means
# the hash IS the answer; the corpus proof page can't help.
sessions = request.sessions
if sessions is not None and sessions.is_open:
# session cache_key? show all referencing nodes.
ck_hits = sessions.find_by_cache_key(root_hash)
if ck_hits:
ctx = _common_ctx(request)
ctx["title"] = "Arborist VIZ — cache_key {0}".format(
root_hash[:12]
)
ctx["cache_key"] = root_hash
ctx["hits"] = ck_hits
ctx["sessions_stats"] = _sessions_stats_for_ctx(request)
return render_to_response(
"cache_key.jinja2", ctx, request=request
)
# session node hash (node_hash or subtree_hash)?
node = _find_session_node_by_hash(sessions, root_hash)
if node is not None:
return _render_session_in_proof(
request, node.sid, focus_bates=node.bates,
)
# session_root? (= subtree_hash of a session's root node)
sid_for_root = _find_sid_by_session_root(sessions, root_hash)
if sid_for_root is not None:
return _render_session_in_proof(request, sid_for_root)
# Default: corpus root. Hand off to proof.jinja2.
ctx = {
"title": "Arborist VIZ — root {0}".format(root_hash[:12]),
"root_hash": root_hash,
"leaf_index": leaf_param,
"cache_key": cache_key,
**_common_ctx(request),
}
return render_to_response("proof.jinja2", ctx, request=request)
def _sessions_stats_for_ctx(request) -> dict:
s = request.sessions
if s is None or not s.is_open:
return {"n_sessions": 0, "n_nodes": 0,
"n_audit_events": 0, "open": False}
c = s.counts()
return {
"n_sessions": c.n_sessions, "n_nodes": c.n_nodes,
"n_audit_events": c.n_audit_events,
"open": True, "path": c.shard_path,
}
def _find_session_node_by_hash(sessions, h: str):
"""Lookup by node_hash OR subtree_hash. Returns first match or None."""
try:
r = sessions._conn.execute( # type: ignore[attr-defined]
"SELECT * FROM nodes WHERE node_hash=? OR subtree_hash=? "
"LIMIT 1",
(h, h),
).fetchone()
except Exception:
return None
if not r:
return None
from arborist.read import _row_to_session_node
return _row_to_session_node(r)
def _find_sid_by_session_root(sessions, h: str):
"""A session_root is the synthetic root node's subtree_hash — so
look for a seq=0 node with that subtree_hash."""
try:
r = sessions._conn.execute( # type: ignore[attr-defined]
"SELECT sid FROM nodes WHERE seq=0 AND subtree_hash=? LIMIT 1",
(h,),
).fetchone()
except Exception:
return None
return r["sid"] if r else None
def _render_session_in_proof(request, sid, focus_bates=None):
"""Render the session tree inside the unified /proof/{hash} dispatch.
Re-uses sessions_tree.jinja2 so the visualization is consistent
whether you arrived via /proof/{session_root}, /proof/{node_hash},
or directly via /sessions/{sid}."""
from pyramid.renderers import render_to_response
sessions = request.sessions
meta = sessions.get_session(sid)
if meta is None:
return _common_ctx(request)
nodes = sessions.all_nodes_in_session(sid)
children: dict[str, list] = {}
for n in nodes:
if n.parent_bates:
children.setdefault(n.parent_bates, []).append(n)
branches = sessions.branches_in_session(sid)
ctx = _common_ctx(request)
ctx["title"] = "Arborist VIZ — session {0}".format(sid)
ctx["session"] = meta
ctx["nodes"] = nodes
ctx["children"] = children
ctx["branches"] = set(branches)
ctx["focus_bates"] = focus_bates
ctx["sessions_stats"] = _sessions_stats_for_ctx(request)
return render_to_response("sessions_tree.jinja2", ctx, request=request)
@view_config(route_name="audit_page", renderer="audit.jinja2")
def audit_page(request):
event_hash = request.matchdict["event_hash"]
return {
"title": "Arborist VIZ — audit {0}".format(event_hash[:12]),
"event_hash": event_hash,
**_common_ctx(request),
}
@view_config(route_name="version")
def version(request):
return Response(
json_body={
"service": SERVICE_NAME,
"git_commit": GIT_COMMIT,
"phase": "0-5+8",
}
)
@view_config(route_name="favicon")
def favicon(request):
path = os.path.join(HERE, "static", "favicon.ico")
if os.path.exists(path):
return FileResponse(path, request=request)
return Response(status=404)
@view_config(route_name="robots")
def robots(request):
return Response(body="User-agent: *\nDisallow:\n", content_type="text/plain")