6 Commits

Author SHA1 Message Date
Joseph Doherty 8efbcdf6c3 merge: T58 scene compression + thread emission on close 2026-04-26 20:21:01 -04:00
Joseph Doherty 8aeadfd0e4 merge: T57 significance-aware retrieval ranking 2026-04-26 20:21:01 -04:00
Joseph Doherty 88350d7d2e merge: T56 event-completion promotion service 2026-04-26 20:21:00 -04:00
Joseph Doherty 343f305587 feat: significance-driven quote retention + thread emission on close (T58) 2026-04-26 20:18:34 -04:00
Joseph Doherty 021587b3df feat: event-completion promotion service (T56) 2026-04-26 20:15:51 -04:00
Joseph Doherty 5e6b29e0c5 feat: significance-aware retrieval ranking (T57) 2026-04-26 20:15:19 -04:00
6 changed files with 811 additions and 8 deletions
+149
View File
@@ -0,0 +1,149 @@
"""Event-completion promotion (T56).
When an event reaches ``status='completed'``, read its ``props_json``
and emit promotion events into the appropriate state stores.
Synchronous, no LLM. Skips when the event status is not ``completed``
(cancelled / expired terminate the event without promoting).
Props recognized:
- ``acquired_objects: list[str]`` — emits a ``manual_edit`` with
``target_kind="memory_pov_summary"`` per object on the host's memory
row, recording the acquisition. Phase 3 is a stub: it requires both
``host_bot_id`` and ``host_memory_id`` (an existing memories.id) to
be present in props; missing either skips that object cleanly.
Phase 4 will introduce a real inventory schema.
- ``knowledge_facts: list[{owner_id, target_id, fact}]`` — emits an
``edge_update`` event on the directed ``owner_id -> target_id`` edge
with the fact appended to ``knowledge_facts``. The ``edge_update``
projector accepts ``knowledge_facts`` as a list and extends the
edge's stored ``knowledge_json``.
- ``relationship_change: {summary, source_id, target_id}`` — emits a
``manual_edit`` with ``target_kind="edge_summary"`` overwriting the
edge's ``summary`` field on the directed pair.
Anything else stays in the closed event record (the projector kept
the row; no further promotion).
"""
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.log import append_and_apply
from chat.state.events import get_event
def promote_completed_event(
conn: Connection,
*,
event_id: str,
chat_id: str,
chat_clock_at: str | None,
) -> dict:
"""Read the completed event's props and emit promotion events.
Returns a dict of counts keyed by promoted artifact:
``{"acquired_objects", "knowledge_facts", "relationship_change"}``.
Skips silently if the event row is missing or its status is not
``completed`` — cancelled / expired events terminate without any
promotion.
"""
counts = {
"acquired_objects": 0,
"knowledge_facts": 0,
"relationship_change": 0,
}
event = get_event(conn, event_id)
if event is None or event["status"] != "completed":
return counts
props = event.get("props") or {}
# acquired_objects: each becomes a memory_pov_summary edit (Phase 3
# stub). The manual_edit projector requires a valid memory rowid as
# ``target_id`` (it does ``int(target_id)``), so skip cleanly when
# neither a host_bot_id nor a host_memory_id is supplied.
host_bot_id = props.get("host_bot_id")
host_memory_id = props.get("host_memory_id")
for obj in props.get("acquired_objects", []) or []:
if host_bot_id is None or host_memory_id is None:
continue
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_pov_summary",
"target_id": host_memory_id,
"owner_id": host_bot_id,
"chat_id": chat_id,
"prior_value": "",
"new_value": f"Acquired: {obj}",
"source": "event_promotion",
"event_id": event_id,
"chat_clock_at": chat_clock_at,
},
)
counts["acquired_objects"] += 1
# knowledge_facts: each becomes an edge_update appending the fact.
for fact_entry in props.get("knowledge_facts", []) or []:
owner_id = fact_entry.get("owner_id")
target_id = fact_entry.get("target_id")
fact = fact_entry.get("fact", "")
if not owner_id or not target_id or not fact:
continue
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": owner_id,
"target_id": target_id,
"chat_id": chat_id,
"affinity_delta": 0,
"trust_delta": 0,
"knowledge_facts": [fact],
"last_interaction_at": chat_clock_at,
"last_interaction_chat_id": chat_id,
"source": "event_promotion",
"event_id": event_id,
},
)
counts["knowledge_facts"] += 1
# relationship_change: edge_summary manual_edit on the directed pair.
# The manual_edit projector for ``edge_summary`` keys on a
# ``target_id`` dict ``{source_id, target_id}`` (see
# chat/state/manual_edit.py); we shape the payload to match.
rc = props.get("relationship_change") or {}
if rc:
source_id = rc.get("source_id")
rc_target_id = rc.get("target_id")
summary = rc.get("summary", "")
if source_id and rc_target_id and summary:
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "edge_summary",
"target_id": {
"source_id": source_id,
"target_id": rc_target_id,
},
"chat_id": chat_id,
"prior_value": "",
"new_value": summary,
"source": "event_promotion",
"event_id": event_id,
"chat_clock_at": chat_clock_at,
},
)
counts["relationship_change"] += 1
return counts
__all__ = ["promote_completed_event"]
+102 -1
View File
@@ -29,6 +29,8 @@ keeps moving.
from __future__ import annotations from __future__ import annotations
import json import json
import uuid
from datetime import datetime, timezone
from sqlite3 import Connection from sqlite3 import Connection
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -167,6 +169,7 @@ async def _summarize_and_apply_for_witness(
you_name: str, you_name: str,
dialogue: list[dict], dialogue: list[dict],
timeout_s: float, timeout_s: float,
key_quotes_suffix: str = "",
) -> ScenePOVSummary: ) -> ScenePOVSummary:
"""Run :func:`summarize_scene` for one bot witness and apply the """Run :func:`summarize_scene` for one bot witness and apply the
three projected updates (memory pov_summary rewrite, edge summary three projected updates (memory pov_summary rewrite, edge summary
@@ -175,6 +178,10 @@ async def _summarize_and_apply_for_witness(
Tolerant of missing pieces in the same way Phase 1 was: no memory Tolerant of missing pieces in the same way Phase 1 was: no memory
row -> skip the rewrite; no edge row -> skip the edge_summary write row -> skip the rewrite; no edge row -> skip the edge_summary write
(the empty-default classifier output simply yields no rewrites). (the empty-default classifier output simply yields no rewrites).
``key_quotes_suffix`` is appended verbatim to the per-POV summary
text before the rewrite lands (T58.1) — empty string is the no-op
default for low-significance scenes.
""" """
from chat.state.edges import get_edge from chat.state.edges import get_edge
from chat.state.entities import get_bot from chat.state.entities import get_bot
@@ -206,6 +213,7 @@ async def _summarize_and_apply_for_witness(
# Empty default -> skip the memory rewrite; the seeded # Empty default -> skip the memory rewrite; the seeded
# per-turn pov_summary stays in place. # per-turn pov_summary stays in place.
continue continue
new_value = pov.summary + key_quotes_suffix
append_and_apply( append_and_apply(
conn, conn,
kind="manual_edit", kind="manual_edit",
@@ -213,7 +221,7 @@ async def _summarize_and_apply_for_witness(
"target_kind": "memory_pov_summary", "target_kind": "memory_pov_summary",
"target_id": int(memory_id), "target_id": int(memory_id),
"prior_value": prior_pov, "prior_value": prior_pov,
"new_value": pov.summary, "new_value": new_value,
}, },
) )
@@ -255,6 +263,40 @@ async def _summarize_and_apply_for_witness(
return pov return pov
def _build_key_quotes_suffix(conn: Connection, scene_id: int) -> str:
"""If the scene's max-turn-significance is >= 2, build the
"Key quotes:" suffix from the top-3 highest-significance memory rows
(per requirements §11.1). Otherwise return the empty string so the
per-POV summaries collapse fully (low-significance scenes lose all
raw text in favor of the classifier rewrite).
Quote source is each memory's current ``pov_summary`` — the raw
per-turn narrative seeded by T21, since this helper is called BEFORE
the per-POV rewrite. Texts are truncated to 200 chars to bound
memory row growth across many witnesses.
"""
row = conn.execute(
"SELECT MAX(significance) FROM memories WHERE scene_id = ?",
(scene_id,),
).fetchone()
max_sig = (row[0] if row else None) or 0
if max_sig < 2:
return ""
cur = conn.execute(
"SELECT pov_summary FROM memories WHERE scene_id = ? "
"ORDER BY significance DESC, id ASC LIMIT 3",
(scene_id,),
)
quotes = [
(r[0] or "")[:200]
for r in cur.fetchall()
]
if not quotes:
return ""
lines = "\n".join(f'- "{q}"' for q in quotes)
return f"\n\nKey quotes:\n{lines}"
async def apply_scene_close_summary( async def apply_scene_close_summary(
conn: Connection, conn: Connection,
client: LLMClient, client: LLMClient,
@@ -296,8 +338,10 @@ async def apply_scene_close_summary(
""" """
# Local imports to keep the module-level surface tight and avoid # Local imports to keep the module-level surface tight and avoid
# any chance of a circular dep through chat.state.*. # any chance of a circular dep through chat.state.*.
from chat.services.thread_detection import detect_threads
from chat.state.entities import get_bot, get_you from chat.state.entities import get_bot, get_you
from chat.state.group_node import get_group_node from chat.state.group_node import get_group_node
from chat.state.threads import list_open_threads
from chat.state.world import get_chat from chat.state.world import get_chat
you_entity = get_you(conn) or {"name": "you", "persona": ""} you_entity = get_you(conn) or {"name": "you", "persona": ""}
@@ -308,6 +352,11 @@ async def apply_scene_close_summary(
dialogue = _read_recent_dialogue(conn, chat_id) dialogue = _read_recent_dialogue(conn, chat_id)
# T58.1: build the "Key quotes:" suffix BEFORE the per-POV rewrites
# land — quote source is the raw seeded pov_summary text on each
# memory row, which the rewrite about to fire would clobber.
key_quotes_suffix = _build_key_quotes_suffix(conn, scene_id)
host_pov = await _summarize_and_apply_for_witness( host_pov = await _summarize_and_apply_for_witness(
conn, conn,
client, client,
@@ -318,6 +367,7 @@ async def apply_scene_close_summary(
you_name=you_name, you_name=you_name,
dialogue=dialogue, dialogue=dialogue,
timeout_s=timeout_s, timeout_s=timeout_s,
key_quotes_suffix=key_quotes_suffix,
) )
guest_pov: ScenePOVSummary | None = None guest_pov: ScenePOVSummary | None = None
@@ -332,6 +382,7 @@ async def apply_scene_close_summary(
you_name=you_name, you_name=you_name,
dialogue=dialogue, dialogue=dialogue,
timeout_s=timeout_s, timeout_s=timeout_s,
key_quotes_suffix=key_quotes_suffix,
) )
# Group node update: T70 runs a third classifier call to merge the # Group node update: T70 runs a third classifier call to merge the
@@ -364,6 +415,56 @@ async def apply_scene_close_summary(
}, },
) )
# T58.2: thread detection on close. Reuses the dialogue we already
# gathered for per-POV summarization — same {speaker, text} shape
# detect_threads expects. Failure-tolerant: classify() returns the
# empty default on retry-exhaustion, and the broad except below
# protects the close pipeline from any other classifier/mock flap.
try:
thread_result = await detect_threads(
client,
classifier_model=classifier_model,
scene_transcript=dialogue,
open_threads=list_open_threads(conn, chat_id),
timeout_s=timeout_s,
)
except Exception:
from chat.services.thread_detection import ThreadDetectionResult
thread_result = ThreadDetectionResult()
for cand in thread_result.candidates:
if cand.action == "open":
new_thread_id = f"thr_{uuid.uuid4().hex[:12]}"
append_and_apply(
conn,
kind="thread_opened",
payload={
"thread_id": new_thread_id,
"chat_id": chat_id,
"title": cand.title,
"summary": cand.summary,
},
)
elif cand.action == "update" and cand.existing_thread_id:
append_and_apply(
conn,
kind="thread_updated",
payload={
"thread_id": cand.existing_thread_id,
"summary": cand.summary,
"last_referenced_scene_id": scene_id,
},
)
elif cand.action == "close" and cand.existing_thread_id:
append_and_apply(
conn,
kind="thread_closed",
payload={
"thread_id": cand.existing_thread_id,
"closed_at": datetime.now(timezone.utc).isoformat(),
},
)
return host_pov return host_pov
+15 -2
View File
@@ -94,6 +94,14 @@ def get_pinned(conn: Connection, owner_id: str) -> list[dict]:
_SIGNIFICANCE_WEIGHT = 0.3 _SIGNIFICANCE_WEIGHT = 0.3
_RECENCY_WEIGHT = 0.5 _RECENCY_WEIGHT = 0.5
# T57 (Phase 3, §11.1): significance multiplier applied to the SQL ORDER BY in
# ``search_memories`` so that the FTS over-fetch already prefers
# higher-significance rows for tied / near-tied BM25 ranks. Module-level so it
# can be tuned without a code change. BM25 ``rank`` is lower-is-better, so the
# bias is *subtracted* from rank in the ASC ordering — equivalent to multiplying
# a higher-is-better score by a positive constant per the spec wording.
SIGNIFICANCE_RANK_BIAS = 0.5
def search_memories( def search_memories(
conn: Connection, conn: Connection,
@@ -137,10 +145,15 @@ def search_memories(
"JOIN memories m ON m.id = memories_fts.rowid " "JOIN memories m ON m.id = memories_fts.rowid "
f"WHERE m.owner_id = ? AND m.{witness_col} = 1 " f"WHERE m.owner_id = ? AND m.{witness_col} = 1 "
"AND memories_fts MATCH ? " "AND memories_fts MATCH ? "
"ORDER BY memories_fts.rank " # T57: significance multiplier biases the FTS over-fetch order. BM25
# ``rank`` is lower-is-better, so subtracting ``significance * BIAS``
# surfaces higher-significance rows above lower-significance rows with
# equal/near-equal match strength. Equivalent to ``score × constant``
# per §11.1 once the rank is inverted to a higher-is-better score.
"ORDER BY (memories_fts.rank - m.significance * ?) ASC "
"LIMIT ?" "LIMIT ?"
) )
cur = conn.execute(sql, (owner_id, query, over_fetch)) cur = conn.execute(sql, (owner_id, query, SIGNIFICANCE_RANK_BIAS, over_fetch))
rows = cur.fetchall() rows = cur.fetchall()
if not rows: if not rows:
return [] return []
+256
View File
@@ -0,0 +1,256 @@
"""Tests for the event-completion promotion service (T56).
When an event reaches ``status='completed'``, the orchestrator promotes
structured artifacts the event carried (``acquired_objects``,
``knowledge_facts``, ``relationship_change``) into the appropriate
state stores via downstream events. Cancelled / expired events do NOT
promote — the closed event row is left in place but no follow-on
events fire.
"""
from __future__ import annotations
import json
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.services.event_promotion import promote_completed_event
from chat.state.edges import get_edge
import chat.state.edges # noqa: F401 - register edge_update handler
import chat.state.entities # noqa: F401 - register handlers
import chat.state.events # noqa: F401 - register events handlers
import chat.state.manual_edit # noqa: F401 - register manual_edit handler
import chat.state.world # noqa: F401 - register handlers
def _bot_payload(bot_id: str, name: str) -> dict:
return {
"id": bot_id,
"name": name,
"persona": "thoughtful, observant",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "coworker",
"kickoff_prose": "",
}
def _chat_payload(chat_id: str = "chat_bot_a") -> dict:
return {
"id": chat_id,
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1 evening",
"weather": "clear",
}
def _seed_chat(conn) -> None:
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(conn, kind="chat_created", payload=_chat_payload())
def _seed_event(
conn,
*,
event_id: str,
props: dict,
terminal_kind: str = "event_completed",
) -> None:
"""Append event_planned, then a terminal transition (default completed)."""
append_event(
conn,
kind="event_planned",
payload={
"event_id": event_id,
"chat_id": "chat_bot_a",
"kind": "story_event",
"props": props,
"planned_for": "2026-04-30T18:00:00+00:00",
},
)
append_event(
conn,
kind=terminal_kind,
payload={
"event_id": event_id,
"completed_at": "2026-04-30T20:00:00+00:00",
},
)
project(conn)
def _max_event_id(conn) -> int:
return conn.execute("SELECT COALESCE(MAX(id), 0) FROM event_log").fetchone()[0]
def _events_after(conn, after_id: int, kind: str) -> list[dict]:
rows = conn.execute(
"SELECT id, kind, payload_json FROM event_log "
"WHERE id > ? AND kind = ? ORDER BY id ASC",
(after_id, kind),
).fetchall()
return [
{"id": r[0], "kind": r[1], "payload": json.loads(r[2])} for r in rows
]
def test_empty_props_no_op(tmp_path):
"""Completed event with empty props produces no promotion events."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_chat(conn)
_seed_event(conn, event_id="evt_empty", props={})
before = _max_event_id(conn)
counts = promote_completed_event(
conn,
event_id="evt_empty",
chat_id="chat_bot_a",
chat_clock_at="2026-04-30T20:00:00+00:00",
)
assert counts == {
"acquired_objects": 0,
"knowledge_facts": 0,
"relationship_change": 0,
}
# No new edge_update or manual_edit rows after the promote call.
assert _events_after(conn, before, "edge_update") == []
assert _events_after(conn, before, "manual_edit") == []
def test_knowledge_facts_emits_edge_update(tmp_path):
"""A knowledge_facts entry promotes to an edge_update on the directed edge."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_chat(conn)
_seed_event(
conn,
event_id="evt_kf",
props={
"knowledge_facts": [
{
"owner_id": "bot_a",
"target_id": "you",
"fact": "Maya prefers tea over coffee",
}
]
},
)
before = _max_event_id(conn)
counts = promote_completed_event(
conn,
event_id="evt_kf",
chat_id="chat_bot_a",
chat_clock_at="2026-04-30T20:00:00+00:00",
)
assert counts["knowledge_facts"] == 1
assert counts["acquired_objects"] == 0
assert counts["relationship_change"] == 0
# An edge_update event landed in the event_log AFTER the promote call.
new_edge_updates = _events_after(conn, before, "edge_update")
assert len(new_edge_updates) == 1
payload = new_edge_updates[0]["payload"]
assert payload["source_id"] == "bot_a"
assert payload["target_id"] == "you"
assert payload["knowledge_facts"] == ["Maya prefers tea over coffee"]
# And the projected edge has the fact applied.
edge = get_edge(conn, "bot_a", "you")
assert edge is not None
assert "Maya prefers tea over coffee" in edge["knowledge"]
def test_relationship_change_emits_manual_edit(tmp_path):
"""A relationship_change promotes to a manual_edit edge_summary."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_chat(conn)
_seed_event(
conn,
event_id="evt_rc",
props={
"relationship_change": {
"source_id": "bot_a",
"target_id": "you",
"summary": "they're now dating",
}
},
)
before = _max_event_id(conn)
counts = promote_completed_event(
conn,
event_id="evt_rc",
chat_id="chat_bot_a",
chat_clock_at="2026-04-30T20:00:00+00:00",
)
assert counts["relationship_change"] == 1
assert counts["knowledge_facts"] == 0
assert counts["acquired_objects"] == 0
new_manual_edits = _events_after(conn, before, "manual_edit")
# Filter to edge_summary only — Phase 3 stub may also emit
# memory_pov_summary entries for acquired_objects, but here there
# are none.
edge_summary_edits = [
m for m in new_manual_edits
if m["payload"].get("target_kind") == "edge_summary"
]
assert len(edge_summary_edits) == 1
payload = edge_summary_edits[0]["payload"]
assert payload["target_kind"] == "edge_summary"
assert payload["target_id"] == {"source_id": "bot_a", "target_id": "you"}
assert payload["new_value"] == "they're now dating"
def test_cancelled_event_does_not_promote(tmp_path):
"""Cancelled events have promotable props ignored — no follow-on events."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_chat(conn)
_seed_event(
conn,
event_id="evt_canx",
props={
"knowledge_facts": [
{"owner_id": "bot_a", "target_id": "you", "fact": "x"}
],
"relationship_change": {
"source_id": "bot_a",
"target_id": "you",
"summary": "ignored",
},
},
terminal_kind="event_cancelled",
)
before = _max_event_id(conn)
counts = promote_completed_event(
conn,
event_id="evt_canx",
chat_id="chat_bot_a",
chat_clock_at="2026-04-30T20:00:00+00:00",
)
assert counts == {
"acquired_objects": 0,
"knowledge_facts": 0,
"relationship_change": 0,
}
assert _events_after(conn, before, "edge_update") == []
assert _events_after(conn, before, "manual_edit") == []
+34
View File
@@ -125,3 +125,37 @@ def test_search_invalid_witness_role_raises(tmp_path):
with open_db(db) as conn: with open_db(db) as conn:
with pytest.raises(ValueError): with pytest.raises(ValueError):
search_memories(conn, "bot_a", "invalid_role", "anything", k=4) search_memories(conn, "bot_a", "invalid_role", "anything", k=4)
def test_higher_significance_outranks_equal_rank(tmp_path):
"""T57: significance multiplier biases the SQL ORDER BY.
Two memories with IDENTICAL FTS-matching text yield (effectively) equal
BM25 ranks. The significance bias applied in the SQL ORDER BY must
surface the higher-significance row first.
"""
db = tmp_path / "t.db"
_seed(
db,
memory_specs=[
# Identical pov_summary text -> FTS BM25 rank is the same for both.
{"pov_summary": "she swore an oath", "significance": 0},
{"pov_summary": "she swore an oath", "significance": 3},
],
)
with open_db(db) as conn:
out = search_memories(conn, "bot_a", "host", "oath", k=5)
assert len(out) == 2
# Higher significance wins despite tied FTS rank.
assert out[0]["significance"] == 3
assert out[1]["significance"] == 0
def test_significance_bias_is_constant_module_level():
"""T57: pin ``SIGNIFICANCE_RANK_BIAS`` as a tunable module-level numeric."""
from chat.state.memory import SIGNIFICANCE_RANK_BIAS
assert isinstance(SIGNIFICANCE_RANK_BIAS, (int, float))
# Must be non-negative -- a negative bias would invert the desired
# "higher significance ranks higher" semantics.
assert SIGNIFICANCE_RANK_BIAS >= 0
+255 -5
View File
@@ -504,13 +504,15 @@ async def test_close_with_no_guest_matches_phase1(tmp_path):
"relationship_summary": "BotA leaned in supportively.", "relationship_summary": "BotA leaned in supportively.",
} }
) )
no_threads = json.dumps({"candidates": []})
with open_db(db) as conn: with open_db(db) as conn:
_seed_single_bot_scene(conn) _seed_single_bot_scene(conn)
project(conn) project(conn)
# canned has 2 entries to detect any over-call; the assertion below # 1 host-POV entry + 1 thread-detection entry (T58.2) + 1 spare
# confirms only one was consumed. # to detect any over-call. Assertion below confirms exactly two
client = MockLLMClient(canned=[canned, canned]) # were consumed.
client = MockLLMClient(canned=[canned, no_threads, canned])
await apply_scene_close_summary( await apply_scene_close_summary(
conn, conn,
client, client,
@@ -520,8 +522,8 @@ async def test_close_with_no_guest_matches_phase1(tmp_path):
host_bot_id="bot_a", host_bot_id="bot_a",
) )
# Exactly one classifier call -> exactly one canned entry consumed, # Host POV + thread detection -> exactly two canned entries
# leaving the second untouched. # consumed, leaving the spare untouched.
assert len(client._canned) == 1 assert len(client._canned) == 1
# Host memory rewritten with the per-POV summary content. # Host memory rewritten with the per-POV summary content.
@@ -845,3 +847,251 @@ async def test_group_summary_skipped_when_no_guest(tmp_path):
"SELECT 1 FROM event_log WHERE kind = 'group_node_updated'" "SELECT 1 FROM event_log WHERE kind = 'group_node_updated'"
).fetchall() ).fetchall()
assert rows == [] assert rows == []
# ---------------------------------------------------------------------------
# T58: significance-driven quote retention + thread detection on close.
# ---------------------------------------------------------------------------
def _seed_single_bot_scene_no_memory(conn) -> None:
"""Like ``_seed_single_bot_scene`` but skips the memory_written event so
callers can seed memories with custom significance / text themselves."""
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
)
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="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a"],
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "Quick chat about the deadline",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "It's going to be okay.",
"truncated": False,
"user_turn_id": 1,
},
)
def _seed_memory(conn, *, pov_summary: str, significance: int) -> None:
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": pov_summary,
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": significance,
},
)
@pytest.mark.asyncio
async def test_low_significance_scene_omits_quotes(tmp_path):
"""When the scene's max-turn-significance is < 2, the per-POV summary
rewrite collapses fully — no "Key quotes:" suffix is appended."""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA had a low-key chat with you.",
"knowledge_facts": [],
"relationship_summary": "Nothing major shifted.",
}
)
no_threads = json.dumps({"candidates": []})
with open_db(db) as conn:
_seed_single_bot_scene_no_memory(conn)
_seed_memory(conn, pov_summary="Maya rambled about coffee", significance=1)
_seed_memory(conn, pov_summary="Maya glanced at the clock", significance=0)
project(conn)
client = MockLLMClient(canned=[canned, no_threads])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
rows = conn.execute(
"SELECT pov_summary FROM memories WHERE scene_id = 1"
).fetchall()
assert rows
for (pov,) in rows:
assert "Key quotes:" not in pov
assert "BotA had a low-key chat" in pov
@pytest.mark.asyncio
async def test_high_significance_scene_includes_top_3_quotes(tmp_path):
"""When max-turn-significance is >= 2, each per-POV summary text gains
a "Key quotes:" suffix listing the top-3 highest-significance memory
rows verbatim, ordered by (significance DESC, id ASC)."""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA had a heavy talk with you.",
"knowledge_facts": [],
"relationship_summary": "Things shifted.",
}
)
no_threads = json.dumps({"candidates": []})
with open_db(db) as conn:
_seed_single_bot_scene_no_memory(conn)
# Insertion order matches id ASC. Top-3 by (sig DESC, id ASC):
# quote 1 (sig 3) -> quote 2 (sig 2, lower id) -> quote 4 (sig 2,
# higher id). quote 3 (sig 1) is dropped.
_seed_memory(conn, pov_summary="Maya quote one", significance=3)
_seed_memory(conn, pov_summary="Maya quote two", significance=2)
_seed_memory(conn, pov_summary="Maya quote three", significance=1)
_seed_memory(conn, pov_summary="Maya quote four", significance=2)
project(conn)
client = MockLLMClient(canned=[canned, no_threads])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
rows = conn.execute(
"SELECT pov_summary FROM memories WHERE scene_id = 1"
).fetchall()
assert rows
for (pov,) in rows:
assert "Key quotes:" in pov
assert '"Maya quote one"' in pov
assert '"Maya quote two"' in pov
assert '"Maya quote four"' in pov
# The sig-1 quote falls outside the top-3 cap.
assert '"Maya quote three"' not in pov
# Ordering: sig 3 first, then the two sig-2s by id ASC.
i_one = pov.index('"Maya quote one"')
i_two = pov.index('"Maya quote two"')
i_four = pov.index('"Maya quote four"')
assert i_one < i_two < i_four
@pytest.mark.asyncio
async def test_thread_detection_emits_events(tmp_path, monkeypatch):
"""On scene close, ``detect_threads`` is invoked and each "open"
candidate yields a ``thread_opened`` event with a fresh thread_id."""
from chat.services import thread_detection as td_mod
canned = json.dumps(
{
"summary": "BotA noticed something unresolved.",
"knowledge_facts": [],
"relationship_summary": "Tension lingered.",
}
)
async def fake_detect_threads(client, **kwargs):
return td_mod.ThreadDetectionResult(
candidates=[
td_mod.ThreadCandidate(
action="open",
title="Test thread",
summary="A test",
existing_thread_id=None,
),
]
)
monkeypatch.setattr(td_mod, "detect_threads", fake_detect_threads)
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_single_bot_scene(conn)
project(conn)
client = MockLLMClient(canned=[canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'thread_opened'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["title"] == "Test thread"
assert payload["summary"] == "A test"
assert payload["chat_id"] == "chat_bot_a"
assert payload["thread_id"].startswith("thr_")
# The threads-table projection ran via append_and_apply.
from chat.state.threads import list_open_threads
open_threads = list_open_threads(conn, "chat_bot_a")
assert len(open_threads) == 1
assert open_threads[0]["title"] == "Test thread"