feat: drawer edits with manual_edit event capture
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -40,6 +40,18 @@
|
||||
<div class="edge-row">
|
||||
<strong>{{ host_bot.name }} → you</strong>
|
||||
<p>Affinity: {{ edge_b2y.affinity }}/100 · 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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user