sessions: richer tree cards, answer inline, /cache/<key> permalink

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).
This commit is contained in:
russell@unturf.com 2026-06-01 17:49:35 -04:00
parent 60e8640d86
commit f78eab6fa4
No known key found for this signature in database
6 changed files with 269 additions and 31 deletions

View file

@ -59,9 +59,14 @@ def includeme(config):
config.add_route("sessions_list", "/sessions") config.add_route("sessions_list", "/sessions")
config.add_route("sessions_find", "/sessions/find") config.add_route("sessions_find", "/sessions/find")
config.add_route("sessions_tree", "/sessions/{sid}") 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_list", "/api/sessions")
config.add_route("api_sessions_find", "/api/sessions/find") config.add_route("api_sessions_find", "/api/sessions/find")
config.add_route("api_sessions_tree", "/api/sessions/{sid}") 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. # SSE — §3-corrected replaces WS routes with text/event-stream.
config.add_route("sse_audit_live", "/sse/audit/live") config.add_route("sse_audit_live", "/sse/audit/live")

View file

@ -673,3 +673,70 @@ code, pre { font-family: var(--mono); font-size: 12px; }
.cmd-cite { font-size: 0.75em; opacity: 0.7; .cmd-cite { font-size: 0.75em; opacity: 0.7;
padding: 0.05em 0.4em; border-radius: 0.2em; padding: 0.05em 0.4em; border-radius: 0.2em;
background: rgba(255,255,255,0.06); } 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; }

View file

