feat: drawer remaining v1 field edits (T98.5)
Audit of chat/state/manual_edit.py target_kind dispatch found two §6.4 fields without drawer affordances despite being already-projected text columns: chat_state.narrative_anchor and chat_state.weather. Both land via new manual_edit branches (target_kind chat_narrative_anchor and chat_weather) plus paired drawer routes and Scene-section text inputs. The container properties_json blob is intentionally deferred — bounded JSON edits aren't wired through manual_edit and the drawer never surfaces multiple containers at once, so v1 leaves it out.
This commit is contained in:
@@ -38,6 +38,12 @@ T98.3 adds a hide-from-view toggle:
|
||||
``event_log.id`` of the turn; ``new_value`` is ``{"hidden": 0|1}``
|
||||
and ``prior_value`` mirrors the shape so an inverse edit restores it.
|
||||
|
||||
T98.5 finishes the v1 drawer surface with two chat-scope text edits:
|
||||
- ``chat_narrative_anchor`` and ``chat_weather`` — string overwrites of
|
||||
the matching ``chat_state`` columns. ``target_id`` is the chat id
|
||||
(``chats.id``); ``new_value`` is the new string and ``prior_value``
|
||||
carries the previous content for §6.4 reversibility.
|
||||
|
||||
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.
|
||||
@@ -157,5 +163,18 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None:
|
||||
"UPDATE event_log SET hidden = ? WHERE id = ?",
|
||||
(hidden_int, int(target_id)),
|
||||
)
|
||||
elif kind == "chat_narrative_anchor":
|
||||
# T98.5: string overwrite of ``chat_state.narrative_anchor`` for
|
||||
# the chat keyed by ``target_id``.
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET narrative_anchor = ? WHERE chat_id = ?",
|
||||
(str(new_value), str(target_id)),
|
||||
)
|
||||
elif kind == "chat_weather":
|
||||
# T98.5: string overwrite of ``chat_state.weather``.
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET weather = ? WHERE chat_id = ?",
|
||||
(str(new_value), str(target_id)),
|
||||
)
|
||||
# Unknown target_kind: silently no-op for v1. Future kinds (activity
|
||||
# fields, etc.) extend the dispatch above.
|
||||
|
||||
@@ -16,6 +16,26 @@
|
||||
<p class="muted">No active container.</p>
|
||||
{% endif %}
|
||||
<p>Time: {{ chat.time }}</p>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/chat/narrative-anchor"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Narrative anchor:
|
||||
<input type="text" name="new_value" maxlength="500"
|
||||
value="{{ chat.narrative_anchor or '' }}">
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/chat/weather"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Weather:
|
||||
<input type="text" name="new_value" maxlength="500"
|
||||
value="{{ chat.weather or '' }}">
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% if scene %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/scene/close"
|
||||
|
||||
@@ -1358,6 +1358,93 @@ async def hide_turn(
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
# --- T98.5 chat narrative anchor + weather ----------------------------
|
||||
#
|
||||
# Audit (T98.5) found two §6.4 fields without drawer affordances despite
|
||||
# both being prose strings stored on ``chat_state``: ``narrative_anchor``
|
||||
# (the "Day 1" / "morning of the gala" hint above the chat clock) and
|
||||
# ``weather``. Both land via the existing ``manual_edit`` projector with
|
||||
# new branches added in :mod:`chat.state.manual_edit`. The container
|
||||
# ``properties_json`` blob is more invasive — bounded JSON edits aren't
|
||||
# wired through manual_edit and the drawer never surfaces multiple
|
||||
# containers at once, so it stays out of v1.
|
||||
|
||||
CHAT_NARRATIVE_ANCHOR_MAX = 500
|
||||
CHAT_WEATHER_MAX = 500
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/chat/narrative-anchor",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_chat_narrative_anchor(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
new_value: str = 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}")
|
||||
if len(new_value) > CHAT_NARRATIVE_ANCHOR_MAX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"narrative_anchor exceeds {CHAT_NARRATIVE_ANCHOR_MAX} chars "
|
||||
f"(got {len(new_value)})"
|
||||
),
|
||||
)
|
||||
|
||||
prior = chat.get("narrative_anchor") or ""
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "chat_narrative_anchor",
|
||||
"target_id": chat_id,
|
||||
"prior_value": prior,
|
||||
"new_value": new_value,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/chat/weather",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_chat_weather(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
new_value: str = 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}")
|
||||
if len(new_value) > CHAT_WEATHER_MAX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"weather exceeds {CHAT_WEATHER_MAX} chars "
|
||||
f"(got {len(new_value)})"
|
||||
),
|
||||
)
|
||||
|
||||
prior = chat.get("weather") or ""
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "chat_weather",
|
||||
"target_id": chat_id,
|
||||
"prior_value": prior,
|
||||
"new_value": new_value,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/branch/from-turn/{event_id}",
|
||||
response_class=HTMLResponse,
|
||||
|
||||
@@ -456,3 +456,68 @@ def test_t98_4_delete_invokes_rewind_and_drops_cascade(client, tmp_path):
|
||||
"SELECT 1 FROM event_log WHERE id = ?", (ev_id,)
|
||||
).fetchone()
|
||||
assert row is None, f"event {ev_id} should have been deleted"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T98.5 — remaining v1 edits (chat narrative anchor + weather).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_t98_5_edit_chat_narrative_anchor_emits_manual_edit(client, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
_seed_chat(db)
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/chat/narrative-anchor",
|
||||
data={"new_value": "Late evening, after dinner"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(db) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT narrative_anchor FROM chat_state WHERE chat_id = ?",
|
||||
("chat_bot_a",),
|
||||
).fetchone()
|
||||
assert row[0] == "Late evening, after dinner"
|
||||
|
||||
import json as _json
|
||||
|
||||
log = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'manual_edit' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
payload = _json.loads(log[0])
|
||||
assert payload["target_kind"] == "chat_narrative_anchor"
|
||||
assert payload["target_id"] == "chat_bot_a"
|
||||
assert payload["prior_value"] == "Day 1"
|
||||
assert payload["new_value"] == "Late evening, after dinner"
|
||||
|
||||
|
||||
def test_t98_5_edit_chat_weather_emits_manual_edit(client, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
_seed_chat(db)
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/chat/weather",
|
||||
data={"new_value": "thunderstorm rolling in"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(db) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT weather FROM chat_state WHERE chat_id = ?",
|
||||
("chat_bot_a",),
|
||||
).fetchone()
|
||||
assert row[0] == "thunderstorm rolling in"
|
||||
|
||||
import json as _json
|
||||
|
||||
log = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'manual_edit' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
payload = _json.loads(log[0])
|
||||
assert payload["target_kind"] == "chat_weather"
|
||||
assert payload["target_id"] == "chat_bot_a"
|
||||
assert payload["prior_value"] == ""
|
||||
assert payload["new_value"] == "thunderstorm rolling in"
|
||||
|
||||
Reference in New Issue
Block a user