From f78eab6fa4184219306c8c8b5004b60c41af23ef Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 1 Jun 2026 17:49:35 -0400 Subject: [PATCH] sessions: richer tree cards, answer inline, /cache/ permalink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -
"answer (N chars)" expandable to the full claim-lattice pointer-line answer (monospace, scrollable, indented with a cyan accent stripe) -
"hashes / metadata" with cache_key (clickable to /cache/), 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/ (was /sessions/find?q= 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). --- arborist_viz/routes.py | 5 ++ arborist_viz/static/css/viz.css | 67 ++++++++++++++++++ arborist_viz/templates/cache_key.jinja2 | 52 ++++++++++++++ arborist_viz/templates/sessions_tree.jinja2 | 68 +++++++++++++----- arborist_viz/views/roots.py | 76 +++++++++++++++++---- arborist_viz/views/sessions.py | 32 +++++++++ 6 files changed, 269 insertions(+), 31 deletions(-) create mode 100644 arborist_viz/templates/cache_key.jinja2 diff --git a/arborist_viz/routes.py b/arborist_viz/routes.py index 10c1a32..4a99ee0 100644 --- a/arborist_viz/routes.py +++ b/arborist_viz/routes.py @@ -59,9 +59,14 @@ def includeme(config): config.add_route("sessions_list", "/sessions") config.add_route("sessions_find", "/sessions/find") config.add_route("sessions_tree", "/sessions/{sid}") + config.add_route("cache_key_page", "/cache/{cache_key}") 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}") + # Sessions-shard view of a providence cache_key (every session node + # referencing it). Distinct from /api/cache/ which targets the + # providence_cache row itself (ticket #000069 §13 §6.7). + config.add_route("api_session_cache_key", "/api/cache-key/{cache_key}") # SSE — §3-corrected replaces WS routes with text/event-stream. config.add_route("sse_audit_live", "/sse/audit/live") diff --git a/arborist_viz/static/css/viz.css b/arborist_viz/static/css/viz.css index 9a96e25..a83048a 100644 --- a/arborist_viz/static/css/viz.css +++ b/arborist_viz/static/css/viz.css @@ -673,3 +673,70 @@ code, pre { font-family: var(--mono); font-size: 12px; } .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); } + +/* ---- node cards + answer + metadata (richer tree views) -------------- */ + +.cmd-panel-wide { max-width: none; padding: 1.5em 2em; } + +.cmd-hash-block { word-break: break-all; font-size: 0.85em; opacity: 0.85; } + +.cmd-node-card { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 0.3em; + padding: 0.8em 1em; + margin: 0.4em 0; +} +.cmd-node-card-head { + display: flex; gap: 0.6em; align-items: baseline; flex-wrap: wrap; + margin-bottom: 0.4em; +} +.cmd-node-q { + font-size: 1.05em; font-weight: 500; + margin: 0.3em 0 0.5em 0; +} +.cmd-current-pill, .cmd-branch-pill { + font-size: 0.7em; padding: 0.05em 0.5em; + border-radius: 0.2em; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; +} +.cmd-current-pill { background: rgba(120, 200, 255, 0.25); color: #8af; } +.cmd-branch-pill { background: rgba(220, 180, 80, 0.25); color: #db8; } + +.cmd-node-answer summary, +.cmd-node-meta summary { + cursor: pointer; opacity: 0.7; font-size: 0.85em; + padding: 0.3em 0; user-select: none; +} +.cmd-node-answer[open] summary, +.cmd-node-meta[open] summary { opacity: 1; } +.cmd-answer-pre { + background: rgba(0,0,0,0.25); + padding: 0.8em 1em; + border-left: 2px solid rgba(120, 200, 255, 0.4); + border-radius: 0.2em; + font-size: 0.85em; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + max-height: 600px; + overflow-y: auto; +} + +.cmd-meta-dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.2em 1em; + font-size: 0.85em; + padding: 0.5em 0; +} +.cmd-meta-dl dt { opacity: 0.65; } +.cmd-meta-dl dd { margin: 0; word-break: break-all; } +.cmd-meta-dl code { font-size: 0.95em; } + +.cmd-tree-current > .cmd-node-card { border-color: rgba(120, 200, 255, 0.5); } +.cmd-tree-branch > .cmd-node-card { border-color: rgba(220, 180, 80, 0.5); } + +/* Override the tree-line styles (old layout) — cards replace them. */ +.cmd-tree-current > .cmd-tree-line::after { content: none; } diff --git a/arborist_viz/templates/cache_key.jinja2 b/arborist_viz/templates/cache_key.jinja2 new file mode 100644 index 0000000..a555117 --- /dev/null +++ b/arborist_viz/templates/cache_key.jinja2 @@ -0,0 +1,52 @@ +{% extends "base.jinja2" %} +{% block body %} +
+
+

cache_key

+

{{ cache_key }}

+

+ {{ hits|length }} session node(s) reference this providence cache entry — + a cache hit reused across {{ hits|map(attribute='sid')|unique|list|length }} session(s). +

+

← all sessions · JSON

+
+ + {% if hits %} + {% set first = hits[0] %} +
+
+ {{ first.audit_mode }} + {{ first.question }} +
+ {% if first.cited_titles %} +
+ {% for t in first.cited_titles %}{{ t }}{% endfor %} +
+ {% endif %} +
+ answer +
{{ first.answer_text }}
+
+
+ +

Referenced by

+ + + + + + {% for n in hits %} + + + + + + + {% endfor %} + +
batessessionauditcreated
{{ n.bates }}{{ n.sid }}{{ n.audit_mode }}{{ n.created_at }}
+ {% else %} +

