Compare commits
6 Commits
a34931375c
...
8efbcdf6c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 8efbcdf6c3 | |||
| 8aeadfd0e4 | |||
| 88350d7d2e | |||
| 343f305587 | |||
| 021587b3df | |||
| 5e6b29e0c5 |
@@ -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"]
|
||||||
@@ -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
@@ -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 []
|
||||||
|
|||||||
@@ -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") == []
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user