From a45dabb6ae3876ae6a5890668ea9a0171a15746d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 13:20:43 -0400 Subject: [PATCH] feat: per-turn memory writes with witness flags --- chat/services/memory_write.py | 61 ++++++++ chat/web/turns.py | 18 ++- tests/test_memory_write.py | 283 ++++++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 chat/services/memory_write.py create mode 100644 tests/test_memory_write.py diff --git a/chat/services/memory_write.py b/chat/services/memory_write.py new file mode 100644 index 0000000..ee27d3d --- /dev/null +++ b/chat/services/memory_write.py @@ -0,0 +1,61 @@ +"""Per-turn memory writes (T21). + +After ``assistant_turn`` lands, the turn flow records a ``memory_written`` +event for each present POV owner. Phase 1 single-bot turns only have the +host bot as a memory-store owner — ``you`` doesn't have a memory store in +v1 — so we write exactly one row per turn. + +Phase 1 simplifications (per plan §11.1, T27 will refine): + +- ``pov_summary`` is the assistant's raw narrative text. T27 rewrites at + scene close into per-POV summary form. +- ``significance`` defaults to ``1`` (Notable). T22's async significance + pass overwrites via a follow-up event. +- Witness flags are hard-coded ``[you=1, host=1, guest=0]``. Phase 2 will + derive them from ``chat.guest_bot_id`` once a guest can be present. +""" + +from __future__ import annotations + +from sqlite3 import Connection + +from chat.eventlog.log import append_and_apply + + +def record_turn_memory( + conn: Connection, + *, + chat_id: str, + host_bot_id: str, + narrative_text: str, + scene_id: int | None = None, + chat_clock_at: str | None = None, + source: str = "direct", + significance: int = 1, +) -> int: + """Append a ``memory_written`` event for the host bot's POV of this turn. + + Uses :func:`chat.eventlog.log.append_and_apply` (not raw + :func:`append_event`) so the new memory row is projected immediately + without re-running prior non-idempotent handlers (e.g. ``edge_update`` + deltas). Returns the new event id. + """ + payload: dict = { + "owner_id": host_bot_id, + "chat_id": chat_id, + "pov_summary": narrative_text, + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "source": source, + "reliability": 1.0, + "significance": significance, + "pinned": 0, + "auto_pinned": 0, + } + if scene_id is not None: + payload["scene_id"] = scene_id + if chat_clock_at is not None: + payload["chat_clock_at"] = chat_clock_at + + return append_and_apply(conn, kind="memory_written", payload=payload) diff --git a/chat/web/turns.py b/chat/web/turns.py index 5128ce3..ba678a6 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -39,12 +39,13 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import Response from chat.eventlog.log import append_and_apply, append_event +from chat.services.memory_write import record_turn_memory from chat.services.prompt import assemble_narrative_prompt from chat.services.state_update import compute_state_update from chat.services.turn_parse import ParsedTurn, parse_turn from chat.state.edges import get_edge from chat.state.entities import get_bot, get_you -from chat.state.world import get_chat +from chat.state.world import active_scene, get_chat from chat.web.bots import get_conn from chat.web.kickoff import get_llm_client from chat.web.pubsub import publish @@ -219,6 +220,21 @@ async def post_turn( }, ) + # 6a. Per-turn memory write (Plan §11.1, T21). Phase 1 single-bot: + # only the host bot has a memory store, witness flags are + # ``[you=1, host=1, guest=0]``, and ``pov_summary`` is the raw + # narrative text (T27 will rewrite at scene close). Significance + # defaults to 1; T22's async classifier pass will overwrite it. + scene = active_scene(conn, chat_id) + record_turn_memory( + conn, + chat_id=chat_id, + host_bot_id=host_bot["id"], + narrative_text=full_text, + scene_id=scene["id"] if scene else None, + chat_clock_at=chat.get("time"), + ) + # 6b. Post-turn state-update pass (Requirements §3.4). For Phase 1 # the only present entities are ``you`` and ``host_bot`` so we run # two classifier calls — one per directed edge — and append the diff --git a/tests/test_memory_write.py b/tests/test_memory_write.py new file mode 100644 index 0000000..f25fb4f --- /dev/null +++ b/tests/test_memory_write.py @@ -0,0 +1,283 @@ +"""Per-turn memory writes (T21). + +After ``assistant_turn`` lands the turn flow records a ``memory_written`` +event for each present POV owner. Phase 1 single-bot turns only have the +host bot as a memory-store owner — ``you`` doesn't have a memory store in +v1 — so we write exactly one row per turn with witness flags +``[you=1, host=1, guest=0]``. The ``pov_summary`` is the assistant's raw +narrative text; T27 rewrites at scene close into per-POV summary form. +""" + +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.db.migrate import apply_migrations +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.llm.mock import MockLLMClient +from chat.services.memory_write import record_turn_memory +import chat.state.entities # noqa: F401 - register handlers +import chat.state.memory # noqa: F401 +import chat.state.world # noqa: F401 + + +def _seed_minimal(db_path: Path) -> None: + """Author a bot and create a chat — bare minimum for memory writes.""" + with open_db(db_path) 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": "", + }, + ) + project(conn) + + +def test_record_turn_memory_writes_event_and_projects(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + _seed_minimal(db) + with open_db(db) as conn: + eid = record_turn_memory( + conn, + chat_id="chat_bot_a", + host_bot_id="bot_a", + narrative_text="BotA looks up. 'You're back late.'", + scene_id=None, + chat_clock_at="2026-04-26T20:00:00+00:00", + ) + assert eid > 0 + + rows = conn.execute( + "SELECT id, owner_id, chat_id, pov_summary, " + "witness_you, witness_host, witness_guest, " + "source, reliability, significance, pinned, auto_pinned, " + "chat_clock_at " + "FROM memories WHERE owner_id = ?", + ("bot_a",), + ).fetchall() + assert len(rows) == 1 + m = rows[0] + assert m[1] == "bot_a" + assert m[2] == "chat_bot_a" + assert "looks up" in m[3] + assert m[4] == 1 # witness_you + assert m[5] == 1 # witness_host + assert m[6] == 0 # witness_guest + assert m[7] == "direct" + assert m[8] == 1.0 # reliability default + assert m[9] == 1 # significance default + assert m[10] == 0 # pinned default + assert m[11] == 0 # auto_pinned default + assert m[12] == "2026-04-26T20:00:00+00:00" + + # And the underlying event_log row is the canonical source. + cur = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written'" + ) + assert cur.fetchone()[0] == 1 + + +def test_record_turn_memory_omits_optional_fields(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + _seed_minimal(db) + with open_db(db) as conn: + # Call without scene_id/chat_clock_at — should default to None. + eid = record_turn_memory( + conn, + chat_id="chat_bot_a", + host_bot_id="bot_a", + narrative_text="A simple memory.", + ) + assert eid > 0 + + row = conn.execute( + "SELECT scene_id, chat_clock_at, source, reliability, " + "significance, pinned, auto_pinned " + "FROM memories WHERE owner_id = 'bot_a'" + ).fetchone() + assert row is not None + scene_id, chat_clock_at, source, reliability, significance, pinned, auto_pinned = row + assert scene_id is None + assert chat_clock_at is None + assert source == "direct" + assert reliability == 1.0 + assert significance == 1 + assert pinned == 0 + assert auto_pinned == 0 + + +# --------------------------------------------------------------------------- +# Integration: POST /chats//turns produces a memory_written event. +# --------------------------------------------------------------------------- + + +@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)) + + canned_parse = json.dumps( + {"segments": [{"kind": "dialogue", "text": "hello"}]} + ) + canned_response = "BotA nods. 'Hi there.'" + canned_state_update = json.dumps( + {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} + ) + + from chat.web.kickoff import get_llm_client + + mock = MockLLMClient( + canned=[ + canned_parse, + canned_response, + canned_state_update, + canned_state_update, + ] + ) + app.dependency_overrides[get_llm_client] = lambda: mock + + with TestClient(app) as c: + c.mock_llm = mock # type: ignore[attr-defined] + yield c + + app.dependency_overrides.clear() + + +def _seed_full(db_path: Path) -> None: + """Seed enough state for a full turn flow (matches test_turn_flow.py).""" + with open_db(db_path) as conn: + append_event( + conn, + kind="bot_authored", + payload={ + "id": "bot_a", + "name": "BotA", + "persona": "thoughtful, observant", + "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": "", + }, + ) + append_event( + conn, + kind="edge_update", + payload={ + "source_id": "bot_a", + "target_id": "you", + "chat_id": "chat_bot_a", + "knowledge_facts": ["coworker"], + }, + ) + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": "you", + "posture": "sitting", + "action": { + "verb": "talking", + "interruptible": True, + "required_attention": "low", + "expected_duration": "ongoing", + }, + "attention": "", + "holding": [], + "status": {}, + }, + ) + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": "bot_a", + "posture": "sitting", + "action": { + "verb": "listening", + "interruptible": True, + "required_attention": "low", + "expected_duration": "ongoing", + }, + "attention": "", + "holding": [], + "status": {}, + }, + ) + project(conn) + + +def test_post_turn_writes_memory_for_host_bot(client, tmp_path): + """After a POST turn, exactly one memory_written event is appended and + a corresponding memory row is projected for the host bot's POV.""" + _seed_full(tmp_path / "test.db") + response = client.post( + "/chats/chat_bot_a/turns", data={"prose": "hello"} + ) + assert response.status_code == 204 + + with open_db(tmp_path / "test.db") as conn: + cur = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written'" + ) + assert cur.fetchone()[0] == 1 + + cur = conn.execute( + "SELECT owner_id, chat_id, pov_summary, " + "witness_you, witness_host, witness_guest, source, significance " + "FROM memories" + ) + rows = cur.fetchall() + assert len(rows) == 1 + owner_id, chat_id, pov_summary, w_you, w_host, w_guest, source, sig = rows[0] + assert owner_id == "bot_a" + assert chat_id == "chat_bot_a" + # pov_summary is the assistant's narrative text (Phase 1 simplification). + assert pov_summary == "BotA nods. 'Hi there.'" + assert w_you == 1 + assert w_host == 1 + assert w_guest == 0 + assert source == "direct" + assert sig == 1