feat: drawer edits with manual_edit event capture

This commit is contained in:
Joseph Doherty
2026-04-26 13:40:40 -04:00
parent 5fc5b8ac23
commit db3005fc17
5 changed files with 450 additions and 7 deletions
+1
View File
@@ -12,6 +12,7 @@ from chat.services.background import BackgroundWorker
# Trigger handler registration:
import chat.state.entities # noqa: F401
import chat.state.edges # noqa: F401
import chat.state.manual_edit # noqa: F401
import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
+73
View File
@@ -0,0 +1,73 @@
"""Handler for ``manual_edit`` events (T25, §6.4 final paragraph).
A ``manual_edit`` event captures a user override of a projected field — its
payload snapshots both the prior value and the new value so any edit can
be reversed by emitting an inverse ``manual_edit`` later. This module
applies the new value to the appropriate target table; the snapshot of
``prior_value`` is taken by the route handler before this fires.
Phase 1 covers three target kinds:
- ``edge_affinity`` and ``edge_trust`` — slider edits on a specific edge,
clamped to 0..100.
- ``memory_significance`` — dropdown edit, clamped to 0..3.
- ``memory_pov_summary`` — textarea edit (string).
Other §6.4 editable fields (activity verb / attention / posture, edge
summary, knowledge_facts list manipulation) are deferred to Phase 1.5.
Pin toggles intentionally use the existing ``memory_pin_changed`` event
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
the projection writes both ``pinned`` and ``auto_pinned`` atomically.
"""
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.log import Event
from chat.eventlog.projector import on
def _clamp(value: int, lo: int, hi: int) -> int:
return max(lo, min(hi, value))
@on("manual_edit")
def _apply_manual_edit(conn: Connection, e: Event) -> None:
p = e.payload
kind = p["target_kind"]
target_id = p["target_id"]
new_value = p["new_value"]
if kind == "edge_affinity":
conn.execute(
"UPDATE edges SET affinity = ? "
"WHERE source_id = ? AND target_id = ?",
(
_clamp(int(new_value), 0, 100),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "edge_trust":
conn.execute(
"UPDATE edges SET trust = ? "
"WHERE source_id = ? AND target_id = ?",
(
_clamp(int(new_value), 0, 100),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "memory_significance":
conn.execute(
"UPDATE memories SET significance = ? WHERE id = ?",
(_clamp(int(new_value), 0, 3), int(target_id)),
)
elif kind == "memory_pov_summary":
conn.execute(
"UPDATE memories SET pov_summary = ? WHERE id = ?",
(str(new_value), int(target_id)),
)
# Unknown target_kind: silently no-op for v1. Future kinds (activity
# fields, edge summary, knowledge_facts) extend the dispatch above.
+36
View File
@@ -40,6 +40,18 @@
<div class="edge-row">
<strong>{{ host_bot.name }} &rarr; you</strong>
<p>Affinity: {{ edge_b2y.affinity }}/100 &middot; Trust: {{ edge_b2y.trust }}/100</p>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/edge/{{ host_bot.id }}/you/affinity"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Affinity:
<input type="range" name="affinity" min="0" max="100"
value="{{ edge_b2y.affinity }}"
oninput="this.nextElementSibling.value = this.value">
<output>{{ edge_b2y.affinity }}</output>
</label>
<button type="submit">Save</button>
</form>
{% if edge_b2y.summary %}<p class="muted">{{ edge_b2y.summary }}</p>{% endif %}
{% if edge_b2y.knowledge %}
<details><summary>Knowledge ({{ edge_b2y.knowledge|length }})</summary>
@@ -68,6 +80,12 @@
<li>
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
{{ m.pov_summary }}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/pin"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="pinned" value="0">
<button type="submit">Unpin</button>
</form>
</li>
{% endfor %}
</ul>
@@ -84,6 +102,24 @@
<li>
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
{{ m.pov_summary[:200] }}{% if m.pov_summary|length > 200 %}…{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/significance"
hx-target="#drawer" hx-swap="innerHTML">
<select name="significance">
{% for s in [0, 1, 2, 3] %}
<option value="{{ s }}" {% if m.significance == s %}selected{% endif %}>
{{ ['·','•','★','★★'][s] }} ({{ s }})
</option>
{% endfor %}
</select>
<button type="submit">Set</button>
</form>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/pin"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}">
<button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button>
</form>
</li>
{% endfor %}
</ul>
+150 -7
View File
@@ -1,21 +1,37 @@
"""Read-only chat drawer (T24).
"""Chat drawer — read view (T24) and inline edits (T25).
Returns an HTML partial rendered into the chat shell's `<aside id="drawer">`
on first reveal. Shows the current scene + container, per-entity activity,
host <-> you edges, pinned memories with an `n / cap` counter, and recent
witnessed memories from the host's POV with significance markers.
The GET endpoint renders an HTML partial showing the current scene +
container, per-entity activity, host <-> you edges, pinned memories with
an ``n / cap`` counter, and recent witnessed memories from the host's
POV with significance markers.
Edit affordances are added in T25; this endpoint is intentionally read-only.
T25 adds three POST endpoints for the most useful inline edits, each
returning the refreshed drawer partial so HTMX can swap it in:
* affinity slider on an edge (emits ``manual_edit``);
* significance dropdown on a memory (emits ``manual_edit``);
* pin toggle on a memory (emits ``memory_pin_changed`` with
``auto_pinned=0`` so a manual pin is not subject to auto-eviction).
Each ``manual_edit`` payload snapshots the prior value alongside the new
one so a later inverse edit can restore state (§6.4 final paragraph).
Other §6.4 editable fields (activity verb/attention/posture, edge_trust,
edge summary, knowledge_facts list, memory pov_summary) are deferred to
a Phase 1.5 follow-up — the dispatch in :mod:`chat.state.manual_edit`
already accepts more ``target_kind`` values, so adding their routes is a
mechanical extension.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_and_apply
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you
from chat.state.memory import get_pinned
@@ -104,3 +120,130 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
"pin_cap": PIN_CAP,
},
)
# --- T25 edit endpoints ---------------------------------------------------
#
# Each endpoint:
# 1. Loads the chat (404 if missing) and the target row (404 if missing).
# 2. Reads the prior value before mutating, so the event payload carries
# it for §6.4 reversibility.
# 3. Calls ``append_and_apply`` so the projected table updates atomically
# with the event log append; full reprojection would re-add deltas
# from earlier ``edge_update`` events.
# 4. Returns the refreshed drawer partial via ``await drawer(...)``, which
# HTMX swaps into ``#drawer``.
@router.post(
"/chats/{chat_id}/drawer/edge/{source_id}/{target_id}/affinity",
response_class=HTMLResponse,
)
async def edit_edge_affinity(
chat_id: str,
source_id: str,
target_id: str,
request: Request,
affinity: int = Form(...),
conn=Depends(get_conn),
):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
edge = get_edge(conn, source_id, target_id)
if edge is None:
raise HTTPException(
status_code=404,
detail=f"edge not found: {source_id}->{target_id}",
)
prior = int(edge["affinity"])
new_value = max(0, min(100, int(affinity)))
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "edge_affinity",
"target_id": {"source_id": source_id, "target_id": target_id},
"prior_value": prior,
"new_value": new_value,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/memory/{memory_id}/significance",
response_class=HTMLResponse,
)
async def edit_memory_significance(
chat_id: str,
memory_id: int,
request: Request,
significance: int = Form(...),
conn=Depends(get_conn),
):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
row = conn.execute(
"SELECT significance FROM memories WHERE id = ?", (memory_id,)
).fetchone()
if row is None:
raise HTTPException(
status_code=404, detail=f"memory not found: {memory_id}"
)
prior = int(row[0])
new_value = max(0, min(3, int(significance)))
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_significance",
"target_id": int(memory_id),
"prior_value": prior,
"new_value": new_value,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/memory/{memory_id}/pin",
response_class=HTMLResponse,
)
async def toggle_memory_pin(
chat_id: str,
memory_id: int,
request: Request,
pinned: int = Form(...),
conn=Depends(get_conn),
):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
row = conn.execute(
"SELECT pinned FROM memories WHERE id = ?", (memory_id,)
).fetchone()
if row is None:
raise HTTPException(
status_code=404, detail=f"memory not found: {memory_id}"
)
new_pinned = 1 if int(pinned) else 0
# Manual pin: ``auto_pinned=0`` so the §8.5 eviction query (which only
# touches auto-pinned rows) leaves this alone.
append_and_apply(
conn,
kind="memory_pin_changed",
payload={
"memory_id": int(memory_id),
"pinned": new_pinned,
"auto_pinned": 0,
},
)
return await drawer(chat_id, request, conn)
+190
View File
@@ -0,0 +1,190 @@
"""T25: drawer edits with manual_edit event capture.
Each editable field on the drawer is exposed as a small POST endpoint.
Edits emit either a ``manual_edit`` event (snapshotting the prior value
for §6.4 reversibility) or, for pin toggles, a ``memory_pin_changed``
event with ``auto_pinned=0`` so manual pins survive auto-eviction.
Phase 1 narrowed scope: affinity slider, significance dropdown, pin
toggle. Other §6.4 fields (activity, edge_summary, edge_trust, pov_summary,
knowledge_facts list) are deferred to a Phase 1.5 follow-up.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from chat.app import app
from chat.db.connection import open_db
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = tmp_path / "config.toml"
cfg.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
db = tmp_path / "test.db"
monkeypatch.setenv("CHAT_DB_PATH", str(db))
with TestClient(app) as c:
if hasattr(app.state, "background_worker"):
app.state.background_worker.enabled = False
yield c
def _seed(db: Path) -> None:
with open_db(db) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
# Edge bot_a -> you with affinity_delta=0 to materialise the row at
# default 50/50.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
"affinity_delta": 0,
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"pov_summary": "A memory",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
project(conn)
def test_edit_edge_affinity_emits_manual_edit_and_updates(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/edge/bot_a/you/affinity",
data={"affinity": "75"},
)
assert response.status_code == 200 # returns refreshed drawer partial
# Refresh shows the new affinity value.
assert "75" in response.text
with open_db(tmp_path / "test.db") as conn:
cur = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(cur) == 1
payload = json.loads(cur[0][0])
assert payload["target_kind"] == "edge_affinity"
assert payload["prior_value"] == 50
assert payload["new_value"] == 75
assert payload["target_id"]["source_id"] == "bot_a"
assert payload["target_id"]["target_id"] == "you"
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert edge["affinity"] == 75
def test_edit_memory_significance_emits_event(client, tmp_path):
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
response = client.post(
f"/chats/chat_bot_a/drawer/memory/{memory_id}/significance",
data={"significance": "3"},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
sig = conn.execute(
"SELECT significance FROM memories WHERE id = ?", (memory_id,)
).fetchone()[0]
assert sig == 3
cur = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(cur) == 1
payload = json.loads(cur[0][0])
assert payload["target_kind"] == "memory_significance"
assert payload["prior_value"] == 1
assert payload["new_value"] == 3
assert payload["target_id"] == memory_id
def test_toggle_memory_pin_manual_emits_event_with_auto_pinned_0(client, tmp_path):
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
response = client.post(
f"/chats/chat_bot_a/drawer/memory/{memory_id}/pin",
data={"pinned": "1"},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
row = conn.execute(
"SELECT pinned, auto_pinned FROM memories WHERE id = ?", (memory_id,)
).fetchone()
assert row[0] == 1
assert row[1] == 0 # NOT auto-pinned (manual pin survives auto-eviction)
# The pin toggle uses memory_pin_changed (not manual_edit).
cur = conn.execute(
"SELECT payload_json FROM event_log "
"WHERE kind = 'memory_pin_changed' ORDER BY id DESC LIMIT 1"
).fetchone()
payload = json.loads(cur[0])
assert payload["pinned"] == 1
assert payload["auto_pinned"] == 0
assert payload["memory_id"] == memory_id
def test_edit_404_when_chat_missing(client):
response = client.post(
"/chats/no_such/drawer/edge/bot_a/you/affinity",
data={"affinity": "75"},
)
assert response.status_code == 404
def test_edit_404_when_target_missing(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/memory/99999/significance",
data={"significance": "2"},
)
assert response.status_code == 404