feat: drawer significance review panel (T98.2)
This commit is contained in:
@@ -456,6 +456,52 @@
|
|||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<h3>Significance review</h3>
|
||||||
|
{% set total_mem = significance_distribution.values()|sum %}
|
||||||
|
{% if total_mem %}
|
||||||
|
<ul class="significance-distribution">
|
||||||
|
{% for level in [0, 1, 2, 3] %}
|
||||||
|
{% set count = significance_distribution[level] %}
|
||||||
|
{% set marker = ['·','•','★','★★'][level] %}
|
||||||
|
{% set pct = (100 * count / total_mem)|round(0, 'floor')|int if total_mem else 0 %}
|
||||||
|
<li class="sig-bar sig-{{ level }}">
|
||||||
|
<span class="sig-label">{{ marker }} ({{ level }})</span>
|
||||||
|
<span class="sig-bar-fill" style="width: {{ pct }}%"></span>
|
||||||
|
<span class="sig-count">{{ count }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No memories yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if recent_memories %}
|
||||||
|
<details>
|
||||||
|
<summary>Edit significance (recent memories)</summary>
|
||||||
|
<ul class="significance-edit-list">
|
||||||
|
{% for m in recent_memories %}
|
||||||
|
<li>
|
||||||
|
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
|
||||||
|
{{ m.pov_summary[:80] }}{% if m.pov_summary|length > 80 %}…{% endif %}
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/significance"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<label>
|
||||||
|
Significance:
|
||||||
|
<input type="range" name="significance" min="0" max="3"
|
||||||
|
value="{{ m.significance|default(0) }}"
|
||||||
|
oninput="this.nextElementSibling.value = this.value">
|
||||||
|
<output>{{ m.significance|default(0) }}</output>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="drawer-section">
|
<section class="drawer-section">
|
||||||
<h3>Pinned memories ({{ pinned|length }} / {{ pin_cap }})</h3>
|
<h3>Pinned memories ({{ pinned|length }} / {{ pin_cap }})</h3>
|
||||||
{% if pinned %}
|
{% if pinned %}
|
||||||
|
|||||||
@@ -181,6 +181,21 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
# returns both flavours and the template highlights the active one).
|
# returns both flavours and the template highlights the active one).
|
||||||
branches = list_branches_with_metadata(conn, chat_id)
|
branches = list_branches_with_metadata(conn, chat_id)
|
||||||
|
|
||||||
|
# T98.2: significance distribution across this chat's memories. Powers
|
||||||
|
# the "Significance review" panel — a small histogram letting authors
|
||||||
|
# spot lopsided buckets (e.g. nothing significant=3 yet) and triage by
|
||||||
|
# editing individual memory significance values.
|
||||||
|
sig_rows = conn.execute(
|
||||||
|
"SELECT significance, COUNT(*) FROM memories "
|
||||||
|
"WHERE chat_id = ? GROUP BY significance ORDER BY significance",
|
||||||
|
(chat_id,),
|
||||||
|
).fetchall()
|
||||||
|
significance_distribution = {int(r[0]): int(r[1]) for r in sig_rows}
|
||||||
|
# Ensure every bucket 0..3 is present so the bar-chart template can
|
||||||
|
# render a stable axis even when a level has zero rows.
|
||||||
|
for level in (0, 1, 2, 3):
|
||||||
|
significance_distribution.setdefault(level, 0)
|
||||||
|
|
||||||
return TEMPLATES.TemplateResponse(
|
return TEMPLATES.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"_drawer.html",
|
"_drawer.html",
|
||||||
@@ -209,6 +224,7 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
"active_events": active_events,
|
"active_events": active_events,
|
||||||
"open_threads": open_threads,
|
"open_threads": open_threads,
|
||||||
"branches": branches,
|
"branches": branches,
|
||||||
|
"significance_distribution": significance_distribution,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -187,3 +187,96 @@ def test_t98_1_branch_from_turn_emits_branch_created(client, tmp_path):
|
|||||||
)
|
)
|
||||||
assert dup.status_code == 400
|
assert dup.status_code == 400
|
||||||
assert seed_id < turn_id # sanity: turn is after chat_created
|
assert seed_id < turn_id # sanity: turn is after chat_created
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T98.2 — significance review panel.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_memories_for_significance(db: Path) -> list[int]:
|
||||||
|
"""Seed three memories with significance levels 0, 1, 2. Returns ids.
|
||||||
|
|
||||||
|
Uses ``append_and_apply`` (vs ``append_event`` + a final ``project``)
|
||||||
|
so each row is applied exactly once — the chat row was already
|
||||||
|
materialised by ``_seed_chat`` and a re-projection would conflict
|
||||||
|
on ``chats.id`` UNIQUE.
|
||||||
|
"""
|
||||||
|
ids: list[int] = []
|
||||||
|
with open_db(db) as conn:
|
||||||
|
for sig in (0, 1, 2):
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="memory_written",
|
||||||
|
payload={
|
||||||
|
"owner_id": "bot_a",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"pov_summary": f"memory at significance {sig}",
|
||||||
|
"witness_you": 1,
|
||||||
|
"witness_host": 1,
|
||||||
|
"witness_guest": 0,
|
||||||
|
"significance": sig,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id FROM memories WHERE chat_id = 'chat_bot_a' "
|
||||||
|
"ORDER BY id ASC"
|
||||||
|
).fetchall()
|
||||||
|
ids = [int(r[0]) for r in rows]
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_t98_2_distribution_renders_per_significance_bucket(client, tmp_path):
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
_seed_chat(db)
|
||||||
|
_seed_memories_for_significance(db)
|
||||||
|
|
||||||
|
response = client.get("/chats/chat_bot_a/drawer")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
|
||||||
|
# Section heading + bar entries for each significance level.
|
||||||
|
assert "<h3>Significance review</h3>" in body
|
||||||
|
# All four buckets appear by their canonical label even when count=0.
|
||||||
|
assert ">★★ (3)<" in body or "(3)" in body
|
||||||
|
# The distribution markup names each level explicitly.
|
||||||
|
for level in (0, 1, 2, 3):
|
||||||
|
assert f"sig-bar sig-{level}" in body
|
||||||
|
# Three seeded memories (sigs 0, 1, 2) — each has a count = 1 bar.
|
||||||
|
# We don't pin exact text formatting, just verify the per-level bars
|
||||||
|
# are present.
|
||||||
|
|
||||||
|
|
||||||
|
def test_t98_2_edit_significance_via_existing_route_lands_manual_edit(
|
||||||
|
client, tmp_path
|
||||||
|
):
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
_seed_chat(db)
|
||||||
|
ids = _seed_memories_for_significance(db)
|
||||||
|
|
||||||
|
target_id = ids[0] # initially significance=0
|
||||||
|
response = client.post(
|
||||||
|
f"/chats/chat_bot_a/drawer/memory/{target_id}/significance",
|
||||||
|
data={"significance": "3"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(db) as conn:
|
||||||
|
# Significance updated in the projected table.
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT significance FROM memories WHERE id = ?", (target_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert int(row[0]) == 3
|
||||||
|
|
||||||
|
# manual_edit landed in the event log with the prior snapshot.
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
log_rows = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log "
|
||||||
|
"WHERE kind = 'manual_edit' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
payload = _json.loads(log_rows[0])
|
||||||
|
assert payload["target_kind"] == "memory_significance"
|
||||||
|
assert int(payload["target_id"]) == target_id
|
||||||
|
assert payload["prior_value"] == 0
|
||||||
|
assert payload["new_value"] == 3
|
||||||
|
|||||||
Reference in New Issue
Block a user