@ -0,0 +1,52 @@
{% extends "base.jinja2" %}
{% block body %}
<section class="cmd-panel">
<header class="cmd-panel-head">
<h1>cache_key</h1>
<p class="cmd-hash-block"><code class="cmd-hash">{{ cache_key }}</code></p>
<p class="cmd-panel-sub">
{{ hits|length }} session node(s) reference this providence cache entry —
a cache hit reused across {{ hits|map(attribute='sid')|unique|list|length }} session(s).
</p>
<p><a href="/sessions">← all sessions</a> · <a href="/api/cache-key/{{ cache_key }}">JSON</a></p>
</header>
{% if hits %}
{% set first = hits[0] %}
<article class="cmd-node-card">
<header class="cmd-node-card-head">
<span class="cmd-tree-audit cmd-audit-{{ first.audit_mode|lower }}">{{ first.audit_mode }}</span>
<span class="cmd-node-q">{{ first.question }}</span>
</header>
{% if first.cited_titles %}
<div class="cmd-tree-cites">
{% for t in first.cited_titles %}<span class="cmd-cite">{{ t }}</span>{% endfor %}
</div>
{% endif %}
<details class="cmd-node-answer" open>
<summary>answer</summary>
<pre class="cmd-answer-pre">{{ first.answer_text }}</pre>
</details>
</article>
<h2>Referenced by</h2>
<table class="cmd-table">
<thead>
<tr><th>bates</th><th>session</th><th>audit</th><th>created</th></tr>
</thead>
<tbody>
{% for n in hits %}
<tr>
<td><a href="/sessions/{{ n.sid }}"><code>{{ n.bates }}</code></a></td>
<td><code>{{ n.sid }}</code></td>
<td><span class="cmd-tree-audit cmd-audit-{{ n.audit_mode|lower }}">{{ n.audit_mode }}</span></td>
<td>{{ n.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="cmd-empty">no session nodes reference this cache_key</p>
{% endif %}
</section>
{% endblock %}

View file

@ -2,18 +2,50 @@
{% macro render_node(node, kids_of, branch_set, current_bates, this_sid, depth=0) -%} {% macro render_node(node, kids_of, branch_set, current_bates, this_sid, depth=0) -%}
<li class="cmd-tree-node {% if node.bates == current_bates %}cmd-tree-current{% endif %} {% if node.bates in branch_set %}cmd-tree-branch{% endif %}"> <li class="cmd-tree-node {% if node.bates == current_bates %}cmd-tree-current{% endif %} {% if node.bates in branch_set %}cmd-tree-branch{% endif %}">
<div class="cmd-tree-line"> <article class="cmd-node-card">
<span class="cmd-tree-bates"><code>{{ node.bates }}</code></span> <header class="cmd-node-card-head">
<span class="cmd-tree-audit cmd-audit-{{ node.audit_mode|lower }}">{{ node.audit_mode }}</span> <code class="cmd-tree-bates">{{ node.bates }}</code>
{% if node.sid != this_sid %}<span class="cmd-tree-from">[from {{ node.sid }}]</span>{% endif %} <span class="cmd-tree-audit cmd-audit-{{ node.audit_mode|lower }}">{{ node.audit_mode }}</span>
{% if node.label %}<span class="cmd-tree-label">{{ '{' + node.label + '}' }}</span>{% endif %} {% if node.sid != this_sid %}<span class="cmd-tree-from">[from {{ node.sid }}]</span>{% endif %}
<span class="cmd-tree-q">{{ (node.question or '')[:120] }}</span> {% if node.label %}<span class="cmd-tree-label">{{ '{' + node.label + '}' }}</span>{% endif %}
</div> {% if node.bates == current_bates %}<span class="cmd-current-pill">current</span>{% endif %}
{% if node.cited_titles %} {% if node.bates in branch_set %}<span class="cmd-branch-pill">branch</span>{% endif %}
<div class="cmd-tree-cites"> </header>
{% for t in node.cited_titles %}<span class="cmd-cite">{{ t }}</span>{% endfor %}
</div> <div class="cmd-node-q">{{ node.question }}</div>
{% endif %}
{% if node.cited_titles %}
<div class="cmd-tree-cites">
{% for t in node.cited_titles %}<span class="cmd-cite">{{ t }}</span>{% endfor %}
</div>
{% endif %}
{% if node.answer_text %}
<details class="cmd-node-answer">
<summary>answer ({{ node.answer_text|length }} chars)</summary>
<pre class="cmd-answer-pre">{{ node.answer_text }}</pre>
</details>
{% endif %}
<details class="cmd-node-meta">
<summary>hashes / metadata</summary>
<dl class="cmd-meta-dl">
<dt>cache_key</dt>
<dd>{% if node.cache_key %}<a href="/cache/{{ node.cache_key }}"><code class="cmd-hash">{{ node.cache_key }}</code></a>{% else %}<em>(none — canonical_projection)</em>{% endif %}</dd>
<dt>node_hash</dt>
<dd><code class="cmd-hash">{{ node.node_hash }}</code></dd>
<dt>subtree_hash</dt>
<dd><code class="cmd-hash">{{ node.subtree_hash }}</code></dd>
<dt>created</dt>
<dd>{{ node.created_at }}</dd>
{% if node.parent_bates %}
<dt>parent</dt>
<dd><code>{{ node.parent_bates }}</code></dd>
{% endif %}
</dl>
</details>
</article>
{% set kids = kids_of.get(node.bates, []) %} {% set kids = kids_of.get(node.bates, []) %}
{% if kids %} {% if kids %}
<ul class="cmd-tree-children"> <ul class="cmd-tree-children">
@ -26,27 +58,27 @@
{%- endmacro %} {%- endmacro %}
{% block body %} {% block body %}
<section class="cmd-panel"> <section class="cmd-panel cmd-panel-wide">
<header class="cmd-panel-head"> <header class="cmd-panel-head">
<h1>session <code>{{ session.sid }}</code></h1> <h1>session <code>{{ session.sid }}</code>{% if session.label %} <span class="cmd-tree-label">{{ '{' + session.label + '}' }}</span>{% endif %}</h1>
<p class="cmd-panel-sub"> <p class="cmd-panel-sub">
{{ session.n_nodes }} node(s) · {{ branches|length }} branch point(s) · {{ session.n_nodes }} node(s) · {{ branches|length }} branch point(s) ·
<code class="cmd-hash">root={{ session.session_root }}</code> · current → <a href="#node-{{ session.current_bates }}"><code>{{ session.current_bates }}</code></a>
current → <code>{{ session.current_bates }}</code>
</p> </p>
<p class="cmd-hash-block">root <code class="cmd-hash">{{ session.session_root }}</code></p>
<p><a href="/sessions">← all sessions</a> · <a href="/api/sessions/{{ session.sid }}">JSON</a></p> <p><a href="/sessions">← all sessions</a> · <a href="/api/sessions/{{ session.sid }}">JSON</a></p>
</header> </header>
{% set kids_of = children %} {% set kids_of = children %}
{% set root_node = nodes[0] if nodes and nodes[0].seq == 0 else None %} {% 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) %}
<ul class="cmd-tree"> <ul class="cmd-tree">
{% for child in 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) }} {{ render_node(child, kids_of, branches, session.current_bates, session.sid) }}
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="cmd-empty">empty session</p> <p class="cmd-empty">empty session — no turns yet. <code>make session SID={{ session.sid }}</code> to continue.</p>
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}

View file

@ -192,22 +192,72 @@ def get_lattice(request):
@view_config(route_name="api_resolve", request_method="GET") @view_config(route_name="api_resolve", request_method="GET")
def resolve_hash(request): def resolve_hash(request):
"""Drop-a-hash dispatcher. Returns whichever arborist object owns the hash.""" """Drop-a-hash dispatcher. Returns whichever arborist object owns the hash.
if request.shards is None:
return _no_shard() 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() h = request.matchdict["hex_hash"].strip().lower()
if not h or len(h) > 128 or not all(c in "0123456789abcdef" for c in h): 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) 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. # First: corpus shards (when configured).
url = _nav_url(res) if request.shards is not None:
return _json({ res = request.shards.resolve(h)
"kind": res.kind, if res.kind != "unknown":
"hash": res.hash, return _json({
"shard": res.shard_path, "kind": res.kind, "hash": res.hash,
"url": url, "shard": res.shard_path, "url": _nav_url(res),
**res.extra, **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: def _nav_url(res) -> str:

View file

@ -81,6 +81,38 @@ def sessions_tree(request):
return ctx 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") @view_config(route_name="sessions_find", renderer="sessions_find.jinja2")
def sessions_find(request): def sessions_find(request):
sessions = request.sessions sessions = request.sessions