Files
chat/chat/state/manual_edit.py
T
Joseph Doherty 4546bc0d9c 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.
2026-04-27 03:35:54 -04:00

181 lines
7.4 KiB
Python

"""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 five 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). Also reused by T27's
scene-close pipeline to rewrite per-turn raw narratives into a proper
per-POV scene summary.
- ``edge_summary`` — string overwrite of the directed edge's ``summary``
field. Driven by T27 from the classifier's ``relationship_summary``
output combined with the prior summary.
T72.1 (Phase 2.5) adds one list-shaped edit:
- ``edge_knowledge_fact`` — add/remove a single fact on an edge's
``knowledge_json`` list. Payload carries an ``action`` of ``"add"`` or
``"remove"`` and a ``fact`` string; remove matches the first occurrence
by string equality so the route handler doesn't have to track fact
indices across re-renders.
T72.3 adds a per-flag witness toggle:
- ``memory_witness`` — flip one of ``witness_you`` / ``witness_host`` /
``witness_guest`` on a memory row. Payload's ``new_value`` is a dict
``{"flag": "you"|"host"|"guest", "value": 0|1}`` and ``prior_value``
mirrors the same shape so an inverse edit can restore the flag.
T98.3 adds a hide-from-view toggle:
- ``turn_hidden`` — flip ``event_log.hidden`` on a single turn row.
Hidden turns are filtered by ``read_recent_dialogue`` (see
:mod:`chat.services.turn_common`) so they vanish from the prompt
without being deleted from the log. ``target_id`` is the integer
``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.
"""
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.log import Event
from chat.eventlog.projector import on
_VALID_WITNESS_FLAGS = {"you", "host", "guest"}
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)),
)
elif kind == "edge_summary":
# ``target_id`` here is a {"source_id", "target_id"} pair like
# the affinity/trust edits, since edges are keyed by the
# directed pair, not a single rowid.
conn.execute(
"UPDATE edges SET summary = ? "
"WHERE source_id = ? AND target_id = ?",
(
str(new_value),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "edge_knowledge_fact":
# T72.1: add or remove a single fact on an edge's knowledge list.
# ``target_id`` is the {"source_id", "target_id"} edge pair;
# ``new_value`` carries ``{"action": "add"|"remove", "fact": str}``.
# Remove matches by string equality (first occurrence) so callers
# don't have to thread a fact_index through re-rendered drawers.
action = new_value["action"]
fact = str(new_value["fact"])
row = conn.execute(
"SELECT knowledge_json FROM edges "
"WHERE source_id = ? AND target_id = ?",
(target_id["source_id"], target_id["target_id"]),
).fetchone()
if row is not None:
knowledge = json.loads(row[0])
if action == "add":
knowledge.append(fact)
elif action == "remove" and fact in knowledge:
knowledge.remove(fact)
conn.execute(
"UPDATE edges SET knowledge_json = ? "
"WHERE source_id = ? AND target_id = ?",
(
json.dumps(knowledge),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "memory_witness":
# T72.3: toggle one of the three witness flags on a memory row.
# ``new_value`` is the dict ``{"flag", "value"}``; ``prior_value``
# mirrors the same shape so an inverse edit restores the flag.
flag = new_value["flag"]
if flag in _VALID_WITNESS_FLAGS:
conn.execute(
f"UPDATE memories SET witness_{flag} = ? WHERE id = ?",
(1 if int(new_value["value"]) else 0, int(target_id)),
)
elif kind == "turn_hidden":
# T98.3: hide-from-view toggle on a turn (event_log row). Sets
# ``event_log.hidden`` so :func:`read_recent_dialogue` (which
# filters ``hidden = 0``) drops the row from the prompt window
# without deleting it from the log. ``new_value`` is
# ``{"hidden": 0|1}``.
hidden_int = 1 if int(new_value.get("hidden", 0)) else 0
conn.execute(
"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.