diff --git a/docs/contracts/web_debug_surface.contract.md b/docs/contracts/web_debug_surface.contract.md index fab8d87..4e1d39d 100644 --- a/docs/contracts/web_debug_surface.contract.md +++ b/docs/contracts/web_debug_surface.contract.md @@ -105,6 +105,16 @@ functions: - "POST-004: the Tools inventory renders tool NAMES only — a compact comma-joined summary ('what does the LLM have', the debug glance); per-tool DESCRIPTIONS are surfaced in the BifrostState pane's tools list, deliberately NOT duplicated here. (Heid panel Hulda/Regin precision finding — accepted: contract wording clarified, code unchanged; the earlier 'names/descriptions' phrasing in INV-004 refers to the SET of value types that MAY appear across the new panes and must be escaped, not a mandate that every pane render descriptions.)" flexibility: "open" + - name: "renderAffectPane + trend (v0.19.4 — relation_edge/1 render + sparkline)" + signature: "renderAffectPane(snap) ; pushAffectHistory(snap) ; sparkline(vals) ; trendDelta(vals)" + description: "Render the Tier-3 affect snapshot as PAD mood + the durable per-entity relational model, each value with a Δ-vs-previous + a session-lived sparkline." + postconditions: + - "POST-001: reads snap.relations (relation_edge/1: target_entity + trust_ability/benevolence/integrity + warmth as {value,confidence,evidence_count} + agency + relation_context + obligation_balance) — the CURRENT Worldtree emit shape; falls back to the legacy flat snap.valence for an older emitter. SUPERSEDES the #18-D2 contract's valence assumption (Worldtree's #265 Vili rework replaced valence/regard with the relation_edge/trust model; the old renderer read snap.valence and showed an empty 'valence (0)' — the bug this fixes)." + - "POST-002: each metric shows current value + Δ-vs-previous (▲/▼) + a unicode sparkline auto-scaled to its OWN observed range (flat ▄ when sub-0.01 stable — no noise amplification), drawn from AFFECT_HIST (rolling, HIST_CAP=24, session-lived)." + - "POST-003: pushAffectHistory dedupes by emitted_at so the ~4x/turn post-turn PAD poll contributes ONE sample/turn; history is CLIENT-side only (lost on reload — durable cross-session history via a provider-side snapshot log is a deferred follow-up, NOT built here)." + - "POST-004: INV-001 honesty — no fabricated Tier-1 fields (no synthesized dominant_emotion). INV-004 — head() escapes its whole argument (incl. target_entity + relation_context from the snapshot) and metric() escapes every cell; numeric values go through toFixed, never innerHTML-raw." + flexibility: "open" + invariants: - "INV-004 (untrusted-render): ALL model / tool / admin / agent-supplied text is escaped before entering the DOM (esc via textContent, or esc(JSON.stringify)). No new render path introduces an innerHTML sink for upstream content. This is the highest-value review target — the new JS render paths are NOT unit-tested." - "INV-ADMIN-KEY: the admin key exists ONLY at app.state.admin_key (from RATATOSKR_ADMIN_API_KEY). It is never serialized into any response, never sent to the browser, never logged. The browser receives only the session-filtered RESULT of admin-scoped reads." diff --git a/pyproject.toml b/pyproject.toml index e4c8a43..977dd46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.19.3" +version = "0.19.4" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/web/static/index.html b/src/ratatoskr/web/static/index.html index ab27bf7..f0562dd 100644 --- a/src/ratatoskr/web/static/index.html +++ b/src/ratatoskr/web/static/index.html @@ -361,7 +361,22 @@ body { /* persona pane structured render */ #pane-persona .pk { color: var(--fg-dim); } #pane-persona .pv { color: var(--blue); } -#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; } +#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; margin-top: 4px; } + +/* affect metric rows: label · value · Δ · sparkline · n · descriptor */ +#pane-persona .mono-note { color: var(--fg-faint); font-size: 10px; margin: 2px 0 8px; } +#pane-persona .mrow { + display: flex; gap: 9px; align-items: baseline; padding: 1px 0; + font-size: 12px; white-space: nowrap; +} +#pane-persona .mrow .mk { color: var(--fg-dim); min-width: 118px; } +#pane-persona .mrow .mv { color: var(--blue); min-width: 46px; text-align: right; font-variant-numeric: tabular-nums; } +#pane-persona .mrow .md { min-width: 58px; font-size: 11px; color: var(--fg-faint); } +#pane-persona .mrow .md.up { color: var(--green); } +#pane-persona .mrow .md.dn { color: var(--red); } +#pane-persona .mrow .msp { color: var(--cyan); letter-spacing: 1px; min-width: 28px; } +#pane-persona .mrow .mn { color: var(--fg-faint); font-size: 10px; min-width: 34px; } +#pane-persona .mrow .mdesc { color: var(--fg-faint); font-style: italic; font-size: 10px; } /* thinking-pane per-turn dividers */ .pane-turn { @@ -807,25 +822,102 @@ async function loadPersona(agentId) { // shape only — pad + per-entity valence + emitted_at; NO fabricated Tier-1 persona // fields (dominant_emotion / mood_drift), which Tier-3 structurally lacks (INV-001). // Labelled "affect", not "persona" (INV-005). +// ---- affect trend accumulation (client-side, session-lived) ---- +// The affect pane refreshes on session-open + after each turn (the post-turn PAD poll +// fires ~4x/turn — deduped here by emitted_at so a turn contributes ONE sample). Each +// tracked value keeps a rolling, capped history so the pane can show a Δ + sparkline. +const AFFECT_HIST = { at: [], pad: {}, rel: {} }; // pad[axis]=[]; rel[target][metric]=[] +const HIST_CAP = 24; +const _relVal = (x) => (x && typeof x === "object" && "value" in x) ? x.value : x; +const _relN = (x) => (x && typeof x === "object" && "evidence_count" in x) ? x.evidence_count : undefined; +function pushAffectHistory(snap) { + const at = snap.emitted_at || ""; + if (at && AFFECT_HIST.at[AFFECT_HIST.at.length - 1] === at) return; // same snapshot — skip + AFFECT_HIST.at.push(at); + if (AFFECT_HIST.at.length > HIST_CAP) AFFECT_HIST.at.shift(); + const push = (bucket, key, v) => { + if (typeof v !== "number") return; + (bucket[key] = bucket[key] || []).push(v); + if (bucket[key].length > HIST_CAP) bucket[key].shift(); + }; + const pad = snap.pad || {}; + for (const ax of ["pleasure", "arousal", "dominance"]) push(AFFECT_HIST.pad, ax, pad[ax]); + for (const rel of (snap.relations || snap.valence || [])) { + const tgt = rel.target_entity || rel.entity_id || "?"; + const b = (AFFECT_HIST.rel[tgt] = AFFECT_HIST.rel[tgt] || {}); + push(b, "trust_ability", _relVal(rel.trust_ability)); + push(b, "trust_benevolence", _relVal(rel.trust_benevolence)); + push(b, "trust_integrity", _relVal(rel.trust_integrity)); + push(b, "warmth", _relVal(rel.warmth ?? rel.familiarity)); + } +} +// unicode sparkline auto-scaled to the value's own observed range; flat when stable +// (don't amplify sub-0.01 noise into a fake trend). +const _SPARK = "▁▂▃▄▅▆▇█"; +function sparkline(vals) { + if (!vals || vals.length < 2) return (vals && vals.length) ? "·" : ""; + const lo = Math.min(...vals), hi = Math.max(...vals); + if (hi - lo < 0.01) return "▄".repeat(vals.length); + const span = hi - lo; + return vals.map((v) => _SPARK[Math.min(7, Math.floor(((v - lo) / span) * 7.999))]).join(""); +} +function trendDelta(vals) { + if (!vals || vals.length < 2) return ""; + const d = vals[vals.length - 1] - vals[vals.length - 2]; + if (Math.abs(d) < 0.0005) return "→"; + return (d > 0 ? "▲+" : "▼") + d.toFixed(3); +} + +// Issue #18 D2 (relation_edge/1 rework): Worldtree's affect snapshot now carries +// `relations[]` (target + trust_ability/benevolence/integrity + warmth + agency + +// relation_context, each {value,confidence,evidence_count}) — NOT the old flat +// `valence[]`. Render mood (PAD) + the durable per-entity relational model, each with +// a Δ + sparkline from AFFECT_HIST. Falls back to `valence` for an older emitter. +// INV-001: no fabricated Tier-1 fields. INV-004: every dynamic value escaped (head() +// escapes its whole argument; metric() escapes each cell). function renderAffectPane(snap) { - const row = (k, v) => `
${esc(k)} ${esc(v)}
`; const head = (t) => `
${esc(t)}
`; - const all = snap.valence || []; - const shown = all.slice(0, 8); // bounded render — valence[] is unbounded in principle - const valRows = shown.map((v) => - row(v.entity_id || "?", - `familiarity ${JSON.stringify(v.familiarity)} · regard ${JSON.stringify(v.regard)}` - + ` · n=${JSON.stringify(v.interaction_count)}`) - ).join(""); - $("pane-persona").innerHTML = - head("affect snapshot · " + (snap.agent_id || "?")) + - `
` + head("pad") + - row("pleasure", JSON.stringify(snap.pad?.pleasure)) + - row("arousal", JSON.stringify(snap.pad?.arousal)) + - row("dominance", JSON.stringify(snap.pad?.dominance)) + - `
` + head("valence (" + all.length + ")") + - (valRows || `
none
`) + - `
` + row("emitted_at", snap.emitted_at || "?"); + const num = (v) => (typeof v === "number") ? ((v >= 0 ? "+" : "") + v.toFixed(3)) : "—"; + const metric = (label, val, hist, desc, n) => { + const d = trendDelta(hist); + const dcls = d.startsWith("▲") ? "up" : d.startsWith("▼") ? "dn" : ""; + const nlab = (n !== undefined && n !== null) ? "n=" + esc(n) : ""; + return `
${esc(label)}` + + `${esc(num(val))}` + + `${esc(d)}` + + `${esc(sparkline(hist))}` + + `${nlab}` + + `${esc(desc)}
`; + }; + const pad = snap.pad || {}, H = AFFECT_HIST; + let html = head("affect · " + (snap.agent_id || "?")) + + `
emitted ${esc((snap.emitted_at || "?").slice(11, 19))} · ` + + `${H.at.length} sample${H.at.length === 1 ? "" : "s"} this session
` + + head("mood · PAD (transient, −1‥+1)") + + metric("pleasure", pad.pleasure, H.pad.pleasure, "feeling") + + metric("arousal", pad.arousal, H.pad.arousal, "activation") + + metric("dominance", pad.dominance, H.pad.dominance, "control"); + const rels = snap.relations || snap.valence || []; + if (!rels.length) { + html += head("relations") + `
no relations tracked yet — take a turn
`; + } + for (const rel of rels.slice(0, 8)) { + const tgt = rel.target_entity || rel.entity_id || "?"; + const ctx = rel.relation_context ? " · stage: " + rel.relation_context : ""; + const b = H.rel[tgt] || {}; + html += head("relation → " + tgt + ctx) + + metric("trust·ability", _relVal(rel.trust_ability), b.trust_ability, "is-competent", _relN(rel.trust_ability)) + + metric("trust·benevolence", _relVal(rel.trust_benevolence), b.trust_benevolence, "means-well", _relN(rel.trust_benevolence)) + + metric("trust·integrity", _relVal(rel.trust_integrity), b.trust_integrity, "is-honest", _relN(rel.trust_integrity)) + + metric("warmth", _relVal(rel.warmth ?? rel.familiarity), b.warmth, "affection", _relN(rel.warmth)); + const ag = _relVal(rel.agency), agn = _relN(rel.agency); + if (typeof ag === "number" && agn) html += metric("agency", ag, null, "autonomy", agn); + if (rel.obligation_balance !== null && rel.obligation_balance !== undefined) { + html += `
obligation` + + `${esc(JSON.stringify(rel.obligation_balance))}
`; + } + } + $("pane-persona").innerHTML = html; } async function loadAffect(agentId) { @@ -833,6 +925,7 @@ async function loadAffect(agentId) { const r = await fetch("/api/affect/" + encodeURIComponent(agentId)); if (r.status === 200) { const snap = await r.json(); + pushAffectHistory(snap); // accumulate the per-value trend BEFORE rendering renderAffectPane(snap); setPersonaStrip(snap); // pad bars are the live signal state.lastAffectAt = snap.emitted_at || state.lastAffectAt; // post-turn poll stop-signal diff --git a/uv.lock b/uv.lock index b57bb4e..26765fd 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.19.3" +version = "0.19.4" source = { editable = "." } dependencies = [ { name = "httpx" },