no session nodes reference this cache_key

+ {% endif %} +
+{% endblock %} diff --git a/arborist_viz/templates/sessions_tree.jinja2 b/arborist_viz/templates/sessions_tree.jinja2 index 110fb65..726014c 100644 --- a/arborist_viz/templates/sessions_tree.jinja2 +++ b/arborist_viz/templates/sessions_tree.jinja2 @@ -2,18 +2,50 @@ {% 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 %} +
    +
    + {{ node.bates }} + {{ node.audit_mode }} + {% if node.sid != this_sid %}[from {{ node.sid }}]{% endif %} + {% if node.label %}{{ '{' + node.label + '}' }}{% endif %} + {% if node.bates == current_bates %}current{% endif %} + {% if node.bates in branch_set %}branch{% endif %} +
    + +
    {{ node.question }}
    + + {% if node.cited_titles %} +
    + {% for t in node.cited_titles %}{{ t }}{% endfor %} +
    + {% endif %} + + {% if node.answer_text %} +
    + answer ({{ node.answer_text|length }} chars) +
    {{ node.answer_text }}
    +
    + {% endif %} + +
    + hashes / metadata +
    +
    cache_key
    +
    {% if node.cache_key %}{{ node.cache_key }}{% else %}(none — canonical_projection){% endif %}
    +
    node_hash
    +
    {{ node.node_hash }}
    +
    subtree_hash
    +
    {{ node.subtree_hash }}
    +
    created
    +
    {{ node.created_at }}
    + {% if node.parent_bates %} +
    parent
    +
    {{ node.parent_bates }}
    + {% endif %} +
    +
    +
    + {% set kids = kids_of.get(node.bates, []) %} {% if kids %}
      @@ -26,27 +58,27 @@ {%- endmacro %} {% block body %} -
      +
      -

      session {{ session.sid }}

      +

      session {{ session.sid }}{% if session.label %} {{ '{' + session.label + '}' }}{% endif %}

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

      +

      root {{ session.session_root }}

      ← all sessions · JSON

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

      empty session

      +

      empty session — no turns yet. make session SID={{ session.sid }} to continue.

      {% endif %}
      {% endblock %} diff --git a/arborist_viz/views/roots.py b/arborist_viz/views/roots.py index e760008..4cfe007 100644 --- a/arborist_viz/views/roots.py +++ b/arborist_viz/views/roots.py @@ -192,22 +192,72 @@ def get_lattice(request): @view_config(route_name="api_resolve", request_method="GET") def resolve_hash(request): - """Drop-a-hash dispatcher. Returns whichever arborist object owns the hash.""" - if request.shards is None: - return _no_shard() + """Drop-a-hash dispatcher. Returns whichever arborist object owns the hash. + + Resolution order: corpus shards first (document_root / leaf_hash / + audit_event / merkle_interior / qa cache_key / context_root / + run_dag_root), then sessions shard (cache_key referenced by N + session nodes; node_hash / subtree_hash on a session node). + """ h = request.matchdict["hex_hash"].strip().lower() if not h or len(h) > 128 or not all(c in "0123456789abcdef" for c in h): return _json({"error": "not_hex", "hash": h}, status=400) - res = request.shards.resolve(h) - # The browser still needs a /url to navigate to — synthesize from kind. - url = _nav_url(res) - return _json({ - "kind": res.kind, - "hash": res.hash, - "shard": res.shard_path, - "url": url, - **res.extra, - }) + + # First: corpus shards (when configured). + if request.shards is not None: + res = request.shards.resolve(h) + if res.kind != "unknown": + return _json({ + "kind": res.kind, "hash": res.hash, + "shard": res.shard_path, "url": _nav_url(res), + **res.extra, + }) + + # Second: sessions shard. cache_key OR node_hash OR subtree_hash. + sessions = request.sessions + if sessions is not None and sessions.is_open: + hits = sessions.find_by_cache_key(h) + if hits: + first = hits[0] + return _json({ + "kind": "session_cache_key", + "hash": h, + "shard": sessions.path, + "n_session_nodes": len(hits), + "sids": sorted({n.sid for n in hits}), + "bates_list": [n.bates for n in hits], + "audit_modes": sorted({n.audit_mode for n in hits}), + "question": first.question, + "url": "/cache/{0}".format(h), + }) + # node_hash / subtree_hash search: scan one row. + # (No index on these; rare path — direct query.) + try: + row = sessions._conn.execute( # type: ignore[attr-defined] + "SELECT bates, sid, audit_mode, question, node_hash, " + " subtree_hash FROM nodes " + "WHERE node_hash=? OR subtree_hash=? LIMIT 1", + (h, h), + ).fetchone() + except Exception: + row = None + if row: + kind = ( + "session_node_hash" if row["node_hash"] == h + else "session_subtree_hash" + ) + return _json({ + "kind": kind, + "hash": h, + "shard": sessions.path, + "bates": row["bates"], + "sid": row["sid"], + "audit_mode": row["audit_mode"], + "question": row["question"], + "url": "/sessions/{0}".format(row["sid"]), + }) + + return _json({"kind": "unknown", "hash": h, "url": "/"}) def _nav_url(res) -> str: diff --git a/arborist_viz/views/sessions.py b/arborist_viz/views/sessions.py index 95555cb..2a3400c 100644 --- a/arborist_viz/views/sessions.py +++ b/arborist_viz/views/sessions.py @@ -81,6 +81,38 @@ def sessions_tree(request): 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