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:
|
# Trigger handler registration:
|
||||||
import chat.state.entities # noqa: F401
|
import chat.state.entities # noqa: F401
|
||||||
import chat.state.edges # noqa: F401
|
import chat.state.edges # noqa: F401
|
||||||
|
import chat.state.manual_edit # noqa: F401
|
||||||
import chat.state.memory # noqa: F401
|
import chat.state.memory # noqa: F401
|
||||||
import chat.state.world # 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">
|
<div class="edge-row">
|
||||||
<strong>{{ host_bot.name }} → you</strong>
|
<strong>{{ host_bot.name }} → you</strong>
|
||||||
<p>Affinity: {{ edge_b2y.affinity }}/100 · Trust: {{ edge_b2y.trust }}/100</p>
|
<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.summary %}<p class="muted">{{ edge_b2y.summary }}</p>{% endif %}
|
||||||
{% if edge_b2y.knowledge %}
|
{% if edge_b2y.knowledge %}
|
||||||
<details><summary>Knowledge ({{ edge_b2y.knowledge|length }})</summary>
|
<details><summary>Knowledge ({{ edge_b2y.knowledge|length }})</summary>
|
||||||
@@ -68,6 +80,12 @@
|
|||||||
<li>
|
<li>
|
||||||
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
|
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
|
||||||
{{ m.pov_summary }}
|
{{ 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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -84,6 +102,24 @@
|
|||||||
<li>
|
<li>
|
||||||
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
|
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
|
||||||
{{ m.pov_summary[:200] }}{% if m.pov_summary|length > 200 %}…{% endif %}
|
{{ 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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</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">`
|
The GET endpoint renders an HTML partial showing the current scene +
|
||||||
on first reveal. Shows the current scene + container, per-entity activity,
|
container, per-entity activity, host <-> you edges, pinned memories with
|
||||||
host <-> you edges, pinned memories with an `n / cap` counter, and recent
|
an ``n / cap`` counter, and recent witnessed memories from the host's
|
||||||
witnessed memories from the host's POV with significance markers.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
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.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from chat.eventlog.log import append_and_apply
|
||||||
from chat.state.edges import get_edge
|
from chat.state.edges import get_edge
|
||||||
from chat.state.entities import get_bot, get_you
|
from chat.state.entities import get_bot, get_you
|
||||||
from chat.state.memory import get_pinned
|
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,
|
"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