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}``
|
``event_log.id`` of the turn; ``new_value`` is ``{"hidden": 0|1}``
|
||||||
and ``prior_value`` mirrors the shape so an inverse edit restores it.
|
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
|
Pin toggles intentionally use the existing ``memory_pin_changed`` event
|
||||||
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
|
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
|
||||||
the projection writes both ``pinned`` and ``auto_pinned`` atomically.
|
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 = ?",
|
"UPDATE event_log SET hidden = ? WHERE id = ?",
|
||||||
(hidden_int, int(target_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
|
# Unknown target_kind: silently no-op for v1. Future kinds (activity
|
||||||
# fields, etc.) extend the dispatch above.
|
# fields, etc.) extend the dispatch above.
|
||||||
|
|||||||
@@ -16,6 +16,26 @@
|
|||||||
<p class="muted">No active container.</p>
|
<p class="muted">No active container.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p>Time: {{ chat.time }}</p>
|
<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 %}
|
{% if scene %}
|
||||||
<form class="inline-edit"
|
<form class="inline-edit"
|
||||||
hx-post="/chats/{{ chat.id }}/drawer/scene/close"
|
hx-post="/chats/{{ chat.id }}/drawer/scene/close"
|
||||||
|
|||||||
@@ -1358,6 +1358,93 @@ async def hide_turn(
|
|||||||
return await drawer(chat_id, request, conn)
|
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(
|
@router.post(
|
||||||
"/chats/{chat_id}/drawer/branch/from-turn/{event_id}",
|
"/chats/{chat_id}/drawer/branch/from-turn/{event_id}",
|
||||||
response_class=HTMLResponse,
|
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,)
|
"SELECT 1 FROM event_log WHERE id = ?", (ev_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert row is None, f"event {ev_id} should have been deleted"
|
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