4546bc0d9c
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.
181 lines
7.4 KiB
Python
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.
|