Compare commits
64 Commits
phase-2
...
dc35833534
| Author | SHA1 | Date | |
|---|---|---|---|
| dc35833534 | |||
| 0cd41636b3 | |||
| 2c7aa68af9 | |||
| cf43ba0993 | |||
| a781732ee6 | |||
| c9d58b8229 | |||
| c463dc70b2 | |||
| 819803da84 | |||
| a7eedb8037 | |||
| e236bcadcd | |||
| 3678bcaca6 | |||
| b582567521 | |||
| 21c4ffa63c | |||
| 83f94a4325 | |||
| 2d14197553 | |||
| 8efbcdf6c3 | |||
| 8aeadfd0e4 | |||
| 88350d7d2e | |||
| 343f305587 | |||
| 021587b3df | |||
| 5e6b29e0c5 | |||
| a34931375c | |||
| 959fe11410 | |||
| 2959e1ac2a | |||
| afe940259a | |||
| c2144cd9df | |||
| 7857da4112 | |||
| adbbd32873 | |||
| 98250644ad | |||
| da1f67fb6a | |||
| 03ba34272b | |||
| e26885b011 | |||
| 5b7a195cf5 | |||
| 25bcbac055 | |||
| ab2b494c21 | |||
| b6888ff36a | |||
| e4fd888b53 | |||
| 079774dce5 | |||
| 3be7920f41 | |||
| e61bd9cb08 | |||
| c6e0130e59 | |||
| 67d6f3fe68 | |||
| dbc9690358 | |||
| 6d98728a2e | |||
| bfb2ffb6f6 | |||
| bd13b64959 | |||
| f2a57005e5 | |||
| 88fae33152 | |||
| c874883a84 | |||
| 6f22e86f54 | |||
| e632a6247d | |||
| 607d0971c4 | |||
| c265e4ce0f | |||
| 21404a373b | |||
| 789b9bd042 | |||
| 73bb8c1f17 | |||
| afd1a50958 | |||
| 428438b223 | |||
| b13f3b4e47 | |||
| f701f9d7dd | |||
| 1b9144442a | |||
| 13c23fd898 | |||
| c1e419e012 | |||
| 994728b5ed |
@@ -174,13 +174,7 @@ Deferred to Phase 2: second bot, group node, scene configurations, witness filte
|
||||
|
||||
### Phase 1.5 cleanup backlog
|
||||
|
||||
Small follow-ups identified during Phase 1 reviews. Pick up at any time; none are blocking.
|
||||
|
||||
- **`open_db` refactor.** `chat/web/bots.py:get_conn()` duplicates the context-manager body to add `check_same_thread=False`. Extend `open_db(path, *, check_same_thread=True)` and have `get_conn` call it directly — eliminates the duplicated PRAGMA setup and ensures any future PRAGMA tweak only happens in one place.
|
||||
- **Regenerate broadcasts `turn_html` over SSE.** Currently a refresh is needed (see T29 limitation above). Mirror the broadcast logic from `chat/web/turns.py:post_turn` after the new `assistant_turn` lands.
|
||||
- **`bot_reset` purges orphaned "you" activity rows** (see limitation above). Either delete `activity` rows by chat-membership or accept the noise indefinitely; the projection-layer fix is one extra `DELETE FROM activity WHERE entity_id='you' AND container_id IN (SELECT id FROM containers WHERE chat_id IN (...))` clause inside `_apply_bot_reset`.
|
||||
- **Drawer edits for the deferred v1 fields**: edge_trust slider, edge_summary textarea, memory pov_summary textarea, knowledge_facts add/remove. The `manual_edit` projector already supports `edge_trust` / `edge_summary` / `memory_pov_summary` target_kinds — only the routes are missing. Knowledge_facts needs a new dispatch branch.
|
||||
- **NICE trim order in prompt assembly** drops previous-scene first instead of last (T18 review). Greedy-cuts heuristic vs spec listing order; revisit if v1 play surfaces a real regression.
|
||||
All items shipped — see Phase 2.5 status below.
|
||||
|
||||
## Phase 2 status
|
||||
|
||||
@@ -194,15 +188,28 @@ Phase 2 shipped end-to-end across **13 tasks** (T36–T48 wave). The multi-entit
|
||||
|
||||
### Phase 2.5 / 3 backlog
|
||||
|
||||
Carry-overs from Phase 2 reviews and implementer notes. None are blocking; pick up at any time.
|
||||
All items shipped — see Phase 2.5 status below.
|
||||
|
||||
- **Interjection regenerate**: regenerate currently only acts on the addressee turn. Phase 2.5 should extend regenerate to cover the interjection turn too.
|
||||
- **Classifier-based addressee detection**: substring match is brittle (e.g., names that are common English words, or names appearing inside a quoted aside). A small classifier call could disambiguate.
|
||||
- **LLM-merged group meta-summary**: current `group_node.summary` is a naive concat of host + guest per-POV summaries. Phase 2.5 should polish with an LLM-merged group view.
|
||||
- **First-meeting gate**: the drawer's "have they met?" textarea fires every time. Phase 2.5 should check whether the host→guest edge already exists and offer a "they already know each other" toggle to skip re-seeding.
|
||||
- **Witness flag editing**: drawer doesn't allow editing memory witness flags (read-only). Phase 2.5+ may expose this.
|
||||
- **Significance for interjection memories**: the interjection's `memory_written` event doesn't enqueue a `SignificanceJob` (per the T44 implementer note). Phase 2.5 should wire this in so interjection memories are scored alongside primary turns.
|
||||
- **Stale guest reference defensive degrade in `post_turn`**: T44 added a degrade-to-1:1 when `chat.guest_bot_id` points at a deleted bot. T47 fixes the root cause (resets clear the reference); the degrade can probably be removed but is harmless.
|
||||
- **Scene close on cancel**: scene close runs even when the primary turn is cancelled. Behavior may be intentional but could be argued either way; revisit if it surfaces a real UX regression.
|
||||
- **Dual `ACTIVITIES:` block**: T43's prompt assembly adds a second `ACTIVITIES:` block for guest activity. Cleaner would be a single block with three bullets and per-bullet trim.
|
||||
- **Witness role hardcoded in prompt assembly**: `chat/services/prompt.py:436` hardcodes `witness_role="host"` regardless of which bot is speaking. Phase 2.5 should derive the role from chat membership (e.g. `"host" if speaker_bot_id == chat.host_bot_id else "guest"`) so guest-as-speaker prompts retrieve the right memory slice. Test contract pinned in `tests/test_witness_filter_multi.py`.
|
||||
## Phase 2.5 status
|
||||
|
||||
Phase 2.5 cleanup shipped end-to-end across 8 tasks (T68–T75). Two CLAUDE.md backlogs (Phase 1.5 cleanup, Phase 2.5/3) are now empty; deferred follow-ups discovered during execution are tracked in a new "Phase 2.6 / 3 backlog" section below.
|
||||
|
||||
- **`open_db` with check_same_thread parameter (T68)**: refactored `chat/db/connection.py` so `chat/web/bots.py:get_conn` no longer duplicates the PRAGMA setup. Default behavior preserved.
|
||||
- **`bot_reset` cross-chat cleanup (T69)**: now purges orphaned "you" activity rows. Note: this also fixed a latent FK constraint crash that was lurking in the projector — `activity.container_id` is FK-referenced and the prior code would have crashed on any reset of a bot whose chat had a non-NULL `container_id` "you" activity row. The bug was masked because no prior test seeded such a row.
|
||||
- **LLM-merged group meta-summary (T70)**: replaces Phase 2 T45's naive concat with a classifier merge call. Falls back to the naive concat on classifier failure.
|
||||
- **`prompt.py` polish (T71)**: witness role parametric (`host` vs `guest` derived from chat membership); single `ACTIVITIES:` block with bullet-level trim; NICE trim order kept with documented rationale (greedy cheapest-impact-first beats spec-listing order in practice).
|
||||
- **Drawer polish (T72)**: deferred v1 edits (edge_trust slider, edge_summary textarea, memory pov_summary textarea, knowledge_facts add/remove) + first-meeting gate (Add-guest form disables prose textarea when host→guest edge already exists; "re-seed anyway" toggle re-enables) + witness flag inline-edit (per-memory checkboxes for [you, host, guest] flags). Two new `manual_edit` projector branches: `edge_knowledge_fact` and `memory_witness`.
|
||||
- **Regenerate polish (T73)**: regenerate now broadcasts `turn_html_replace` over SSE (NEW event distinct from `turn_html` to avoid breaking the existing append-semantic consumer); regenerate covers interjection turns (re-detects + re-streams or supersedes); defensive stale-guest degrade removed.
|
||||
- **Turn-flow polish + addressee service (T74)**: classifier-based addressee detection (substring helper kept as no-guest fast path); SignificanceJob enqueued for interjection memories; scene-close-on-cancel pinned with comment + regression test (close detection is genuinely user-prose-only); defensive stale-guest degrade removed.
|
||||
|
||||
### Phase 2.6 / 3 backlog
|
||||
|
||||
New follow-ups discovered during Phase 2.5 execution. None are blocking; pick up at any time.
|
||||
|
||||
- **Frontend handler for `turn_html_replace` SSE event (from T73.1 review)**: regenerate's backend broadcast lands, but no live tab swaps the regenerated turn until a JS handler is wired. The existing `turn_html` event uses HTMX `sse-swap` to append; `turn_html_replace` ships JSON with `supersedes_id` for replacement semantics. Phase 2.6 should wire the JS to swap the prior turn's DOM node in place.
|
||||
- **Cancel/stop hook for in-flight regenerate streams (from T73 review)**: `post_turn` registers stream tasks in `_in_flight_tasks` so the user can stop them. Regenerate doesn't. A user clicking "Stop" mid-regenerate has no cancel hook today.
|
||||
- **DRY: regenerate vs post_turn (from T73 review)**: recent-dialogue assembly and prior-edges block are duplicated between `chat/services/regenerate.py` and `chat/web/turns.py`. Extract to shared helpers analogous to `_gather_state_update_inputs`.
|
||||
- **Sibling-discovery query optimization (from T73 review)**: `regenerate.py`'s sibling-assistant-turn lookup scans all non-superseded `assistant_turn` rows globally. Adding a `chat_id` predicate via JSON extraction (or a denormalized column) bounds the cost to per-chat scale.
|
||||
- **`_witness_role_for` defensive coding (from T71 review)**: helper returns `"guest"` when `host_bot_id is None`, which is wrong for Phase-1 chats. Defensive: `return "host" if host_bot_id is None or speaker_bot_id == host_bot_id else "guest"`. Not exercised by current tests; harden as a precaution.
|
||||
- **Confidence type tightening (from T74 review)**: `chat/services/addressee.py::AddresseeDecision.confidence` could be typed as `Literal["high","medium","low"]` for stricter validation. Currently `str` with a comment.
|
||||
- **Scene-close-on-cancel UX revisit**: T74.3 pinned the existing behavior (close fires even on cancel). If real play-testing surfaces a regression, revisit.
|
||||
|
||||
@@ -5,9 +5,9 @@ from pathlib import Path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_db(path: Path):
|
||||
def open_db(path: Path, *, check_same_thread: bool = True):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn = sqlite3.connect(path, check_same_thread=check_same_thread)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
chat_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'planned',
|
||||
props_json TEXT NOT NULL DEFAULT '{}',
|
||||
planned_for TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX events_chat_idx ON events(chat_id, status);
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE threads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL UNIQUE,
|
||||
chat_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
closed_at TEXT,
|
||||
last_referenced_scene_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX threads_chat_status_idx ON threads(chat_id, status);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- T63: Meanwhile scene support — extend scenes with a present-set discriminator
|
||||
-- and a parent link (you-scene -> meanwhile child), plus a pending-digest queue.
|
||||
--
|
||||
-- Existing scenes table (0007) has columns:
|
||||
-- id, chat_id, container_id, started_at, ended_at, significance,
|
||||
-- participants_json
|
||||
-- It has no `status` / `closed_at` columns. We treat `ended_at IS NULL` as
|
||||
-- "active" and `ended_at IS NOT NULL` as "closed" — consistent with the
|
||||
-- existing scene_opened/scene_closed handlers.
|
||||
|
||||
ALTER TABLE scenes ADD COLUMN present_set_kind TEXT NOT NULL DEFAULT 'you_host';
|
||||
ALTER TABLE scenes ADD COLUMN parent_scene_id INTEGER;
|
||||
|
||||
CREATE INDEX scenes_present_set_idx
|
||||
ON scenes(chat_id, present_set_kind, ended_at);
|
||||
|
||||
CREATE TABLE meanwhile_digest_pending (
|
||||
id INTEGER PRIMARY KEY,
|
||||
scene_id INTEGER NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
consumed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX meanwhile_digest_chat_idx
|
||||
ON meanwhile_digest_pending(chat_id) WHERE consumed_at IS NULL;
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Addressee classifier service (T74.1).
|
||||
|
||||
Phase 2 (T44) detected the addressee — host vs. guest — with a simple
|
||||
case-insensitive whole-word substring match against the bots' names.
|
||||
That worked for the obvious case ("BotB, what do you think?") but lost
|
||||
the long tail: pronouns, paraphrases, indirect address, narrative
|
||||
focus on a particular party. T74.1 swaps the substring helper for a
|
||||
classifier call that reads the prose holistically.
|
||||
|
||||
The substring helper in :mod:`chat.web.turns` is kept as a fast-path
|
||||
for the no-guest case (only one bot present means there is nothing to
|
||||
classify) and as a non-breaking fallback for the regenerate path. The
|
||||
multi-entity branch in :func:`chat.web.turns.post_turn` calls
|
||||
:func:`detect_addressee` from this module.
|
||||
|
||||
Failure mode: classifier flake or low-confidence response degrades to
|
||||
the host (the default speaker per Phase 2's host-keeps-the-floor
|
||||
bias). The decision carries ``confidence`` and ``reason`` so callers
|
||||
that want to log degraded decisions can distinguish a real "host" call
|
||||
from a fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class AddresseeDecision(BaseModel):
|
||||
"""Which present bot the user is addressing.
|
||||
|
||||
``addressee_id`` is the chosen bot's id. ``confidence`` is one of
|
||||
``"high"`` / ``"medium"`` / ``"low"`` — callers may treat ``"low"``
|
||||
as a soft fallback to the host. ``reason`` is a short free-form
|
||||
string. The classifier-failure fallback uses ``reason="fallback"``
|
||||
so it's distinguishable from a real low-confidence call.
|
||||
"""
|
||||
|
||||
addressee_id: str
|
||||
confidence: str = "medium" # "high" | "medium" | "low"
|
||||
reason: str = ""
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"Given a user's turn prose and the names of present bots, decide "
|
||||
"which bot the user is addressing. If the user is speaking to no "
|
||||
"specific bot (descriptive narration, action without dialogue), "
|
||||
"default to the host. Output strict JSON matching the schema. "
|
||||
"The addressee_id MUST be one of the ids supplied in the user "
|
||||
"message — do not invent ids."
|
||||
)
|
||||
|
||||
|
||||
async def detect_addressee(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
user_prose: str,
|
||||
host_id: str,
|
||||
host_name: str,
|
||||
guest_id: str | None,
|
||||
guest_name: str | None,
|
||||
timeout_s: float = 30.0,
|
||||
) -> AddresseeDecision:
|
||||
"""Classify which present bot the user is addressing.
|
||||
|
||||
Defaults to host on classifier failure or when the classifier picks
|
||||
an id that isn't one of the supplied ids. The caller is expected to
|
||||
only invoke this in the multi-entity case (a guest is present);
|
||||
when no guest is present the substring fast-path in
|
||||
:mod:`chat.web.turns` is used instead and this function is not
|
||||
called.
|
||||
"""
|
||||
fallback = AddresseeDecision(
|
||||
addressee_id=host_id, confidence="low", reason="fallback"
|
||||
)
|
||||
user = (
|
||||
f"Host: {host_name} (id={host_id})\n"
|
||||
+ (
|
||||
f"Guest: {guest_name} (id={guest_id})\n"
|
||||
if guest_id is not None
|
||||
else ""
|
||||
)
|
||||
+ f"\nUser prose:\n{user_prose}"
|
||||
)
|
||||
decision = await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=AddresseeDecision,
|
||||
default=fallback,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
# Defensive: if the classifier returned an id outside the supplied
|
||||
# set, treat it as a fallback to the host. This catches pathological
|
||||
# outputs that pass schema validation but pick a phantom id.
|
||||
valid_ids = {host_id}
|
||||
if guest_id is not None:
|
||||
valid_ids.add(guest_id)
|
||||
if decision.addressee_id not in valid_ids:
|
||||
return fallback
|
||||
return decision
|
||||
|
||||
|
||||
__all__ = ["AddresseeDecision", "detect_addressee"]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Event-lifecycle detection (T52).
|
||||
|
||||
After each turn, classify whether any active events transitioned
|
||||
(started, completed, cancelled). Conservative bias — most turns
|
||||
return empty. T61 turn flow appends one event_started/completed/
|
||||
cancelled per transition via append_and_apply.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class EventTransition(BaseModel):
|
||||
event_id: str
|
||||
new_status: str # "active" | "completed" | "cancelled"
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class EventLifecycleDecision(BaseModel):
|
||||
transitions: list[EventTransition] = Field(default_factory=list)
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"You decide whether any active events transitioned this turn. "
|
||||
"STRONGLY default to empty transitions — most turns do NOT resolve "
|
||||
"or start a known event. Output only transitions that the narrative "
|
||||
"text clearly resolves or starts. Each transition MUST reference an "
|
||||
"event_id from the active_events list. new_status is one of "
|
||||
"'active' (planned -> active), 'completed', or 'cancelled'. "
|
||||
"Output strict JSON matching the schema."
|
||||
)
|
||||
|
||||
|
||||
async def detect_event_transitions(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
narrative_text: str,
|
||||
active_events: list[dict], # [{event_id, kind, status, props}, ...]
|
||||
timeout_s: float = 30.0,
|
||||
) -> EventLifecycleDecision:
|
||||
"""Classify event transitions for the latest turn. Empty active_events
|
||||
short-circuits without an LLM call."""
|
||||
if not active_events:
|
||||
return EventLifecycleDecision()
|
||||
|
||||
user_lines = ["Active events:"]
|
||||
for ev in active_events:
|
||||
user_lines.append(
|
||||
f"- event_id={ev['event_id']} kind={ev['kind']} "
|
||||
f"status={ev['status']} props={ev.get('props', {})}"
|
||||
)
|
||||
user_lines.append("")
|
||||
user_lines.append("Latest narrative:")
|
||||
user_lines.append(narrative_text.strip())
|
||||
user = "\n".join(user_lines)
|
||||
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=EventLifecycleDecision,
|
||||
default=EventLifecycleDecision(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["EventTransition", "EventLifecycleDecision", "detect_event_transitions"]
|
||||
@@ -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"]
|
||||
@@ -176,3 +176,60 @@ def record_turn_memory_for_present(
|
||||
significance=significance,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def record_meanwhile_memory(
|
||||
conn: Connection,
|
||||
*,
|
||||
chat_id: str,
|
||||
host_bot_id: str,
|
||||
guest_bot_id: str,
|
||||
narrative_text: str,
|
||||
scene_id: int | None = None,
|
||||
chat_clock_at: str | None = None,
|
||||
source: str = "direct",
|
||||
significance: int = 1,
|
||||
) -> dict[str, tuple[int, int | None]]:
|
||||
"""Write per-POV ``memory_written`` events for a meanwhile turn (T64).
|
||||
|
||||
A meanwhile scene runs entirely between host + guest, with "you"
|
||||
absent. Both bots are present witnesses, so each one gets a row with
|
||||
witness flags ``[you=0, host=1, guest=1]`` — different from the
|
||||
normal-turn ``record_turn_memory_for_present`` shape, which assumes
|
||||
the user is always a witness (``witness_you=1``).
|
||||
|
||||
The ``guest_bot_id`` is required (a meanwhile scene by definition
|
||||
has both bots) — callers passing ``None`` is a programming error.
|
||||
|
||||
Returns ``{bot_id: (event_id, memory_id)}`` mirroring
|
||||
:func:`record_turn_memory_for_present` so downstream queues
|
||||
(significance scoring) can pull memory ids without re-querying.
|
||||
"""
|
||||
result: dict[str, tuple[int, int | None]] = {}
|
||||
result[host_bot_id] = _write_one_memory(
|
||||
conn,
|
||||
owner_id=host_bot_id,
|
||||
chat_id=chat_id,
|
||||
narrative_text=narrative_text,
|
||||
witness_you=0,
|
||||
witness_host=1,
|
||||
witness_guest=1,
|
||||
scene_id=scene_id,
|
||||
chat_clock_at=chat_clock_at,
|
||||
source=source,
|
||||
significance=significance,
|
||||
)
|
||||
result[guest_bot_id] = _write_one_memory(
|
||||
conn,
|
||||
owner_id=guest_bot_id,
|
||||
chat_id=chat_id,
|
||||
narrative_text=narrative_text,
|
||||
witness_you=0,
|
||||
witness_host=1,
|
||||
witness_guest=1,
|
||||
scene_id=scene_id,
|
||||
chat_clock_at=chat_clock_at,
|
||||
source=source,
|
||||
significance=significance,
|
||||
)
|
||||
return result
|
||||
|
||||
+390
-45
@@ -37,8 +37,11 @@ import tiktoken
|
||||
from chat.llm.client import Message
|
||||
from chat.state.edges import get_edge, list_edges_for
|
||||
from chat.state.entities import get_bot, get_you
|
||||
from chat.state.events import list_active_events
|
||||
from chat.state.group_node import get_group_node
|
||||
from chat.state.meanwhile import list_pending_meanwhile_digests
|
||||
from chat.state.memory import search_memories
|
||||
from chat.state.threads import list_open_threads
|
||||
from chat.state.world import (
|
||||
active_scene,
|
||||
get_activity,
|
||||
@@ -227,6 +230,101 @@ def _build_group_node_block(group_node: dict | None) -> str | None:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _props_excerpt(props: dict | None, limit: int = 80) -> str:
|
||||
"""Return a one-line excerpt of an event's ``props`` dict.
|
||||
|
||||
Renders ``key=value`` pairs separated by ", " (deterministic by dict
|
||||
insertion order) and truncates to ~``limit`` characters with a
|
||||
trailing ellipsis. Returns empty string for falsy/empty props so the
|
||||
caller can omit the line entirely.
|
||||
"""
|
||||
if not props:
|
||||
return ""
|
||||
pieces: list[str] = []
|
||||
for k, v in props.items():
|
||||
pieces.append(f"{k}={v}")
|
||||
rendered = ", ".join(pieces)
|
||||
if len(rendered) > limit:
|
||||
# Reserve 1 char for the ellipsis so the total never exceeds limit.
|
||||
rendered = rendered[: max(0, limit - 1)] + "…"
|
||||
return rendered
|
||||
|
||||
|
||||
def _build_active_events_block(events: list[dict]) -> str | None:
|
||||
"""Render the ``Active events:`` block for Phase 3 Task 60.
|
||||
|
||||
One bullet per event. The sub-label depends on status:
|
||||
- ``planned`` → ``(planned for {planned_for})``
|
||||
- ``active`` → ``(active, started_at={started_at})``
|
||||
A second indented line carries a one-line excerpt of the event's
|
||||
``props`` (truncated ~80 chars) when non-empty. Returns ``None`` when
|
||||
there are no active events so the caller can omit the entire block.
|
||||
"""
|
||||
if not events:
|
||||
return None
|
||||
lines = ["Active events:"]
|
||||
for ev in events:
|
||||
kind = ev.get("kind") or "?"
|
||||
status = ev.get("status") or ""
|
||||
if status == "active":
|
||||
started = ev.get("started_at") or ""
|
||||
lines.append(f"- {kind} (active, started_at={started})")
|
||||
else:
|
||||
planned = ev.get("planned_for") or ""
|
||||
lines.append(f"- {kind} (planned for {planned})")
|
||||
excerpt = _props_excerpt(ev.get("props"))
|
||||
if excerpt:
|
||||
lines.append(f" {excerpt}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_meanwhile_digests_block(digests: list[dict]) -> str | None:
|
||||
"""Render the ``Meanwhile while you were away:`` block for T65.
|
||||
|
||||
One bullet per pending digest, formatted as ``- {summary}`` with the
|
||||
summary truncated to ~200 characters per spec. Returns ``None`` when
|
||||
there are no pending digests so the caller can omit the entire block.
|
||||
|
||||
The block is rendered ONLY when the prompt is for a regular you-scene
|
||||
(``present_set_kind != "host_guest"``); the caller filters before
|
||||
constructing the digests list.
|
||||
"""
|
||||
if not digests:
|
||||
return None
|
||||
lines = ["Meanwhile while you were away:"]
|
||||
for d in digests:
|
||||
summary = d.get("summary") or ""
|
||||
if len(summary) > 200:
|
||||
summary = summary[:199] + "…"
|
||||
if summary:
|
||||
lines.append(f"- {summary}")
|
||||
if len(lines) == 1:
|
||||
return None
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_open_threads_block(threads: list[dict]) -> str | None:
|
||||
"""Render the ``Open threads:`` block for Phase 3 Task 60.
|
||||
|
||||
One bullet per thread, formatted as ``- {title}: {summary}`` with the
|
||||
summary truncated to ~120 characters. Returns ``None`` when there are
|
||||
no open threads so the caller can omit the entire block.
|
||||
"""
|
||||
if not threads:
|
||||
return None
|
||||
lines = ["Open threads:"]
|
||||
for t in threads:
|
||||
title = t.get("title") or "?"
|
||||
summary = t.get("summary") or ""
|
||||
if len(summary) > 120:
|
||||
summary = summary[:119] + "…"
|
||||
if summary:
|
||||
lines.append(f"- {title}: {summary}")
|
||||
else:
|
||||
lines.append(f"- {title}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _closing_instruction(speaker_name: str, addressee_name: str) -> str:
|
||||
return (
|
||||
f"Continue the scene as {speaker_name}, in their voice, responding "
|
||||
@@ -273,6 +371,18 @@ def _resolve_previous_scene_summary(
|
||||
return mem[0]
|
||||
|
||||
|
||||
def _witness_role_for(speaker_bot_id: str, host_bot_id: str | None) -> str:
|
||||
"""Return the witness POV role for the speaker's memory query.
|
||||
|
||||
The host bot of a chat queries memories with ``witness_role="host"``;
|
||||
the guest bot queries with ``witness_role="guest"``. Phase 2 T46
|
||||
pinned the contract on ``search_memories``; this helper applies it
|
||||
at the call site so a guest-as-speaker doesn't silently retrieve
|
||||
memories under the wrong POV mask.
|
||||
"""
|
||||
return "host" if speaker_bot_id == host_bot_id else "guest"
|
||||
|
||||
|
||||
def _resolve_addressee(
|
||||
conn: Connection, addressee: str, you: dict | None
|
||||
) -> tuple[str, str]:
|
||||
@@ -309,6 +419,7 @@ def assemble_narrative_prompt(
|
||||
budget_hard: int = 8000,
|
||||
encoding_name: str = "cl100k_base",
|
||||
guest_id: str | None = None,
|
||||
present_set_kind: str = "you_host",
|
||||
) -> list[Message]:
|
||||
"""Assemble the narrative prompt for ``speaker_bot_id`` to respond.
|
||||
|
||||
@@ -347,6 +458,14 @@ def assemble_narrative_prompt(
|
||||
you = get_you(conn)
|
||||
addressee_id, addressee_name = _resolve_addressee(conn, addressee, you)
|
||||
|
||||
# T64: meanwhile-mode marker. When present_set_kind == "host_guest"
|
||||
# the user ("you") is NOT a witness in the scene — bots speak only to
|
||||
# each other. The local flag below is consumed by the activity-block
|
||||
# builder (skip the "you" bullet entirely) and the other-edges filter
|
||||
# (drop any speaker -> "you" rendering). Default "you_host" preserves
|
||||
# the Phase 1/2/3 behavior for normal turns.
|
||||
_exclude_you = present_set_kind == "host_guest"
|
||||
|
||||
# ---- Build all components as text strings ------------------------------
|
||||
|
||||
speaker_identity = _build_speaker_identity(bot)
|
||||
@@ -356,34 +475,58 @@ def assemble_narrative_prompt(
|
||||
addressee_name,
|
||||
)
|
||||
|
||||
# Activity for present entities. Core (MUST): you + speaker bot.
|
||||
# Phase 2 (SHOULD-tier): when a third party (guest) is present in
|
||||
# the chat, append their activity in a separate block so it can be
|
||||
# trimmed independently under tight budget.
|
||||
activities: list[dict] = []
|
||||
you_act = get_activity(conn, "you")
|
||||
if you_act is not None:
|
||||
you_act = dict(you_act)
|
||||
you_act["_display_name"] = (you or {}).get("name") or "you"
|
||||
activities.append(you_act)
|
||||
# Activity for present entities — single ACTIVITIES: block with up
|
||||
# to three bullets (you, speaker, guest). The block itself is
|
||||
# MUST-tier and survives all trims, but bullet-level trim drops
|
||||
# bullets in the order guest -> you, keeping the speaker bullet
|
||||
# (the speaker's own current activity is the load-bearing slice).
|
||||
#
|
||||
# T71.2 chose Option B from the polish plan: pre-truncate the
|
||||
# bullets list at trim time before _build_activity_block runs,
|
||||
# rather than introducing a granular tier mode in the trim
|
||||
# machinery. The single-block render avoids the dual-ACTIVITIES:
|
||||
# header that Phase 2 T43 introduced (read by some LLMs as a
|
||||
# duplicate-section bug).
|
||||
you_activity: dict | None = None
|
||||
if not _exclude_you:
|
||||
you_act = get_activity(conn, "you")
|
||||
if you_act is not None:
|
||||
you_activity = dict(you_act)
|
||||
you_activity["_display_name"] = (you or {}).get("name") or "you"
|
||||
|
||||
speaker_activity: dict | None = None
|
||||
bot_act = get_activity(conn, speaker_bot_id)
|
||||
if bot_act is not None:
|
||||
bot_act = dict(bot_act)
|
||||
bot_act["_display_name"] = bot["name"]
|
||||
activities.append(bot_act)
|
||||
activity_block = _build_activity_block(activities)
|
||||
speaker_activity = dict(bot_act)
|
||||
speaker_activity["_display_name"] = bot["name"]
|
||||
|
||||
# SHOULD-tier guest activity extension (Phase 2 / Task 43).
|
||||
guest_activity_block: str | None = None
|
||||
guest_activity: dict | None = None
|
||||
if guest_id is not None:
|
||||
guest_act = get_activity(conn, guest_id)
|
||||
if guest_act is not None:
|
||||
guest_act = dict(guest_act)
|
||||
guest_activity = dict(guest_act)
|
||||
guest_bot = get_bot(conn, guest_id)
|
||||
guest_act["_display_name"] = (
|
||||
guest_activity["_display_name"] = (
|
||||
guest_bot["name"] if guest_bot else guest_id
|
||||
)
|
||||
guest_activity_block = _build_activity_block([guest_act])
|
||||
|
||||
def _activity_block_for(
|
||||
*, include_you: bool, include_guest: bool
|
||||
) -> str | None:
|
||||
"""Render the single ACTIVITIES: block with the requested bullets.
|
||||
|
||||
Speaker bullet is always included (it's the MUST-tier baseline);
|
||||
``you`` and ``guest`` bullets are toggled by the caller during
|
||||
trim. Returns None when no bullets remain.
|
||||
"""
|
||||
bullets: list[dict] = []
|
||||
if include_you and you_activity is not None:
|
||||
bullets.append(you_activity)
|
||||
if speaker_activity is not None:
|
||||
bullets.append(speaker_activity)
|
||||
if include_guest and guest_activity is not None:
|
||||
bullets.append(guest_activity)
|
||||
return _build_activity_block(bullets)
|
||||
|
||||
# SHOULD-tier group-node block (Phase 2 / Task 43): rendered only
|
||||
# when the group_node row is present AND it covers all three of
|
||||
@@ -401,6 +544,43 @@ def assemble_narrative_prompt(
|
||||
if required.issubset(members):
|
||||
group_node_block = _build_group_node_block(gn)
|
||||
|
||||
# SHOULD-tier active events + open threads (Phase 3 / Task 60).
|
||||
# Auto-detect both from the chat_id per the Phase 2 T43 precedent —
|
||||
# no new function parameter. Both blocks are omit-when-empty so a
|
||||
# Phase 1 chat with no events/threads renders identically to before.
|
||||
active_events_block = _build_active_events_block(
|
||||
list_active_events(conn, chat_id)
|
||||
)
|
||||
open_threads_block = _build_open_threads_block(
|
||||
list_open_threads(conn, chat_id)
|
||||
)
|
||||
|
||||
# SHOULD-tier meanwhile digest (Phase 3 / Task 65). Only surfaces
|
||||
# when the prompt is for a regular you-scene (NOT for a meanwhile
|
||||
# child scene — the absent player is the audience, not the bots
|
||||
# currently mid-meanwhile). We distinguish via the chat's active
|
||||
# scene's ``present_set_kind``; a missing scene row defaults to a
|
||||
# you-scene render so the block can still surface during the
|
||||
# post-meanwhile-close transition before the next scene opens.
|
||||
#
|
||||
# Consumption is INTENTIONALLY left to the post_turn flow (a
|
||||
# ``consume_pending_meanwhile_digests`` helper, see below) rather
|
||||
# than emitted inline here. Surfacing has no side-effects; the
|
||||
# caller appends ``meanwhile_digest_consumed`` after the response
|
||||
# streams. This keeps prompt assembly pure and deterministic — the
|
||||
# Phase 1 invariant T29's regenerate flow relies on.
|
||||
meanwhile_digests_block: str | None = None
|
||||
active_scene_kind: str | None = None
|
||||
if chat.get("active_scene_id"):
|
||||
active_sc = get_scene(conn, chat["active_scene_id"])
|
||||
if active_sc:
|
||||
active_scene_kind = active_sc.get("present_set_kind")
|
||||
if active_scene_kind != "host_guest":
|
||||
pending_digests = list_pending_meanwhile_digests(conn, chat_id)
|
||||
meanwhile_digests_block = _build_meanwhile_digests_block(
|
||||
pending_digests
|
||||
)
|
||||
|
||||
container = None
|
||||
if chat.get("active_scene_id"):
|
||||
scene = get_scene(conn, chat["active_scene_id"])
|
||||
@@ -412,9 +592,15 @@ def assemble_narrative_prompt(
|
||||
container = get_container(conn, scene["container_id"])
|
||||
scene_block = _build_scene_block(chat, container, scene)
|
||||
|
||||
# Other edges: speaker → non-addressee.
|
||||
# Other edges: speaker → non-addressee. In meanwhile mode (host_guest)
|
||||
# the speaker -> "you" edge is filtered out as well — "you" isn't
|
||||
# part of the present set, so surfacing the speaker's relationship
|
||||
# to the user inside a private bot-to-bot beat would leak context
|
||||
# the bots aren't supposed to be drawing on right now.
|
||||
all_outgoing = list_edges_for(conn, speaker_bot_id)
|
||||
other_edges_raw = [e for e in all_outgoing if e.get("target_id") != addressee_id]
|
||||
if _exclude_you:
|
||||
other_edges_raw = [e for e in other_edges_raw if e.get("target_id") != "you"]
|
||||
for e in other_edges_raw:
|
||||
tid = e.get("target_id")
|
||||
if tid == "you":
|
||||
@@ -433,7 +619,12 @@ def assemble_narrative_prompt(
|
||||
memory_summaries = []
|
||||
if query:
|
||||
try:
|
||||
hits = search_memories(conn, speaker_bot_id, "host", query, k=4)
|
||||
witness_role = _witness_role_for(
|
||||
speaker_bot_id, chat.get("host_bot_id")
|
||||
)
|
||||
hits = search_memories(
|
||||
conn, speaker_bot_id, witness_role, query, k=4
|
||||
)
|
||||
memory_summaries = [h["pov_summary"] for h in hits]
|
||||
except Exception:
|
||||
memory_summaries = []
|
||||
@@ -452,11 +643,18 @@ def assemble_narrative_prompt(
|
||||
last4 = dialogue_full[-4:] if dialogue_full else []
|
||||
must_dialogue_block = _build_dialogue_block(last4, earlier_summary=None)
|
||||
|
||||
# MUST-tier ACTIVITIES floor: the speaker bullet alone (you and
|
||||
# guest bullets are dropped first under bullet-level trim before
|
||||
# the block bottoms out at speaker-only).
|
||||
must_activity_block = _activity_block_for(
|
||||
include_you=False, include_guest=False
|
||||
)
|
||||
|
||||
must_blocks: list[str | None] = [
|
||||
speaker_identity,
|
||||
edge_to_addressee,
|
||||
scene_block,
|
||||
activity_block,
|
||||
must_activity_block,
|
||||
must_dialogue_block,
|
||||
closing,
|
||||
]
|
||||
@@ -481,8 +679,12 @@ def assemble_narrative_prompt(
|
||||
include_previous_scene: bool,
|
||||
include_memories_top_k: int,
|
||||
dialogue_keep: int,
|
||||
include_you_activity: bool = True,
|
||||
include_guest_activity: bool = True,
|
||||
include_group_node: bool = True,
|
||||
include_active_events: bool = True,
|
||||
include_open_threads: bool = True,
|
||||
include_meanwhile_digests: bool = True,
|
||||
) -> tuple[str, int, list[dict]]:
|
||||
# dialogue: keep the last `dialogue_keep` turns verbatim; older
|
||||
# turns become an "earlier:" placeholder line.
|
||||
@@ -503,14 +705,27 @@ def assemble_narrative_prompt(
|
||||
if include_previous_scene else None
|
||||
)
|
||||
|
||||
# Single ACTIVITIES: block, bullet-level trim (T71.2). Guest
|
||||
# bullet drops first, then the you bullet; speaker bullet is the
|
||||
# MUST-tier floor and always present when an activity row exists.
|
||||
activity_block = _activity_block_for(
|
||||
include_you=include_you_activity,
|
||||
include_guest=include_guest_activity,
|
||||
)
|
||||
|
||||
body = _join_blocks([
|
||||
speaker_identity,
|
||||
edge_to_addressee,
|
||||
other_edges_block if include_other_edges else None,
|
||||
scene_block,
|
||||
activity_block,
|
||||
guest_activity_block if include_guest_activity else None,
|
||||
group_node_block if include_group_node else None,
|
||||
active_events_block if include_active_events else None,
|
||||
open_threads_block if include_open_threads else None,
|
||||
(
|
||||
meanwhile_digests_block
|
||||
if include_meanwhile_digests else None
|
||||
),
|
||||
prev_block,
|
||||
memories_block,
|
||||
dialogue_block,
|
||||
@@ -527,25 +742,37 @@ def assemble_narrative_prompt(
|
||||
nice_memories_k = min(4, len(memory_summaries))
|
||||
include_prev = previous_scene_summary is not None
|
||||
include_other = other_edges_block is not None
|
||||
include_guest_activity = guest_activity_block is not None
|
||||
include_you_activity = you_activity is not None
|
||||
include_guest_activity = guest_activity is not None
|
||||
include_group_node = group_node_block is not None
|
||||
include_active_events = active_events_block is not None
|
||||
include_open_threads = open_threads_block is not None
|
||||
include_meanwhile_digests = meanwhile_digests_block is not None
|
||||
|
||||
def _build(*, prev: bool, mem_k: int, dlg: int, other: bool,
|
||||
guest_act: bool, group: bool) -> tuple[str, int]:
|
||||
you_act: bool, guest_act: bool, group: bool,
|
||||
events: bool, threads: bool,
|
||||
digests: bool) -> tuple[str, int]:
|
||||
body, total, _ = assemble(
|
||||
include_other_edges=other,
|
||||
include_previous_scene=prev,
|
||||
include_memories_top_k=mem_k,
|
||||
dialogue_keep=dlg,
|
||||
include_you_activity=you_act,
|
||||
include_guest_activity=guest_act,
|
||||
include_group_node=group,
|
||||
include_active_events=events,
|
||||
include_open_threads=threads,
|
||||
include_meanwhile_digests=digests,
|
||||
)
|
||||
return body, total
|
||||
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, guest_act=include_guest_activity,
|
||||
group=include_group_node,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
# If under soft, we're done.
|
||||
@@ -554,12 +781,34 @@ def assemble_narrative_prompt(
|
||||
|
||||
# Drop NICE in order: previous scene → memories beyond top-2 →
|
||||
# older dialogue turns (collapse to 4).
|
||||
#
|
||||
# T71.3 — order rationale: the §6.3 spec lists NICE-tier members
|
||||
# with previous-scene LAST, which read as a literal trim order
|
||||
# during T18 review. We deliberately keep the greedy order shown
|
||||
# here (previous-scene FIRST) for two reasons:
|
||||
#
|
||||
# 1. Cheapest-impact-first: a per-POV previous-scene summary is
|
||||
# a single short paragraph that loses very little narrative
|
||||
# continuity when dropped, while the older dialogue turns it
|
||||
# is competing with carry the speaker's last few beats — those
|
||||
# ground the next response far more concretely.
|
||||
# 2. Greedy lookahead is more expensive than the marginal
|
||||
# narrative loss. Dropping previous-scene typically clears
|
||||
# the soft-budget slack in one step; trying memories or
|
||||
# dialogue first would routinely require multiple recompute
|
||||
# passes through the assembler.
|
||||
#
|
||||
# The pin test test_nice_trim_order_documented locks this order so
|
||||
# a future refactor can't quietly invert it without surfacing the
|
||||
# decision.
|
||||
if include_prev:
|
||||
include_prev = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, guest_act=include_guest_activity,
|
||||
group=include_group_node,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
if total <= budget_soft:
|
||||
return _emit(body, user_turn_prose)
|
||||
@@ -568,8 +817,10 @@ def assemble_narrative_prompt(
|
||||
nice_memories_k = 2
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, guest_act=include_guest_activity,
|
||||
group=include_group_node,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
if total <= budget_soft:
|
||||
return _emit(body, user_turn_prose)
|
||||
@@ -578,8 +829,10 @@ def assemble_narrative_prompt(
|
||||
nice_dialogue_keep = baseline_keep
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, guest_act=include_guest_activity,
|
||||
group=include_group_node,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
if total <= budget_soft:
|
||||
return _emit(body, user_turn_prose)
|
||||
@@ -589,35 +842,93 @@ def assemble_narrative_prompt(
|
||||
nice_memories_k = max(0, nice_memories_k - 1)
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, guest_act=include_guest_activity,
|
||||
group=include_group_node,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
# Drop SHOULD-tier extras in order:
|
||||
# 1. meanwhile digests block (T65: SHOULD-tier; refers to a past
|
||||
# meanwhile scene — least critical to the speaker's immediate
|
||||
# voice, so dropped first among SHOULD)
|
||||
# 2. open threads block (T60: SHOULD-tier; least critical to the
|
||||
# speaker's immediate voice — drop next among SHOULD)
|
||||
# 3. active events block (T60: same tier, drops next)
|
||||
# 4. guest activity bullet (T71.2: bullet-level trim within the
|
||||
# single ACTIVITIES: block — guest goes first per Task 43 spec)
|
||||
# 5. group node block
|
||||
# 6. you activity bullet (still SHOULD-tier; speaker bullet is the
|
||||
# MUST-tier floor and never dropped)
|
||||
# 7. other edges
|
||||
if include_meanwhile_digests and total > budget_hard:
|
||||
include_meanwhile_digests = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_open_threads and total > budget_hard:
|
||||
include_open_threads = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_active_events and total > budget_hard:
|
||||
include_active_events = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
# Drop SHOULD-tier blocks in order: guest activity → group node →
|
||||
# other edges. (Guest activity goes first per Task 43 spec — it's
|
||||
# the most expendable additive context.)
|
||||
if include_guest_activity and total > budget_hard:
|
||||
include_guest_activity = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, guest_act=include_guest_activity,
|
||||
group=include_group_node,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_group_node and total > budget_hard:
|
||||
include_group_node = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, guest_act=include_guest_activity,
|
||||
group=include_group_node,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_you_activity and total > budget_hard:
|
||||
include_you_activity = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_other and total > budget_hard:
|
||||
include_other = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, guest_act=include_guest_activity,
|
||||
group=include_group_node,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if total > budget_hard:
|
||||
@@ -643,4 +954,38 @@ def _emit(system_body: str, user_turn_prose: str | None) -> list[Message]:
|
||||
return msgs
|
||||
|
||||
|
||||
__all__ = ["assemble_narrative_prompt"]
|
||||
def consume_pending_meanwhile_digests(conn: Connection, chat_id: str) -> int:
|
||||
"""Mark every pending meanwhile digest for ``chat_id`` as consumed.
|
||||
|
||||
Called by the post_turn flow AFTER the assistant response streams,
|
||||
once for the first you-turn that surfaced any pending digests. We
|
||||
keep this side-effect out of :func:`assemble_narrative_prompt` so
|
||||
prompt assembly stays pure (T29's regenerate flow rebuilds prompts
|
||||
repeatedly without state mutation).
|
||||
|
||||
Returns the number of digests consumed (0 when none were pending).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
|
||||
pending = list_pending_meanwhile_digests(conn, chat_id)
|
||||
if not pending:
|
||||
return 0
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for d in pending:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="meanwhile_digest_consumed",
|
||||
payload={
|
||||
"digest_id": d["id"],
|
||||
"consumed_at": now,
|
||||
},
|
||||
)
|
||||
return len(pending)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"assemble_narrative_prompt",
|
||||
"consume_pending_meanwhile_digests",
|
||||
]
|
||||
|
||||
+370
-6
@@ -26,6 +26,7 @@ Phase 1 simplifications (per the plan's "bound it" guidance):
|
||||
so affinity/trust/knowledge reflect the new output.
|
||||
- The route does not broadcast a fresh ``turn_html`` SSE event; T34
|
||||
polishes UI swaps. The user refreshes the page to see the new turn.
|
||||
*(T73.1 closed this gap — see Phase 2.5 changes below.)*
|
||||
|
||||
Phase 2 changes (T44):
|
||||
|
||||
@@ -42,6 +43,27 @@ Phase 2 changes (T44):
|
||||
is not invoked here. If the prior turn fired an interjection it
|
||||
remains attached to the original assistant_turn (which is superseded
|
||||
alongside the regenerated turn) — Phase 2.5 will revisit.
|
||||
|
||||
Phase 2.5 changes:
|
||||
|
||||
- T73.1: After the new ``assistant_turn`` lands we publish a
|
||||
``turn_html_replace`` SSE event carrying the rendered HTML for the
|
||||
regenerated turn plus the original assistant_turn's event_id as
|
||||
``supersedes_id`` so connected tabs can swap the prior DOM node
|
||||
in-place. We use a NEW event name (rather than re-using ``turn_html``)
|
||||
because the existing HTMX ``sse-swap="turn_html"`` consumer expects a
|
||||
raw-HTML body and an *append* semantic; ``turn_html_replace`` is a
|
||||
JSON payload (sse.py auto-serialises when extra keys accompany
|
||||
``data``) so the front-end JS can read ``supersedes_id`` and replace
|
||||
the right node.
|
||||
- T73.2: Interjection regeneration. When the original assistant_turn
|
||||
group included an interjection beat we redo BOTH the primary and the
|
||||
interjection — re-running ``detect_interjection`` against the new
|
||||
primary text. If the classifier returns False this time we supersede
|
||||
the original interjection without appending a replacement.
|
||||
- T73.3: The defensive degrade-to-1:1 for stale ``guest_bot_id``
|
||||
references was removed — Phase 2 T47 fixed the root cause (resets
|
||||
clear the reference) so the guard is dead code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -51,13 +73,18 @@ from sqlite3 import Connection
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.services.event_lifecycle import detect_event_transitions
|
||||
from chat.services.event_promotion import promote_completed_event
|
||||
from chat.services.interjection import detect_interjection
|
||||
from chat.services.memory_write import record_turn_memory_for_present
|
||||
from chat.services.multi_state_update import compute_state_updates_for_present
|
||||
from chat.services.prompt import assemble_narrative_prompt
|
||||
from chat.state.edges import get_edge
|
||||
from chat.state.entities import get_bot, get_you
|
||||
from chat.state.events import list_active_events
|
||||
from chat.state.world import active_scene, get_chat
|
||||
from chat.web.pubsub import publish
|
||||
from chat.web.render import render_turn_html
|
||||
|
||||
|
||||
async def regenerate_assistant_turn(
|
||||
@@ -90,13 +117,13 @@ async def regenerate_assistant_turn(
|
||||
|
||||
# Phase 2: surface the guest (if any) so the prompt assembler and
|
||||
# downstream multi-entity passes see the same shape post_turn does.
|
||||
# Phase 2 T47 made bot_reset cascade-clear ``chat.guest_bot_id`` when
|
||||
# the referenced bot is purged (verified by tests/test_reset.py), so
|
||||
# we trust the column here: it's either a valid bot id or NULL.
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
guest_bot: dict | None = None
|
||||
if guest_bot_id is not None:
|
||||
guest_bot = get_bot(conn, guest_bot_id)
|
||||
if guest_bot is None:
|
||||
# Stale guest reference — degrade to single-bot regenerate.
|
||||
guest_bot_id = None
|
||||
guest_bot: dict | None = (
|
||||
get_bot(conn, guest_bot_id) if guest_bot_id is not None else None
|
||||
)
|
||||
|
||||
# 1. Locate the original assistant_turn event.
|
||||
row = conn.execute(
|
||||
@@ -108,6 +135,33 @@ async def regenerate_assistant_turn(
|
||||
raise ValueError("assistant_turn event not found")
|
||||
original_assistant_payload = json.loads(row[0])
|
||||
original_user_turn_id = original_assistant_payload.get("user_turn_id")
|
||||
|
||||
# 1a. Look up any sibling interjection beat in the same turn group
|
||||
# (T73.2). The original group is (primary + optional interjection),
|
||||
# both pinned to the same ``user_turn_id``. The interjection has a
|
||||
# populated ``interjection_of`` field in its payload — its speaker is
|
||||
# the silent witness (the bot that wasn't the primary addressee).
|
||||
# Filter on ``superseded_by IS NULL`` so prior regenerates of this
|
||||
# group don't reappear as siblings.
|
||||
original_interjection_event_id: int | None = None
|
||||
original_interjection_payload: dict | None = None
|
||||
if original_user_turn_id is not None:
|
||||
sibling_cur = conn.execute(
|
||||
"SELECT id, payload_json FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' "
|
||||
" AND id != ? "
|
||||
" AND superseded_by IS NULL",
|
||||
(original_assistant_event_id,),
|
||||
)
|
||||
for sib_id, sib_payload_json in sibling_cur.fetchall():
|
||||
sib_payload = json.loads(sib_payload_json)
|
||||
if sib_payload.get("user_turn_id") != original_user_turn_id:
|
||||
continue
|
||||
if not sib_payload.get("interjection_of"):
|
||||
continue
|
||||
original_interjection_event_id = sib_id
|
||||
original_interjection_payload = sib_payload
|
||||
break
|
||||
# Phase 2 v2 regenerates only the addressee turn — preserve whichever
|
||||
# bot the original turn was attributed to, falling back to the host
|
||||
# for legacy rows that pre-date multi-entity support.
|
||||
@@ -238,6 +292,27 @@ async def regenerate_assistant_turn(
|
||||
(new_assistant_event_id, original_assistant_event_id),
|
||||
)
|
||||
|
||||
# 7a. Broadcast a turn_html_replace SSE event so connected tabs can
|
||||
# swap the prior assistant_turn DOM node in-place (T73.1, Phase 1.5
|
||||
# backlog #2). Uses a separate event name from post_turn's
|
||||
# ``turn_html`` (which is append-only) because regenerate is a
|
||||
# *replace* operation — see module docstring for the rationale.
|
||||
speaker_name_for_render = (
|
||||
speaker_bot.get("name", "bot") if speaker_bot is not None else "bot"
|
||||
)
|
||||
new_turn_html = render_turn_html(
|
||||
speaker_name_for_render, new_text, role="bot"
|
||||
)
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "turn_html_replace",
|
||||
"data": new_turn_html,
|
||||
"turn_id": new_assistant_event_id,
|
||||
"supersedes_id": original_assistant_event_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 8. Re-run downstream classifier passes (memory write + state update
|
||||
# for every directed pair across present entities). Significance is
|
||||
# intentionally skipped on regenerate (the prior score remains
|
||||
@@ -317,6 +392,295 @@ async def regenerate_assistant_turn(
|
||||
},
|
||||
)
|
||||
|
||||
# 9. Interjection regenerate branch (T73.2). When the original
|
||||
# assistant_turn group included a follow-on interjection beat we need
|
||||
# to revisit that beat against the regenerated primary. Three outcomes:
|
||||
#
|
||||
# - No original interjection: nothing to do; we already short-circuit
|
||||
# above by leaving ``original_interjection_event_id`` as None.
|
||||
# - Original interjection + classifier returns True: stream a fresh
|
||||
# interjection from the silent witness, append it (with
|
||||
# ``interjection_of`` linking to the new primary speaker), and
|
||||
# supersede the original interjection's row. Also re-run memory
|
||||
# + state-update so the second beat moves edges + writes memories.
|
||||
# - Original interjection + classifier returns False: supersede the
|
||||
# original interjection without appending a replacement. The
|
||||
# regenerated group becomes "primary only" because the new primary
|
||||
# no longer warrants a follow-on. No memory / state work needed
|
||||
# for the absent beat.
|
||||
#
|
||||
# ``superseded_by`` on the original interjection's row points at the
|
||||
# *new primary* in the no-replacement case (rather than NULL or a
|
||||
# nonexistent id) so the row is consistently hidden by the standard
|
||||
# ``superseded_by IS NULL`` timeline filter and the back-pointer
|
||||
# leads somewhere meaningful for an "originally said …" affordance.
|
||||
if original_interjection_event_id is not None and guest_bot is not None:
|
||||
# Identify the silent witness from the original interjection's
|
||||
# speaker_id (which is the bot that interjected last time). When
|
||||
# we regenerate we keep the *same pair of present entities*, so
|
||||
# the silent witness is whichever bot isn't the new primary
|
||||
# speaker — derive it from present rather than reusing the prior
|
||||
# speaker_id verbatim, in case the regenerated primary swapped
|
||||
# who held the floor.
|
||||
if speaker_bot_id == host_bot_id:
|
||||
silent_witness = guest_bot
|
||||
else:
|
||||
silent_witness = host_bot
|
||||
silent_witness_id = silent_witness.get("id")
|
||||
|
||||
edge_w_to_addr = get_edge(conn, silent_witness_id, speaker_bot_id) or {
|
||||
"affinity": 50,
|
||||
"trust": 50,
|
||||
"summary": "",
|
||||
}
|
||||
edge_w_to_you = get_edge(conn, silent_witness_id, "you") or {
|
||||
"affinity": 50,
|
||||
"trust": 50,
|
||||
"summary": "",
|
||||
}
|
||||
|
||||
decision = await detect_interjection(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
addressee_name=speaker_bot.get("name", "bot"),
|
||||
addressee_just_said=new_text,
|
||||
silent_witness_name=silent_witness.get("name", "bot"),
|
||||
silent_witness_persona=silent_witness.get("persona") or "",
|
||||
silent_witness_edge_to_addressee=edge_w_to_addr,
|
||||
silent_witness_edge_to_you=edge_w_to_you,
|
||||
you_just_said=prose_for_prompt or "",
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
|
||||
if decision.should_interject:
|
||||
# Re-read recent so the just-appended primary is in the prompt.
|
||||
interject_cur = conn.execute(
|
||||
"SELECT id, kind, payload_json FROM event_log "
|
||||
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
|
||||
" AND superseded_by IS NULL AND hidden = 0 "
|
||||
"ORDER BY id DESC LIMIT 20",
|
||||
)
|
||||
interject_rows = list(reversed(interject_cur.fetchall()))
|
||||
interject_recent: list[dict] = []
|
||||
for _eid, kind, payload_json in interject_rows:
|
||||
p = json.loads(payload_json)
|
||||
if p.get("chat_id") != chat_id:
|
||||
continue
|
||||
if kind in ("user_turn", "user_turn_edit"):
|
||||
interject_recent.append(
|
||||
{"speaker": you_name, "text": p.get("prose", "")}
|
||||
)
|
||||
else:
|
||||
spk = p.get("speaker_id", "bot")
|
||||
if spk == host_bot_id:
|
||||
spk_name = host_bot.get("name", "bot")
|
||||
elif spk == guest_bot.get("id"):
|
||||
spk_name = guest_bot.get("name", "bot")
|
||||
else:
|
||||
spk_name = "bot"
|
||||
interject_recent.append(
|
||||
{"speaker": spk_name, "text": p.get("text", "")}
|
||||
)
|
||||
if interject_recent and interject_recent[-1].get("speaker") == you_name:
|
||||
interject_recent = interject_recent[:-1]
|
||||
|
||||
interject_messages = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
speaker_bot_id=silent_witness_id,
|
||||
addressee=speaker_bot_id,
|
||||
user_turn_prose=prose_for_prompt or None,
|
||||
recent_dialogue=interject_recent,
|
||||
budget_soft=settings.narrative_budget_soft,
|
||||
budget_hard=settings.narrative_budget_hard,
|
||||
guest_id=guest_bot_id,
|
||||
)
|
||||
|
||||
interject_accumulated: list[str] = []
|
||||
async for chunk in client.stream(
|
||||
interject_messages,
|
||||
model=settings.narrative_model,
|
||||
max_tokens=settings.narrative_max_tokens,
|
||||
temperature=settings.narrative_temperature,
|
||||
):
|
||||
interject_accumulated.append(chunk)
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "token",
|
||||
"text": chunk,
|
||||
"speaker_id": silent_witness_id,
|
||||
},
|
||||
)
|
||||
interject_text = "".join(interject_accumulated)
|
||||
|
||||
new_interjection_event_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": silent_witness_id,
|
||||
"text": interject_text,
|
||||
"truncated": False,
|
||||
"user_turn_id": (
|
||||
new_user_event_id
|
||||
if new_user_event_id is not None
|
||||
else original_user_turn_id
|
||||
),
|
||||
"regenerated_from": original_interjection_event_id,
|
||||
"interjection_of": speaker_bot_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Supersede the original interjection by the new one.
|
||||
conn.execute(
|
||||
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
||||
(new_interjection_event_id, original_interjection_event_id),
|
||||
)
|
||||
|
||||
# Broadcast a replace event so connected tabs swap the prior
|
||||
# interjection node in-place (mirrors T73.1's primary swap).
|
||||
interject_html = render_turn_html(
|
||||
silent_witness.get("name", "bot"), interject_text, role="bot"
|
||||
)
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "turn_html_replace",
|
||||
"data": interject_html,
|
||||
"turn_id": new_interjection_event_id,
|
||||
"supersedes_id": original_interjection_event_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Memory write for the new interjection beat (one event per
|
||||
# present witness).
|
||||
record_turn_memory_for_present(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=host_bot_id,
|
||||
guest_bot_id=guest_bot_id,
|
||||
narrative_text=interject_text,
|
||||
scene_id=scene["id"] if scene else None,
|
||||
chat_clock_at=chat.get("time"),
|
||||
)
|
||||
|
||||
# Re-run the multi-pair state-update with the post-interjection
|
||||
# dialogue tail so deltas land on the post-primary baseline.
|
||||
recent_post_interject = recent_for_update + [
|
||||
{
|
||||
"speaker": silent_witness.get("name", "bot"),
|
||||
"text": interject_text,
|
||||
}
|
||||
]
|
||||
prior_edges_post: dict[tuple[str, str], dict] = {}
|
||||
for src in present_ids:
|
||||
for tgt in present_ids:
|
||||
if src == tgt:
|
||||
continue
|
||||
edge = get_edge(conn, src, tgt) or {
|
||||
"affinity": 50,
|
||||
"trust": 50,
|
||||
"summary": "",
|
||||
}
|
||||
prior_edges_post[(src, tgt)] = edge
|
||||
|
||||
state_updates_post = await compute_state_updates_for_present(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
present_ids=present_ids,
|
||||
present_names=present_names,
|
||||
personas=personas,
|
||||
prior_edges=prior_edges_post,
|
||||
recent_dialogue=recent_post_interject,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for src_id, tgt_id, update in state_updates_post:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": src_id,
|
||||
"target_id": tgt_id,
|
||||
"chat_id": chat_id,
|
||||
"affinity_delta": update.affinity_delta,
|
||||
"trust_delta": update.trust_delta,
|
||||
"knowledge_facts": update.knowledge_facts,
|
||||
"last_interaction_at": last_at,
|
||||
"last_interaction_chat_id": chat_id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Classifier said "no follow-on this time" — supersede the
|
||||
# original interjection without a replacement. Point the
|
||||
# back-pointer at the new primary so the row is consistently
|
||||
# hidden by the standard timeline filter.
|
||||
conn.execute(
|
||||
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
||||
(new_assistant_event_id, original_interjection_event_id),
|
||||
)
|
||||
|
||||
# 10. Event-lifecycle detection (Phase 3, T61). Mirrors the post_turn
|
||||
# block: classify whether any active events transitioned in the
|
||||
# regenerated narrative and append the corresponding event_started /
|
||||
# event_completed / event_cancelled. ``promote_completed_event``
|
||||
# runs inline after a completion so promotion artifacts land in the
|
||||
# same regenerate path.
|
||||
#
|
||||
# Phase 3.5 follow-up: when a regenerate replaces a turn that had
|
||||
# already produced event transitions, those original transitions are
|
||||
# NOT undone here. The superseded ``assistant_turn`` group keeps its
|
||||
# prior ``event_started`` / ``event_completed`` events in the log
|
||||
# (they remain projected onto the events table). Phase 3.5 will add
|
||||
# an "undo lifecycle" step to roll back the prior transitions before
|
||||
# re-classifying the regenerated text. For v3 we accept that a
|
||||
# regenerate-after-completion will double-emit promotion artifacts
|
||||
# if the new text re-completes the same event — narratively rare,
|
||||
# and a true fix needs the lifecycle-undo pass.
|
||||
new_active_events = list_active_events(conn, chat_id)
|
||||
if new_active_events:
|
||||
lifecycle_decision = await detect_event_transitions(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
narrative_text=new_text,
|
||||
active_events=new_active_events,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for transition in lifecycle_decision.transitions:
|
||||
if transition.new_status == "active":
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": transition.event_id,
|
||||
"started_at": chat.get("time"),
|
||||
},
|
||||
)
|
||||
elif transition.new_status == "completed":
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_completed",
|
||||
payload={
|
||||
"event_id": transition.event_id,
|
||||
"completed_at": chat.get("time"),
|
||||
},
|
||||
)
|
||||
promote_completed_event(
|
||||
conn,
|
||||
event_id=transition.event_id,
|
||||
chat_id=chat_id,
|
||||
chat_clock_at=chat.get("time"),
|
||||
)
|
||||
elif transition.new_status == "cancelled":
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": transition.event_id,
|
||||
"completed_at": chat.get("time"),
|
||||
},
|
||||
)
|
||||
|
||||
return new_text
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ keeps moving.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlite3 import Connection
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -167,6 +169,7 @@ async def _summarize_and_apply_for_witness(
|
||||
you_name: str,
|
||||
dialogue: list[dict],
|
||||
timeout_s: float,
|
||||
key_quotes_suffix: str = "",
|
||||
) -> ScenePOVSummary:
|
||||
"""Run :func:`summarize_scene` for one bot witness and apply the
|
||||
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
|
||||
row -> skip the rewrite; no edge row -> skip the edge_summary write
|
||||
(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.entities import get_bot
|
||||
@@ -206,6 +213,7 @@ async def _summarize_and_apply_for_witness(
|
||||
# Empty default -> skip the memory rewrite; the seeded
|
||||
# per-turn pov_summary stays in place.
|
||||
continue
|
||||
new_value = pov.summary + key_quotes_suffix
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
@@ -213,7 +221,7 @@ async def _summarize_and_apply_for_witness(
|
||||
"target_kind": "memory_pov_summary",
|
||||
"target_id": int(memory_id),
|
||||
"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
|
||||
|
||||
|
||||
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(
|
||||
conn: Connection,
|
||||
client: LLMClient,
|
||||
@@ -296,9 +338,11 @@ async def apply_scene_close_summary(
|
||||
"""
|
||||
# Local imports to keep the module-level surface tight and avoid
|
||||
# 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.group_node import get_group_node
|
||||
from chat.state.world import get_chat
|
||||
from chat.state.threads import list_open_threads
|
||||
from chat.state.world import get_chat, get_scene
|
||||
|
||||
you_entity = get_you(conn) or {"name": "you", "persona": ""}
|
||||
you_name = you_entity.get("name", "you") or "you"
|
||||
@@ -306,8 +350,22 @@ async def apply_scene_close_summary(
|
||||
chat = get_chat(conn, chat_id) or {}
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
|
||||
# T65: detect meanwhile child scenes via the migration-0011
|
||||
# ``present_set_kind`` column. The mechanism is a single field read
|
||||
# — meanwhile scenes carry ``"host_guest"``, regular you-scenes
|
||||
# carry the default ``"you_host"``. We read this once up front so
|
||||
# both the dialogue source and the post-summary digest emission
|
||||
# branches can reference it.
|
||||
closing_scene = get_scene(conn, scene_id) or {}
|
||||
is_meanwhile = closing_scene.get("present_set_kind") == "host_guest"
|
||||
|
||||
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(
|
||||
conn,
|
||||
client,
|
||||
@@ -318,6 +376,7 @@ async def apply_scene_close_summary(
|
||||
you_name=you_name,
|
||||
dialogue=dialogue,
|
||||
timeout_s=timeout_s,
|
||||
key_quotes_suffix=key_quotes_suffix,
|
||||
)
|
||||
|
||||
guest_pov: ScenePOVSummary | None = None
|
||||
@@ -332,28 +391,187 @@ async def apply_scene_close_summary(
|
||||
you_name=you_name,
|
||||
dialogue=dialogue,
|
||||
timeout_s=timeout_s,
|
||||
key_quotes_suffix=key_quotes_suffix,
|
||||
)
|
||||
|
||||
# Group node update: naive per-POV concat for v2. Only fires when
|
||||
# both POVs ran (i.e. the guest is present) and a group_node row
|
||||
# exists for this chat.
|
||||
# Group node update: T70 runs a third classifier call to merge the
|
||||
# two per-POV summaries into a coherent group-level view + a brief
|
||||
# group-dynamic note. Falls back to the Phase 2 naive concat on
|
||||
# classifier failure (see :func:`merge_group_summary`). Only fires
|
||||
# when both POVs ran (i.e. the guest is present) and a group_node
|
||||
# row exists for this chat.
|
||||
if guest_pov is not None and get_group_node(conn, chat_id) is not None:
|
||||
host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id}
|
||||
guest_bot = get_bot(conn, guest_bot_id) or {"name": guest_bot_id}
|
||||
host_name = host_bot.get("name", host_bot_id) or host_bot_id
|
||||
guest_name = guest_bot.get("name", guest_bot_id) or guest_bot_id
|
||||
group_summary = (
|
||||
f"{host_name}: {host_pov.summary}\n\n"
|
||||
f"{guest_name}: {guest_pov.summary}"
|
||||
merged = await merge_group_summary(
|
||||
client,
|
||||
classifier_model=classifier_model,
|
||||
host_name=host_name,
|
||||
host_pov_summary=host_pov.summary,
|
||||
guest_name=guest_name,
|
||||
guest_pov_summary=guest_pov.summary,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="group_node_updated",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"summary": group_summary,
|
||||
"dynamic": "",
|
||||
"summary": merged.summary,
|
||||
"dynamic": merged.dynamic,
|
||||
},
|
||||
)
|
||||
|
||||
# T65: when the closing scene was a meanwhile child (host_guest
|
||||
# present set), generate an omniscient briefing for the absent
|
||||
# "you" and queue it as a pending digest. We reuse summarize_scene
|
||||
# with a narrator persona so the digest text is shaped by the same
|
||||
# classifier — only the ``summary`` field is consumed downstream.
|
||||
# Emitted AFTER per-POV summaries land so witness memories carry
|
||||
# their own POV text first; this mirrors how group_node_updated is
|
||||
# ordered relative to the per-POV writes above.
|
||||
if is_meanwhile:
|
||||
digest_pov = await summarize_scene(
|
||||
client,
|
||||
model=classifier_model,
|
||||
bot_name="Narrator",
|
||||
bot_persona=_MEANWHILE_DIGEST_PERSONA,
|
||||
you_name=you_name,
|
||||
prior_edge_summary="",
|
||||
dialogue=dialogue,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if digest_pov.summary:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="meanwhile_digest_created",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"scene_id": scene_id,
|
||||
"summary": digest_pov.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
|
||||
|
||||
|
||||
class GroupMetaSummary(BaseModel):
|
||||
"""Classifier output: a merged group-level view of a closed scene.
|
||||
|
||||
Defaults are an empty no-op so callers can use the schema's default
|
||||
as a sentinel; in practice :func:`merge_group_summary` builds an
|
||||
explicit naive-concat fallback rather than returning these defaults
|
||||
directly so existing Phase 2 behavior is preserved on classifier
|
||||
failure.
|
||||
"""
|
||||
|
||||
summary: str = ""
|
||||
dynamic: str = ""
|
||||
|
||||
|
||||
_GROUP_MERGE_SYSTEM = (
|
||||
"Given two per-POV scene summaries from a 3-entity scene (you + "
|
||||
"host + guest), produce a coherent group-level summary capturing "
|
||||
"the shared events as both witnesses experienced them, plus a "
|
||||
"brief 'dynamic' note describing the trio's group dynamic during "
|
||||
"the scene. Output strict JSON matching schema."
|
||||
)
|
||||
|
||||
|
||||
# T65: meanwhile-scene digest. The "you" player was absent during this
|
||||
# scene; the digest is a short neutral briefing they'll read on the next
|
||||
# you-scene resume. Reuses the ScenePOVSummary schema so the same
|
||||
# `summarize_scene` helper can be called with a different persona — only
|
||||
# the ``summary`` field is used downstream.
|
||||
_MEANWHILE_DIGEST_PERSONA = (
|
||||
"an omniscient narrator briefing the absent player in 2-3 neutral "
|
||||
"sentences on what happened while they were away — no editorializing, "
|
||||
"no second-person address"
|
||||
)
|
||||
|
||||
|
||||
async def merge_group_summary(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
host_name: str,
|
||||
host_pov_summary: str,
|
||||
guest_name: str,
|
||||
guest_pov_summary: str,
|
||||
timeout_s: float = 30.0,
|
||||
) -> GroupMetaSummary:
|
||||
"""Merge two per-POV scene summaries into a coherent group-level
|
||||
summary + group-dynamic note. Falls back to the naive concat (the
|
||||
existing behavior) on classifier failure."""
|
||||
user = (
|
||||
f"{host_name} (host) POV summary:\n{host_pov_summary}\n\n"
|
||||
f"{guest_name} (guest) POV summary:\n{guest_pov_summary}"
|
||||
)
|
||||
fallback = GroupMetaSummary(
|
||||
summary=(
|
||||
f"{host_name}: {host_pov_summary}\n\n"
|
||||
f"{guest_name}: {guest_pov_summary}"
|
||||
),
|
||||
dynamic="",
|
||||
)
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_GROUP_MERGE_SYSTEM,
|
||||
user=user,
|
||||
schema=GroupMetaSummary,
|
||||
default=fallback,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Skip narration service (T53).
|
||||
|
||||
Generates brief transition prose for elision and jump skips.
|
||||
|
||||
Skips come in two flavors that read very differently:
|
||||
|
||||
* **Elision** — collapses an in-progress activity into its expected
|
||||
end-state in 1-2 sentences, narrated from the speaker bot's POV.
|
||||
Example: "skip ahead to when we arrive" while the characters are
|
||||
driving — output describes pulling into the lot.
|
||||
* **Jump** — bridges a longer fiction-time delta ("next morning", "a
|
||||
week later") in 2-3 sentences, setting the scene at the new time.
|
||||
|
||||
Output is free-form prose, not structured JSON, so this service calls
|
||||
``client.generate`` directly rather than going through the classifier
|
||||
path used by, e.g., :mod:`chat.services.scene_summarize`. A
|
||||
deterministic template fallback fires on any LLM failure so the skip
|
||||
flow keeps moving even when the model is down — important because
|
||||
skips are a UI-blocking operation; we'd rather show a parenthetical
|
||||
sentence than hang the chat indefinitely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chat.llm.client import LLMClient, Message
|
||||
|
||||
|
||||
_ELISION_SYSTEM = (
|
||||
"You write a brief 1-2 sentence transition that elides the time "
|
||||
"between an in-progress activity and its expected end-state, "
|
||||
"narrated from the speaker's POV. Keep it grounded and concrete. "
|
||||
"Do not invent new events or characters."
|
||||
)
|
||||
|
||||
_JUMP_SYSTEM = (
|
||||
"You write a brief 2-3 sentence transition narrating a jump in "
|
||||
"fiction time (e.g., 'next morning', 'a week later'), narrated "
|
||||
"from the speaker's POV. Set the scene at the new time. Keep it "
|
||||
"grounded — no invented major events. If a landing-state hint is "
|
||||
"provided, weave it in naturally."
|
||||
)
|
||||
|
||||
|
||||
async def narrate_skip(
|
||||
client: LLMClient,
|
||||
*,
|
||||
narrative_model: str,
|
||||
skip_kind: str,
|
||||
speaker_bot: dict,
|
||||
you_name: str,
|
||||
current_time: str,
|
||||
new_time: str,
|
||||
current_activity: str,
|
||||
landing_state_hint: str = "",
|
||||
timeout_s: float = 60.0,
|
||||
) -> str:
|
||||
"""Generate brief transition prose for a time skip.
|
||||
|
||||
``skip_kind`` is ``"elision"`` or ``"jump"``; any other value short-
|
||||
circuits to the deterministic fallback (defensive — callers
|
||||
shouldn't be inventing new kinds without updating this service).
|
||||
|
||||
Returns plain text. Never raises: any LLM error, an empty/blank
|
||||
result, or an unknown ``skip_kind`` falls back to a parenthetical
|
||||
template like ``"(next morning: having coffee in the kitchen.)"``
|
||||
so the skip UI always has *something* to render.
|
||||
"""
|
||||
fallback = _build_fallback(
|
||||
skip_kind=skip_kind,
|
||||
new_time=new_time,
|
||||
current_activity=current_activity,
|
||||
landing_state_hint=landing_state_hint,
|
||||
)
|
||||
|
||||
if skip_kind not in ("elision", "jump"):
|
||||
return fallback
|
||||
|
||||
system = _ELISION_SYSTEM if skip_kind == "elision" else _JUMP_SYSTEM
|
||||
user = (
|
||||
f"Speaker: {speaker_bot.get('name', 'speaker')}\n"
|
||||
f"Persona: {speaker_bot.get('persona', '')}\n"
|
||||
f"Other party: {you_name}\n"
|
||||
f"Current time: {current_time}\n"
|
||||
f"New time: {new_time}\n"
|
||||
f"Current activity: {current_activity}\n"
|
||||
)
|
||||
if landing_state_hint:
|
||||
user += f"Landing state hint: {landing_state_hint}\n"
|
||||
|
||||
try:
|
||||
result = await client.generate(
|
||||
[
|
||||
Message(role="system", content=system),
|
||||
Message(role="user", content=user),
|
||||
],
|
||||
model=narrative_model,
|
||||
max_tokens=200,
|
||||
temperature=0.7,
|
||||
)
|
||||
text = (result or "").strip()
|
||||
if not text:
|
||||
return fallback
|
||||
return text
|
||||
except Exception:
|
||||
# Any failure — network blip, timeout, mock raising in tests —
|
||||
# collapses to the deterministic template so the skip pipeline
|
||||
# is never blocked on the LLM being available.
|
||||
return fallback
|
||||
|
||||
|
||||
def _build_fallback(
|
||||
*,
|
||||
skip_kind: str,
|
||||
new_time: str,
|
||||
current_activity: str,
|
||||
landing_state_hint: str,
|
||||
) -> str:
|
||||
"""Deterministic parenthetical narration used when the LLM fails.
|
||||
|
||||
Both flavors render the same shape today: ``(<new_time>:
|
||||
<detail>.)``. They're separated as branches to make it easy to
|
||||
diverge later (e.g. an elision-specific template) without churning
|
||||
the call site or the public signature.
|
||||
"""
|
||||
detail = landing_state_hint or current_activity or "moments later"
|
||||
if skip_kind == "elision":
|
||||
return f"({new_time}: {detail}.)"
|
||||
return f"({new_time}: {detail}.)"
|
||||
|
||||
|
||||
__all__ = ["narrate_skip"]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Synthesized-memories service (T54).
|
||||
|
||||
When the user jump-skips with 'anything notable happen?' prose, parse
|
||||
that prose into 1-N synthesized memories per present bot. Each memory
|
||||
carries source="synthesized" and reliability=0.7 (lower than direct).
|
||||
Caller (T62 skip flow) writes the memories via record_turn_memory_for_present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class SynthesizedMemory(BaseModel):
|
||||
text: str
|
||||
significance: int = 1 # 0..3, default 1
|
||||
affinity_delta: int = 0
|
||||
trust_delta: int = 0
|
||||
|
||||
|
||||
class SynthesizedDigest(BaseModel):
|
||||
memories: list[SynthesizedMemory] = Field(default_factory=list)
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"You parse a short user-supplied prose describing 'anything notable' "
|
||||
"that happened during a time skip into 1-N synthesized memories from "
|
||||
"a single bot's POV. Each memory has: text (one factual sentence "
|
||||
"from that bot's perspective), significance (0-3, default 1; only "
|
||||
"use 2 or 3 for genuinely scene-level or relationship-altering "
|
||||
"events), affinity_delta and trust_delta (-10..+10, default 0; "
|
||||
"use small adjustments only when prose explicitly describes a shift). "
|
||||
"Empty/whitespace prose returns an empty memories list. Output "
|
||||
"strict JSON matching the schema."
|
||||
)
|
||||
|
||||
|
||||
async def synthesize_memories(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
prose: str,
|
||||
bot_name: str,
|
||||
bot_persona: str,
|
||||
you_name: str,
|
||||
timeout_s: float = 30.0,
|
||||
) -> SynthesizedDigest:
|
||||
"""Parse 'anything notable' prose into structured memories from a
|
||||
single bot's POV. Empty/whitespace prose short-circuits to an
|
||||
empty digest (no LLM call)."""
|
||||
if not prose or not prose.strip():
|
||||
return SynthesizedDigest()
|
||||
|
||||
user = (
|
||||
f"Bot: {bot_name}\n"
|
||||
f"Persona: {bot_persona}\n"
|
||||
f"Other party: {you_name}\n\n"
|
||||
f"Prose:\n{prose.strip()}"
|
||||
)
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=SynthesizedDigest,
|
||||
default=SynthesizedDigest(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SynthesizedMemory", "SynthesizedDigest", "synthesize_memories"]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Thread-detection service (T55).
|
||||
|
||||
On scene close, classify the transcript into thread open/update/close
|
||||
candidates. Returns ThreadCandidate list; caller (T58 scene compression)
|
||||
emits one thread_opened/thread_updated/thread_closed event per candidate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class ThreadCandidate(BaseModel):
|
||||
action: str # "open" | "update" | "close"
|
||||
title: str = "" # required for "open"; ignored otherwise
|
||||
summary: str = ""
|
||||
existing_thread_id: str | None = None # required for "update" / "close"
|
||||
|
||||
|
||||
class ThreadDetectionResult(BaseModel):
|
||||
candidates: list[ThreadCandidate] = Field(default_factory=list)
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"You analyze a closed scene's transcript to identify narrative "
|
||||
"threads (unresolved arcs, dangling questions, promises made, "
|
||||
"open obligations). Choose actions:\n"
|
||||
"- 'open': a NEW thread the scene introduced. Provide title (short "
|
||||
"noun phrase) + summary (one sentence).\n"
|
||||
"- 'update': an EXISTING open thread that the scene developed. "
|
||||
"Provide existing_thread_id + new summary.\n"
|
||||
"- 'close': an EXISTING open thread that the scene resolved. "
|
||||
"Provide existing_thread_id; summary may capture the resolution.\n"
|
||||
"Conservative bias: most scenes do NOT open new threads. Only "
|
||||
"produce candidates when the transcript clearly justifies them. "
|
||||
"Output strict JSON matching the schema."
|
||||
)
|
||||
|
||||
|
||||
async def detect_threads(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
scene_transcript: list[dict], # [{speaker, text}, ...]
|
||||
open_threads: list[dict], # [{thread_id, title, summary}, ...]
|
||||
timeout_s: float = 30.0,
|
||||
) -> ThreadDetectionResult:
|
||||
"""Classify scene close into thread open/update/close candidates."""
|
||||
if not scene_transcript:
|
||||
return ThreadDetectionResult()
|
||||
|
||||
transcript_lines = [
|
||||
f"{turn.get('speaker', 'unknown')}: {turn.get('text', '')}"
|
||||
for turn in scene_transcript
|
||||
]
|
||||
threads_lines = []
|
||||
if open_threads:
|
||||
threads_lines.append("Currently open threads:")
|
||||
for t in open_threads:
|
||||
threads_lines.append(
|
||||
f"- thread_id={t['thread_id']} "
|
||||
f"title={t.get('title', '')} "
|
||||
f"summary={t.get('summary', '')}"
|
||||
)
|
||||
else:
|
||||
threads_lines.append("No currently open threads.")
|
||||
|
||||
user = (
|
||||
"Scene transcript:\n"
|
||||
+ "\n".join(transcript_lines)
|
||||
+ "\n\n"
|
||||
+ "\n".join(threads_lines)
|
||||
)
|
||||
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=ThreadDetectionResult,
|
||||
default=ThreadDetectionResult(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ThreadCandidate", "ThreadDetectionResult", "detect_threads"]
|
||||
@@ -16,6 +16,16 @@ nested quotes, mixed punctuation), so v1 delegates the segmentation to
|
||||
the classifier. The configurable ``Settings.ooc_marker`` is *not* read
|
||||
here: the classifier figures OOC out from ``((`` ``))`` regardless of
|
||||
config-time choice; marker-based stripping is a downstream concern.
|
||||
|
||||
T62 extends the parser with an ``intent`` field so the turn flow can
|
||||
short-circuit time-skip phrases before the regular narrative path.
|
||||
``intent`` defaults to ``"narrative"``; the classifier may set it to
|
||||
``"skip_elision"`` when prose like "skip to when we arrive" or
|
||||
``"skip_jump"`` when prose like "next morning" / "a week later" is
|
||||
detected. ``landing_state_hint`` carries the residual descriptor for
|
||||
elision skips (the "to when we ..." phrase). Existing callers that
|
||||
don't read ``intent`` continue to work because the default keeps the
|
||||
narrative path intact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,9 +49,19 @@ class TurnSegment(BaseModel):
|
||||
|
||||
|
||||
class ParsedTurn(BaseModel):
|
||||
"""A turn split into ordered, typed segments."""
|
||||
"""A turn split into ordered, typed segments.
|
||||
|
||||
``intent`` distinguishes a regular narrative beat (the default) from
|
||||
a natural-language time-skip command (T62). ``landing_state_hint``
|
||||
captures the descriptor following "skip to when we ..." for elision
|
||||
skips so the downstream skip controller can pass it to the
|
||||
narration helper. Both fields are optional and default-empty so
|
||||
older fixtures and tests that don't supply them keep working.
|
||||
"""
|
||||
|
||||
segments: list[TurnSegment]
|
||||
intent: str = "narrative" # "narrative" | "skip_elision" | "skip_jump"
|
||||
landing_state_hint: str = ""
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
@@ -52,13 +72,24 @@ _SYSTEM_PROMPT = (
|
||||
"- ((text in double parens)) is an OOC (out-of-character) segment — "
|
||||
"the author talking to the system, not the in-fiction bot.\n\n"
|
||||
"Output a JSON object with shape "
|
||||
'{"segments": [{"kind": "...", "text": "..."}, ...]} '
|
||||
"where each ``kind`` is exactly one of: dialogue, action, ooc. "
|
||||
"Preserve the original substring text as ``text``: do not rewrite, "
|
||||
"translate, or normalize punctuation — strip only the marker "
|
||||
"characters (asterisks, surrounding quotes, double parens) so "
|
||||
"``text`` is the inner content. Emit segments in the order they "
|
||||
"appear in the input."
|
||||
'{"segments": [{"kind": "...", "text": "..."}, ...], '
|
||||
'"intent": "...", "landing_state_hint": "..."} '
|
||||
"where each segment ``kind`` is exactly one of: dialogue, action, "
|
||||
"ooc. Preserve the original substring text as ``text``: do not "
|
||||
"rewrite, translate, or normalize punctuation — strip only the "
|
||||
"marker characters (asterisks, surrounding quotes, double parens) "
|
||||
"so ``text`` is the inner content. Emit segments in the order they "
|
||||
"appear in the input.\n\n"
|
||||
"``intent`` is exactly one of: narrative, skip_elision, skip_jump. "
|
||||
"Default to ``narrative``. Use ``skip_elision`` when the prose is a "
|
||||
"directive to fast-forward an in-progress activity to a near-term "
|
||||
"landing state — e.g. 'skip to when we arrive', 'fast-forward to "
|
||||
"after dinner'. Use ``skip_jump`` when the prose denotes a longer "
|
||||
"fiction-time bridge — e.g. 'next morning', 'a week later', 'the "
|
||||
"following day'.\n"
|
||||
"``landing_state_hint`` is a short descriptor of the landing state "
|
||||
"for ``skip_elision`` (e.g. 'we arrive at the park'). Empty string "
|
||||
"for ``skip_jump`` and ``narrative``."
|
||||
)
|
||||
|
||||
|
||||
|
||||
+11
-2
@@ -48,6 +48,17 @@ def _apply_bot_reset(conn: Connection, e: Event) -> None:
|
||||
"SELECT id FROM chats WHERE host_bot_id = ?", (bot_id,)
|
||||
).fetchall()
|
||||
]
|
||||
# T69: purge orphaned "you" activity rows pointing at containers in this
|
||||
# bot's chats BEFORE the containers/chats themselves are deleted, otherwise
|
||||
# the subqueries find nothing and the FK constraint on activity.container_id
|
||||
# blocks the container delete.
|
||||
conn.execute(
|
||||
"DELETE FROM activity WHERE entity_id = 'you' "
|
||||
"AND container_id IN (SELECT id FROM containers WHERE chat_id IN ("
|
||||
" SELECT id FROM chats WHERE host_bot_id = ?"
|
||||
"))",
|
||||
(bot_id,),
|
||||
)
|
||||
for chat_id in chat_ids:
|
||||
conn.execute("DELETE FROM scenes WHERE chat_id = ?", (chat_id,))
|
||||
conn.execute("DELETE FROM containers WHERE chat_id = ?", (chat_id,))
|
||||
@@ -74,8 +85,6 @@ def _apply_bot_reset(conn: Connection, e: Event) -> None:
|
||||
(bot_id,),
|
||||
)
|
||||
# NOTE: bots row itself is preserved (identity, kickoff_prose intact).
|
||||
# NOTE: "you" activity (entity_id="you") may linger from a deleted chat;
|
||||
# acceptable for v1 — Phase 1.5 cleanup if needed.
|
||||
|
||||
|
||||
def get_bot(conn: Connection, bot_id: str) -> dict | None:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
_TERMINAL_STATUSES = {"completed", "cancelled", "expired"}
|
||||
|
||||
|
||||
@on("event_planned")
|
||||
def _apply_event_planned(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO events "
|
||||
"(event_id, chat_id, kind, status, props_json, planned_for) "
|
||||
"VALUES (?, ?, ?, 'planned', ?, ?)",
|
||||
(
|
||||
p["event_id"],
|
||||
p["chat_id"],
|
||||
p["kind"],
|
||||
json.dumps(p.get("props", {})),
|
||||
p.get("planned_for"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("event_started")
|
||||
def _apply_event_started(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
# Idempotent: only transition from non-terminal status.
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'active', started_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("started_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("event_completed")
|
||||
def _apply_event_completed(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'completed', completed_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("completed_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("event_cancelled")
|
||||
def _apply_event_cancelled(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'cancelled', completed_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("completed_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("event_expired")
|
||||
def _apply_event_expired(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'expired', completed_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("completed_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
def get_event(conn: Connection, event_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
||||
"started_at, completed_at, created_at, updated_at "
|
||||
"FROM events WHERE event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"event_id": row[0],
|
||||
"chat_id": row[1],
|
||||
"kind": row[2],
|
||||
"status": row[3],
|
||||
"props": json.loads(row[4]),
|
||||
"planned_for": row[5],
|
||||
"started_at": row[6],
|
||||
"completed_at": row[7],
|
||||
"created_at": row[8],
|
||||
"updated_at": row[9],
|
||||
}
|
||||
|
||||
|
||||
def list_active_events(conn: Connection, chat_id: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
||||
"started_at, completed_at, created_at, updated_at "
|
||||
"FROM events WHERE chat_id = ? AND status IN ('planned','active') "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3],
|
||||
"props": json.loads(r[4]),
|
||||
"planned_for": r[5], "started_at": r[6], "completed_at": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def list_events_in_status(conn: Connection, chat_id: str, status: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
||||
"started_at, completed_at, created_at, updated_at "
|
||||
"FROM events WHERE chat_id = ? AND status = ? ORDER BY id ASC",
|
||||
(chat_id, status),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3],
|
||||
"props": json.loads(r[4]),
|
||||
"planned_for": r[5], "started_at": r[6], "completed_at": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -6,7 +6,7 @@ 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 four target kinds:
|
||||
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.
|
||||
@@ -17,8 +17,18 @@ Phase 1 covers four target kinds:
|
||||
field. Driven by T27 from the classifier's ``relationship_summary``
|
||||
output combined with the prior summary.
|
||||
|
||||
Other §6.4 editable fields (activity verb / attention / posture,
|
||||
knowledge_facts list manipulation) are deferred to Phase 1.5.
|
||||
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.
|
||||
|
||||
Pin toggles intentionally use the existing ``memory_pin_changed`` event
|
||||
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
|
||||
@@ -27,11 +37,14 @@ 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))
|
||||
@@ -87,5 +100,43 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None:
|
||||
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)),
|
||||
)
|
||||
# Unknown target_kind: silently no-op for v1. Future kinds (activity
|
||||
# fields, knowledge_facts list manipulation) extend the dispatch above.
|
||||
# fields, etc.) extend the dispatch above.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Meanwhile-scene projection (T63).
|
||||
|
||||
A "meanwhile" scene is a 2-bot scene where ``present_set = {host_bot_id,
|
||||
guest_bot_id}`` and "you" is absent. It runs alongside an active you-scene
|
||||
(its parent) so bots can have private interactions whose outcome later
|
||||
surfaces back to the you-scene as a pending digest.
|
||||
|
||||
The underlying ``scenes`` table (migration 0007) has no explicit ``status``
|
||||
column; "active" is encoded as ``ended_at IS NULL`` and "closed" as
|
||||
``ended_at IS NOT NULL``. This module preserves that convention and adds
|
||||
two new columns introduced by migration 0011:
|
||||
|
||||
- ``present_set_kind`` — ``'you_host'`` (default) for normal scenes,
|
||||
``'host_guest'`` for meanwhile child scenes.
|
||||
- ``parent_scene_id`` — the you-scene a meanwhile child hangs off of.
|
||||
|
||||
Pending meanwhile digests live in their own table
|
||||
(``meanwhile_digest_pending``) and are consumed when their summary is
|
||||
surfaced in the next you-scene's prompt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("meanwhile_scene_started")
|
||||
def _apply_meanwhile_scene_started(conn: Connection, e: Event) -> None:
|
||||
"""Insert a new scenes row for the meanwhile child.
|
||||
|
||||
The caller supplies an explicit ``scene_id`` so subsequent events
|
||||
(close, digest) can reference it without round-tripping through
|
||||
``lastrowid``.
|
||||
"""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT INTO scenes ("
|
||||
"id, chat_id, started_at, ended_at, significance, "
|
||||
"participants_json, present_set_kind, parent_scene_id"
|
||||
") VALUES (?, ?, ?, NULL, 0, ?, 'host_guest', ?)",
|
||||
(
|
||||
p["scene_id"],
|
||||
p["chat_id"],
|
||||
p.get("started_at"),
|
||||
# participants_json mirrors the present_set: host + guest bot.
|
||||
# json.dumps ensures bot ids with quotes/backslashes can't corrupt the JSON literal.
|
||||
json.dumps([p["host_bot_id"], p["guest_bot_id"]]),
|
||||
p["parent_scene_id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("meanwhile_scene_closed")
|
||||
def _apply_meanwhile_scene_closed(conn: Connection, e: Event) -> None:
|
||||
"""Mark the meanwhile scene closed by stamping ``ended_at``."""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE scenes SET ended_at = ? "
|
||||
"WHERE id = ? AND present_set_kind = 'host_guest' "
|
||||
"AND ended_at IS NULL",
|
||||
(p.get("closed_at"), p["scene_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("meanwhile_digest_created")
|
||||
def _apply_meanwhile_digest_created(conn: Connection, e: Event) -> None:
|
||||
"""Queue a digest for surfacing to the next you-scene's prompt."""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT INTO meanwhile_digest_pending (scene_id, chat_id, summary) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(p["scene_id"], p["chat_id"], p["summary"]),
|
||||
)
|
||||
|
||||
|
||||
@on("meanwhile_digest_consumed")
|
||||
def _apply_meanwhile_digest_consumed(conn: Connection, e: Event) -> None:
|
||||
"""Mark a pending digest as consumed (idempotent on re-projection)."""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE meanwhile_digest_pending SET consumed_at = ? "
|
||||
"WHERE id = ? AND consumed_at IS NULL",
|
||||
(p.get("consumed_at"), p["digest_id"]),
|
||||
)
|
||||
|
||||
|
||||
def _scene_row_to_dict(row: tuple) -> dict:
|
||||
"""Shape a meanwhile-scene row.
|
||||
|
||||
``status`` is derived from ``ended_at`` for callers that prefer the
|
||||
higher-level vocabulary; ``closed_at`` aliases ``ended_at`` for the
|
||||
same reason. The underlying column remains ``ended_at``.
|
||||
"""
|
||||
ended_at = row[5]
|
||||
return {
|
||||
"id": row[0],
|
||||
"chat_id": row[1],
|
||||
"started_at": row[2],
|
||||
"present_set_kind": row[3],
|
||||
"parent_scene_id": row[4],
|
||||
"closed_at": ended_at,
|
||||
"status": "closed" if ended_at is not None else "active",
|
||||
}
|
||||
|
||||
|
||||
def list_meanwhile_scenes(
|
||||
conn: Connection, chat_id: str, status: str = "active"
|
||||
) -> list[dict]:
|
||||
"""Return meanwhile scenes for ``chat_id`` filtered by derived status."""
|
||||
if status == "active":
|
||||
ended_clause = "s.ended_at IS NULL"
|
||||
elif status == "closed":
|
||||
ended_clause = "s.ended_at IS NOT NULL"
|
||||
else:
|
||||
raise ValueError(f"unknown status: {status!r}")
|
||||
rows = conn.execute(
|
||||
"SELECT s.id, s.chat_id, s.started_at, s.present_set_kind, "
|
||||
"s.parent_scene_id, s.ended_at "
|
||||
"FROM scenes s "
|
||||
"WHERE s.chat_id = ? AND s.present_set_kind = 'host_guest' "
|
||||
f"AND {ended_clause} "
|
||||
"ORDER BY s.id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [_scene_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_parent_scene(conn: Connection, scene_id: int) -> dict | None:
|
||||
"""Given a meanwhile scene id, return its parent (you-scene) row."""
|
||||
row = conn.execute(
|
||||
"SELECT s.id, s.chat_id, s.started_at, s.present_set_kind, "
|
||||
"s.parent_scene_id, s.ended_at "
|
||||
"FROM scenes s JOIN scenes m ON m.parent_scene_id = s.id "
|
||||
"WHERE m.id = ?",
|
||||
(scene_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _scene_row_to_dict(row)
|
||||
|
||||
|
||||
def list_pending_meanwhile_digests(
|
||||
conn: Connection, chat_id: str
|
||||
) -> list[dict]:
|
||||
"""Return digests for ``chat_id`` that haven't been consumed yet."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, scene_id, chat_id, summary, created_at "
|
||||
"FROM meanwhile_digest_pending "
|
||||
"WHERE chat_id = ? AND consumed_at IS NULL "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"scene_id": r[1],
|
||||
"chat_id": r[2],
|
||||
"summary": r[3],
|
||||
"created_at": r[4],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
+15
-2
@@ -94,6 +94,14 @@ def get_pinned(conn: Connection, owner_id: str) -> list[dict]:
|
||||
_SIGNIFICANCE_WEIGHT = 0.3
|
||||
_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(
|
||||
conn: Connection,
|
||||
@@ -137,10 +145,15 @@ def search_memories(
|
||||
"JOIN memories m ON m.id = memories_fts.rowid "
|
||||
f"WHERE m.owner_id = ? AND m.{witness_col} = 1 "
|
||||
"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 ?"
|
||||
)
|
||||
cur = conn.execute(sql, (owner_id, query, over_fetch))
|
||||
cur = conn.execute(sql, (owner_id, query, SIGNIFICANCE_RANK_BIAS, over_fetch))
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("thread_opened")
|
||||
def _apply_thread_opened(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO threads "
|
||||
"(thread_id, chat_id, title, summary, status) "
|
||||
"VALUES (?, ?, ?, ?, 'open')",
|
||||
(
|
||||
p["thread_id"],
|
||||
p["chat_id"],
|
||||
p["title"],
|
||||
p.get("summary", ""),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("thread_updated")
|
||||
def _apply_thread_updated(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
# Idempotent: closed threads ignore subsequent updates.
|
||||
conn.execute(
|
||||
"UPDATE threads SET summary = ?, last_referenced_scene_id = ?, "
|
||||
"updated_at = datetime('now') "
|
||||
"WHERE thread_id = ? AND status = 'open'",
|
||||
(
|
||||
p.get("summary", ""),
|
||||
p.get("last_referenced_scene_id"),
|
||||
p["thread_id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("thread_closed")
|
||||
def _apply_thread_closed(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE threads SET status = 'closed', closed_at = ?, "
|
||||
"updated_at = datetime('now') "
|
||||
"WHERE thread_id = ? AND status = 'open'",
|
||||
(p.get("closed_at"), p["thread_id"]),
|
||||
)
|
||||
|
||||
|
||||
def get_thread(conn: Connection, thread_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE thread_id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"thread_id": row[0],
|
||||
"chat_id": row[1],
|
||||
"title": row[2],
|
||||
"summary": row[3],
|
||||
"status": row[4],
|
||||
"opened_at": row[5],
|
||||
"closed_at": row[6],
|
||||
"last_referenced_scene_id": row[7],
|
||||
"created_at": row[8],
|
||||
"updated_at": row[9],
|
||||
}
|
||||
|
||||
|
||||
def list_open_threads(conn: Connection, chat_id: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE chat_id = ? AND status = 'open' "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"thread_id": r[0], "chat_id": r[1], "title": r[2],
|
||||
"summary": r[3], "status": r[4],
|
||||
"opened_at": r[5], "closed_at": r[6],
|
||||
"last_referenced_scene_id": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def list_threads(conn: Connection, chat_id: str, status: str | None = None) -> list[dict]:
|
||||
if status is None:
|
||||
rows = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE chat_id = ? ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE chat_id = ? AND status = ? "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id, status),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"thread_id": r[0], "chat_id": r[1], "title": r[2],
|
||||
"summary": r[3], "status": r[4],
|
||||
"opened_at": r[5], "closed_at": r[6],
|
||||
"last_referenced_scene_id": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -29,6 +29,34 @@ def _apply_chat_created(conn: Connection, e: Event) -> None:
|
||||
)
|
||||
|
||||
|
||||
@on("time_skip_elision")
|
||||
def _apply_time_skip_elision(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET time = ? WHERE chat_id = ?",
|
||||
(p["new_time"], p["chat_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("time_skip_jump")
|
||||
def _apply_time_skip_jump(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
chat_id = p["chat_id"]
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET time = ? WHERE chat_id = ?",
|
||||
(p["new_time"], chat_id),
|
||||
)
|
||||
if p.get("reset_activity", False):
|
||||
# Activity rows are keyed by entity_id with a container_id FK.
|
||||
# Each chat owns its containers, so deleting activity rows whose
|
||||
# container_id belongs to this chat clears every present entity.
|
||||
conn.execute(
|
||||
"DELETE FROM activity "
|
||||
"WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)",
|
||||
(chat_id,),
|
||||
)
|
||||
|
||||
|
||||
@on("guest_added")
|
||||
def _apply_guest_added(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
|
||||
+275
-10
@@ -41,6 +41,121 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<details class="skip-controls">
|
||||
<summary>Elision skip</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/skip/elision"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Landing state hint:
|
||||
<input type="text" name="landing_state_hint"
|
||||
placeholder="e.g. arriving at the office">
|
||||
</label>
|
||||
<label>
|
||||
New time (ISO 8601):
|
||||
<input type="text" name="new_time" required
|
||||
placeholder="2026-04-26T20:30:00+00:00">
|
||||
</label>
|
||||
<button type="submit">Skip ahead</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="skip-controls">
|
||||
<summary>Jump skip</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/skip/jump"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
New time (ISO 8601):
|
||||
<input type="text" name="new_time" required
|
||||
placeholder="2026-04-27T08:00:00+00:00">
|
||||
</label>
|
||||
<label>
|
||||
Anything notable happen? (optional)
|
||||
<textarea name="notable_prose" rows="3"
|
||||
placeholder="leave blank to jump without synthesizing memories"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="reset_activity" value="1">
|
||||
Reset activity at landing
|
||||
</label>
|
||||
<button type="submit">Jump ahead</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Events</h3>
|
||||
{% if active_events %}
|
||||
<ul class="event-list">
|
||||
{% for ev in active_events %}
|
||||
<li class="event-row">
|
||||
<strong>{{ ev.kind }}</strong>
|
||||
<span class="muted"> ({{ ev.status }})</span>
|
||||
{% if ev.planned_for %}
|
||||
<p class="muted">planned for: {{ ev.planned_for }}</p>
|
||||
{% endif %}
|
||||
{% if ev.props %}
|
||||
<p class="muted">{{ ev.props|tojson }}</p>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/event/cancel/{{ ev.event_id }}"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Cancel</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No active events.</p>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary>Plan event</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/event/plan"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Kind:
|
||||
<input type="text" name="kind" required
|
||||
placeholder="e.g. dinner_reservation">
|
||||
</label>
|
||||
<label>
|
||||
Planned for (ISO 8601):
|
||||
<input type="text" name="planned_for" required
|
||||
placeholder="2026-04-26T19:00:00+00:00">
|
||||
</label>
|
||||
<label>
|
||||
Props (JSON):
|
||||
<textarea name="props_json" rows="3"
|
||||
placeholder='{"location": "Bistro X"}'>{}</textarea>
|
||||
</label>
|
||||
<button type="submit">Plan event</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Threads</h3>
|
||||
{% if open_threads %}
|
||||
<ul class="thread-list">
|
||||
{% for th in open_threads %}
|
||||
<li class="thread-row">
|
||||
<strong>{{ th.title }}</strong>
|
||||
{% if th.summary %}
|
||||
<p>{{ th.summary }}</p>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/thread/close/{{ th.thread_id }}"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Close</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No open threads.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if guest_bot %}
|
||||
@@ -100,24 +215,71 @@
|
||||
<section class="drawer-section">
|
||||
<h3>Add guest</h3>
|
||||
{% if available_guests %}
|
||||
<form class="inline-edit"
|
||||
{% set first_guest_id = available_guests[0].id %}
|
||||
{% set first_existing = existing_guest_edges.get(first_guest_id, False) %}
|
||||
<form class="inline-edit add-guest-form"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/guest/add"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Bot:
|
||||
<select name="guest_bot_id" required>
|
||||
<select name="guest_bot_id" required class="add-guest-select">
|
||||
{% for b in available_guests %}
|
||||
<option value="{{ b.id }}">{{ b.name }}</option>
|
||||
<option value="{{ b.id }}"
|
||||
data-existing-edge="{{ 'true' if existing_guest_edges.get(b.id) else 'false' }}">
|
||||
{{ b.name }}{% if existing_guest_edges.get(b.id) %} (already met){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted add-guest-existing-note"
|
||||
{% if not first_existing %}hidden{% endif %}>
|
||||
they already know each other (edge exists from a prior chat)
|
||||
</p>
|
||||
<label class="add-guest-reseed-label"
|
||||
{% if not first_existing %}hidden{% endif %}>
|
||||
<input type="checkbox" name="reseed" value="1" class="add-guest-reseed">
|
||||
re-seed anyway
|
||||
</label>
|
||||
<label>
|
||||
Have they met before? Describe how (leave blank if not):
|
||||
<textarea name="relationship_prose" rows="3"
|
||||
class="add-guest-prose"
|
||||
{% if first_existing %}disabled{% endif %}
|
||||
placeholder="e.g. Old college friends who studied physics together."></textarea>
|
||||
</label>
|
||||
<button type="submit">Add guest</button>
|
||||
</form>
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.currentScript.previousElementSibling;
|
||||
while (form && !form.classList.contains('add-guest-form')) {
|
||||
form = form.previousElementSibling;
|
||||
}
|
||||
if (!form) return;
|
||||
var sel = form.querySelector('.add-guest-select');
|
||||
var prose = form.querySelector('.add-guest-prose');
|
||||
var reseed = form.querySelector('.add-guest-reseed');
|
||||
var note = form.querySelector('.add-guest-existing-note');
|
||||
var reseedLabel = form.querySelector('.add-guest-reseed-label');
|
||||
function refresh() {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
var existing = opt && opt.getAttribute('data-existing-edge') === 'true';
|
||||
if (existing) {
|
||||
note.removeAttribute('hidden');
|
||||
reseedLabel.removeAttribute('hidden');
|
||||
prose.disabled = !reseed.checked;
|
||||
} else {
|
||||
note.setAttribute('hidden', '');
|
||||
reseedLabel.setAttribute('hidden', '');
|
||||
reseed.checked = false;
|
||||
prose.disabled = false;
|
||||
}
|
||||
}
|
||||
sel.addEventListener('change', refresh);
|
||||
reseed.addEventListener('change', refresh);
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<p class="muted">No other bots authored yet.</p>
|
||||
{% endif %}
|
||||
@@ -156,19 +318,95 @@
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% if edge_b2y.summary %}<p class="muted">{{ edge_b2y.summary }}</p>{% endif %}
|
||||
{% if edge_b2y.knowledge %}
|
||||
<details><summary>Knowledge ({{ edge_b2y.knowledge|length }})</summary>
|
||||
<ul>{% for fact in edge_b2y.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/trust"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||
<input type="hidden" name="target_id" value="you">
|
||||
<label>
|
||||
Trust:
|
||||
<input type="range" name="new_value" min="0" max="100"
|
||||
value="{{ edge_b2y.trust }}"
|
||||
oninput="this.nextElementSibling.value = this.value">
|
||||
<output>{{ edge_b2y.trust }}</output>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/summary"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||
<input type="hidden" name="target_id" value="you">
|
||||
<label>
|
||||
Summary:
|
||||
<textarea name="new_summary" rows="3" maxlength="2000">{{ edge_b2y.summary or "" }}</textarea>
|
||||
</label>
|
||||
<button type="submit">Save summary</button>
|
||||
</form>
|
||||
<details>
|
||||
<summary>Knowledge ({{ (edge_b2y.knowledge or [])|length }})</summary>
|
||||
{% if edge_b2y.knowledge %}
|
||||
<ul>
|
||||
{% for fact in edge_b2y.knowledge %}
|
||||
<li>
|
||||
{{ fact }}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/knowledge-facts"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||
<input type="hidden" name="target_id" value="you">
|
||||
<input type="hidden" name="action" value="remove">
|
||||
<input type="hidden" name="fact" value="{{ fact }}">
|
||||
<button type="submit">Remove</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/knowledge-facts"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||
<input type="hidden" name="target_id" value="you">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<label>
|
||||
Add fact:
|
||||
<input type="text" name="fact" maxlength="500" required>
|
||||
</label>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if edge_y2b %}
|
||||
<div class="edge-row">
|
||||
<strong>you → {{ host_bot.name }}</strong>
|
||||
<p>Affinity: {{ edge_y2b.affinity }}/100 · Trust: {{ edge_y2b.trust }}/100</p>
|
||||
{% if edge_y2b.summary %}<p class="muted">{{ edge_y2b.summary }}</p>{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/trust"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="you">
|
||||
<input type="hidden" name="target_id" value="{{ host_bot.id }}">
|
||||
<label>
|
||||
Trust:
|
||||
<input type="range" name="new_value" min="0" max="100"
|
||||
value="{{ edge_y2b.trust }}"
|
||||
oninput="this.nextElementSibling.value = this.value">
|
||||
<output>{{ edge_y2b.trust }}</output>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/summary"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="you">
|
||||
<input type="hidden" name="target_id" value="{{ host_bot.id }}">
|
||||
<label>
|
||||
Summary:
|
||||
<textarea name="new_summary" rows="3" maxlength="2000">{{ edge_y2b.summary or "" }}</textarea>
|
||||
</label>
|
||||
<button type="submit">Save summary</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not edge_b2y and not edge_y2b %}
|
||||
@@ -224,6 +462,33 @@
|
||||
<input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}">
|
||||
<button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button>
|
||||
</form>
|
||||
<div class="witness-row">
|
||||
{% for flag in ['you', 'host', 'guest'] %}
|
||||
{% set witnessed = m['witness_' ~ flag] %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/memory/witness"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="memory_id" value="{{ m.id }}">
|
||||
<input type="hidden" name="flag" value="{{ flag }}">
|
||||
<input type="hidden" name="new_value" value="{{ 0 if witnessed else 1 }}">
|
||||
<label>
|
||||
<input type="checkbox" {% if witnessed %}checked{% endif %}
|
||||
onchange="this.form.requestSubmit()">
|
||||
{{ flag }}
|
||||
</label>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<details>
|
||||
<summary>Edit POV summary</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/memory/pov-summary"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="memory_id" value="{{ m.id }}">
|
||||
<textarea name="new_summary" rows="3" maxlength="2000">{{ m.pov_summary }}</textarea>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
+2
-9
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.eventlog.log import append_event
|
||||
from chat.eventlog.projector import project
|
||||
from chat.state.entities import list_bots
|
||||
@@ -19,15 +19,8 @@ REQUIRED_FIELDS = ("id", "name", "persona", "initial_relationship_to_you", "kick
|
||||
def get_conn(request: Request):
|
||||
settings = request.app.state.settings
|
||||
db_path: Path = settings.db_path
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
try:
|
||||
with open_db(db_path, check_same_thread=False) as conn:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _split_voice_samples(text: str) -> list[str]:
|
||||
|
||||
+571
-20
@@ -1,4 +1,4 @@
|
||||
"""Chat drawer — read view (T24) and inline edits (T25).
|
||||
"""Chat drawer — read view (T24) and inline edits (T25, T72).
|
||||
|
||||
The GET endpoint renders an HTML partial showing the current scene +
|
||||
container, per-entity activity, host <-> you edges, pinned memories with
|
||||
@@ -13,18 +13,22 @@ returning the refreshed drawer partial so HTMX can swap it in:
|
||||
* pin toggle on a memory (emits ``memory_pin_changed`` with
|
||||
``auto_pinned=0`` so a manual pin is not subject to auto-eviction).
|
||||
|
||||
T72 (Phase 2.5) extends the inline-edit set to cover the remaining
|
||||
§6.4 editable fields whose state-layer support already lands in the
|
||||
``manual_edit`` projector: edge trust slider, edge summary textarea,
|
||||
memory POV summary textarea, and per-edge knowledge-fact add/remove. It
|
||||
also exposes a witness-flag toggle (``you/host/guest``) per memory row
|
||||
and a "first-meeting gate" on the Add-guest form so an existing edge
|
||||
isn't quietly overwritten by a re-seed.
|
||||
|
||||
Each ``manual_edit`` payload snapshots the prior value alongside the new
|
||||
one so a later inverse edit can restore state (§6.4 final paragraph).
|
||||
|
||||
Other §6.4 editable fields (activity verb/attention/posture, edge_trust,
|
||||
edge summary, knowledge_facts list, memory pov_summary) are deferred to
|
||||
a Phase 1.5 follow-up — the dispatch in :mod:`chat.state.manual_edit`
|
||||
already accepts more ``target_kind`` values, so adding their routes is a
|
||||
mechanical extension.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
@@ -36,11 +40,18 @@ from chat.services.relationship_seed import seed_inter_bot_edges
|
||||
from chat.services.scene_summarize import apply_scene_close_summary
|
||||
from chat.state.edges import get_edge
|
||||
from chat.state.entities import get_bot, get_you, list_bots
|
||||
from chat.state.events import list_active_events
|
||||
from chat.state.group_node import get_group_node
|
||||
from chat.state.memory import get_pinned
|
||||
from chat.state.threads import list_open_threads
|
||||
from chat.state.world import active_scene, get_activity, get_chat, get_container
|
||||
from chat.web.bots import get_conn
|
||||
from chat.web.kickoff import get_llm_client
|
||||
from chat.web.skip import (
|
||||
_now_iso,
|
||||
process_elision_skip,
|
||||
process_jump_skip,
|
||||
)
|
||||
|
||||
TEMPLATES = Jinja2Templates(
|
||||
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
||||
@@ -55,6 +66,13 @@ PIN_CAP = 8
|
||||
# Recent-memories list is bounded to keep the drawer cheap to render.
|
||||
RECENT_LIMIT = 10
|
||||
|
||||
# T72.1 caps on free-form textarea edits. Edge summaries and per-POV
|
||||
# memory summaries are drawer-driven prose — bound them so a stray paste
|
||||
# can't blow up the projected row size or the SSE drawer refresh payload.
|
||||
EDGE_SUMMARY_MAX = 2000
|
||||
MEMORY_POV_SUMMARY_MAX = 2000
|
||||
KNOWLEDGE_FACT_MAX = 500
|
||||
|
||||
|
||||
@router.get("/chats/{chat_id}/drawer", response_class=HTMLResponse)
|
||||
async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
@@ -104,14 +122,25 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
available_guests = [
|
||||
b for b in list_bots(conn) if b["id"] != chat["host_bot_id"]
|
||||
]
|
||||
# T72.2 first-meeting gate: pre-compute whether a host->candidate edge
|
||||
# already exists. Template renders the prose textarea disabled and the
|
||||
# POST handler skips ``seed_inter_bot_edges`` (preserving the existing
|
||||
# edge content) unless the user explicitly toggles "re-seed anyway".
|
||||
existing_guest_edges = {
|
||||
b["id"]: get_edge(conn, chat["host_bot_id"], b["id"]) is not None
|
||||
for b in available_guests
|
||||
}
|
||||
group_node = get_group_node(conn, chat_id)
|
||||
|
||||
# Recent memories from host's POV (witness_host = 1), most recent first.
|
||||
# Raw query keeps this read self-contained — no projector helper exposes
|
||||
# "latest N for an owner" yet and the drawer is the only consumer.
|
||||
# "latest N for an owner" yet and the drawer is the only consumer. The
|
||||
# three witness flags ride along so T72.3's per-row checkboxes can
|
||||
# render the current state without a second query per memory.
|
||||
recent_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, pov_summary, significance, pinned, created_at
|
||||
SELECT id, pov_summary, significance, pinned, created_at,
|
||||
witness_you, witness_host, witness_guest
|
||||
FROM memories
|
||||
WHERE owner_id = ? AND witness_host = 1
|
||||
ORDER BY id DESC
|
||||
@@ -126,12 +155,19 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
"significance": r[2],
|
||||
"pinned": r[3],
|
||||
"created_at": r[4],
|
||||
"witness_you": r[5],
|
||||
"witness_host": r[6],
|
||||
"witness_guest": r[7],
|
||||
}
|
||||
for r in recent_rows
|
||||
]
|
||||
|
||||
pinned = get_pinned(conn, chat["host_bot_id"])
|
||||
|
||||
# T59: active events + open threads for the new drawer sections.
|
||||
active_events = list_active_events(conn, chat_id)
|
||||
open_threads = list_open_threads(conn, chat_id)
|
||||
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"_drawer.html",
|
||||
@@ -152,10 +188,13 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
"edge_y2g": edge_y2g,
|
||||
"edge_g2y": edge_g2y,
|
||||
"available_guests": available_guests,
|
||||
"existing_guest_edges": existing_guest_edges,
|
||||
"group_node": group_node,
|
||||
"recent_memories": recent_memories,
|
||||
"pinned": pinned,
|
||||
"pin_cap": PIN_CAP,
|
||||
"active_events": active_events,
|
||||
"open_threads": open_threads,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -342,6 +381,281 @@ async def toggle_memory_pin(
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
# --- T72.1 deferred v1 drawer edits --------------------------------------
|
||||
#
|
||||
# These four endpoints round out the §6.4 editable surface — the
|
||||
# ``manual_edit`` projector already dispatches ``edge_trust``,
|
||||
# ``edge_summary``, and ``memory_pov_summary`` (T25); ``edge_knowledge_fact``
|
||||
# is a new dispatch branch added alongside this commit. Each route follows
|
||||
# the T25 pattern: snapshot the prior value, append + apply ``manual_edit``,
|
||||
# then re-render the drawer partial.
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/edge/trust",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_edge_trust(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
source_id: str = Form(...),
|
||||
target_id: str = Form(...),
|
||||
new_value: int = Form(...),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
if not 0 <= int(new_value) <= 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"trust must be in [0, 100], got {new_value}",
|
||||
)
|
||||
|
||||
edge = get_edge(conn, source_id, target_id)
|
||||
if edge is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"edge not found: {source_id}->{target_id}",
|
||||
)
|
||||
|
||||
prior = int(edge["trust"])
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "edge_trust",
|
||||
"target_id": {"source_id": source_id, "target_id": target_id},
|
||||
"prior_value": prior,
|
||||
"new_value": int(new_value),
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/edge/summary",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_edge_summary(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
source_id: str = Form(...),
|
||||
target_id: str = Form(...),
|
||||
new_summary: str = Form(...),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
if len(new_summary) > EDGE_SUMMARY_MAX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"edge summary exceeds {EDGE_SUMMARY_MAX} chars "
|
||||
f"(got {len(new_summary)})"
|
||||
),
|
||||
)
|
||||
|
||||
edge = get_edge(conn, source_id, target_id)
|
||||
if edge is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"edge not found: {source_id}->{target_id}",
|
||||
)
|
||||
|
||||
prior = edge.get("summary") or ""
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "edge_summary",
|
||||
"target_id": {"source_id": source_id, "target_id": target_id},
|
||||
"prior_value": prior,
|
||||
"new_value": new_summary,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/memory/pov-summary",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_memory_pov_summary(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
memory_id: int = Form(...),
|
||||
new_summary: str = Form(...),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
if len(new_summary) > MEMORY_POV_SUMMARY_MAX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"memory pov_summary exceeds {MEMORY_POV_SUMMARY_MAX} chars "
|
||||
f"(got {len(new_summary)})"
|
||||
),
|
||||
)
|
||||
|
||||
# 404 when the memory either doesn't exist or belongs to a different
|
||||
# chat — the drawer never surfaces cross-chat memories so editing one
|
||||
# would be a path-traversal-style mistake.
|
||||
row = conn.execute(
|
||||
"SELECT pov_summary FROM memories WHERE id = ? AND chat_id = ?",
|
||||
(int(memory_id), chat_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"memory not found in chat: {memory_id}",
|
||||
)
|
||||
|
||||
prior = row[0] or ""
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "memory_pov_summary",
|
||||
"target_id": int(memory_id),
|
||||
"prior_value": prior,
|
||||
"new_value": new_summary,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/edge/knowledge-facts",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_edge_knowledge_facts(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
source_id: str = Form(...),
|
||||
target_id: str = Form(...),
|
||||
action: str = Form(...),
|
||||
fact: str = Form(...),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Add or remove a single knowledge_fact on an edge.
|
||||
|
||||
Remove semantics are by string match (first occurrence) — the drawer
|
||||
re-renders after every edit so threading a stable index through is
|
||||
fragile when concurrent ``edge_update`` events can append more facts
|
||||
between renders. The projector is a no-op when the fact isn't found,
|
||||
keeping the route idempotent for stale form submissions.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
if action not in ("add", "remove"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"action must be 'add' or 'remove', got {action!r}",
|
||||
)
|
||||
|
||||
if len(fact) > KNOWLEDGE_FACT_MAX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"fact exceeds {KNOWLEDGE_FACT_MAX} chars (got {len(fact)})"
|
||||
),
|
||||
)
|
||||
if not fact.strip():
|
||||
raise HTTPException(status_code=400, detail="fact must not be empty")
|
||||
|
||||
edge = get_edge(conn, source_id, target_id)
|
||||
if edge is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"edge not found: {source_id}->{target_id}",
|
||||
)
|
||||
|
||||
prior = list(edge.get("knowledge") or [])
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "edge_knowledge_fact",
|
||||
"target_id": {"source_id": source_id, "target_id": target_id},
|
||||
"prior_value": prior,
|
||||
"new_value": {"action": action, "fact": fact},
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
# --- T72.3 witness flag inline-edit --------------------------------------
|
||||
#
|
||||
# Witness flags decide which entities can recall a memory (§7 retrieval).
|
||||
# Editing them is rare but high-impact — flipping ``witness_guest`` from 0
|
||||
# to 1 makes the memory available to the guest's prompt context. The route
|
||||
# follows the T25 / T72.1 pattern: snapshot prior, append + apply
|
||||
# ``manual_edit`` with a ``{flag, value}`` payload, refresh the partial.
|
||||
|
||||
|
||||
_VALID_WITNESS_FLAGS = ("you", "host", "guest")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/memory/witness",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def edit_memory_witness(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
memory_id: int = Form(...),
|
||||
flag: str = Form(...),
|
||||
new_value: int = Form(...),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
if flag not in _VALID_WITNESS_FLAGS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"flag must be one of {list(_VALID_WITNESS_FLAGS)}, "
|
||||
f"got {flag!r}"
|
||||
),
|
||||
)
|
||||
|
||||
row = conn.execute(
|
||||
f"SELECT witness_{flag} FROM memories "
|
||||
"WHERE id = ? AND chat_id = ?",
|
||||
(int(memory_id), chat_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"memory not found in chat: {memory_id}",
|
||||
)
|
||||
|
||||
prior_int = int(row[0])
|
||||
new_int = 1 if int(new_value) else 0
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "memory_witness",
|
||||
"target_id": int(memory_id),
|
||||
"prior_value": {"flag": flag, "value": prior_int},
|
||||
"new_value": {"flag": flag, "value": new_int},
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
# --- T42 guest add/remove -------------------------------------------------
|
||||
#
|
||||
# Adding a guest fans out into up to four events: a ``guest_added`` to flip
|
||||
@@ -381,6 +695,7 @@ async def add_guest(
|
||||
request: Request,
|
||||
guest_bot_id: str = Form(...),
|
||||
relationship_prose: str = Form(""),
|
||||
reseed: str = Form(""),
|
||||
conn=Depends(get_conn),
|
||||
client=Depends(get_llm_client),
|
||||
):
|
||||
@@ -412,17 +727,32 @@ async def add_guest(
|
||||
detail=f"host bot not found: {chat['host_bot_id']}",
|
||||
)
|
||||
|
||||
settings = request.app.state.settings
|
||||
seed = await seed_inter_bot_edges(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
bot_a_id=chat["host_bot_id"],
|
||||
bot_a_name=host_bot["name"],
|
||||
bot_b_id=guest_bot_id,
|
||||
bot_b_name=guest_bot["name"],
|
||||
relationship_prose=relationship_prose,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
# T72.2 first-meeting gate: when an edge already exists from a prior
|
||||
# chat, the textarea is rendered disabled. Submission without the
|
||||
# explicit "re-seed anyway" toggle skips ``seed_inter_bot_edges``
|
||||
# entirely so the existing edge content (affinity, trust, knowledge,
|
||||
# summaries) survives. ``guest_added`` and ``group_node_initialized``
|
||||
# still fire so the chat picks up the new participant.
|
||||
existing_edge = (
|
||||
get_edge(conn, chat["host_bot_id"], guest_bot_id) is not None
|
||||
)
|
||||
reseed_requested = reseed.lower() in ("1", "true", "on", "yes")
|
||||
skip_seed = existing_edge and not reseed_requested
|
||||
|
||||
settings = request.app.state.settings
|
||||
if skip_seed:
|
||||
seed = None
|
||||
else:
|
||||
seed = await seed_inter_bot_edges(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
bot_a_id=chat["host_bot_id"],
|
||||
bot_a_name=host_bot["name"],
|
||||
bot_b_id=guest_bot_id,
|
||||
bot_b_name=guest_bot["name"],
|
||||
relationship_prose=relationship_prose,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
|
||||
append_and_apply(
|
||||
conn,
|
||||
@@ -437,7 +767,7 @@ async def add_guest(
|
||||
# per-direction summary is set via the per-pov scene-close path
|
||||
# (T27), not direct edge_update. We therefore drop seed.*_summary
|
||||
# here; the deltas + knowledge_facts are what materializes.
|
||||
if not _seed_is_default(seed):
|
||||
if seed is not None and not _seed_is_default(seed):
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
@@ -524,3 +854,224 @@ async def remove_guest(
|
||||
)
|
||||
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
# --- T59 events / threads / skip controls --------------------------------
|
||||
#
|
||||
# Five drawer-driven endpoints that emit Phase 3 event-log entries:
|
||||
#
|
||||
# * ``event_planned`` / ``event_cancelled`` for the events panel — props
|
||||
# arrive as a JSON-encoded form field so the user can author arbitrary
|
||||
# structured side-info without a custom HTMX widget per kind.
|
||||
# * ``time_skip_elision`` / ``time_skip_jump`` for the skip panel —
|
||||
# each emits the projector event AND an ``assistant_turn`` carrying the
|
||||
# narration prose from :mod:`chat.services.skip_narration`. Jump skips
|
||||
# ALSO write per-bot synthesized memories from any user-supplied
|
||||
# ``notable_prose`` via :func:`synthesize_memories` +
|
||||
# :func:`record_turn_memory_for_present`.
|
||||
# * ``thread_closed`` for the threads panel.
|
||||
#
|
||||
# Skip narration is appended via plain ``append_event`` (assistant_turn
|
||||
# has no projector handler — it's a transcript-only kind, see
|
||||
# :func:`chat.web.turns._read_recent_dialogue`). The user will see the
|
||||
# new turn on the next chat-detail page load; we do NOT broadcast via
|
||||
# ``publish`` here because the SSE channel is scoped to the chat-detail
|
||||
# page and the drawer partial is the response body — adding cross-cutting
|
||||
# SSE here would require dragging the publish import + chat-channel state
|
||||
# into the drawer module without a meaningful UX gain (the drawer only
|
||||
# rerenders itself on these submissions).
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/event/plan",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def plan_event(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
kind: str = Form(...),
|
||||
planned_for: str = Form(...),
|
||||
props_json: str = Form("{}"),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Append an ``event_planned`` row from the drawer's "Plan event" form.
|
||||
|
||||
``props_json`` is parsed into a dict before being attached to the
|
||||
payload so the projector can treat it as structured data. Bad JSON
|
||||
yields ``400`` — the form template renders an inline error in that
|
||||
case so the user can fix-and-resubmit without losing their input.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
try:
|
||||
props = json.loads(props_json) if props_json.strip() else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"props_json must be valid JSON: {exc}"
|
||||
)
|
||||
if not isinstance(props, dict):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="props_json must encode a JSON object"
|
||||
)
|
||||
|
||||
event_id = f"evt_{uuid.uuid4().hex[:12]}"
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": event_id,
|
||||
"chat_id": chat_id,
|
||||
"kind": kind,
|
||||
"props": props,
|
||||
"planned_for": planned_for,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/event/cancel/{event_id}",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def cancel_event(
|
||||
chat_id: str,
|
||||
event_id: str,
|
||||
request: Request,
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Append an ``event_cancelled`` row for ``event_id``.
|
||||
|
||||
``completed_at`` is sourced from the chat clock (so cancellations
|
||||
timeline-align with the rest of the fiction) with a UTC-now fallback
|
||||
when the clock isn't set. The projector is idempotent on terminal
|
||||
statuses so a stale double-submit is harmless.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
completed_at = chat.get("time") or _now_iso()
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": event_id,
|
||||
"completed_at": completed_at,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/skip/elision",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def skip_elision(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
landing_state_hint: str = Form(""),
|
||||
new_time: str = Form(...),
|
||||
conn=Depends(get_conn),
|
||||
client=Depends(get_llm_client),
|
||||
):
|
||||
"""Elision skip: collapse in-progress activity into its end-state.
|
||||
|
||||
Thin HTTP wrapper around :func:`chat.web.skip.process_elision_skip`
|
||||
(T62 extracted the controller). Validation failures surface as
|
||||
``400`` and the route still returns the refreshed drawer partial on
|
||||
success so HTMX swaps in the new chat clock.
|
||||
"""
|
||||
settings = request.app.state.settings
|
||||
try:
|
||||
await process_elision_skip(
|
||||
conn,
|
||||
client,
|
||||
settings,
|
||||
chat_id=chat_id,
|
||||
new_time=new_time,
|
||||
landing_state_hint=landing_state_hint,
|
||||
)
|
||||
except ValueError as exc:
|
||||
# ``process_elision_skip`` raises on missing-chat or malformed /
|
||||
# backwards new_time. The drawer used to 404 / 400 these
|
||||
# separately — preserve the 404-vs-400 split by sniffing the
|
||||
# error message so existing tests keep passing without changes.
|
||||
if str(exc).startswith("chat not found"):
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/skip/jump",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def skip_jump(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
new_time: str = Form(...),
|
||||
notable_prose: str = Form(""),
|
||||
reset_activity: str = Form(""),
|
||||
conn=Depends(get_conn),
|
||||
client=Depends(get_llm_client),
|
||||
):
|
||||
"""Jump skip: bridge a longer fiction-time delta.
|
||||
|
||||
Thin HTTP wrapper around :func:`chat.web.skip.process_jump_skip`
|
||||
(T62 extracted the controller). ``reset_activity`` is parsed
|
||||
permissively here ("1" / "true" / "on" / "yes" — same shape as the
|
||||
add-guest reseed flag) since HTML checkboxes typically post the
|
||||
literal "1" or omit the field entirely.
|
||||
"""
|
||||
reset_flag = reset_activity.lower() in ("1", "true", "on", "yes")
|
||||
settings = request.app.state.settings
|
||||
try:
|
||||
await process_jump_skip(
|
||||
conn,
|
||||
client,
|
||||
settings,
|
||||
chat_id=chat_id,
|
||||
new_time=new_time,
|
||||
notable_prose=notable_prose,
|
||||
reset_activity=reset_flag,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc).startswith("chat not found"):
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/thread/close/{thread_id}",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def close_thread(
|
||||
chat_id: str,
|
||||
thread_id: str,
|
||||
request: Request,
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Append a ``thread_closed`` row for ``thread_id``.
|
||||
|
||||
Mirrors :func:`cancel_event` — chat-clock-or-now timestamp, projector
|
||||
handles idempotency. The drawer's open-threads list is sourced from
|
||||
``list_open_threads`` which filters by ``status='open'`` so a stale
|
||||
double-submit is a no-op visually.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
closed_at = chat.get("time") or _now_iso()
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_closed",
|
||||
payload={
|
||||
"thread_id": thread_id,
|
||||
"closed_at": closed_at,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Meanwhile-mode turn controller (T64).
|
||||
|
||||
A meanwhile scene is a private 2-bot scene running alongside an active
|
||||
you-scene (its parent). The user manually advances it by POSTing to the
|
||||
existing ``/chats/<id>/turns`` endpoint; the route detects an active
|
||||
meanwhile scene at the start of ``post_turn`` and dispatches here.
|
||||
|
||||
Unlike the normal turn flow, "you" is NOT a witness to the scene. The
|
||||
controller mirrors ``post_turn`` shape but with:
|
||||
|
||||
- Speaker alternation derived from the latest meanwhile ``assistant_turn``
|
||||
scoped to this scene_id (host first, then alternating).
|
||||
- Prompt assembly with ``present_set_kind="host_guest"`` so the prompt
|
||||
builder drops the "you" activity bullet and any speaker -> "you" edge.
|
||||
- Memory writes via ``record_meanwhile_memory`` — both bots get rows
|
||||
with witness flags ``[you=0, host=1, guest=1]``.
|
||||
- State updates over exactly 2 directed pairs (host <-> guest); no
|
||||
you-related pairs fire.
|
||||
- The ``assistant_turn`` payload carries ``meanwhile_scene_id`` and
|
||||
``present_set_kind="host_guest"`` so downstream filters (alternation
|
||||
lookup, drawer rendering, scene-close detection) can scope to the
|
||||
meanwhile slice without conflating it with the parent you-scene's
|
||||
history.
|
||||
|
||||
Scene-close detection for meanwhile scenes is not auto-fired here —
|
||||
T65 covers the close + digest pipeline. The controller's job ends
|
||||
after the post-turn classifier passes land.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.llm.client import LLMClient
|
||||
from chat.services.memory_write import record_meanwhile_memory
|
||||
from chat.services.multi_state_update import compute_state_updates_for_present
|
||||
from chat.services.prompt import assemble_narrative_prompt
|
||||
from chat.services.turn_parse import parse_turn
|
||||
from chat.state.edges import get_edge
|
||||
from chat.state.entities import get_bot
|
||||
from chat.state.meanwhile import list_meanwhile_scenes
|
||||
from chat.state.world import get_chat
|
||||
from chat.web.pubsub import publish
|
||||
from chat.web.render import render_turn_html as _render_turn_html
|
||||
|
||||
|
||||
def _strip_ooc_for_prompt(parsed) -> str:
|
||||
"""Mirror of the helper in turns.py — concatenate non-OOC segments."""
|
||||
keep = [s.text for s in parsed.segments if s.kind != "ooc"]
|
||||
return " ".join(keep).strip()
|
||||
|
||||
|
||||
def _read_recent_meanwhile_dialogue(
|
||||
conn, chat_id: str, scene_id: int, limit: int = 50
|
||||
) -> list[dict]:
|
||||
"""Return the meanwhile scene's prior turns shaped as
|
||||
``{"speaker": <id>, "text": <prose>}``.
|
||||
|
||||
Pulls ``user_turn`` rows for the chat (the user-side prose driving
|
||||
this meanwhile scene rides through the same chat) plus only those
|
||||
``assistant_turn`` rows whose ``meanwhile_scene_id`` matches the
|
||||
given scene id. Other meanwhile scenes on the same chat — and the
|
||||
parent you-scene's assistant_turns — are excluded so the prompt
|
||||
context stays scoped to the private beat.
|
||||
|
||||
Filters chat_id (and meanwhile_scene_id for assistant_turn) via
|
||||
``json_extract`` in SQL so SQLite stops at the first ``limit`` rows
|
||||
that already match — avoids an unbounded scan as ``event_log``
|
||||
grows. The user-side rows match on chat_id only since they aren't
|
||||
tagged with a scene id (they ride the chat-wide log).
|
||||
"""
|
||||
cur = conn.execute(
|
||||
"SELECT id, kind, payload_json FROM event_log "
|
||||
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
|
||||
" AND superseded_by IS NULL AND hidden = 0 "
|
||||
" AND json_extract(payload_json, '$.chat_id') = ? "
|
||||
" AND ("
|
||||
" kind IN ('user_turn', 'user_turn_edit') "
|
||||
" OR json_extract(payload_json, '$.meanwhile_scene_id') = ?"
|
||||
" ) "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(chat_id, scene_id, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
rows.reverse()
|
||||
out: list[dict] = []
|
||||
for _row_id, kind, payload_json in rows:
|
||||
p = json.loads(payload_json)
|
||||
if kind in ("user_turn", "user_turn_edit"):
|
||||
out.append({"speaker": "you", "text": p.get("prose", "")})
|
||||
else:
|
||||
out.append(
|
||||
{
|
||||
"speaker": p.get("speaker_id", "bot"),
|
||||
"text": p.get("text", ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _last_meanwhile_speaker(conn, chat_id: str, scene_id: int) -> str | None:
|
||||
"""Return the speaker_id of the latest assistant_turn linked to
|
||||
``scene_id`` for ``chat_id``, or ``None`` if no prior turn exists.
|
||||
|
||||
Used to alternate the speaker across consecutive meanwhile turns —
|
||||
the OTHER bot speaks next. Pushes both filters into SQL via
|
||||
``json_extract`` and bounds with ``LIMIT 1`` so SQLite stops at the
|
||||
first match instead of scanning the whole assistant_turn slice.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT json_extract(payload_json, '$.speaker_id') AS speaker "
|
||||
"FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' "
|
||||
" AND superseded_by IS NULL AND hidden = 0 "
|
||||
" AND json_extract(payload_json, '$.chat_id') = ? "
|
||||
" AND json_extract(payload_json, '$.meanwhile_scene_id') = ? "
|
||||
"ORDER BY id DESC "
|
||||
"LIMIT 1",
|
||||
(chat_id, scene_id),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def process_meanwhile_turn(
|
||||
conn,
|
||||
client: LLMClient,
|
||||
settings: Settings,
|
||||
*,
|
||||
chat_id: str,
|
||||
prose: str,
|
||||
) -> dict:
|
||||
"""Run one meanwhile turn end-to-end.
|
||||
|
||||
Returns a small dict shape ``{"assistant_text": ..., "speaker_id":
|
||||
..., "scene_id": ..., "user_turn_id": ..., "assistant_event_id":
|
||||
...}`` so callers can introspect the produced beat (HTTP route maps
|
||||
to a ``204``; future SSE rebroadcast may use the dict directly).
|
||||
|
||||
Raises ``ValueError`` when there is no active meanwhile scene on
|
||||
``chat_id`` — the caller (turns.py) only dispatches here after a
|
||||
positive ``list_meanwhile_scenes`` lookup, but the defensive raise
|
||||
keeps the controller usable in isolation.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise ValueError(f"chat not found: {chat_id}")
|
||||
|
||||
scenes = list_meanwhile_scenes(conn, chat_id, status="active")
|
||||
if not scenes:
|
||||
raise ValueError(f"no active meanwhile scene on chat: {chat_id}")
|
||||
scene = scenes[0]
|
||||
scene_id = scene["id"]
|
||||
|
||||
host_bot_id = chat["host_bot_id"]
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
if guest_bot_id is None:
|
||||
# A meanwhile scene without a guest is a schema violation —
|
||||
# the projector requires both ids on meanwhile_scene_started.
|
||||
raise ValueError(
|
||||
f"meanwhile scene {scene_id} on chat {chat_id} lacks a guest"
|
||||
)
|
||||
|
||||
host_bot = get_bot(conn, host_bot_id)
|
||||
guest_bot = get_bot(conn, guest_bot_id)
|
||||
if host_bot is None or guest_bot is None:
|
||||
raise ValueError(
|
||||
f"meanwhile bots missing: host={host_bot_id} guest={guest_bot_id}"
|
||||
)
|
||||
|
||||
# 1. Parse the user prose with the classifier — same shape as the
|
||||
# normal turn flow so OOC-stripping, segment-typing, etc. all work.
|
||||
parsed = await parse_turn(
|
||||
client, model=settings.classifier_model, prose=prose
|
||||
)
|
||||
prompt_prose = _strip_ooc_for_prompt(parsed)
|
||||
|
||||
# 2. Append user_turn — kept on the chat-wide log so the user can
|
||||
# see their own prose in the timeline. Tagged with the meanwhile
|
||||
# scene_id so future renderers can group it with the right scene.
|
||||
user_turn_event_id = append_event(
|
||||
conn,
|
||||
kind="user_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"prose": prose,
|
||||
"segments": [s.model_dump() for s in parsed.segments],
|
||||
"meanwhile_scene_id": scene_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 3. Alternate the speaker. First turn -> host speaks; each
|
||||
# subsequent turn -> the OTHER bot from the previous beat. Lookup
|
||||
# is scoped by ``meanwhile_scene_id`` so unrelated assistant_turns
|
||||
# on the same chat don't perturb the alternation.
|
||||
last_speaker = _last_meanwhile_speaker(conn, chat_id, scene_id)
|
||||
if last_speaker is None or last_speaker == guest_bot_id:
|
||||
speaker_bot = host_bot
|
||||
addressee_bot = guest_bot
|
||||
else:
|
||||
speaker_bot = guest_bot
|
||||
addressee_bot = host_bot
|
||||
|
||||
# 4. Placeholder marker so SSE observers see "in flight". No
|
||||
# projector handler is registered for this kind — it's transcript-
|
||||
# only, same as the normal turn flow.
|
||||
append_event(
|
||||
conn,
|
||||
kind="assistant_turn_started",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": speaker_bot["id"],
|
||||
"user_turn_id": user_turn_event_id,
|
||||
"meanwhile_scene_id": scene_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 5. Build the narrative prompt. ``present_set_kind="host_guest"``
|
||||
# tells the assembler to drop the "you" activity bullet and any
|
||||
# speaker -> "you" edge — both irrelevant inside a private beat.
|
||||
# Addressee is the OTHER bot, not "you".
|
||||
recent_dialogue = _read_recent_meanwhile_dialogue(conn, chat_id, scene_id)
|
||||
if recent_dialogue and recent_dialogue[-1].get("speaker") == "you":
|
||||
recent_dialogue = recent_dialogue[:-1]
|
||||
messages = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
speaker_bot_id=speaker_bot["id"],
|
||||
addressee=addressee_bot["id"],
|
||||
user_turn_prose=prompt_prose if prompt_prose else None,
|
||||
recent_dialogue=recent_dialogue,
|
||||
budget_soft=settings.narrative_budget_soft,
|
||||
budget_hard=settings.narrative_budget_hard,
|
||||
guest_id=guest_bot_id,
|
||||
present_set_kind="host_guest",
|
||||
)
|
||||
|
||||
# 6. Stream + accumulate. Same SSE pattern as the normal flow —
|
||||
# tokens publish under the speaker's id so the UI can label the
|
||||
# right bubble. Register the streaming task in the chat-keyed
|
||||
# in-flight registry so POST /chats/<id>/turns/cancel can call
|
||||
# ``.cancel()`` on it; without this, the Stop button is a no-op for
|
||||
# meanwhile beats. We import the underscore name from turns.py
|
||||
# deliberately — it's the same single-process registry the cancel
|
||||
# route reads, and exposing it via a public alias would require
|
||||
# touching every existing call site for no behavioural gain.
|
||||
from chat.web.turns import _in_flight_tasks # noqa: PLC0415
|
||||
|
||||
accumulated: list[str] = []
|
||||
truncated = False
|
||||
cancelled = False
|
||||
|
||||
async def _stream() -> None:
|
||||
async for chunk in client.stream(
|
||||
messages,
|
||||
model=settings.narrative_model,
|
||||
max_tokens=settings.narrative_max_tokens,
|
||||
temperature=settings.narrative_temperature,
|
||||
):
|
||||
accumulated.append(chunk)
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "token",
|
||||
"text": chunk,
|
||||
"speaker_id": speaker_bot["id"],
|
||||
},
|
||||
)
|
||||
|
||||
stream_task = asyncio.create_task(_stream())
|
||||
_in_flight_tasks[chat_id] = stream_task
|
||||
try:
|
||||
await stream_task
|
||||
except asyncio.CancelledError:
|
||||
truncated = True
|
||||
cancelled = True
|
||||
except Exception:
|
||||
truncated = True
|
||||
finally:
|
||||
# Always unregister so a subsequent turn can register a fresh
|
||||
# task. Mirrors the cleanup in turns.py::post_turn.
|
||||
_in_flight_tasks.pop(chat_id, None)
|
||||
|
||||
text = "".join(accumulated)
|
||||
|
||||
# 7. Append assistant_turn — tagged with meanwhile_scene_id so the
|
||||
# next turn's alternation lookup can find it, and present_set_kind
|
||||
# so downstream renderers / digesters can filter scope.
|
||||
assistant_event_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": speaker_bot["id"],
|
||||
"text": text,
|
||||
"truncated": truncated,
|
||||
"user_turn_id": user_turn_event_id,
|
||||
"meanwhile_scene_id": scene_id,
|
||||
"present_set_kind": "host_guest",
|
||||
},
|
||||
)
|
||||
|
||||
# 8. Per-turn memory writes — both bots get a row with witness flags
|
||||
# [you=0, host=1, guest=1]. Skipped on cancellation so we don't
|
||||
# record memory for a partial beat the user never read.
|
||||
if not cancelled and text.strip():
|
||||
record_meanwhile_memory(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=host_bot_id,
|
||||
guest_bot_id=guest_bot_id,
|
||||
narrative_text=text,
|
||||
scene_id=scene_id,
|
||||
chat_clock_at=chat.get("time"),
|
||||
)
|
||||
|
||||
# 9. Post-turn state-update — exactly 2 directed pairs over the
|
||||
# bot pair. No you-related pairs fire (you isn't present).
|
||||
present_ids = [host_bot_id, guest_bot_id]
|
||||
present_names = {
|
||||
host_bot_id: host_bot["name"],
|
||||
guest_bot_id: guest_bot["name"],
|
||||
}
|
||||
personas = {
|
||||
host_bot_id: host_bot.get("persona") or "",
|
||||
guest_bot_id: guest_bot.get("persona") or "",
|
||||
}
|
||||
prior_edges: dict[tuple[str, str], dict] = {}
|
||||
for src in present_ids:
|
||||
for tgt in present_ids:
|
||||
if src == tgt:
|
||||
continue
|
||||
edge = get_edge(conn, src, tgt) or {
|
||||
"affinity": 50,
|
||||
"trust": 50,
|
||||
"summary": "",
|
||||
}
|
||||
prior_edges[(src, tgt)] = edge
|
||||
|
||||
state_updates = await compute_state_updates_for_present(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
present_ids=present_ids,
|
||||
present_names=present_names,
|
||||
personas=personas,
|
||||
prior_edges=prior_edges,
|
||||
recent_dialogue=recent_dialogue,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
last_at = chat.get("time")
|
||||
for src_id, tgt_id, update in state_updates:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": src_id,
|
||||
"target_id": tgt_id,
|
||||
"chat_id": chat_id,
|
||||
"affinity_delta": update.affinity_delta,
|
||||
"trust_delta": update.trust_delta,
|
||||
"knowledge_facts": update.knowledge_facts,
|
||||
"last_interaction_at": last_at,
|
||||
"last_interaction_chat_id": chat_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 10. SSE broadcast for the timeline UI — completion event + an HTML
|
||||
# fragment for the HTMX SSE swap. Same pattern as the normal turn
|
||||
# flow so the rendered transcript shows the meanwhile beat inline.
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "assistant_turn_complete",
|
||||
"speaker_id": speaker_bot["id"],
|
||||
"text": text,
|
||||
"truncated": truncated,
|
||||
},
|
||||
)
|
||||
turn_html = _render_turn_html(speaker_bot["name"], text, role="bot")
|
||||
await publish(chat_id, {"event": "turn_html", "data": turn_html})
|
||||
|
||||
if cancelled:
|
||||
# Re-raise after the partial-turn has been recorded so callers
|
||||
# see the cancel propagate (mirrors normal turn flow).
|
||||
raise asyncio.CancelledError
|
||||
|
||||
return {
|
||||
"assistant_text": text,
|
||||
"speaker_id": speaker_bot["id"],
|
||||
"scene_id": scene_id,
|
||||
"user_turn_id": user_turn_event_id,
|
||||
"assistant_event_id": assistant_event_id,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["process_meanwhile_turn"]
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Shared skip-flow controllers (T62).
|
||||
|
||||
Both the drawer skip routes (T59) and the natural-language skip parse
|
||||
(T62) call into these controllers. Keep the controllers free of HTTP
|
||||
concerns — they take ``conn`` + ``client`` + ``settings`` and structured
|
||||
args, append events, and return a small result dict the caller can map
|
||||
to whatever response shape it owes (drawer partial, 204, 422, etc.).
|
||||
|
||||
``ValueError`` is the controller-level signal for caller-mappable
|
||||
validation failure (bad ISO timestamp, backwards skip). The drawer
|
||||
routes translate it to ``HTTP 400``; the natural-language path either
|
||||
swallows it (the parser handed us a degenerate hint) or surfaces it the
|
||||
same way. Anything else (LLM failure, unexpected exception) propagates
|
||||
uncaught — :func:`narrate_skip` already has its own deterministic
|
||||
fallback for the routine LLM-down case, so a real exception here means
|
||||
something we want to see.
|
||||
|
||||
The two controllers mirror the drawer T59 logic closely so the v1
|
||||
guarantees (``time_skip_*`` lands first → memory writes ride the
|
||||
post-skip clock → narration ``assistant_turn`` is appended last) hold
|
||||
identically across the two entry points.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.llm.client import LLMClient
|
||||
from chat.services.memory_write import record_turn_memory_for_present
|
||||
from chat.services.skip_narration import narrate_skip
|
||||
from chat.services.synthesized_memories import synthesize_memories
|
||||
from chat.state.entities import get_bot, get_you
|
||||
from chat.state.world import get_activity, get_chat
|
||||
|
||||
|
||||
def _parse_iso_time(value: str) -> datetime | None:
|
||||
"""Permissive ISO 8601 parser shared with the drawer routes (T59).
|
||||
|
||||
``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until
|
||||
Python 3.11; we normalize it to ``+00:00`` so older interpreters
|
||||
parse the same set of strings the drawer accepts.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
v = value.strip()
|
||||
if v.endswith("Z"):
|
||||
v = v[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _validate_new_time(chat: dict, new_time: str) -> None:
|
||||
"""Raise ``ValueError`` if ``new_time`` is unparseable or backwards.
|
||||
|
||||
The drawer route maps the raised error to ``HTTP 400``; the
|
||||
natural-language path may also surface it as a ``400``. Centralizing
|
||||
the rule here means both entry points enforce the same invariant
|
||||
(no causality-corrupting backwards jumps).
|
||||
"""
|
||||
new_dt = _parse_iso_time(new_time)
|
||||
if new_dt is None:
|
||||
raise ValueError(f"new_time must be ISO 8601, got {new_time!r}")
|
||||
cur_dt = _parse_iso_time(chat.get("time") or "")
|
||||
if cur_dt is not None and new_dt < cur_dt:
|
||||
raise ValueError(
|
||||
"new_time must not be earlier than the current chat clock"
|
||||
)
|
||||
|
||||
|
||||
async def process_elision_skip(
|
||||
conn: Connection,
|
||||
client: LLMClient,
|
||||
settings: Settings,
|
||||
*,
|
||||
chat_id: str,
|
||||
new_time: str,
|
||||
landing_state_hint: str = "",
|
||||
) -> dict:
|
||||
"""Run an elision skip end-to-end.
|
||||
|
||||
Validates ``new_time`` against the current chat clock, appends a
|
||||
``time_skip_elision`` event (chat clock advances), generates a
|
||||
transition narration via :func:`narrate_skip`, and appends an
|
||||
``assistant_turn`` carrying the narration. ``narrate_skip`` has its
|
||||
own deterministic fallback so this never blocks on the model.
|
||||
|
||||
Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id":
|
||||
..., "assistant_event_id": ...}`` so callers can introspect the
|
||||
generated turn (e.g. for SSE rebroadcast or test assertions).
|
||||
|
||||
Raises ``ValueError`` on validation failure or when the chat row
|
||||
can't be located (the drawer maps it to ``HTTP 400`` / ``404``
|
||||
respectively; the natural-language path follows the same shape).
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise ValueError(f"chat not found: {chat_id}")
|
||||
|
||||
_validate_new_time(chat, new_time)
|
||||
|
||||
host_bot = get_bot(conn, chat["host_bot_id"]) or {
|
||||
"id": chat["host_bot_id"],
|
||||
"name": "host",
|
||||
"persona": "",
|
||||
}
|
||||
you_entity = get_you(conn) or {"name": "you"}
|
||||
|
||||
# The drawer route reaches into the host bot's current activity to
|
||||
# surface the verb to the narration helper — we do the same so both
|
||||
# entry points produce the same prose for the same chat state.
|
||||
bot_activity = get_activity(conn, chat["host_bot_id"]) or {}
|
||||
current_activity = (bot_activity.get("action") or {}).get("verb") or ""
|
||||
|
||||
narration = await narrate_skip(
|
||||
client,
|
||||
narrative_model=settings.narrative_model,
|
||||
skip_kind="elision",
|
||||
speaker_bot=host_bot,
|
||||
you_name=you_entity.get("name") or "you",
|
||||
current_time=chat.get("time") or "",
|
||||
new_time=new_time,
|
||||
current_activity=current_activity,
|
||||
landing_state_hint=landing_state_hint,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
|
||||
skip_event_id = append_and_apply(
|
||||
conn,
|
||||
kind="time_skip_elision",
|
||||
payload={"chat_id": chat_id, "new_time": new_time},
|
||||
)
|
||||
speaker_id = host_bot.get("id") or chat["host_bot_id"]
|
||||
assistant_event_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": speaker_id,
|
||||
"text": narration,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"assistant_text": narration,
|
||||
"speaker_id": speaker_id,
|
||||
"skip_event_id": skip_event_id,
|
||||
"assistant_event_id": assistant_event_id,
|
||||
}
|
||||
|
||||
|
||||
async def process_jump_skip(
|
||||
conn: Connection,
|
||||
client: LLMClient,
|
||||
settings: Settings,
|
||||
*,
|
||||
chat_id: str,
|
||||
new_time: str,
|
||||
notable_prose: str = "",
|
||||
reset_activity: bool = False,
|
||||
) -> dict:
|
||||
"""Run a jump skip end-to-end.
|
||||
|
||||
Same validations as :func:`process_elision_skip`. Emits
|
||||
``time_skip_jump`` *before* synthesizing memories so per-bot writes
|
||||
record the post-jump chat clock (mirroring how a regular turn's
|
||||
memory carries the chat clock). When ``notable_prose`` is non-empty,
|
||||
runs :func:`synthesize_memories` once per present bot witness, then
|
||||
fans the resulting memories out via
|
||||
:func:`record_turn_memory_for_present` with ``source="synthesized"``.
|
||||
Finally appends the narration ``assistant_turn``.
|
||||
|
||||
Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id":
|
||||
..., "assistant_event_id": ...}``.
|
||||
|
||||
Raises ``ValueError`` on validation failure (caller maps to ``400``).
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise ValueError(f"chat not found: {chat_id}")
|
||||
|
||||
_validate_new_time(chat, new_time)
|
||||
|
||||
host_bot = get_bot(conn, chat["host_bot_id"]) or {
|
||||
"id": chat["host_bot_id"],
|
||||
"name": "host",
|
||||
"persona": "",
|
||||
}
|
||||
you_entity = get_you(conn) or {"name": "you"}
|
||||
you_name = you_entity.get("name") or "you"
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
guest_bot = get_bot(conn, guest_bot_id) if guest_bot_id else None
|
||||
|
||||
# Emit time_skip_jump up front so subsequent memory writes ride the
|
||||
# post-jump chat clock (matches the drawer T59 behavior pinned by
|
||||
# test_post_skip_jump_with_notable_prose_writes_synthesized_memories).
|
||||
skip_event_id = append_and_apply(
|
||||
conn,
|
||||
kind="time_skip_jump",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"new_time": new_time,
|
||||
"reset_activity": reset_activity,
|
||||
},
|
||||
)
|
||||
|
||||
# Synthesize per-bot memories when prose is non-empty. The helper
|
||||
# short-circuits on whitespace prose, but gating the loop here keeps
|
||||
# the canned-LLM-queue accounting predictable for tests.
|
||||
if notable_prose.strip():
|
||||
present_bots: list[dict] = [host_bot]
|
||||
if guest_bot is not None:
|
||||
present_bots.append(guest_bot)
|
||||
for bot in present_bots:
|
||||
digest = await synthesize_memories(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
prose=notable_prose,
|
||||
bot_name=bot.get("name") or "",
|
||||
bot_persona=bot.get("persona") or "",
|
||||
you_name=you_name,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for mem in digest.memories:
|
||||
# ``record_turn_memory_for_present`` writes one row per
|
||||
# present bot per call — we already iterate by bot here,
|
||||
# so guest_bot_id=None avoids double-writing the guest's
|
||||
# row when bot==guest.
|
||||
record_turn_memory_for_present(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=bot["id"],
|
||||
guest_bot_id=None,
|
||||
narrative_text=mem.text,
|
||||
chat_clock_at=new_time,
|
||||
source="synthesized",
|
||||
significance=mem.significance,
|
||||
)
|
||||
|
||||
narration = await narrate_skip(
|
||||
client,
|
||||
narrative_model=settings.narrative_model,
|
||||
skip_kind="jump",
|
||||
speaker_bot=host_bot,
|
||||
you_name=you_name,
|
||||
current_time=chat.get("time") or "",
|
||||
new_time=new_time,
|
||||
current_activity="",
|
||||
landing_state_hint=notable_prose,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
speaker_id = host_bot.get("id") or chat["host_bot_id"]
|
||||
assistant_event_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": speaker_id,
|
||||
"text": narration,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"assistant_text": narration,
|
||||
"speaker_id": speaker_id,
|
||||
"skip_event_id": skip_event_id,
|
||||
"assistant_event_id": assistant_event_id,
|
||||
}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""UTC ISO timestamp used by callers as a chat-clock fallback."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"process_elision_skip",
|
||||
"process_jump_skip",
|
||||
"_now_iso",
|
||||
"_parse_iso_time",
|
||||
]
|
||||
+223
-8
@@ -51,11 +51,16 @@ import html
|
||||
import json
|
||||
import re
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.services.addressee import detect_addressee
|
||||
from chat.services.background import SignificanceJob
|
||||
from chat.services.event_lifecycle import detect_event_transitions
|
||||
from chat.services.event_promotion import promote_completed_event
|
||||
from chat.services.interjection import detect_interjection
|
||||
from chat.services.memory_write import record_turn_memory_for_present
|
||||
from chat.services.multi_state_update import compute_state_updates_for_present
|
||||
@@ -66,11 +71,15 @@ from chat.services.scene_summarize import apply_scene_close_summary
|
||||
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.events import list_active_events
|
||||
from chat.state.meanwhile import list_meanwhile_scenes
|
||||
from chat.state.world import active_scene, get_chat, get_container
|
||||
from chat.web.bots import get_conn
|
||||
from chat.web.kickoff import get_llm_client
|
||||
from chat.web.meanwhile import process_meanwhile_turn
|
||||
from chat.web.pubsub import publish
|
||||
from chat.web.render import render_turn_html as _render_turn_html
|
||||
from chat.web.skip import _parse_iso_time, process_elision_skip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -235,20 +244,103 @@ async def post_turn(
|
||||
guest_bot = None
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
if guest_bot_id is not None:
|
||||
# T47's bot_reset cascade clears guest_bot_id from any chat that
|
||||
# referenced the deleted bot, so by the time we read it here it's
|
||||
# either None or a live bot id. The previous defensive
|
||||
# degrade-to-1:1 block (T44) was rendered dead by T47 and removed
|
||||
# in T74.4 — get_bot now returns a real row.
|
||||
guest_bot = get_bot(conn, guest_bot_id)
|
||||
# If the chat references a deleted guest we degrade to single-bot
|
||||
# rather than 404 — the chat is still usable as a 1:1.
|
||||
if guest_bot is None:
|
||||
guest_bot_id = None
|
||||
|
||||
settings = request.app.state.settings
|
||||
|
||||
# 0. Meanwhile-mode short-circuit (T64). When an active meanwhile
|
||||
# scene is running on this chat, the turn flow is entirely between
|
||||
# the two bots — "you" is absent. The meanwhile controller mirrors
|
||||
# the post_turn shape but with no-you semantics: present_set_kind
|
||||
# ``host_guest`` in the prompt assembler, ``record_meanwhile_memory``
|
||||
# for witness flags, only 2 directed pairs in the state update, and
|
||||
# the assistant_turn payload tagged with ``meanwhile_scene_id`` so
|
||||
# alternation lookups can scope to this scene specifically. The
|
||||
# T62 skip-intent dispatch and the regular narrative path below
|
||||
# are skipped — a meanwhile beat is its own self-contained flow.
|
||||
if list_meanwhile_scenes(conn, chat_id, status="active"):
|
||||
try:
|
||||
await process_meanwhile_turn(
|
||||
conn,
|
||||
client,
|
||||
settings,
|
||||
chat_id=chat_id,
|
||||
prose=prose,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return Response(status_code=204)
|
||||
|
||||
# 1. Parse turn (classifier).
|
||||
parsed = await parse_turn(
|
||||
client, model=settings.classifier_model, prose=prose
|
||||
)
|
||||
prompt_prose = _strip_ooc_for_prompt(parsed)
|
||||
|
||||
# 1a. Skip-command short-circuit (T62). The parser may classify the
|
||||
# prose as a time-skip directive — in which case the regular
|
||||
# narrative path (addressee detection, narrative stream, post-turn
|
||||
# state-update + scene-close passes) is skipped entirely. Elision
|
||||
# runs through the shared controller in :mod:`chat.web.skip`; jump
|
||||
# is drawer-only for Phase 3 (the natural-language path returns
|
||||
# 422 directing the user to the drawer's jump form, where they can
|
||||
# supply structured ``notable_prose`` and a target time). Anything
|
||||
# not matching these intents falls through to the narrative branch.
|
||||
intent = getattr(parsed, "intent", "narrative") or "narrative"
|
||||
if intent == "skip_jump":
|
||||
# Drawer-only jump for Phase 3: parsing a free-form fiction-time
|
||||
# delta out of natural language ("next morning" -> ?) is fragile
|
||||
# enough that we'd rather route the user to the drawer form,
|
||||
# where they pick a concrete ISO time and an optional notable-
|
||||
# prose field. 422 = "request shape is understood, but the
|
||||
# required structured input lives on a different surface".
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
"Jump skip requires the drawer's jump form for "
|
||||
"notable_prose."
|
||||
)
|
||||
},
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
if intent == "skip_elision":
|
||||
# Derive ``new_time`` from the chat clock. Phase 3 stub: bump by
|
||||
# 1 hour. The drawer's elision form is the structured path when
|
||||
# the author wants a specific landing time; here the goal is
|
||||
# "elide the dull bit" and any sensible forward step is fine —
|
||||
# ``narrate_skip`` weaves the landing-state hint into the
|
||||
# transition prose so the prose carries the semantic time, not
|
||||
# the timestamp itself.
|
||||
cur_dt = _parse_iso_time(chat.get("time") or "")
|
||||
new_time = (
|
||||
(cur_dt + timedelta(hours=1)).isoformat()
|
||||
if cur_dt is not None
|
||||
else (chat.get("time") or "")
|
||||
)
|
||||
try:
|
||||
await process_elision_skip(
|
||||
conn,
|
||||
client,
|
||||
settings,
|
||||
chat_id=chat_id,
|
||||
new_time=new_time,
|
||||
landing_state_hint=getattr(parsed, "landing_state_hint", "")
|
||||
or "",
|
||||
)
|
||||
except ValueError as exc:
|
||||
# The controller raises on missing chat / bad new_time.
|
||||
# Missing chat is already handled above (we'd have 404'd);
|
||||
# a bad new_time here is a stub-derivation bug rather than
|
||||
# user input — surface as 400 with the controller message.
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return Response(status_code=204)
|
||||
|
||||
# 2. Append user_turn event.
|
||||
user_turn_event_id = append_event(
|
||||
conn,
|
||||
@@ -262,8 +354,25 @@ async def post_turn(
|
||||
|
||||
# 3. Determine the addressee. Done before assistant_turn_started so the
|
||||
# placeholder reflects the bot the user is actually talking to (host
|
||||
# in 1:1, host-or-guest in multi-entity).
|
||||
addressee_id = _detect_addressee_id(prose, host_bot, guest_bot)
|
||||
# in 1:1, host-or-guest in multi-entity). T74.1 routes the multi-entity
|
||||
# case through the addressee classifier; the no-guest case still uses
|
||||
# the substring fast-path because there is nothing to classify when
|
||||
# only one bot is present (and a classifier round-trip there would
|
||||
# just be throughput overhead).
|
||||
if guest_bot is None:
|
||||
addressee_id = _detect_addressee_id(prose, host_bot, guest_bot)
|
||||
else:
|
||||
decision = await detect_addressee(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
user_prose=prose,
|
||||
host_id=host_bot["id"],
|
||||
host_name=host_bot["name"],
|
||||
guest_id=guest_bot["id"],
|
||||
guest_name=guest_bot["name"],
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
addressee_id = decision.addressee_id
|
||||
addressee_bot = (
|
||||
guest_bot if (guest_bot is not None and addressee_id == guest_bot["id"])
|
||||
else host_bot
|
||||
@@ -598,7 +707,7 @@ async def post_turn(
|
||||
|
||||
# Memory write for the interjection beat — a second pair
|
||||
# of memory_written events (host + guest POVs).
|
||||
record_turn_memory_for_present(
|
||||
interject_memory_results = record_turn_memory_for_present(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=host_bot["id"],
|
||||
@@ -608,6 +717,103 @@ async def post_turn(
|
||||
chat_clock_at=chat.get("time"),
|
||||
)
|
||||
|
||||
# T74.2: enqueue a significance pass for the interjection
|
||||
# memory. Mirrors the primary-turn enqueue pattern above —
|
||||
# we score on the host's memory id since the prose is
|
||||
# identical across both POVs (per-POV rewrite happens at
|
||||
# scene close in T45). Without this enqueue the
|
||||
# interjection beat lands in memory but never gets scored,
|
||||
# so it can never auto-pin even when it carries a pivotal
|
||||
# moment.
|
||||
interject_host_event = interject_memory_results.get(
|
||||
host_bot["id"]
|
||||
)
|
||||
interject_host_memory_id = (
|
||||
interject_host_event[1] if interject_host_event else None
|
||||
)
|
||||
if (
|
||||
worker is not None
|
||||
and interject_host_memory_id is not None
|
||||
):
|
||||
worker.enqueue(
|
||||
SignificanceJob(
|
||||
memory_id=interject_host_memory_id,
|
||||
narrative_text=interjection_text,
|
||||
prior_dialogue=recent_post_interject,
|
||||
host_bot_id=host_bot["id"],
|
||||
)
|
||||
)
|
||||
|
||||
# 8a. Event-lifecycle detection (Phase 3, T61). Runs after the post-turn
|
||||
# classifier passes (memory write + state update + optional
|
||||
# interjection) and BEFORE scene-close detection. The classifier reads
|
||||
# ``primary_text`` against the chat's currently-active events and
|
||||
# returns a (usually empty) list of transitions. Each transition lands
|
||||
# an ``event_started`` / ``event_completed`` / ``event_cancelled``
|
||||
# event via ``append_and_apply`` so the events projection updates
|
||||
# synchronously. A completion is followed inline by
|
||||
# ``promote_completed_event`` so any structured artifacts the event
|
||||
# carries (knowledge_facts, relationship_change, acquired_objects)
|
||||
# land in state in the same turn — see chat/services/event_promotion.
|
||||
#
|
||||
# ``detect_event_transitions`` short-circuits when ``active_events``
|
||||
# is empty (per T52), so chats without active events don't pay a
|
||||
# classifier round-trip and existing fixtures need no extra canned
|
||||
# slots.
|
||||
active_events = list_active_events(conn, chat_id)
|
||||
if active_events:
|
||||
lifecycle_decision = await detect_event_transitions(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
narrative_text=primary_text,
|
||||
active_events=active_events,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for transition in lifecycle_decision.transitions:
|
||||
if transition.new_status == "active":
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": transition.event_id,
|
||||
"started_at": chat.get("time"),
|
||||
},
|
||||
)
|
||||
elif transition.new_status == "completed":
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_completed",
|
||||
payload={
|
||||
"event_id": transition.event_id,
|
||||
"completed_at": chat.get("time"),
|
||||
},
|
||||
)
|
||||
# Run promotion inline so the artifact-emitting events
|
||||
# (edge_update / manual_edit) land synchronously after
|
||||
# the completion. ``promote_completed_event`` is
|
||||
# synchronous (no await) and skips silently when the
|
||||
# event row's status isn't 'completed' — a safety net
|
||||
# for races, not expected to trigger in practice.
|
||||
promote_completed_event(
|
||||
conn,
|
||||
event_id=transition.event_id,
|
||||
chat_id=chat_id,
|
||||
chat_clock_at=chat.get("time"),
|
||||
)
|
||||
elif transition.new_status == "cancelled":
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": transition.event_id,
|
||||
"completed_at": chat.get("time"),
|
||||
},
|
||||
)
|
||||
# Any other ``new_status`` value falls through silently —
|
||||
# the lifecycle service constrains the schema to the three
|
||||
# valid transitions, and a defensive no-op here keeps the
|
||||
# turn flow tolerant of unexpected outputs.
|
||||
|
||||
# 9. Scene-close detection (Plan §7.2, T26). Runs AFTER assistant_turn
|
||||
# and the optional interjection so the bots' responses are part of
|
||||
# the closing scene's final beat — closing before narrative would
|
||||
@@ -623,6 +829,15 @@ async def post_turn(
|
||||
# close in the same chat) — we have nothing to close. T13 (kickoff)
|
||||
# is the only scene-opener path in v1; Phase 2-3 will handle
|
||||
# automatic re-opening with the next container.
|
||||
#
|
||||
# T74.3: this branch deliberately runs even when ``cancelled`` is
|
||||
# True. Close detection consumes only the user's prose (which is
|
||||
# fully appended to the event_log BEFORE streaming starts) and the
|
||||
# current container name; it does NOT consume the bot's output.
|
||||
# A user who types "we're done here, fade out" and then hits Stop
|
||||
# mid-stream still meant to close the scene — the cancelled bot
|
||||
# beat doesn't invalidate that intent. Pinned by
|
||||
# test_cancelled_turn_still_closes_scene_when_user_prose_signals_close.
|
||||
if scene is not None and prose.strip():
|
||||
container = None
|
||||
if scene.get("container_id") is not None:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Addressee classifier service tests (T74.1).
|
||||
|
||||
Covers :func:`chat.services.addressee.detect_addressee`:
|
||||
|
||||
- Classifier picks the guest -> ``addressee_id == guest_id``.
|
||||
- Classifier picks the host -> ``addressee_id == host_id``.
|
||||
- Classifier flakes (3 bad-JSON responses, exhausting the built-in
|
||||
retry budget in :func:`chat.llm.classify.classify`) -> fallback to
|
||||
the host with ``reason="fallback"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.llm.mock import MockLLMClient
|
||||
from chat.services.addressee import AddresseeDecision, detect_addressee
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classifier_picks_guest():
|
||||
"""Classifier returns the guest id verbatim — caller propagates it."""
|
||||
canned = [
|
||||
json.dumps(
|
||||
{
|
||||
"addressee_id": "bot_b",
|
||||
"confidence": "high",
|
||||
"reason": "user named BotB",
|
||||
}
|
||||
)
|
||||
]
|
||||
client = MockLLMClient(canned=canned)
|
||||
|
||||
result = await detect_addressee(
|
||||
client,
|
||||
classifier_model="test-model",
|
||||
user_prose="BotB, what do you think?",
|
||||
host_id="bot_a",
|
||||
host_name="BotA",
|
||||
guest_id="bot_b",
|
||||
guest_name="BotB",
|
||||
)
|
||||
|
||||
assert isinstance(result, AddresseeDecision)
|
||||
assert result.addressee_id == "bot_b"
|
||||
assert result.confidence == "high"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classifier_picks_host():
|
||||
"""Classifier returns the host id — caller propagates it."""
|
||||
canned = [
|
||||
json.dumps(
|
||||
{
|
||||
"addressee_id": "bot_a",
|
||||
"confidence": "medium",
|
||||
"reason": "narration aimed at host",
|
||||
}
|
||||
)
|
||||
]
|
||||
client = MockLLMClient(canned=canned)
|
||||
|
||||
result = await detect_addressee(
|
||||
client,
|
||||
classifier_model="test-model",
|
||||
user_prose="I lean back and stretch.",
|
||||
host_id="bot_a",
|
||||
host_name="BotA",
|
||||
guest_id="bot_b",
|
||||
guest_name="BotB",
|
||||
)
|
||||
|
||||
assert result.addressee_id == "bot_a"
|
||||
assert result.confidence == "medium"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classifier_failure_falls_back_to_host():
|
||||
"""Three bad-JSON responses exhaust the retry budget and the
|
||||
classifier-failure fallback returns ``host_id`` with
|
||||
``reason="fallback"``."""
|
||||
canned = ["not json", "still not json", "garbage"]
|
||||
client = MockLLMClient(canned=canned)
|
||||
|
||||
result = await detect_addressee(
|
||||
client,
|
||||
classifier_model="test-model",
|
||||
user_prose="anything",
|
||||
host_id="bot_a",
|
||||
host_name="BotA",
|
||||
guest_id="bot_b",
|
||||
guest_name="BotB",
|
||||
)
|
||||
|
||||
assert result.addressee_id == "bot_a"
|
||||
assert result.reason == "fallback"
|
||||
assert result.confidence == "low"
|
||||
@@ -0,0 +1,403 @@
|
||||
"""T72: deferred v1 drawer edits + witness flag inline-edit.
|
||||
|
||||
T25 shipped affinity / significance / pin. T72.1 fills in the rest of the
|
||||
§6.4 editable surface whose ``manual_edit`` projector dispatch was already
|
||||
in place (or, for ``edge_knowledge_fact``, added alongside the route):
|
||||
|
||||
* ``POST /chats/{chat_id}/drawer/edge/trust`` — slider 0..100.
|
||||
* ``POST /chats/{chat_id}/drawer/edge/summary`` — textarea, capped 2000.
|
||||
* ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped.
|
||||
* ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact.
|
||||
|
||||
T72.3 layers a witness-flag toggle on top:
|
||||
|
||||
* ``POST /chats/{chat_id}/drawer/memory/witness`` — ``manual_edit`` with
|
||||
``target_kind`` = ``memory_witness`` and a ``{flag, value}`` payload.
|
||||
|
||||
Each test asserts (a) the ``manual_edit`` event lands in the log,
|
||||
(b) the projected table reflects the new value, and (c) the response is
|
||||
the refreshed drawer partial.
|
||||
"""
|
||||
|
||||
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.eventlog.log import append_event
|
||||
from chat.eventlog.projector import project
|
||||
|
||||
|
||||
@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))
|
||||
with TestClient(app) as c:
|
||||
if hasattr(app.state, "background_worker"):
|
||||
app.state.background_worker.enabled = False
|
||||
yield c
|
||||
|
||||
|
||||
def _seed(db: Path) -> None:
|
||||
"""Seed a chat with one host bot, one host->you edge with a fact and
|
||||
summary already set, and one memory authored by ``bot_a`` witnessed by
|
||||
all three roles. Tests reach into projected state to verify edits.
|
||||
"""
|
||||
with open_db(db) 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": "",
|
||||
},
|
||||
)
|
||||
# Materialise edge bot_a -> you with a knowledge_fact already on it
|
||||
# so the remove path has something to consume.
|
||||
append_event(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"chat_id": "chat_bot_a",
|
||||
"affinity_delta": 0,
|
||||
"knowledge_facts": ["studied physics together"],
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="memory_written",
|
||||
payload={
|
||||
"owner_id": "bot_a",
|
||||
"chat_id": "chat_bot_a",
|
||||
"pov_summary": "Original summary text.",
|
||||
"witness_you": 1,
|
||||
"witness_host": 1,
|
||||
"witness_guest": 0,
|
||||
"significance": 1,
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
|
||||
# --- T72.1 tests ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_edit_edge_trust_emits_manual_edit_and_updates(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/edge/trust",
|
||||
data={"source_id": "bot_a", "target_id": "you", "new_value": "73"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Refresh shows the new trust value somewhere in the partial.
|
||||
assert "73" in response.text
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0][0])
|
||||
assert payload["target_kind"] == "edge_trust"
|
||||
assert payload["prior_value"] == 50
|
||||
assert payload["new_value"] == 73
|
||||
assert payload["target_id"] == {
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
}
|
||||
|
||||
from chat.state.edges import get_edge
|
||||
|
||||
edge = get_edge(conn, "bot_a", "you")
|
||||
assert edge["trust"] == 73
|
||||
|
||||
|
||||
def test_edit_edge_trust_400_on_out_of_range(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/edge/trust",
|
||||
data={"source_id": "bot_a", "target_id": "you", "new_value": "150"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_edit_edge_summary_emits_manual_edit_and_updates(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/edge/summary",
|
||||
data={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"new_summary": "BotA respects you and shares lab notes.",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0][0])
|
||||
assert payload["target_kind"] == "edge_summary"
|
||||
assert payload["new_value"].startswith("BotA respects")
|
||||
assert payload["target_id"] == {
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
}
|
||||
|
||||
summary = conn.execute(
|
||||
"SELECT summary FROM edges "
|
||||
"WHERE source_id = ? AND target_id = ?",
|
||||
("bot_a", "you"),
|
||||
).fetchone()[0]
|
||||
assert "respects" in summary
|
||||
|
||||
# And the refreshed partial echoes the new summary back.
|
||||
assert "respects" in response.text
|
||||
|
||||
|
||||
def test_edit_edge_summary_400_on_overflow(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/edge/summary",
|
||||
data={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"new_summary": "x" * 2001,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_edit_memory_pov_summary_emits_manual_edit_and_updates(
|
||||
client, tmp_path
|
||||
):
|
||||
_seed(tmp_path / "test.db")
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/memory/pov-summary",
|
||||
data={
|
||||
"memory_id": str(memory_id),
|
||||
"new_summary": "Cleaner per-POV restatement of the moment.",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0][0])
|
||||
assert payload["target_kind"] == "memory_pov_summary"
|
||||
assert payload["prior_value"] == "Original summary text."
|
||||
assert payload["new_value"].startswith("Cleaner per-POV")
|
||||
assert payload["target_id"] == memory_id
|
||||
|
||||
pov = conn.execute(
|
||||
"SELECT pov_summary FROM memories WHERE id = ?", (memory_id,)
|
||||
).fetchone()[0]
|
||||
assert pov.startswith("Cleaner per-POV")
|
||||
|
||||
assert "Cleaner per-POV" in response.text
|
||||
|
||||
|
||||
def test_edit_memory_pov_summary_404_when_wrong_chat(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||
# Re-home the memory to a different chat to confirm the route's
|
||||
# cross-chat guard fires.
|
||||
conn.execute(
|
||||
"UPDATE memories SET chat_id = 'other_chat' WHERE id = ?",
|
||||
(memory_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/memory/pov-summary",
|
||||
data={"memory_id": str(memory_id), "new_summary": "..."},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_edit_edge_knowledge_facts_add_emits_event_and_appends(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/edge/knowledge-facts",
|
||||
data={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"action": "add",
|
||||
"fact": "lent you a textbook",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0][0])
|
||||
assert payload["target_kind"] == "edge_knowledge_fact"
|
||||
assert payload["new_value"] == {
|
||||
"action": "add",
|
||||
"fact": "lent you a textbook",
|
||||
}
|
||||
# Prior value snapshots the entire knowledge list before the edit.
|
||||
assert payload["prior_value"] == ["studied physics together"]
|
||||
|
||||
from chat.state.edges import get_edge
|
||||
|
||||
edge = get_edge(conn, "bot_a", "you")
|
||||
assert "lent you a textbook" in edge["knowledge"]
|
||||
assert "studied physics together" in edge["knowledge"]
|
||||
|
||||
assert "lent you a textbook" in response.text
|
||||
|
||||
|
||||
def test_edit_edge_knowledge_facts_remove_drops_matching_fact(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/edge/knowledge-facts",
|
||||
data={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"action": "remove",
|
||||
"fact": "studied physics together",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
from chat.state.edges import get_edge
|
||||
|
||||
edge = get_edge(conn, "bot_a", "you")
|
||||
assert "studied physics together" not in edge["knowledge"]
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||
).fetchall()
|
||||
payload = json.loads(rows[0][0])
|
||||
assert payload["target_kind"] == "edge_knowledge_fact"
|
||||
assert payload["new_value"]["action"] == "remove"
|
||||
|
||||
|
||||
def test_edit_edge_knowledge_facts_400_on_bad_action(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/edge/knowledge-facts",
|
||||
data={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"action": "delete",
|
||||
"fact": "x",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# --- T72.3 tests (witness flag inline-edit) -------------------------------
|
||||
|
||||
|
||||
def test_witness_flag_toggle_updates_memory_row(client, tmp_path):
|
||||
"""Memory seeded with witness [you=1, host=1, guest=0]; toggling
|
||||
``guest`` to 1 lands as ``witness_guest = 1`` after projection.
|
||||
"""
|
||||
_seed(tmp_path / "test.db")
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/memory/witness",
|
||||
data={
|
||||
"memory_id": str(memory_id),
|
||||
"flag": "guest",
|
||||
"new_value": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
row = conn.execute(
|
||||
"SELECT witness_you, witness_host, witness_guest "
|
||||
"FROM memories WHERE id = ?",
|
||||
(memory_id,),
|
||||
).fetchone()
|
||||
assert row == (1, 1, 1)
|
||||
|
||||
|
||||
def test_witness_flag_toggle_emits_manual_edit_event(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/memory/witness",
|
||||
data={
|
||||
"memory_id": str(memory_id),
|
||||
"flag": "guest",
|
||||
"new_value": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0][0])
|
||||
assert payload["target_kind"] == "memory_witness"
|
||||
assert payload["target_id"] == memory_id
|
||||
assert payload["prior_value"] == {"flag": "guest", "value": 0}
|
||||
assert payload["new_value"] == {"flag": "guest", "value": 1}
|
||||
|
||||
|
||||
def test_witness_flag_toggle_400_on_bad_flag(client, tmp_path):
|
||||
_seed(tmp_path / "test.db")
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/memory/witness",
|
||||
data={
|
||||
"memory_id": str(memory_id),
|
||||
"flag": "narrator",
|
||||
"new_value": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""T59: drawer events / threads / skip controls.
|
||||
|
||||
Extends the chat drawer with three new sections (Events, Threads, Skip)
|
||||
and five new POST endpoints:
|
||||
|
||||
* ``POST /chats/{chat_id}/drawer/event/plan`` — emits ``event_planned``.
|
||||
* ``POST /chats/{chat_id}/drawer/event/cancel/{event_id}`` — emits
|
||||
``event_cancelled``.
|
||||
* ``POST /chats/{chat_id}/drawer/skip/elision`` — validates new_time,
|
||||
emits ``time_skip_elision`` plus an ``assistant_turn`` carrying the
|
||||
narrated transition prose from :mod:`chat.services.skip_narration`.
|
||||
* ``POST /chats/{chat_id}/drawer/skip/jump`` — validates new_time, emits
|
||||
``time_skip_jump`` plus per-bot synthesized ``memory_written`` events
|
||||
derived from the user-supplied "anything notable" prose, and an
|
||||
``assistant_turn`` carrying the narration.
|
||||
* ``POST /chats/{chat_id}/drawer/thread/close/{thread_id}`` — emits
|
||||
``thread_closed``.
|
||||
|
||||
Each route returns the refreshed drawer partial (HTMX swap target) so
|
||||
the tests assert both the persisted event_log effect AND the rendered
|
||||
section content. Wire-up follows the T42 ``MockLLMClient`` pattern.
|
||||
"""
|
||||
|
||||
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.eventlog.log import append_and_apply, append_event
|
||||
from chat.eventlog.projector import project
|
||||
from chat.llm.mock import MockLLMClient
|
||||
|
||||
|
||||
@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))
|
||||
with TestClient(app) as c:
|
||||
if hasattr(app.state, "background_worker"):
|
||||
app.state.background_worker.enabled = False
|
||||
yield c
|
||||
|
||||
|
||||
def _bot_payload(bot_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": "...",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "",
|
||||
"kickoff_prose": "",
|
||||
}
|
||||
|
||||
|
||||
def _seed_chat(db: Path, *, with_scene: bool = True) -> None:
|
||||
"""Seed a chat hosted by ``bot_a`` (with ``bot_b`` authored as a
|
||||
candidate guest) so the skip-jump path can write per-bot synthesized
|
||||
memories when a guest is present.
|
||||
"""
|
||||
with open_db(db) as conn:
|
||||
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="you_authored",
|
||||
payload={"name": "Me", "pronouns": "they/them", "persona": ""},
|
||||
)
|
||||
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": "",
|
||||
},
|
||||
)
|
||||
if with_scene:
|
||||
append_event(
|
||||
conn,
|
||||
kind="scene_opened",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"container_id": None,
|
||||
"started_at": "2026-04-26T20:00:00+00:00",
|
||||
"participants": ["you", "bot_a"],
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
|
||||
def _override_llm(canned: list[str]):
|
||||
"""Wire a ``MockLLMClient`` into the drawer's LLM dependency."""
|
||||
from chat.web.kickoff import get_llm_client
|
||||
|
||||
app.dependency_overrides[get_llm_client] = lambda: MockLLMClient(
|
||||
canned=list(canned)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Empty drawer state — Events + Threads sections render but show
|
||||
# empty-state copy (no row markup) when no events / threads exist.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_drawer_with_no_events_or_threads_omits_sections(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
response = client.get("/chats/chat_bot_a/drawer")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
# Sections render with empty-state copy — deterministic markers.
|
||||
assert "<h3>Events</h3>" in body
|
||||
assert "<h3>Threads</h3>" in body
|
||||
assert "No active events" in body
|
||||
assert "No open threads" in body
|
||||
# Skip controls always render under Activity (gated by chat clock).
|
||||
assert "Elision skip" in body
|
||||
assert "Jump skip" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. POST event/plan — event_planned lands and the drawer lists it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_event_plan_appends_event_planned_and_renders(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/event/plan",
|
||||
data={
|
||||
"kind": "dinner_reservation",
|
||||
"planned_for": "2026-04-26T19:00:00+00:00",
|
||||
"props_json": json.dumps({"restaurant": "Bistro X"}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'event_planned'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0][0])
|
||||
assert payload["kind"] == "dinner_reservation"
|
||||
assert payload["chat_id"] == "chat_bot_a"
|
||||
assert payload["planned_for"] == "2026-04-26T19:00:00+00:00"
|
||||
assert payload["props"] == {"restaurant": "Bistro X"}
|
||||
assert payload["event_id"].startswith("evt_")
|
||||
|
||||
# Refreshed partial lists the new event by kind.
|
||||
body = response.text
|
||||
assert "dinner_reservation" in body
|
||||
|
||||
|
||||
def test_post_event_plan_invalid_props_json_returns_400(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/event/plan",
|
||||
data={
|
||||
"kind": "dinner_reservation",
|
||||
"planned_for": "2026-04-26T19:00:00+00:00",
|
||||
"props_json": "not valid json {",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. POST event/cancel — event_cancelled lands and the active list drops it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_event_cancel_appends_event_cancelled(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
# Plan first via the route so the test exercises both sides.
|
||||
plan_resp = client.post(
|
||||
"/chats/chat_bot_a/drawer/event/plan",
|
||||
data={
|
||||
"kind": "doctor_visit",
|
||||
"planned_for": "2026-04-27T09:00:00+00:00",
|
||||
"props_json": "{}",
|
||||
},
|
||||
)
|
||||
assert plan_resp.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
row = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'event_planned' "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
event_id = json.loads(row[0])["event_id"]
|
||||
|
||||
cancel_resp = client.post(
|
||||
f"/chats/chat_bot_a/drawer/event/cancel/{event_id}"
|
||||
)
|
||||
assert cancel_resp.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
cancelled = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'event_cancelled'"
|
||||
).fetchall()
|
||||
assert len(cancelled) == 1
|
||||
cp = json.loads(cancelled[0][0])
|
||||
assert cp["event_id"] == event_id
|
||||
|
||||
# Active-events query should no longer surface this event.
|
||||
from chat.state.events import list_active_events
|
||||
|
||||
assert list_active_events(conn, "chat_bot_a") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. POST skip/elision — emits time_skip_elision + assistant_turn narration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_skip_elision_advances_clock_and_emits_narration(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
canned_narration = "We pull up to the curb just before sunset."
|
||||
_override_llm([canned_narration])
|
||||
try:
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/elision",
|
||||
data={
|
||||
"landing_state_hint": "arriving at the venue",
|
||||
"new_time": "2026-04-26T20:30:00+00:00",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
from chat.state.world import get_chat
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-26T20:30:00+00:00"
|
||||
|
||||
skip_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'time_skip_elision'"
|
||||
).fetchall()
|
||||
assert len(skip_rows) == 1
|
||||
sp = json.loads(skip_rows[0][0])
|
||||
assert sp["chat_id"] == "chat_bot_a"
|
||||
assert sp["new_time"] == "2026-04-26T20:30:00+00:00"
|
||||
|
||||
# An assistant_turn event landed with the narration text.
|
||||
turn_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'assistant_turn'"
|
||||
).fetchall()
|
||||
assert len(turn_rows) == 1
|
||||
tp = json.loads(turn_rows[0][0])
|
||||
assert tp["chat_id"] == "chat_bot_a"
|
||||
assert tp["text"].strip() # non-empty narration
|
||||
assert tp["speaker_id"] == "bot_a"
|
||||
|
||||
|
||||
def test_post_skip_elision_invalid_time_returns_400(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
_override_llm([])
|
||||
try:
|
||||
# Garbled ISO timestamp.
|
||||
bad_resp = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/elision",
|
||||
data={"landing_state_hint": "x", "new_time": "not-a-time"},
|
||||
)
|
||||
assert bad_resp.status_code == 400
|
||||
|
||||
# Backwards-in-time skip: chat seeded at 20:00, asking 19:00.
|
||||
backwards_resp = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/elision",
|
||||
data={
|
||||
"landing_state_hint": "x",
|
||||
"new_time": "2026-04-26T19:00:00+00:00",
|
||||
},
|
||||
)
|
||||
assert backwards_resp.status_code == 400
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. POST skip/jump — synthesized memories per present bot + narration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_skip_jump_with_notable_prose_writes_synthesized_memories(
|
||||
client, tmp_path
|
||||
):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
# Single host present (no guest) — exactly one synthesize call,
|
||||
# one narration call. The synthesize digest carries two memories so
|
||||
# we can assert N writes lands the right shape.
|
||||
digest_json = json.dumps(
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"text": "We bumped into an old friend at the cafe.",
|
||||
"significance": 1,
|
||||
"affinity_delta": 0,
|
||||
"trust_delta": 0,
|
||||
},
|
||||
{
|
||||
"text": "It started raining on the walk home.",
|
||||
"significance": 1,
|
||||
"affinity_delta": 0,
|
||||
"trust_delta": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
narration = "The afternoon slipped by quickly."
|
||||
_override_llm([digest_json, narration])
|
||||
try:
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/jump",
|
||||
data={
|
||||
"new_time": "2026-04-27T08:00:00+00:00",
|
||||
"notable_prose": (
|
||||
"We ran into an old friend, and it rained on the way back."
|
||||
),
|
||||
"reset_activity": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
from chat.state.world import get_chat
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-27T08:00:00+00:00"
|
||||
|
||||
jump_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'time_skip_jump'"
|
||||
).fetchall()
|
||||
assert len(jump_rows) == 1
|
||||
jp = json.loads(jump_rows[0][0])
|
||||
assert jp["chat_id"] == "chat_bot_a"
|
||||
assert jp["new_time"] == "2026-04-27T08:00:00+00:00"
|
||||
assert jp["reset_activity"] is True
|
||||
|
||||
# Two synthesized memories land for the lone host bot
|
||||
# (record_turn_memory_for_present writes one row per present bot
|
||||
# per call — host only here, so 2 memories x 1 bot = 2 events).
|
||||
mem_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'memory_written'"
|
||||
).fetchall()
|
||||
synth_payloads = [
|
||||
json.loads(r[0])
|
||||
for r in mem_rows
|
||||
if json.loads(r[0]).get("source") == "synthesized"
|
||||
]
|
||||
assert len(synth_payloads) == 2
|
||||
for p in synth_payloads:
|
||||
assert p["owner_id"] == "bot_a"
|
||||
assert p["chat_id"] == "chat_bot_a"
|
||||
|
||||
# And the assistant_turn narration landed.
|
||||
turn_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'assistant_turn'"
|
||||
).fetchall()
|
||||
assert len(turn_rows) == 1
|
||||
tp = json.loads(turn_rows[0][0])
|
||||
assert tp["text"].strip()
|
||||
assert tp["speaker_id"] == "bot_a"
|
||||
|
||||
|
||||
def test_post_skip_jump_with_empty_prose_skips_memory_writes(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
# Empty prose short-circuits in synthesize_memories before any LLM call,
|
||||
# so the canned queue only needs the narration.
|
||||
narration = "(next morning: still in the kitchen.)"
|
||||
_override_llm([narration])
|
||||
try:
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/skip/jump",
|
||||
data={
|
||||
"new_time": "2026-04-27T08:00:00+00:00",
|
||||
"notable_prose": " ",
|
||||
"reset_activity": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
synth = conn.execute(
|
||||
"SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written' "
|
||||
"AND payload_json LIKE '%synthesized%'"
|
||||
).fetchone()[0]
|
||||
assert synth == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. POST thread/close — thread_closed lands and the open list drops it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_thread_close_appends_thread_closed(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
|
||||
# Open a thread directly via append_and_apply so the test focuses on
|
||||
# the close route's effect.
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": "thr_alpha",
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": "the missing key",
|
||||
"summary": "Couldn't find the key.",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post("/chats/chat_bot_a/drawer/thread/close/thr_alpha")
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
closed = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'thread_closed'"
|
||||
).fetchall()
|
||||
assert len(closed) == 1
|
||||
cp = json.loads(closed[0][0])
|
||||
assert cp["thread_id"] == "thr_alpha"
|
||||
|
||||
from chat.state.threads import list_open_threads
|
||||
|
||||
assert list_open_threads(conn, "chat_bot_a") == []
|
||||
@@ -265,6 +265,162 @@ def test_drawer_remove_guest_clears_and_closes_scene(client, tmp_path):
|
||||
assert kinds.index("scene_closed") < kinds.index("guest_removed")
|
||||
|
||||
|
||||
# --- T72.2 first-meeting gate ----------------------------------------------
|
||||
|
||||
|
||||
def _seed_host_to_guest_edge(db: Path) -> None:
|
||||
"""Materialise a bot_a -> bot_b edge so the gate's check fires."""
|
||||
from chat.eventlog.log import append_and_apply
|
||||
|
||||
with open_db(db) as conn:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "bot_b",
|
||||
"chat_id": "chat_bot_a",
|
||||
"affinity_delta": 0,
|
||||
"knowledge_facts": ["already met before"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_add_guest_form_disables_prose_when_edge_exists(client, tmp_path):
|
||||
"""When host->candidate edge already exists, the GET partial renders
|
||||
the textarea disabled and surfaces the "already know each other"
|
||||
message so the user knows submitting will skip the seed.
|
||||
"""
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
_seed_host_to_guest_edge(tmp_path / "test.db")
|
||||
|
||||
response = client.get("/chats/chat_bot_a/drawer")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
# Note + disabled state both present. The textarea sits next to the
|
||||
# ``add-guest-prose`` class so we can match it specifically.
|
||||
assert "already know each other" in body
|
||||
assert 'class="add-guest-prose"' in body
|
||||
# The textarea for the first (auto-selected) candidate should be
|
||||
# disabled in the initial markup since an edge exists.
|
||||
assert "disabled" in body.split('class="add-guest-prose"', 1)[1].split(">", 1)[0]
|
||||
# And the option carries the ``data-existing-edge="true"`` attribute
|
||||
# the inline JS uses to flip state on subsequent select changes.
|
||||
assert 'data-existing-edge="true"' in body
|
||||
|
||||
|
||||
def test_add_guest_with_existing_edge_skips_seed_call(client, tmp_path):
|
||||
"""Submitting the Add-guest form WITHOUT toggling re-seed must skip
|
||||
``seed_inter_bot_edges`` entirely. We assert this via an empty mock
|
||||
queue: if the seed function had been called it would have consumed
|
||||
a canned response (or raised because none was available).
|
||||
"""
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
_seed_host_to_guest_edge(tmp_path / "test.db")
|
||||
|
||||
# Empty queue: any classifier call would raise inside MockLLMClient.
|
||||
canned_queue: list[str] = []
|
||||
_override_llm(canned_queue)
|
||||
try:
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/guest/add",
|
||||
data={
|
||||
"guest_bot_id": "bot_b",
|
||||
"relationship_prose": "ignored prose",
|
||||
# NO reseed flag — gate should suppress the seed call.
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
from chat.state.edges import get_edge
|
||||
from chat.state.world import get_chat
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["guest_bot_id"] == "bot_b"
|
||||
|
||||
# The pre-seeded knowledge fact survives — proof the seed didn't run
|
||||
# and overwrite the existing edge.
|
||||
edge = get_edge(conn, "bot_a", "bot_b")
|
||||
assert "already met before" in edge["knowledge"]
|
||||
|
||||
# Exactly one guest_added; no new edge_update events between
|
||||
# bot_a and bot_b (the pre-seed edge_update from the test setup
|
||||
# is the only edge_update on this pair).
|
||||
added = conn.execute(
|
||||
"SELECT COUNT(*) FROM event_log WHERE kind = 'guest_added'"
|
||||
).fetchone()[0]
|
||||
assert added == 1
|
||||
|
||||
edge_updates = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'edge_update'"
|
||||
).fetchall()
|
||||
# Only the pre-seed edge_update from _seed_host_to_guest_edge.
|
||||
ab_updates = [
|
||||
json.loads(p[0])
|
||||
for p in edge_updates
|
||||
if {
|
||||
json.loads(p[0]).get("source_id"),
|
||||
json.loads(p[0]).get("target_id"),
|
||||
}
|
||||
== {"bot_a", "bot_b"}
|
||||
]
|
||||
assert len(ab_updates) == 1
|
||||
assert ab_updates[0]["knowledge_facts"] == ["already met before"]
|
||||
|
||||
|
||||
def test_add_guest_with_existing_edge_and_reseed_runs_seed(client, tmp_path):
|
||||
"""Toggling ``re-seed anyway`` flips the gate off — the existing
|
||||
flow runs (seed produces deltas, two ``edge_update`` events fire).
|
||||
"""
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
_seed_host_to_guest_edge(tmp_path / "test.db")
|
||||
|
||||
canned = json.dumps(
|
||||
{
|
||||
"a_to_b_summary": "reconnected",
|
||||
"a_to_b_knowledge_facts": ["new fact"],
|
||||
"a_to_b_affinity_delta": 2,
|
||||
"a_to_b_trust_delta": 1,
|
||||
"b_to_a_summary": "reconnected",
|
||||
"b_to_a_knowledge_facts": [],
|
||||
"b_to_a_affinity_delta": 1,
|
||||
"b_to_a_trust_delta": 0,
|
||||
}
|
||||
)
|
||||
_override_llm([canned])
|
||||
try:
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/guest/add",
|
||||
data={
|
||||
"guest_bot_id": "bot_b",
|
||||
"relationship_prose": "fresh prose",
|
||||
"reseed": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
edge_updates = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'edge_update'"
|
||||
).fetchall()
|
||||
# Pre-seed (1) + two from the re-seed = 3 edge_updates total.
|
||||
ab_updates = [
|
||||
json.loads(p[0])
|
||||
for p in edge_updates
|
||||
if {
|
||||
json.loads(p[0]).get("source_id"),
|
||||
json.loads(p[0]).get("target_id"),
|
||||
}
|
||||
== {"bot_a", "bot_b"}
|
||||
]
|
||||
assert len(ab_updates) == 3
|
||||
|
||||
|
||||
def test_drawer_with_guest_renders_guest_and_group_sections(client, tmp_path):
|
||||
_seed_chat(tmp_path / "test.db")
|
||||
from chat.eventlog.log import append_and_apply
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for the event-lifecycle detection service (T52).
|
||||
|
||||
Per Phase 3, after each narrated turn we ask a classifier whether any
|
||||
active events transitioned (started, completed, cancelled). The bias is
|
||||
strongly toward an empty result — most turns do NOT resolve or start a
|
||||
known event, and the turn-flow caller (T61) only appends an
|
||||
event_started/completed/cancelled record when this service yields one.
|
||||
|
||||
These tests cover:
|
||||
|
||||
* The classifier returning a single transition is honored end-to-end.
|
||||
* An empty ``active_events`` list short-circuits before any LLM call,
|
||||
so callers that hold no live events pay zero classifier cost.
|
||||
* Three rounds of malformed JSON exhaust ``classify``'s retries and we
|
||||
fall back to the empty default — graceful degradation per §3.3.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.llm.mock import MockLLMClient
|
||||
from chat.services.event_lifecycle import (
|
||||
EventLifecycleDecision,
|
||||
detect_event_transitions,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detects_one_transition_happy_path():
|
||||
canned = json.dumps(
|
||||
{
|
||||
"transitions": [
|
||||
{
|
||||
"event_id": "evt_1",
|
||||
"new_status": "completed",
|
||||
"reason": "they arrived at the park",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
mock = MockLLMClient(canned=[canned])
|
||||
result = await detect_event_transitions(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
narrative_text="They walked through the park gate, finally there.",
|
||||
active_events=[
|
||||
{
|
||||
"event_id": "evt_1",
|
||||
"kind": "date_at_park",
|
||||
"status": "active",
|
||||
"props": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert isinstance(result, EventLifecycleDecision)
|
||||
assert len(result.transitions) == 1
|
||||
assert result.transitions[0].event_id == "evt_1"
|
||||
assert result.transitions[0].new_status == "completed"
|
||||
assert result.transitions[0].reason == "they arrived at the park"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_active_events_short_circuits_without_classifier_call():
|
||||
"""No active events -> no classifier call.
|
||||
|
||||
The mock has an empty canned list; any ``generate`` call would raise
|
||||
``IndexError`` from ``list.pop(0)``. The test passing proves the
|
||||
short-circuit holds.
|
||||
"""
|
||||
mock = MockLLMClient(canned=[])
|
||||
result = await detect_event_transitions(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
narrative_text="Just a quiet moment between them.",
|
||||
active_events=[],
|
||||
)
|
||||
assert isinstance(result, EventLifecycleDecision)
|
||||
assert result.transitions == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classifier_failure_returns_empty_default():
|
||||
"""``classify`` retries 3 times; after all fail it returns the empty
|
||||
default so the turn flow keeps moving (§3.3 graceful degradation)."""
|
||||
mock = MockLLMClient(canned=["bad", "bad", "bad"])
|
||||
result = await detect_event_transitions(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
narrative_text="Some text the classifier will choke on.",
|
||||
active_events=[
|
||||
{
|
||||
"event_id": "evt_1",
|
||||
"kind": "date_at_park",
|
||||
"status": "active",
|
||||
"props": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert isinstance(result, EventLifecycleDecision)
|
||||
assert result.transitions == []
|
||||
@@ -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") == []
|
||||
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.eventlog.projector import project
|
||||
import chat.state.entities # registers handlers
|
||||
import chat.state.world # registers handlers
|
||||
import chat.state.group_node # registers handlers
|
||||
import chat.state.events # registers handlers
|
||||
from chat.state.events import (
|
||||
get_event,
|
||||
list_active_events,
|
||||
list_events_in_status,
|
||||
)
|
||||
|
||||
|
||||
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 test_event_planned_creates_row(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_chat(conn)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "date_at_park",
|
||||
"props": {"location": "park"},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
ev = get_event(conn, "evt_abc")
|
||||
assert ev is not None
|
||||
assert ev["event_id"] == "evt_abc"
|
||||
assert ev["chat_id"] == "chat_bot_a"
|
||||
assert ev["kind"] == "date_at_park"
|
||||
assert ev["status"] == "planned"
|
||||
assert ev["props"]["location"] == "park"
|
||||
assert ev["planned_for"] == "2026-04-30T18:00:00+00:00"
|
||||
assert ev["started_at"] is None
|
||||
assert ev["completed_at"] is None
|
||||
|
||||
|
||||
def test_event_started_then_completed_updates_status(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_chat(conn)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "date_at_park",
|
||||
"props": {},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"started_at": "2026-04-30T18:01:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_completed",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"completed_at": "2026-04-30T20:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
ev = get_event(conn, "evt_abc")
|
||||
assert ev is not None
|
||||
assert ev["status"] == "completed"
|
||||
assert ev["started_at"] == "2026-04-30T18:01:00+00:00"
|
||||
assert ev["completed_at"] == "2026-04-30T20:00:00+00:00"
|
||||
|
||||
|
||||
def test_event_cancelled_terminal_subsequent_transitions_ignored(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_chat(conn)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "date_at_park",
|
||||
"props": {},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"completed_at": "2026-04-30T17:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
ev = get_event(conn, "evt_abc")
|
||||
assert ev is not None
|
||||
assert ev["status"] == "cancelled"
|
||||
assert ev["completed_at"] == "2026-04-30T17:00:00+00:00"
|
||||
|
||||
# Subsequent event_started must be no-oped because status is terminal.
|
||||
# Use append_and_apply so we apply ONLY this new event without
|
||||
# replaying earlier non-idempotent handlers (e.g. chat_created).
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_abc",
|
||||
"started_at": "2026-04-30T18:01:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
ev2 = get_event(conn, "evt_abc")
|
||||
assert ev2 is not None
|
||||
assert ev2["status"] == "cancelled"
|
||||
assert ev2["started_at"] is None
|
||||
# completed_at unchanged from the cancelled transition
|
||||
assert ev2["completed_at"] == "2026-04-30T17:00:00+00:00"
|
||||
|
||||
|
||||
def test_list_active_events_filters_to_planned_and_active(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_chat(conn)
|
||||
# Four events: one planned, one active, one completed, one cancelled.
|
||||
for ev_id, kind in [
|
||||
("evt_planned", "date_at_park"),
|
||||
("evt_active", "movie_night"),
|
||||
("evt_done", "dinner"),
|
||||
("evt_canx", "trip"),
|
||||
]:
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": ev_id,
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": kind,
|
||||
"props": {},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_active",
|
||||
"started_at": "2026-04-30T18:01:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_done",
|
||||
"started_at": "2026-04-30T18:01:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_completed",
|
||||
payload={
|
||||
"event_id": "evt_done",
|
||||
"completed_at": "2026-04-30T20:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": "evt_canx",
|
||||
"completed_at": "2026-04-30T17:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
active = list_active_events(conn, "chat_bot_a")
|
||||
active_ids = {e["event_id"] for e in active}
|
||||
assert active_ids == {"evt_planned", "evt_active"}
|
||||
|
||||
completed = list_events_in_status(conn, "chat_bot_a", "completed")
|
||||
assert [e["event_id"] for e in completed] == ["evt_done"]
|
||||
|
||||
cancelled = list_events_in_status(conn, "chat_bot_a", "cancelled")
|
||||
assert [e["event_id"] for e in cancelled] == ["evt_canx"]
|
||||
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
import chat.state.entities # registers handlers
|
||||
import chat.state.world # registers handlers
|
||||
import chat.state.meanwhile # registers handlers
|
||||
from chat.state.meanwhile import (
|
||||
get_parent_scene,
|
||||
list_meanwhile_scenes,
|
||||
list_pending_meanwhile_digests,
|
||||
)
|
||||
from chat.state.world import active_scene
|
||||
|
||||
|
||||
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 test_meanwhile_started_creates_scene_with_correct_present_set_kind(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
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())
|
||||
# Parent (you-scene) — uses existing scene_opened handler. Will get
|
||||
# the default present_set_kind='you_host' from the new column.
|
||||
append_event(
|
||||
conn,
|
||||
kind="scene_opened",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"started_at": "2026-04-26T20:00:00+00:00",
|
||||
"participants": ["you", "bot_a", "bot_b"],
|
||||
},
|
||||
)
|
||||
# Now the meanwhile child scene — bot_a + bot_b only.
|
||||
append_event(
|
||||
conn,
|
||||
kind="meanwhile_scene_started",
|
||||
payload={
|
||||
"scene_id": 2,
|
||||
"chat_id": "chat_bot_a",
|
||||
"parent_scene_id": 1,
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"started_at": "2026-04-26T20:05:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
meanwhile_scenes = list_meanwhile_scenes(
|
||||
conn, "chat_bot_a", status="active"
|
||||
)
|
||||
assert len(meanwhile_scenes) == 1
|
||||
m = meanwhile_scenes[0]
|
||||
assert m["id"] == 2
|
||||
assert m["chat_id"] == "chat_bot_a"
|
||||
assert m["status"] == "active"
|
||||
assert m["present_set_kind"] == "host_guest"
|
||||
assert m["parent_scene_id"] == 1
|
||||
assert m["started_at"] == "2026-04-26T20:05:00+00:00"
|
||||
assert m["closed_at"] is None
|
||||
|
||||
# Parent linkage helper.
|
||||
parent = get_parent_scene(conn, 2)
|
||||
assert parent is not None
|
||||
assert parent["id"] == 1
|
||||
assert parent["present_set_kind"] == "you_host"
|
||||
|
||||
|
||||
def test_meanwhile_closed_marks_scene_closed(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
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())
|
||||
append_event(
|
||||
conn,
|
||||
kind="scene_opened",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"started_at": "2026-04-26T20:00:00+00:00",
|
||||
"participants": ["you", "bot_a", "bot_b"],
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="meanwhile_scene_started",
|
||||
payload={
|
||||
"scene_id": 2,
|
||||
"chat_id": "chat_bot_a",
|
||||
"parent_scene_id": 1,
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"started_at": "2026-04-26T20:05:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="meanwhile_scene_closed",
|
||||
payload={
|
||||
"scene_id": 2,
|
||||
"closed_at": "2026-04-26T20:15:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
assert list_meanwhile_scenes(conn, "chat_bot_a", status="active") == []
|
||||
closed = list_meanwhile_scenes(conn, "chat_bot_a", status="closed")
|
||||
assert len(closed) == 1
|
||||
assert closed[0]["id"] == 2
|
||||
assert closed[0]["status"] == "closed"
|
||||
assert closed[0]["closed_at"] == "2026-04-26T20:15:00+00:00"
|
||||
|
||||
|
||||
def test_active_you_scene_can_coexist_with_active_meanwhile_scene(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
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())
|
||||
append_event(
|
||||
conn,
|
||||
kind="scene_opened",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"started_at": "2026-04-26T20:00:00+00:00",
|
||||
"participants": ["you", "bot_a", "bot_b"],
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="meanwhile_scene_started",
|
||||
payload={
|
||||
"scene_id": 2,
|
||||
"chat_id": "chat_bot_a",
|
||||
"parent_scene_id": 1,
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"started_at": "2026-04-26T20:05:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
# The you-scene is still the active scene from the chat's POV.
|
||||
you_scene = active_scene(conn, "chat_bot_a")
|
||||
assert you_scene is not None
|
||||
assert you_scene["id"] == 1
|
||||
|
||||
# And the meanwhile child is independently active.
|
||||
meanwhile_scenes = list_meanwhile_scenes(
|
||||
conn, "chat_bot_a", status="active"
|
||||
)
|
||||
assert len(meanwhile_scenes) == 1
|
||||
assert meanwhile_scenes[0]["id"] == 2
|
||||
assert meanwhile_scenes[0]["present_set_kind"] == "host_guest"
|
||||
|
||||
# Cross-check via raw query: one row per present_set_kind, both unended.
|
||||
rows = conn.execute(
|
||||
"SELECT id, present_set_kind FROM scenes "
|
||||
"WHERE chat_id = ? AND ended_at IS NULL ORDER BY id",
|
||||
("chat_bot_a",),
|
||||
).fetchall()
|
||||
kinds = sorted(r[1] for r in rows)
|
||||
assert kinds == ["host_guest", "you_host"]
|
||||
|
||||
|
||||
def test_meanwhile_digest_created_and_consumed(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
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())
|
||||
append_event(
|
||||
conn,
|
||||
kind="scene_opened",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"started_at": "2026-04-26T20:00:00+00:00",
|
||||
"participants": ["you", "bot_a", "bot_b"],
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="meanwhile_scene_started",
|
||||
payload={
|
||||
"scene_id": 2,
|
||||
"chat_id": "chat_bot_a",
|
||||
"parent_scene_id": 1,
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"started_at": "2026-04-26T20:05:00+00:00",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="meanwhile_digest_created",
|
||||
payload={
|
||||
"scene_id": 2,
|
||||
"chat_id": "chat_bot_a",
|
||||
"summary": "BotA confides in BotB about the missing file.",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
pending = list_pending_meanwhile_digests(conn, "chat_bot_a")
|
||||
assert len(pending) == 1
|
||||
digest_id = pending[0]["id"]
|
||||
assert pending[0]["scene_id"] == 2
|
||||
assert pending[0]["summary"].startswith("BotA confides")
|
||||
|
||||
# Use append_and_apply for the second beat: re-running project()
|
||||
# would re-fire non-idempotent handlers (chat_created, scene_opened)
|
||||
# whose INSERTs conflict on UNIQUE constraints.
|
||||
from chat.eventlog.log import append_and_apply
|
||||
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="meanwhile_digest_consumed",
|
||||
payload={
|
||||
"digest_id": digest_id,
|
||||
"consumed_at": "2026-04-26T20:30:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert list_pending_meanwhile_digests(conn, "chat_bot_a") == []
|
||||
@@ -0,0 +1,572 @@
|
||||
"""Meanwhile-mode turn flow (T64).
|
||||
|
||||
A meanwhile scene runs entirely between two bots — host + guest — with
|
||||
"you" absent. The user manually advances the scene by POSTing prose to
|
||||
the existing ``/chats/<id>/turns`` endpoint; the route detects the active
|
||||
meanwhile scene at the start of ``post_turn`` and dispatches to the
|
||||
``process_meanwhile_turn`` controller in ``chat/web/meanwhile.py``.
|
||||
|
||||
Coverage:
|
||||
|
||||
1. Memory writes for a meanwhile turn carry witness ``[you=0, host=1,
|
||||
guest=1]`` for both the host's and the guest's per-POV memory rows.
|
||||
2. State updates after a meanwhile turn run for exactly 2 directed pairs
|
||||
(host -> guest, guest -> host) — no you-related pairs fire.
|
||||
3. Speakers alternate across consecutive meanwhile turns: the host
|
||||
speaks first (no prior meanwhile assistant_turn), the guest speaks
|
||||
second (the prior turn's speaker was the host, so this turn's
|
||||
speaker is the OTHER bot).
|
||||
4. Scene-close on a meanwhile scene writes per-POV summaries for host +
|
||||
guest only — no "you" POV row is written, mirroring the no-you
|
||||
present_set of the meanwhile scene.
|
||||
"""
|
||||
|
||||
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.eventlog.log import append_event
|
||||
from chat.eventlog.projector import project
|
||||
from chat.llm.mock import MockLLMClient
|
||||
import chat.state.meanwhile # noqa: F401 (registers handlers)
|
||||
|
||||
|
||||
def _bot_payload(bot_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": f"persona for {name}",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "",
|
||||
"kickoff_prose": "...",
|
||||
}
|
||||
|
||||
|
||||
def _seed_meanwhile_chat(db_path: Path) -> None:
|
||||
"""Seed two bots, you, a chat with both wired in, an open parent
|
||||
you-scene, AND an active meanwhile child scene with bot_a + bot_b.
|
||||
|
||||
Edges are seeded for both directed pairs between bot_a and bot_b at
|
||||
schema-default 50/50 so post-turn state-update writes land cleanly.
|
||||
Activities for both bots are recorded so the prompt assembler has
|
||||
something to render.
|
||||
"""
|
||||
with open_db(db_path) as conn:
|
||||
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="you_authored",
|
||||
payload={"name": "Me", "pronouns": "they/them", "persona": ""},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="chat_created",
|
||||
payload={
|
||||
"id": "chat_bot_a",
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"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": {},
|
||||
},
|
||||
)
|
||||
# Parent (you-scene) opens first.
|
||||
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", "bot_b"],
|
||||
},
|
||||
)
|
||||
# Meanwhile child scene — bot_a + bot_b only, parent linked.
|
||||
append_event(
|
||||
conn,
|
||||
kind="meanwhile_scene_started",
|
||||
payload={
|
||||
"scene_id": 2,
|
||||
"chat_id": "chat_bot_a",
|
||||
"parent_scene_id": 1,
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"started_at": "2026-04-26T20:05:00+00:00",
|
||||
},
|
||||
)
|
||||
# Seed both directed edges between the bots so state-update
|
||||
# writes land on initialized rows.
|
||||
for src, tgt in [("bot_a", "bot_b"), ("bot_b", "bot_a")]:
|
||||
append_event(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": src,
|
||||
"target_id": tgt,
|
||||
"chat_id": "chat_bot_a",
|
||||
"knowledge_facts": [],
|
||||
},
|
||||
)
|
||||
for entity_id, verb in [("bot_a", "listening"), ("bot_b", "talking")]:
|
||||
append_event(
|
||||
conn,
|
||||
kind="activity_change",
|
||||
payload={
|
||||
"entity_id": entity_id,
|
||||
"posture": "sitting",
|
||||
"action": {
|
||||
"verb": verb,
|
||||
"interruptible": True,
|
||||
"required_attention": "low",
|
||||
"expected_duration": "ongoing",
|
||||
},
|
||||
"attention": "",
|
||||
"holding": [],
|
||||
"status": {},
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
|
||||
def _override_llm(canned: list[str]) -> MockLLMClient:
|
||||
from chat.web.kickoff import get_llm_client
|
||||
|
||||
mock = MockLLMClient(canned=list(canned))
|
||||
app.dependency_overrides[get_llm_client] = lambda: mock
|
||||
return mock
|
||||
|
||||
|
||||
def _zero_state() -> str:
|
||||
return json.dumps(
|
||||
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_state_setup(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))
|
||||
with TestClient(app) as c:
|
||||
app.state.background_worker.enabled = False
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_meanwhile_turn_writes_memories_with_witness_0_1_1(
|
||||
app_state_setup, tmp_path
|
||||
):
|
||||
"""A meanwhile turn writes one ``memory_written`` event per bot — host
|
||||
and guest — with witness flags ``[you=0, host=1, guest=1]``. "You" is
|
||||
not present in the scene, so the witness_you flag must be 0 for both
|
||||
rows.
|
||||
|
||||
Canned queue (4 calls):
|
||||
1. parse_turn (user prose classification)
|
||||
2. narrative stream (host speaks first; no prior meanwhile turn)
|
||||
3. state-update for bot_a -> bot_b
|
||||
4. state-update for bot_b -> bot_a
|
||||
"""
|
||||
_seed_meanwhile_chat(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
{"segments": [{"kind": "narration", "text": "they exchange a glance"}]}
|
||||
)
|
||||
canned = [
|
||||
canned_parse,
|
||||
"BotA leans in. *quietly* Tell me what you saw.",
|
||||
_zero_state(),
|
||||
_zero_state(),
|
||||
]
|
||||
mock = _override_llm(canned)
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns",
|
||||
data={"prose": "they exchange a glance"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert mock._canned == []
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'memory_written' "
|
||||
"ORDER BY id"
|
||||
).fetchall()
|
||||
payloads = [json.loads(r[0]) for r in rows]
|
||||
|
||||
assert len(payloads) == 2
|
||||
owners = sorted(p["owner_id"] for p in payloads)
|
||||
assert owners == ["bot_a", "bot_b"]
|
||||
for p in payloads:
|
||||
assert p["witness_you"] == 0, p
|
||||
assert p["witness_host"] == 1, p
|
||||
assert p["witness_guest"] == 1, p
|
||||
|
||||
|
||||
def test_meanwhile_turn_emits_2_edge_updates_only(app_state_setup, tmp_path):
|
||||
"""A meanwhile turn runs state-update for exactly 2 directed pairs:
|
||||
host -> guest and guest -> host. No you-related pairs fire.
|
||||
"""
|
||||
_seed_meanwhile_chat(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
{"segments": [{"kind": "narration", "text": "they whisper"}]}
|
||||
)
|
||||
canned = [
|
||||
canned_parse,
|
||||
"BotA whispers. *softly* I noticed something today.",
|
||||
_zero_state(),
|
||||
_zero_state(),
|
||||
]
|
||||
mock = _override_llm(canned)
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns", data={"prose": "they whisper"}
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert mock._canned == []
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
# Edge updates landed AFTER the assistant_turn (i.e. excluding
|
||||
# the seed updates done before the request).
|
||||
max_at = conn.execute(
|
||||
"SELECT MAX(id) FROM event_log WHERE kind = 'assistant_turn'"
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'edge_update' AND id > ? ORDER BY id",
|
||||
(max_at,),
|
||||
).fetchall()
|
||||
payloads = [json.loads(r[0]) for r in rows]
|
||||
|
||||
# Exactly 2 post-turn edge_update events.
|
||||
assert len(payloads) == 2
|
||||
pairs = sorted((p["source_id"], p["target_id"]) for p in payloads)
|
||||
assert pairs == [("bot_a", "bot_b"), ("bot_b", "bot_a")]
|
||||
# And NO you-related pair leaked in.
|
||||
for p in payloads:
|
||||
assert p["source_id"] != "you", p
|
||||
assert p["target_id"] != "you", p
|
||||
|
||||
|
||||
def test_meanwhile_turn_alternates_speaker(app_state_setup, tmp_path):
|
||||
"""Successive meanwhile turns alternate which bot speaks.
|
||||
|
||||
The first turn has no prior meanwhile ``assistant_turn`` linked to
|
||||
this scene, so the host speaks. The second turn finds the latest
|
||||
such ``assistant_turn``'s speaker (the host) and picks the OTHER
|
||||
bot, so the guest speaks. Each ``assistant_turn`` payload carries
|
||||
``meanwhile_scene_id`` so the alternation lookup is unambiguous.
|
||||
"""
|
||||
_seed_meanwhile_chat(tmp_path / "test.db")
|
||||
canned_parse_1 = json.dumps(
|
||||
{"segments": [{"kind": "narration", "text": "they pause"}]}
|
||||
)
|
||||
canned_1 = [
|
||||
canned_parse_1,
|
||||
"BotA speaks first. *quietly*",
|
||||
_zero_state(),
|
||||
_zero_state(),
|
||||
]
|
||||
mock = _override_llm(canned_1)
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns", data={"prose": "they pause"}
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert mock._canned == []
|
||||
|
||||
canned_parse_2 = json.dumps(
|
||||
{"segments": [{"kind": "narration", "text": "and again"}]}
|
||||
)
|
||||
canned_2 = [
|
||||
canned_parse_2,
|
||||
"BotB replies. *thoughtfully*",
|
||||
_zero_state(),
|
||||
_zero_state(),
|
||||
]
|
||||
mock = _override_llm(canned_2)
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns", data={"prose": "and again"}
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert mock._canned == []
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' ORDER BY id"
|
||||
).fetchall()
|
||||
payloads = [json.loads(r[0]) for r in rows]
|
||||
|
||||
assert len(payloads) == 2
|
||||
# First turn — host speaks.
|
||||
assert payloads[0]["speaker_id"] == "bot_a"
|
||||
# Second turn — guest speaks (alternation).
|
||||
assert payloads[1]["speaker_id"] == "bot_b"
|
||||
# Both payloads tag this meanwhile scene id so the alternation
|
||||
# lookup can scope to it specifically (not any other assistant_turn
|
||||
# that might exist on the chat).
|
||||
assert payloads[0]["meanwhile_scene_id"] == 2
|
||||
assert payloads[1]["meanwhile_scene_id"] == 2
|
||||
# Both also carry the present_set_kind discriminator for downstream
|
||||
# filters (digest creation, drawer rendering).
|
||||
assert payloads[0]["present_set_kind"] == "host_guest"
|
||||
assert payloads[1]["present_set_kind"] == "host_guest"
|
||||
|
||||
|
||||
def test_meanwhile_scene_close_writes_per_pov_for_both_bots_only(
|
||||
app_state_setup, tmp_path
|
||||
):
|
||||
"""When a meanwhile scene closes, per-POV summary rewrites land for
|
||||
the host and the guest. No write fires for "you" — there is no
|
||||
"you" memory store and no "you" POV in the meanwhile present set.
|
||||
"""
|
||||
from chat.services.scene_summarize import apply_scene_close_summary
|
||||
from chat.eventlog.log import append_and_apply
|
||||
|
||||
_seed_meanwhile_chat(tmp_path / "test.db")
|
||||
|
||||
# Run a meanwhile turn first so each bot has a memory row scoped to
|
||||
# the meanwhile scene_id (=2). The per-POV rewrite targets these
|
||||
# rows by ``scene_id``.
|
||||
canned_parse = json.dumps(
|
||||
{"segments": [{"kind": "narration", "text": "they speak quietly"}]}
|
||||
)
|
||||
canned = [
|
||||
canned_parse,
|
||||
"BotA speaks. *quietly*",
|
||||
_zero_state(),
|
||||
_zero_state(),
|
||||
]
|
||||
mock = _override_llm(canned)
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns",
|
||||
data={"prose": "they speak quietly"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert mock._canned == []
|
||||
|
||||
# Close the meanwhile scene and run the close-summary pipeline.
|
||||
# Two POV summaries (host + guest) — no "you" POV.
|
||||
pov_payload_host = json.dumps(
|
||||
{
|
||||
"summary": "BotA reflects on the quiet moment with BotB.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "",
|
||||
}
|
||||
)
|
||||
pov_payload_guest = json.dumps(
|
||||
{
|
||||
"summary": "BotB notices BotA's reserved manner.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "",
|
||||
}
|
||||
)
|
||||
# T65 added a meanwhile digest summarize call after per-POV writes
|
||||
# for meanwhile scenes. T58's thread detection is wrapped in try/except
|
||||
# so its IndexError is swallowed gracefully.
|
||||
digest_payload = json.dumps(
|
||||
{
|
||||
"summary": "While you were away, BotA and BotB talked quietly.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "",
|
||||
}
|
||||
)
|
||||
close_mock = MockLLMClient(
|
||||
canned=[pov_payload_host, pov_payload_guest, digest_payload]
|
||||
)
|
||||
|
||||
import asyncio as _asyncio
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
# asyncio.run() can't nest under TestClient's loop, but the
|
||||
# close pipeline is awaitable — drive it via a fresh loop here.
|
||||
_loop = _asyncio.new_event_loop()
|
||||
# Mark the meanwhile scene closed via the projector handler.
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="meanwhile_scene_closed",
|
||||
payload={
|
||||
"scene_id": 2,
|
||||
"closed_at": "2026-04-26T20:30:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
# apply_scene_close_summary takes host_bot_id; here we tell it to
|
||||
# operate on the meanwhile scene id (2). With no "you" memory
|
||||
# row to rewrite (witness_you=0 means "you" doesn't have a
|
||||
# memory for this scene), the call must produce per-POV writes
|
||||
# ONLY for bot_a and bot_b.
|
||||
try:
|
||||
_loop.run_until_complete(
|
||||
apply_scene_close_summary(
|
||||
conn,
|
||||
close_mock,
|
||||
classifier_model="x",
|
||||
chat_id="chat_bot_a",
|
||||
scene_id=2,
|
||||
host_bot_id="bot_a",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
_loop.close()
|
||||
|
||||
# Per-POV memory rewrites: count manual_edits with target_kind
|
||||
# ``memory_pov_summary`` whose target_id maps to a memory row
|
||||
# scoped to scene 2.
|
||||
edits = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||
).fetchall()
|
||||
pov_edits = []
|
||||
for (raw,) in edits:
|
||||
payload = json.loads(raw)
|
||||
if payload.get("target_kind") != "memory_pov_summary":
|
||||
continue
|
||||
mem_row = conn.execute(
|
||||
"SELECT owner_id, scene_id FROM memories WHERE id = ?",
|
||||
(payload["target_id"],),
|
||||
).fetchone()
|
||||
if mem_row is None or mem_row[1] != 2:
|
||||
continue
|
||||
pov_edits.append({"owner": mem_row[0], "new": payload["new_value"]})
|
||||
|
||||
# Verify the actual current pov_summary on each bot's memory row
|
||||
# for scene 2 reflects the rewrite.
|
||||
host_pov = conn.execute(
|
||||
"SELECT pov_summary FROM memories WHERE owner_id = ? AND scene_id = ?",
|
||||
("bot_a", 2),
|
||||
).fetchone()
|
||||
guest_pov = conn.execute(
|
||||
"SELECT pov_summary FROM memories WHERE owner_id = ? AND scene_id = ?",
|
||||
("bot_b", 2),
|
||||
).fetchone()
|
||||
# No "you" memory row should exist for the meanwhile scene —
|
||||
# "you" was never a witness.
|
||||
you_row = conn.execute(
|
||||
"SELECT id FROM memories WHERE owner_id = 'you' AND scene_id = ?",
|
||||
(2,),
|
||||
).fetchone()
|
||||
|
||||
# Exactly two memory_pov_summary rewrites — one per bot witness.
|
||||
assert len(pov_edits) == 2
|
||||
owners = sorted(e["owner"] for e in pov_edits)
|
||||
assert owners == ["bot_a", "bot_b"]
|
||||
assert host_pov is not None and "BotA reflects" in host_pov[0]
|
||||
assert guest_pov is not None and "BotB notices" in guest_pov[0]
|
||||
# No "you" POV row — meanwhile scenes don't surface a you-memory.
|
||||
assert you_row is None
|
||||
|
||||
|
||||
def test_meanwhile_turn_registered_in_in_flight_tasks(
|
||||
app_state_setup, tmp_path
|
||||
):
|
||||
"""A meanwhile turn registers its streaming task in the chat-keyed
|
||||
``_in_flight_tasks`` registry the cancel route reads from, and clears
|
||||
the entry after the stream completes.
|
||||
|
||||
Without registration, ``POST /chats/<id>/turns/cancel`` would be a
|
||||
silent no-op for meanwhile beats — the Stop button wouldn't actually
|
||||
stop them. We pin the behaviour via a streaming mock that snapshots
|
||||
``_in_flight_tasks`` at the moment of its first yield (mid-flight),
|
||||
then assert the entry is removed after the response returns.
|
||||
"""
|
||||
from typing import AsyncIterator, Sequence
|
||||
|
||||
from chat.llm.client import Message
|
||||
from chat.web.turns import _in_flight_tasks
|
||||
|
||||
_seed_meanwhile_chat(tmp_path / "test.db")
|
||||
|
||||
# Snapshot of (chat_id-present?, registered task object) captured
|
||||
# at the first stream yield. The closure runs inside the streaming
|
||||
# coroutine, so when it executes the task is alive and registered.
|
||||
in_flight_snapshot: dict = {}
|
||||
|
||||
class _SnapshotMock(MockLLMClient):
|
||||
async def stream(
|
||||
self, messages: Sequence[Message], *, model: str, **params
|
||||
) -> AsyncIterator[str]:
|
||||
text = self._canned.pop(0)
|
||||
for i, ch in enumerate(text):
|
||||
if i == 0:
|
||||
# Snapshot at first yield — the post_turn coroutine
|
||||
# is awaiting our generator and the streaming Task
|
||||
# is registered in _in_flight_tasks[chat_id].
|
||||
in_flight_snapshot["present"] = (
|
||||
"chat_bot_a" in _in_flight_tasks
|
||||
)
|
||||
in_flight_snapshot["task"] = _in_flight_tasks.get(
|
||||
"chat_bot_a"
|
||||
)
|
||||
yield ch
|
||||
|
||||
canned_parse = json.dumps(
|
||||
{"segments": [{"kind": "narration", "text": "they exchange a glance"}]}
|
||||
)
|
||||
mock = _SnapshotMock(
|
||||
canned=[
|
||||
canned_parse,
|
||||
"BotA leans in. *quietly*",
|
||||
_zero_state(),
|
||||
_zero_state(),
|
||||
]
|
||||
)
|
||||
from chat.web.kickoff import get_llm_client
|
||||
|
||||
app.dependency_overrides[get_llm_client] = lambda: mock
|
||||
try:
|
||||
# Pre-condition: registry is empty for this chat.
|
||||
assert "chat_bot_a" not in _in_flight_tasks
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns",
|
||||
data={"prose": "they exchange a glance"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
# Mid-flight: the streaming task was present in the registry, and
|
||||
# the captured value was an asyncio.Task (not None / not some other
|
||||
# placeholder).
|
||||
import asyncio
|
||||
|
||||
assert in_flight_snapshot.get("present") is True, (
|
||||
"_in_flight_tasks was empty at first yield — meanwhile stream "
|
||||
"isn't registering its task"
|
||||
)
|
||||
assert isinstance(in_flight_snapshot.get("task"), asyncio.Task)
|
||||
# Post-flight: the entry has been cleaned up so the next turn (or
|
||||
# the cancel route) doesn't see a stale task.
|
||||
assert "chat_bot_a" not in _in_flight_tasks
|
||||
@@ -125,3 +125,37 @@ def test_search_invalid_witness_role_raises(tmp_path):
|
||||
with open_db(db) as conn:
|
||||
with pytest.raises(ValueError):
|
||||
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
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
from chat.db.connection import open_db
|
||||
|
||||
|
||||
def test_open_db_default_uses_check_same_thread_true(tmp_path):
|
||||
"""Default open_db must reject cross-thread use (safe default)."""
|
||||
db = tmp_path / "t.db"
|
||||
captured: list[BaseException | None] = []
|
||||
|
||||
with open_db(db) as conn:
|
||||
conn.execute("CREATE TABLE t (x INTEGER)")
|
||||
|
||||
def worker():
|
||||
try:
|
||||
conn.execute("SELECT 1").fetchall()
|
||||
captured.append(None)
|
||||
except BaseException as e: # noqa: BLE001
|
||||
captured.append(e)
|
||||
|
||||
t = threading.Thread(target=worker)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
assert len(captured) == 1
|
||||
err = captured[0]
|
||||
assert isinstance(err, sqlite3.ProgrammingError), (
|
||||
f"expected sqlite3.ProgrammingError on cross-thread use, got {err!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_open_db_can_disable_check_same_thread(tmp_path):
|
||||
"""open_db(check_same_thread=False) must allow cross-thread use."""
|
||||
db = tmp_path / "t.db"
|
||||
captured: list[BaseException | None] = []
|
||||
rows: list[object] = []
|
||||
|
||||
with open_db(db, check_same_thread=False) as conn:
|
||||
conn.execute("CREATE TABLE t (x INTEGER)")
|
||||
conn.execute("INSERT INTO t (x) VALUES (42)")
|
||||
|
||||
def worker():
|
||||
try:
|
||||
result = conn.execute("SELECT x FROM t").fetchall()
|
||||
rows.extend(result)
|
||||
captured.append(None)
|
||||
except BaseException as e: # noqa: BLE001
|
||||
captured.append(e)
|
||||
|
||||
t = threading.Thread(target=worker)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
assert captured == [None], f"worker thread raised: {captured}"
|
||||
assert rows == [(42,)]
|
||||
+748
-10
@@ -504,13 +504,15 @@ async def test_close_with_no_guest_matches_phase1(tmp_path):
|
||||
"relationship_summary": "BotA leaned in supportively.",
|
||||
}
|
||||
)
|
||||
no_threads = json.dumps({"candidates": []})
|
||||
with open_db(db) as conn:
|
||||
_seed_single_bot_scene(conn)
|
||||
project(conn)
|
||||
|
||||
# canned has 2 entries to detect any over-call; the assertion below
|
||||
# confirms only one was consumed.
|
||||
client = MockLLMClient(canned=[canned, canned])
|
||||
# 1 host-POV entry + 1 thread-detection entry (T58.2) + 1 spare
|
||||
# to detect any over-call. Assertion below confirms exactly two
|
||||
# were consumed.
|
||||
client = MockLLMClient(canned=[canned, no_threads, canned])
|
||||
await apply_scene_close_summary(
|
||||
conn,
|
||||
client,
|
||||
@@ -520,8 +522,8 @@ async def test_close_with_no_guest_matches_phase1(tmp_path):
|
||||
host_bot_id="bot_a",
|
||||
)
|
||||
|
||||
# Exactly one classifier call -> exactly one canned entry consumed,
|
||||
# leaving the second untouched.
|
||||
# Host POV + thread detection -> exactly two canned entries
|
||||
# consumed, leaving the spare untouched.
|
||||
assert len(client._canned) == 1
|
||||
|
||||
# Host memory rewritten with the per-POV summary content.
|
||||
@@ -636,8 +638,10 @@ async def test_close_with_guest_updates_both_edges(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_with_group_node_updates_group_summary(tmp_path):
|
||||
"""When a group_node row exists, scene close emits group_node_updated
|
||||
with a non-empty summary that mentions both bots' names (v2 naive
|
||||
concat of per-POV summaries)."""
|
||||
with a non-empty summary that mentions both bots' names. T70 swapped
|
||||
the Phase 2 naive concat for an LLM-merged summary; this regression
|
||||
test feeds bad-JSON merge responses so the helper falls back to the
|
||||
original naive-concat shape, preserving the original assertions."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
import chat.state.group_node # noqa: F401 -- register handlers
|
||||
@@ -660,7 +664,11 @@ async def test_close_with_group_node_updates_group_summary(tmp_path):
|
||||
_seed_two_bot_scene(conn, with_group_node=True)
|
||||
project(conn)
|
||||
|
||||
client = MockLLMClient(canned=[host_canned, guest_canned])
|
||||
# 2 valid (host POV, guest POV) + 3 bad-JSON merge attempts ->
|
||||
# merge_group_summary falls back to the naive concat default.
|
||||
client = MockLLMClient(
|
||||
canned=[host_canned, guest_canned, "bad1", "bad2", "bad3"]
|
||||
)
|
||||
await apply_scene_close_summary(
|
||||
conn,
|
||||
client,
|
||||
@@ -675,8 +683,738 @@ async def test_close_with_group_node_updates_group_summary(tmp_path):
|
||||
gn = get_group_node(conn, "chat_bot_a")
|
||||
assert gn is not None
|
||||
assert gn["summary"] # non-empty
|
||||
# Naive concat surfaces both bot names in the group summary.
|
||||
# Naive-concat fallback surfaces both bot names in the group summary.
|
||||
assert "BotA" in gn["summary"]
|
||||
assert "BotB" in gn["summary"]
|
||||
# Phase 2 v2 keeps dynamic empty (Phase 3 polishes).
|
||||
# Naive-concat fallback keeps dynamic empty.
|
||||
assert gn["dynamic"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T70: LLM-merged group meta-summary on scene close.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_summary_merges_per_pov_via_classifier_when_guest_present(
|
||||
tmp_path,
|
||||
):
|
||||
"""With a guest present and a group_node row, scene close runs the
|
||||
merge classifier as a third call after the two per-POV summarize_scene
|
||||
calls; its output drives the group_node summary + dynamic fields."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
import chat.state.group_node # noqa: F401 -- register handlers
|
||||
|
||||
host_canned = json.dumps(
|
||||
{
|
||||
"summary": "BotA appreciated the calm.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "BotA felt steady.",
|
||||
}
|
||||
)
|
||||
guest_canned = json.dumps(
|
||||
{
|
||||
"summary": "BotB found the room friendly.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "BotB warmed up.",
|
||||
}
|
||||
)
|
||||
merge_canned = json.dumps(
|
||||
{"summary": "merged group view", "dynamic": "warm rapport"}
|
||||
)
|
||||
with open_db(db) as conn:
|
||||
_seed_two_bot_scene(conn, with_group_node=True)
|
||||
project(conn)
|
||||
|
||||
# Canned-queue layout matches the production call order in
|
||||
# apply_scene_close_summary: host POV summarize_scene runs first,
|
||||
# then guest POV summarize_scene, then merge_group_summary.
|
||||
client = MockLLMClient(
|
||||
canned=[host_canned, guest_canned, merge_canned]
|
||||
)
|
||||
await apply_scene_close_summary(
|
||||
conn,
|
||||
client,
|
||||
classifier_model="x",
|
||||
chat_id="chat_bot_a",
|
||||
scene_id=1,
|
||||
host_bot_id="bot_a",
|
||||
)
|
||||
|
||||
# All three canned entries consumed -> classifier ran exactly 3x.
|
||||
assert client._canned == []
|
||||
|
||||
from chat.state.group_node import get_group_node
|
||||
|
||||
gn = get_group_node(conn, "chat_bot_a")
|
||||
assert gn is not None
|
||||
assert gn["summary"] == "merged group view"
|
||||
assert gn["dynamic"] == "warm rapport"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_summary_falls_back_to_naive_concat_on_classifier_failure(
|
||||
tmp_path,
|
||||
):
|
||||
"""If the merge classifier flaps (bad JSON across all 3 retries), the
|
||||
helper falls back to the original Phase 2 naive concat shape and
|
||||
leaves dynamic empty."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
import chat.state.group_node # noqa: F401 -- register handlers
|
||||
|
||||
host_canned = json.dumps(
|
||||
{
|
||||
"summary": "BotA appreciated the calm.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "BotA felt steady.",
|
||||
}
|
||||
)
|
||||
guest_canned = json.dumps(
|
||||
{
|
||||
"summary": "BotB found the room friendly.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "BotB warmed up.",
|
||||
}
|
||||
)
|
||||
with open_db(db) as conn:
|
||||
_seed_two_bot_scene(conn, with_group_node=True)
|
||||
project(conn)
|
||||
|
||||
# 2 valid POV summaries + 3 bad-JSON merge attempts trip the
|
||||
# classifier's retry-then-default path; the default is the naive
|
||||
# concat fallback.
|
||||
client = MockLLMClient(
|
||||
canned=[host_canned, guest_canned, "bad1", "bad2", "bad3"]
|
||||
)
|
||||
await apply_scene_close_summary(
|
||||
conn,
|
||||
client,
|
||||
classifier_model="x",
|
||||
chat_id="chat_bot_a",
|
||||
scene_id=1,
|
||||
host_bot_id="bot_a",
|
||||
)
|
||||
|
||||
from chat.state.group_node import get_group_node
|
||||
|
||||
gn = get_group_node(conn, "chat_bot_a")
|
||||
assert gn is not None
|
||||
expected = (
|
||||
"BotA: BotA appreciated the calm.\n\n"
|
||||
"BotB: BotB found the room friendly."
|
||||
)
|
||||
assert gn["summary"] == expected
|
||||
assert gn["dynamic"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_summary_skipped_when_no_guest(tmp_path):
|
||||
"""No-guest path: scene close does NOT invoke merge_group_summary
|
||||
and emits no group_node_updated event. Confirms the existing
|
||||
`if guest_bot_id is not None` gating at the call site."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
canned = json.dumps(
|
||||
{
|
||||
"summary": "BotA helped you talk through the deadline anxiety.",
|
||||
"knowledge_facts": ["Deadline next Friday."],
|
||||
"relationship_summary": "BotA leaned in supportively.",
|
||||
}
|
||||
)
|
||||
with open_db(db) as conn:
|
||||
_seed_single_bot_scene(conn)
|
||||
project(conn)
|
||||
|
||||
# Only 1 canned entry; if merge_group_summary were called the
|
||||
# MockLLMClient would IndexError on the empty queue.
|
||||
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",
|
||||
)
|
||||
|
||||
# Exactly the host POV call consumed, nothing else.
|
||||
assert client._canned == []
|
||||
|
||||
# No group_node_updated event was emitted.
|
||||
rows = conn.execute(
|
||||
"SELECT 1 FROM event_log WHERE kind = 'group_node_updated'"
|
||||
).fetchall()
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T65: meanwhile summary digest emitted on meanwhile-scene close, surfaced in
|
||||
# the next you-scene's prompt as a SHOULD-tier "Meanwhile while you were away:"
|
||||
# block, then consumed so it never re-renders.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_meanwhile_scene(conn) -> None:
|
||||
"""Seed a parent you-scene + a meanwhile child scene with one assistant
|
||||
turn so apply_scene_close_summary has dialogue to summarize.
|
||||
|
||||
The meanwhile scene id is 2 (parent is scene 1). The meanwhile dialogue
|
||||
is appended via assistant_turn events under chat_bot_a; the
|
||||
_read_recent_dialogue helper picks them up by chat_id.
|
||||
"""
|
||||
import chat.state.meanwhile # noqa: F401 -- register handlers
|
||||
|
||||
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="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",
|
||||
"guest_bot_id": "bot_b",
|
||||
"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": {},
|
||||
},
|
||||
)
|
||||
# Parent you-scene (scene_id=1).
|
||||
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", "bot_b"],
|
||||
},
|
||||
)
|
||||
# Meanwhile child scene (scene_id=2) — bot_a + bot_b only.
|
||||
append_event(
|
||||
conn,
|
||||
kind="meanwhile_scene_started",
|
||||
payload={
|
||||
"scene_id": 2,
|
||||
"chat_id": "chat_bot_a",
|
||||
"parent_scene_id": 1,
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"started_at": "2026-04-26T20:05:00+00:00",
|
||||
},
|
||||
)
|
||||
# Edges so per-POV apply has rows to update.
|
||||
append_event(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"chat_id": "chat_bot_a",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": "bot_b",
|
||||
"target_id": "you",
|
||||
"chat_id": "chat_bot_a",
|
||||
},
|
||||
)
|
||||
# One memory per witness in the meanwhile scene.
|
||||
append_event(
|
||||
conn,
|
||||
kind="memory_written",
|
||||
payload={
|
||||
"owner_id": "bot_a",
|
||||
"chat_id": "chat_bot_a",
|
||||
"scene_id": 2,
|
||||
"pov_summary": "Original raw narrative (host, meanwhile)",
|
||||
"witness_you": 0,
|
||||
"witness_host": 1,
|
||||
"witness_guest": 1,
|
||||
"significance": 1,
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="memory_written",
|
||||
payload={
|
||||
"owner_id": "bot_b",
|
||||
"chat_id": "chat_bot_a",
|
||||
"scene_id": 2,
|
||||
"pov_summary": "Original raw narrative (guest, meanwhile)",
|
||||
"witness_you": 0,
|
||||
"witness_host": 1,
|
||||
"witness_guest": 1,
|
||||
"significance": 1,
|
||||
},
|
||||
)
|
||||
# A bot-bot turn happens during the meanwhile scene.
|
||||
append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"speaker_id": "bot_a",
|
||||
"text": "Did you hear what happened with the missing file?",
|
||||
"truncated": False,
|
||||
"user_turn_id": None,
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"speaker_id": "bot_b",
|
||||
"text": "I have a theory but no proof yet.",
|
||||
"truncated": False,
|
||||
"user_turn_id": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_meanwhile_close_creates_digest(tmp_path):
|
||||
"""When apply_scene_close_summary runs on a meanwhile scene
|
||||
(present_set_kind == 'host_guest'), it emits a meanwhile_digest_created
|
||||
event after the per-POV summaries land; the meanwhile_digest_pending
|
||||
table then holds a row with non-empty summary text."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
host_canned = json.dumps(
|
||||
{
|
||||
"summary": "BotA confided in BotB about the missing file.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "BotA leaned on BotB.",
|
||||
}
|
||||
)
|
||||
guest_canned = json.dumps(
|
||||
{
|
||||
"summary": "BotB listened and offered to help investigate.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "BotB grew protective.",
|
||||
}
|
||||
)
|
||||
digest_canned = json.dumps(
|
||||
{
|
||||
"summary": (
|
||||
"While you were away, BotA confided in BotB about a "
|
||||
"missing file; BotB offered to help quietly investigate."
|
||||
),
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "",
|
||||
}
|
||||
)
|
||||
no_threads = json.dumps({"candidates": []})
|
||||
with open_db(db) as conn:
|
||||
_seed_meanwhile_scene(conn)
|
||||
project(conn)
|
||||
|
||||
# Order: host POV summary, guest POV summary, digest summary,
|
||||
# thread detection.
|
||||
client = MockLLMClient(
|
||||
canned=[host_canned, guest_canned, digest_canned, no_threads]
|
||||
)
|
||||
await apply_scene_close_summary(
|
||||
conn,
|
||||
client,
|
||||
classifier_model="x",
|
||||
chat_id="chat_bot_a",
|
||||
scene_id=2,
|
||||
host_bot_id="bot_a",
|
||||
)
|
||||
|
||||
# The meanwhile_digest_pending row was written.
|
||||
from chat.state.meanwhile import list_pending_meanwhile_digests
|
||||
|
||||
pending = list_pending_meanwhile_digests(conn, "chat_bot_a")
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["scene_id"] == 2
|
||||
assert pending[0]["summary"]
|
||||
assert "missing file" in pending[0]["summary"]
|
||||
|
||||
# And the meanwhile_digest_created event was logged.
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'meanwhile_digest_created'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0][0])
|
||||
assert payload["chat_id"] == "chat_bot_a"
|
||||
assert payload["scene_id"] == 2
|
||||
assert "missing file" in payload["summary"]
|
||||
|
||||
|
||||
def test_pending_digest_renders_in_you_scene_prompt(tmp_path):
|
||||
"""A pending meanwhile digest (created via direct event append) renders
|
||||
as a 'Meanwhile while you were away:' SHOULD-tier block in the
|
||||
assembled you-scene narrative prompt."""
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.services.prompt import assemble_narrative_prompt
|
||||
import chat.state.meanwhile # noqa: F401 -- register handlers
|
||||
import chat.state.threads # noqa: F401
|
||||
import chat.state.events # noqa: F401
|
||||
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_single_bot_scene(conn)
|
||||
project(conn)
|
||||
|
||||
digest_text = (
|
||||
"While you were away, BotA confided in BotB about a missing file."
|
||||
)
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="meanwhile_digest_created",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"scene_id": 2,
|
||||
"summary": digest_text,
|
||||
},
|
||||
)
|
||||
|
||||
msgs = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a",
|
||||
recent_dialogue=[],
|
||||
retrieved_memory_summaries=[],
|
||||
)
|
||||
body = msgs[0].content
|
||||
assert "Meanwhile while you were away:" in body
|
||||
assert digest_text in body
|
||||
|
||||
|
||||
def test_consumed_digest_does_not_render_again(tmp_path):
|
||||
"""After meanwhile_digest_consumed lands for a digest, reassembling the
|
||||
you-scene prompt must NOT include that digest's text — the pending
|
||||
list is filtered by ``consumed_at IS NULL``."""
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.services.prompt import assemble_narrative_prompt
|
||||
import chat.state.meanwhile # noqa: F401 -- register handlers
|
||||
import chat.state.threads # noqa: F401
|
||||
import chat.state.events # noqa: F401
|
||||
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_single_bot_scene(conn)
|
||||
project(conn)
|
||||
|
||||
digest_text = (
|
||||
"While you were away, BotA confided in BotB about a missing file."
|
||||
)
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="meanwhile_digest_created",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"scene_id": 2,
|
||||
"summary": digest_text,
|
||||
},
|
||||
)
|
||||
|
||||
# Sanity: it renders before consumption.
|
||||
msgs = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a",
|
||||
recent_dialogue=[],
|
||||
retrieved_memory_summaries=[],
|
||||
)
|
||||
assert digest_text in msgs[0].content
|
||||
|
||||
# Look up the pending digest id, then consume it.
|
||||
from chat.state.meanwhile import list_pending_meanwhile_digests
|
||||
|
||||
pending = list_pending_meanwhile_digests(conn, "chat_bot_a")
|
||||
assert len(pending) == 1
|
||||
digest_id = pending[0]["id"]
|
||||
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="meanwhile_digest_consumed",
|
||||
payload={
|
||||
"digest_id": digest_id,
|
||||
"consumed_at": "2026-04-26T20:30:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
msgs2 = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a",
|
||||
recent_dialogue=[],
|
||||
retrieved_memory_summaries=[],
|
||||
)
|
||||
body2 = msgs2[0].content
|
||||
assert "Meanwhile while you were away:" not in body2
|
||||
assert digest_text not in body2
|
||||
|
||||
+359
-1
@@ -12,12 +12,14 @@ import pytest
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.eventlog.log import append_event
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.eventlog.projector import project
|
||||
import chat.state.entities # noqa: F401 (registers handlers)
|
||||
import chat.state.edges # noqa: F401
|
||||
import chat.state.memory # noqa: F401
|
||||
import chat.state.world # noqa: F401
|
||||
import chat.state.events # noqa: F401
|
||||
import chat.state.threads # noqa: F401
|
||||
from chat.llm.client import Message
|
||||
from chat.services.prompt import assemble_narrative_prompt
|
||||
|
||||
@@ -452,6 +454,273 @@ def test_assemble_when_speaker_is_guest_orients_edges_correctly(tmp_path):
|
||||
assert "68/100" in body
|
||||
|
||||
|
||||
def test_speaker_is_guest_uses_guest_witness_role(tmp_path, monkeypatch):
|
||||
"""T71.1: when the guest is the speaker, ``search_memories`` is
|
||||
called with ``witness_role="guest"``, not the previously-hardcoded
|
||||
``"host"``. Pins the parametric witness role at the prompt call site
|
||||
so guest-as-speaker honours the witness mask via Phase 2 T46.
|
||||
"""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_search(conn, owner_id, witness_role, query, k=4):
|
||||
captured["owner_id"] = owner_id
|
||||
captured["witness_role"] = witness_role
|
||||
captured["query"] = query
|
||||
return []
|
||||
|
||||
# Patch the imported reference inside the prompt module so the
|
||||
# production call site uses the fake.
|
||||
import chat.services.prompt as prompt_mod
|
||||
monkeypatch.setattr(prompt_mod, "search_memories", _fake_search)
|
||||
|
||||
with open_db(db) as conn:
|
||||
_seed_with_guest(conn)
|
||||
# Guest as speaker — must request memories with witness_role="guest".
|
||||
assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_b",
|
||||
recent_dialogue=[],
|
||||
# retrieved_memory_summaries=None forces the search path.
|
||||
retrieved_memory_summaries=None,
|
||||
)
|
||||
assert captured["owner_id"] == "bot_b"
|
||||
assert captured["witness_role"] == "guest"
|
||||
|
||||
|
||||
def test_speaker_is_host_uses_host_witness_role(tmp_path, monkeypatch):
|
||||
"""T71.1 (regression): host-as-speaker still queries with
|
||||
``witness_role="host"``."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_search(conn, owner_id, witness_role, query, k=4):
|
||||
captured["witness_role"] = witness_role
|
||||
return []
|
||||
|
||||
import chat.services.prompt as prompt_mod
|
||||
monkeypatch.setattr(prompt_mod, "search_memories", _fake_search)
|
||||
|
||||
with open_db(db) as conn:
|
||||
_seed_with_guest(conn)
|
||||
assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a", # host as speaker
|
||||
recent_dialogue=[],
|
||||
retrieved_memory_summaries=None,
|
||||
)
|
||||
assert captured["witness_role"] == "host"
|
||||
|
||||
|
||||
def test_single_activities_block_with_three_bullets_when_3_entities(tmp_path):
|
||||
"""T71.2: with you + host + guest present, the assembled prompt
|
||||
contains exactly ONE ``ACTIVITIES:`` header and bullets for all
|
||||
three entities (no duplicate header from the prior dual-block
|
||||
rendering).
|
||||
"""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_with_guest(conn)
|
||||
msgs = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a",
|
||||
recent_dialogue=[],
|
||||
retrieved_memory_summaries=[],
|
||||
)
|
||||
body = msgs[0].content
|
||||
# Exactly one ACTIVITIES: header.
|
||||
assert body.count("ACTIVITIES:") == 1
|
||||
# Bullets for all three entities (you=Sam, host=Aria, guest=Iris)
|
||||
# — pin on the unique action verbs from the seed data.
|
||||
assert "finishing emails" in body # you bullet
|
||||
assert "pretending to work" in body # speaker (host) bullet
|
||||
assert "smirking-distinctively" in body # guest bullet
|
||||
|
||||
|
||||
def test_tight_budget_drops_guest_activity_bullet_first(tmp_path):
|
||||
"""T71.2: under tight budget the speaker bullet survives but the
|
||||
guest activity bullet is the first ACTIVITIES: bullet to drop. The
|
||||
block as a whole stays present (it's MUST-tier); only its body
|
||||
contracts.
|
||||
"""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_with_guest(conn)
|
||||
dialogue = [
|
||||
{"speaker": "you", "text": "line-16 hi there"},
|
||||
{"speaker": "bot_a", "text": "line-17 hey"},
|
||||
{"speaker": "you", "text": "line-18 quiet night"},
|
||||
{"speaker": "bot_a", "text": "line-19 indeed"},
|
||||
]
|
||||
msgs = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a",
|
||||
recent_dialogue=dialogue,
|
||||
retrieved_memory_summaries=[],
|
||||
budget_soft=250,
|
||||
budget_hard=340,
|
||||
)
|
||||
body = msgs[0].content
|
||||
# Speaker bullet survives (MUST-tier floor).
|
||||
assert "pretending to work" in body
|
||||
assert "ACTIVITIES:" in body
|
||||
# Guest bullet is dropped first under budget pressure.
|
||||
assert "smirking-distinctively" not in body
|
||||
|
||||
|
||||
def test_nice_trim_order_documented(tmp_path):
|
||||
"""T71.3: pin the NICE-tier trim order so a future refactor can't
|
||||
quietly invert it.
|
||||
|
||||
Order under NICE pressure is:
|
||||
1. previous-scene summary (dropped FIRST)
|
||||
2. memories beyond top-2
|
||||
3. older dialogue turns (collapsed to last-4)
|
||||
|
||||
We size the budget so that all-NICE-included is over soft, but
|
||||
dropping ONLY previous-scene gets us back under soft. The observed
|
||||
behaviour we pin: previous-scene gone, memories/dialogue intact.
|
||||
"""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
# Heavy previous-scene summary — large enough that dropping it
|
||||
# alone clears the soft-budget overage. Defined out here so the
|
||||
# marker is in scope for the assertions below.
|
||||
prev_scene_blob = "PREVSCENE-MARKER " + ("filler " * 200)
|
||||
with open_db(db) as conn:
|
||||
# Append all events first, project once at the end (project is
|
||||
# not idempotent — it replays every event in the log).
|
||||
from chat.eventlog.log import append_event as _append
|
||||
_append(conn, kind="bot_authored", payload={
|
||||
"id": "bot_a",
|
||||
"name": "Aria",
|
||||
"persona": "reserved coworker who notices things",
|
||||
"voice_samples": ["I — sorry, I didn't mean to."],
|
||||
"traits": ["introverted"],
|
||||
"backstory": "An archivist who joined the firm last spring.",
|
||||
"initial_relationship_to_you": "coworker",
|
||||
"kickoff_prose": "you stay late at the office",
|
||||
})
|
||||
_append(conn, kind="you_authored", payload={
|
||||
"name": "Sam",
|
||||
"pronouns": "they/them",
|
||||
"persona": "tired analyst",
|
||||
})
|
||||
_append(conn, kind="chat_created", payload={
|
||||
"id": "chat_bot_a",
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": None,
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1 evening",
|
||||
"weather": "clear",
|
||||
})
|
||||
_append(conn, kind="container_created", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"name": "office bullpen",
|
||||
"type": "workplace",
|
||||
"properties": {"public": False, "moving": False, "audible_range": "room"},
|
||||
})
|
||||
_append(conn, kind="edge_update", payload={
|
||||
"source_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"affinity_delta": 12,
|
||||
"trust_delta": 5,
|
||||
"knowledge_facts": ["they work on the same floor"],
|
||||
})
|
||||
_append(conn, kind="activity_change", payload={
|
||||
"entity_id": "you",
|
||||
"container_id": 1,
|
||||
"posture": "sitting at your desk",
|
||||
"action": {"verb": "finishing emails"},
|
||||
"attention": "the screen",
|
||||
})
|
||||
_append(conn, kind="activity_change", payload={
|
||||
"entity_id": "bot_a",
|
||||
"container_id": 1,
|
||||
"posture": "sitting at her desk",
|
||||
"action": {"verb": "pretending to work"},
|
||||
"attention": "you, in glances",
|
||||
})
|
||||
_append(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"],
|
||||
})
|
||||
# Close the seeded scene and write a per-POV summary memory so
|
||||
# _resolve_previous_scene_summary returns a non-empty string.
|
||||
_append(conn, kind="scene_closed", payload={
|
||||
"scene_id": 1,
|
||||
"ended_at": "2026-04-26T20:30:00+00:00",
|
||||
"significance": 2,
|
||||
})
|
||||
_append(conn, kind="memory_written", payload={
|
||||
"owner_id": "bot_a",
|
||||
"chat_id": "chat_bot_a",
|
||||
"scene_id": 1,
|
||||
"pov_summary": prev_scene_blob,
|
||||
"witness_you": 1,
|
||||
"witness_host": 1,
|
||||
"witness_guest": 0,
|
||||
"source": "direct",
|
||||
"reliability": 1.0,
|
||||
"significance": 2,
|
||||
})
|
||||
project(conn)
|
||||
|
||||
# Six dialogue turns — last 4 plus 2 older. If older turns are
|
||||
# dropped under NICE pressure, the unique markers for turns 0/1
|
||||
# disappear; we'll assert they REMAIN to prove dialogue trim
|
||||
# didn't fire.
|
||||
dialogue = [
|
||||
{"speaker": "you", "text": "DLG-OLD-00 hello"},
|
||||
{"speaker": "bot_a", "text": "DLG-OLD-01 hi"},
|
||||
{"speaker": "you", "text": "DLG-LAST-16 ok"},
|
||||
{"speaker": "bot_a", "text": "DLG-LAST-17 sure"},
|
||||
{"speaker": "you", "text": "DLG-LAST-18 night"},
|
||||
{"speaker": "bot_a", "text": "DLG-LAST-19 indeed"},
|
||||
]
|
||||
# Four small memories — if "memories beyond top-2" trim fires,
|
||||
# MEM-C/MEM-D disappear; we'll assert they REMAIN to prove
|
||||
# memories trim didn't fire either.
|
||||
memories = ["MEM-A short", "MEM-B short", "MEM-C short", "MEM-D short"]
|
||||
|
||||
# Soft tuned so the all-NICE config (with the heavy previous
|
||||
# scene summary) overflows, but dropping just previous-scene
|
||||
# fits comfortably. Hard set high so SHOULD-tier never trims.
|
||||
msgs = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a",
|
||||
recent_dialogue=dialogue,
|
||||
retrieved_memory_summaries=memories,
|
||||
budget_soft=400,
|
||||
budget_hard=8000,
|
||||
)
|
||||
body = msgs[0].content
|
||||
# Previous-scene summary was the FIRST NICE drop — its unique
|
||||
# marker must be absent.
|
||||
assert "PREVSCENE-MARKER" not in body
|
||||
# Memories beyond top-2 stayed (proves memories trim did NOT fire).
|
||||
assert "MEM-A" in body
|
||||
assert "MEM-B" in body
|
||||
assert "MEM-C" in body
|
||||
assert "MEM-D" in body
|
||||
# Older dialogue turns stayed (proves dialogue trim did NOT fire).
|
||||
assert "DLG-OLD-00" in body
|
||||
assert "DLG-OLD-01" in body
|
||||
# Last-4 dialogue turns of course present.
|
||||
assert "DLG-LAST-19" in body
|
||||
|
||||
|
||||
def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path):
|
||||
"""Under tight budget MUST blocks survive but SHOULD-tier guest
|
||||
activity is dropped first."""
|
||||
@@ -494,3 +763,92 @@ def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path):
|
||||
import tiktoken
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
assert len(enc.encode(body)) <= 340
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 60: Active events + open threads in prompt assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_assemble_with_no_events_or_threads_omits_blocks(tmp_path):
|
||||
"""Regression: with the basic 2-entity scenario (no events seeded, no
|
||||
threads seeded), the assembled prompt must NOT contain the
|
||||
``Active events:`` or ``Open threads:`` headers — both blocks are
|
||||
omit-when-empty."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_basic(conn)
|
||||
msgs = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a",
|
||||
recent_dialogue=[],
|
||||
retrieved_memory_summaries=[],
|
||||
)
|
||||
body = msgs[0].content
|
||||
assert "Active events:" not in body
|
||||
assert "Open threads:" not in body
|
||||
|
||||
|
||||
def test_assemble_with_active_events_renders_block(tmp_path):
|
||||
"""Seed a planned event then transition it to active; the assembled
|
||||
prompt should render the ``Active events:`` block listing the event
|
||||
by kind."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_basic(conn)
|
||||
# event_planned then event_started → status="active". Use
|
||||
# append_and_apply because _seed_basic already projected; calling
|
||||
# project() again would replay every prior event (and trip
|
||||
# UNIQUE constraints on chat_created etc.).
|
||||
append_and_apply(conn, kind="event_planned", payload={
|
||||
"event_id": "evt_park",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "date_at_park",
|
||||
"props": {"location": "Riverside Park", "vibe": "casual"},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
})
|
||||
append_and_apply(conn, kind="event_started", payload={
|
||||
"event_id": "evt_park",
|
||||
"started_at": "2026-04-30T18:05:00+00:00",
|
||||
})
|
||||
msgs = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a",
|
||||
recent_dialogue=[],
|
||||
retrieved_memory_summaries=[],
|
||||
)
|
||||
body = msgs[0].content
|
||||
assert "Active events:" in body
|
||||
assert "date_at_park" in body
|
||||
|
||||
|
||||
def test_assemble_with_open_thread_renders_block(tmp_path):
|
||||
"""Seed a single open thread; the assembled prompt should render the
|
||||
``Open threads:`` block listing the thread by title."""
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_basic(conn)
|
||||
# _seed_basic already projected; use append_and_apply for the
|
||||
# post-seed event so we don't re-trigger UNIQUE constraint
|
||||
# collisions on the prior chat_created/etc. events.
|
||||
append_and_apply(conn, kind="thread_opened", payload={
|
||||
"thread_id": "thr_job",
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": "Maya's job hunt",
|
||||
"summary": "Maya is looking for a new job",
|
||||
})
|
||||
msgs = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id="chat_bot_a",
|
||||
speaker_bot_id="bot_a",
|
||||
recent_dialogue=[],
|
||||
retrieved_memory_summaries=[],
|
||||
)
|
||||
body = msgs[0].content
|
||||
assert "Open threads:" in body
|
||||
assert "Maya's job hunt" in body
|
||||
|
||||
@@ -271,3 +271,394 @@ def test_regenerate_404_when_assistant_turn_missing(client, tmp_path):
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _seed_with_interjection_group(db_path):
|
||||
"""Seed a multi-entity scene with a (primary + interjection) group.
|
||||
|
||||
Returns ``(user_turn_id, primary_at_id, interjection_at_id)``.
|
||||
|
||||
The primary speaker is the host (bot_a); the silent witness who
|
||||
interjected is the guest (bot_b). Mirrors the convention in
|
||||
chat/web/turns.py — both assistant_turns share the same
|
||||
``user_turn_id`` and the interjection's payload carries
|
||||
``interjection_of=<primary speaker_id>``.
|
||||
"""
|
||||
with open_db(db_path) as conn:
|
||||
for bot_id, name, persona in (
|
||||
("bot_a", "BotA", "thoughtful"),
|
||||
("bot_b", "BotB", "loud"),
|
||||
):
|
||||
append_event(
|
||||
conn,
|
||||
kind="bot_authored",
|
||||
payload={
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": persona,
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "",
|
||||
"kickoff_prose": "",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="chat_created",
|
||||
payload={
|
||||
"id": "chat_multi",
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1",
|
||||
"weather": "",
|
||||
},
|
||||
)
|
||||
for src, tgt in (
|
||||
("bot_a", "you"),
|
||||
("you", "bot_a"),
|
||||
("bot_b", "you"),
|
||||
("you", "bot_b"),
|
||||
("bot_a", "bot_b"),
|
||||
("bot_b", "bot_a"),
|
||||
):
|
||||
append_event(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": src,
|
||||
"target_id": tgt,
|
||||
"chat_id": "chat_multi",
|
||||
},
|
||||
)
|
||||
for entity_id in ("you", "bot_a", "bot_b"):
|
||||
append_event(
|
||||
conn,
|
||||
kind="activity_change",
|
||||
payload={
|
||||
"entity_id": entity_id,
|
||||
"posture": "sitting",
|
||||
"action": {"verb": "talking"},
|
||||
"attention": "",
|
||||
"holding": [],
|
||||
"status": {},
|
||||
},
|
||||
)
|
||||
ut_id = append_event(
|
||||
conn,
|
||||
kind="user_turn",
|
||||
payload={
|
||||
"chat_id": "chat_multi",
|
||||
"prose": "hello",
|
||||
"segments": [],
|
||||
},
|
||||
)
|
||||
primary_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": "chat_multi",
|
||||
"speaker_id": "bot_a",
|
||||
"text": "Original primary.",
|
||||
"truncated": False,
|
||||
"user_turn_id": ut_id,
|
||||
},
|
||||
)
|
||||
interjection_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": "chat_multi",
|
||||
"speaker_id": "bot_b",
|
||||
"text": "Original interjection!",
|
||||
"truncated": False,
|
||||
"user_turn_id": ut_id,
|
||||
"interjection_of": "bot_a",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
return ut_id, primary_id, interjection_id
|
||||
|
||||
|
||||
def test_regenerate_broadcasts_turn_html_over_sse(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""T73.1: regenerate publishes a ``turn_html_replace`` SSE event so
|
||||
connected tabs swap the prior turn's DOM node in place.
|
||||
|
||||
The event carries:
|
||||
- ``data``: rendered HTML for the new turn
|
||||
- ``turn_id``: event_id of the new assistant_turn
|
||||
- ``supersedes_id``: event_id of the original assistant_turn
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.services import regenerate as regenerate_module
|
||||
from chat.services.regenerate import regenerate_assistant_turn
|
||||
|
||||
db_path = tmp_path / "test.db"
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('featherless_api_key = "test"\n')
|
||||
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||
monkeypatch.setenv("CHAT_DB_PATH", str(db_path))
|
||||
apply_migrations(db_path)
|
||||
|
||||
ut_id, at_id = _seed_with_one_turn(db_path)
|
||||
|
||||
published: list[tuple[str, dict]] = []
|
||||
|
||||
async def _capture(chat_id, event):
|
||||
published.append((chat_id, event))
|
||||
|
||||
# Patch the imported reference inside the regenerate module so the
|
||||
# service's call site goes through our spy.
|
||||
monkeypatch.setattr(regenerate_module, "publish", _capture)
|
||||
|
||||
narrative_canned = "Refreshed reply."
|
||||
state_canned = json.dumps(
|
||||
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
||||
)
|
||||
canned = [narrative_canned, state_canned, state_canned]
|
||||
mock_client = MockLLMClient(canned=list(canned))
|
||||
|
||||
settings = Settings(featherless_api_key="test")
|
||||
|
||||
with open_db(db_path) as conn:
|
||||
new_text = asyncio.run(
|
||||
regenerate_assistant_turn(
|
||||
conn,
|
||||
mock_client,
|
||||
settings=settings,
|
||||
chat_id="chat_bot_a",
|
||||
original_assistant_event_id=at_id,
|
||||
)
|
||||
)
|
||||
assert new_text == narrative_canned
|
||||
|
||||
# Find the new assistant_turn event_id for cross-checking.
|
||||
cur = conn.execute(
|
||||
"SELECT id FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' AND id != ? "
|
||||
"AND superseded_by IS NULL",
|
||||
(at_id,),
|
||||
).fetchone()
|
||||
new_at_id = cur[0]
|
||||
|
||||
# Filter out per-token publishes; we want the replace broadcast.
|
||||
replace_calls = [
|
||||
ev for (_cid, ev) in published if ev.get("event") == "turn_html_replace"
|
||||
]
|
||||
assert len(replace_calls) == 1
|
||||
payload = replace_calls[0]
|
||||
assert payload["supersedes_id"] == at_id
|
||||
assert payload["turn_id"] == new_at_id
|
||||
# The HTML carries the new narrative text and the speaker name.
|
||||
assert "Refreshed reply." in payload["data"]
|
||||
assert "BotA" in payload["data"]
|
||||
# Sanity: every publish targeted this chat.
|
||||
for cid, _ev in published:
|
||||
assert cid == "chat_bot_a"
|
||||
|
||||
|
||||
def test_regenerate_with_interjection_redoes_both_turns(tmp_path, monkeypatch):
|
||||
"""T73.2: when the original turn group included an interjection, both
|
||||
the primary and the interjection are regenerated.
|
||||
|
||||
Setup: 3-entity scene (host BotA + guest BotB + you) with a prior
|
||||
(primary by BotA + interjection by BotB) group. Mock the
|
||||
interjection classifier to return ``should_interject=True`` so the
|
||||
follow-on regenerates too.
|
||||
|
||||
Assert: 2 new assistant_turns exist for the same user_turn_id, the
|
||||
second carrying ``interjection_of`` pointing at the new primary's
|
||||
speaker_id. Both originals are superseded.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.services import regenerate as regenerate_module
|
||||
from chat.services.interjection import InterjectionDecision
|
||||
from chat.services.regenerate import regenerate_assistant_turn
|
||||
|
||||
db_path = tmp_path / "test.db"
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('featherless_api_key = "test"\n')
|
||||
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||
monkeypatch.setenv("CHAT_DB_PATH", str(db_path))
|
||||
apply_migrations(db_path)
|
||||
|
||||
ut_id, primary_id, interjection_id = _seed_with_interjection_group(db_path)
|
||||
|
||||
# Stub detect_interjection so the classifier "fires" with new prose.
|
||||
async def _stub_should_interject(*_args, **_kwargs):
|
||||
return InterjectionDecision(should_interject=True, reason="fired")
|
||||
|
||||
monkeypatch.setattr(
|
||||
regenerate_module, "detect_interjection", _stub_should_interject
|
||||
)
|
||||
|
||||
# Canned queue:
|
||||
# 1. New primary narrative stream.
|
||||
# 2-7. Six state-update classifier calls (one per directed pair
|
||||
# across host/you/guest = 6 pairs) for the primary pass.
|
||||
# 8. New interjection narrative stream.
|
||||
# 9-14. Six state-update classifier calls for the post-interjection
|
||||
# pass.
|
||||
state_canned = json.dumps(
|
||||
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
||||
)
|
||||
canned: list[str] = []
|
||||
canned.append("New primary text.")
|
||||
canned.extend([state_canned] * 6)
|
||||
canned.append("New interjection text!")
|
||||
canned.extend([state_canned] * 6)
|
||||
mock_client = MockLLMClient(canned=list(canned))
|
||||
|
||||
settings = Settings(featherless_api_key="test")
|
||||
|
||||
with open_db(db_path) as conn:
|
||||
new_text = asyncio.run(
|
||||
regenerate_assistant_turn(
|
||||
conn,
|
||||
mock_client,
|
||||
settings=settings,
|
||||
chat_id="chat_multi",
|
||||
original_assistant_event_id=primary_id,
|
||||
)
|
||||
)
|
||||
assert new_text == "New primary text."
|
||||
|
||||
# Both originals are superseded.
|
||||
primary_super = conn.execute(
|
||||
"SELECT superseded_by FROM event_log WHERE id = ?", (primary_id,)
|
||||
).fetchone()[0]
|
||||
interjection_super = conn.execute(
|
||||
"SELECT superseded_by FROM event_log WHERE id = ?",
|
||||
(interjection_id,),
|
||||
).fetchone()[0]
|
||||
assert primary_super is not None
|
||||
assert interjection_super is not None
|
||||
|
||||
# Two NEW assistant_turn events exist (the regenerated primary
|
||||
# and the regenerated interjection), both pinned to the same
|
||||
# user_turn_id as the originals.
|
||||
cur = conn.execute(
|
||||
"SELECT id, payload_json FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' AND id NOT IN (?, ?) "
|
||||
"ORDER BY id",
|
||||
(primary_id, interjection_id),
|
||||
).fetchall()
|
||||
assert len(cur) == 2
|
||||
new_primary_id, new_primary_payload_json = cur[0]
|
||||
new_interjection_id, new_interjection_payload_json = cur[1]
|
||||
new_primary_payload = json.loads(new_primary_payload_json)
|
||||
new_interjection_payload = json.loads(new_interjection_payload_json)
|
||||
|
||||
assert new_primary_payload["text"] == "New primary text."
|
||||
assert new_primary_payload["speaker_id"] == "bot_a"
|
||||
assert new_primary_payload["user_turn_id"] == ut_id
|
||||
assert new_primary_payload["regenerated_from"] == primary_id
|
||||
assert "interjection_of" not in new_primary_payload
|
||||
|
||||
assert new_interjection_payload["text"] == "New interjection text!"
|
||||
assert new_interjection_payload["speaker_id"] == "bot_b"
|
||||
assert new_interjection_payload["user_turn_id"] == ut_id
|
||||
assert new_interjection_payload["regenerated_from"] == interjection_id
|
||||
# interjection_of links to the new primary's speaker (matches
|
||||
# the existing convention in chat/web/turns.py).
|
||||
assert new_interjection_payload["interjection_of"] == "bot_a"
|
||||
|
||||
# The originals' supersede pointers reach the new ones.
|
||||
assert primary_super == new_primary_id
|
||||
assert interjection_super == new_interjection_id
|
||||
|
||||
|
||||
def test_regenerate_drops_interjection_when_classifier_returns_false(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""T73.2: when the original group included an interjection but the
|
||||
classifier returns False this time, the new group is primary-only.
|
||||
|
||||
The original interjection is still superseded (we don't leave it
|
||||
visible in the timeline alongside a regenerated primary it no longer
|
||||
follows from), but no replacement assistant_turn is appended.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.services import regenerate as regenerate_module
|
||||
from chat.services.interjection import InterjectionDecision
|
||||
from chat.services.regenerate import regenerate_assistant_turn
|
||||
|
||||
db_path = tmp_path / "test.db"
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('featherless_api_key = "test"\n')
|
||||
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||
monkeypatch.setenv("CHAT_DB_PATH", str(db_path))
|
||||
apply_migrations(db_path)
|
||||
|
||||
ut_id, primary_id, interjection_id = _seed_with_interjection_group(db_path)
|
||||
|
||||
async def _stub_no_interject(*_args, **_kwargs):
|
||||
return InterjectionDecision(
|
||||
should_interject=False, reason="quiet"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
regenerate_module, "detect_interjection", _stub_no_interject
|
||||
)
|
||||
|
||||
# Canned queue: primary narrative + 6 state-update calls. No
|
||||
# interjection stream because the classifier short-circuits.
|
||||
state_canned = json.dumps(
|
||||
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
||||
)
|
||||
canned: list[str] = ["New primary text."] + [state_canned] * 6
|
||||
mock_client = MockLLMClient(canned=list(canned))
|
||||
|
||||
settings = Settings(featherless_api_key="test")
|
||||
|
||||
with open_db(db_path) as conn:
|
||||
new_text = asyncio.run(
|
||||
regenerate_assistant_turn(
|
||||
conn,
|
||||
mock_client,
|
||||
settings=settings,
|
||||
chat_id="chat_multi",
|
||||
original_assistant_event_id=primary_id,
|
||||
)
|
||||
)
|
||||
assert new_text == "New primary text."
|
||||
|
||||
# Original primary superseded by the new primary.
|
||||
primary_super = conn.execute(
|
||||
"SELECT superseded_by FROM event_log WHERE id = ?", (primary_id,)
|
||||
).fetchone()[0]
|
||||
# Original interjection ALSO superseded — we don't leave a
|
||||
# dangling beat attached to a regenerated primary that no longer
|
||||
# warrants a follow-on. Back-pointer goes to the new primary.
|
||||
interjection_super = conn.execute(
|
||||
"SELECT superseded_by FROM event_log WHERE id = ?",
|
||||
(interjection_id,),
|
||||
).fetchone()[0]
|
||||
assert primary_super is not None
|
||||
assert interjection_super is not None
|
||||
assert interjection_super == primary_super # both point at new primary
|
||||
|
||||
# Exactly ONE new assistant_turn — the primary; no replacement
|
||||
# interjection.
|
||||
cur = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' AND id NOT IN (?, ?) "
|
||||
"AND superseded_by IS NULL",
|
||||
(primary_id, interjection_id),
|
||||
).fetchall()
|
||||
assert len(cur) == 1
|
||||
new_primary_payload = json.loads(cur[0][0])
|
||||
assert new_primary_payload["text"] == "New primary text."
|
||||
assert "interjection_of" not in new_primary_payload
|
||||
|
||||
@@ -292,6 +292,189 @@ def test_reset_clears_guest_reference_in_other_chats(client, tmp_path):
|
||||
).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_reset_purges_orphaned_you_activity_rows(client, tmp_path):
|
||||
"""T69: when a bot's chats are deleted, "you" activity rows tied to those
|
||||
chats' containers should also be purged (otherwise they linger orphaned)."""
|
||||
db = tmp_path / "test.db"
|
||||
with open_db(db) as conn:
|
||||
append_event(
|
||||
conn,
|
||||
kind="bot_authored",
|
||||
payload={
|
||||
"id": "bot_a",
|
||||
"name": "BotA",
|
||||
"persona": "thoughtful",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "coworker",
|
||||
"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="container_created",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"name": "office",
|
||||
"type": "workplace",
|
||||
"properties": {},
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="activity_change",
|
||||
payload={
|
||||
"entity_id": "you",
|
||||
"container_id": 1,
|
||||
"posture": "standing",
|
||||
"action": {"verb": "watching"},
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
# Sanity: the "you" activity row exists and points at the container.
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM activity WHERE entity_id = 'you'"
|
||||
).fetchone()[0] == 1
|
||||
|
||||
response = client.post(
|
||||
"/bots/bot_a/reset",
|
||||
data={"confirm_name": "BotA"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
with open_db(db) as conn:
|
||||
# The orphaned "you" activity row tied to bot_a's purged container is gone.
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM activity WHERE entity_id = 'you'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_reset_does_not_purge_you_activity_in_other_chats(client, tmp_path):
|
||||
"""T69: resetting bot_a must leave a "you" activity row pointing at
|
||||
bot_b's container intact — only orphans from the reset bot's chats go."""
|
||||
db = tmp_path / "test.db"
|
||||
with open_db(db) as conn:
|
||||
# bot_a + its chat + container.
|
||||
append_event(
|
||||
conn,
|
||||
kind="bot_authored",
|
||||
payload={
|
||||
"id": "bot_a",
|
||||
"name": "BotA",
|
||||
"persona": "thoughtful",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "coworker",
|
||||
"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="container_created",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"name": "office",
|
||||
"type": "workplace",
|
||||
"properties": {},
|
||||
},
|
||||
)
|
||||
# bot_b + its chat + container.
|
||||
append_event(
|
||||
conn,
|
||||
kind="bot_authored",
|
||||
payload={
|
||||
"id": "bot_b",
|
||||
"name": "BotB",
|
||||
"persona": "curious",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "friend",
|
||||
"kickoff_prose": "",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="chat_created",
|
||||
payload={
|
||||
"id": "chat_bot_b",
|
||||
"host_bot_id": "bot_b",
|
||||
"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_b",
|
||||
"name": "kitchen",
|
||||
"type": "home",
|
||||
"properties": {},
|
||||
},
|
||||
)
|
||||
# The activity table is keyed on entity_id (PRIMARY KEY), so only one
|
||||
# "you" row exists at a time. Point it at bot_b's container so reset of
|
||||
# bot_a should NOT touch it.
|
||||
append_event(
|
||||
conn,
|
||||
kind="activity_change",
|
||||
payload={
|
||||
"entity_id": "you",
|
||||
"container_id": 2, # kitchen, in chat_bot_b
|
||||
"posture": "sitting",
|
||||
"action": {"verb": "reading"},
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
# Sanity: the "you" activity row is in bot_b's container.
|
||||
row = conn.execute(
|
||||
"SELECT container_id FROM activity WHERE entity_id = 'you'"
|
||||
).fetchone()
|
||||
assert row is not None and row[0] == 2
|
||||
|
||||
response = client.post(
|
||||
"/bots/bot_a/reset",
|
||||
data={"confirm_name": "BotA"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
with open_db(db) as conn:
|
||||
# The "you" activity in bot_b's container is preserved.
|
||||
row = conn.execute(
|
||||
"SELECT container_id FROM activity WHERE entity_id = 'you'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == 2
|
||||
|
||||
|
||||
def test_reset_purges_guest_memories_from_other_chats(client, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
_seed_two_bots_with_guest_link(
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Skip narration service tests (T53).
|
||||
|
||||
The skip-narration service generates short transition prose between an
|
||||
in-progress moment and a post-skip moment. Two flavors:
|
||||
|
||||
* ``elision`` — collapses an in-progress activity to its expected
|
||||
end-state in 1-2 sentences (e.g. "skip ahead to when we arrive").
|
||||
* ``jump`` — bridges a longer fiction-time delta in 2-3 sentences
|
||||
(e.g. "next morning", "a week later").
|
||||
|
||||
Output is free-form prose, not structured JSON, so the service goes
|
||||
through ``client.generate`` directly rather than the classifier path.
|
||||
A deterministic template fallback fires on any LLM failure so the skip
|
||||
flow never blocks even when the model is down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncIterator, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.llm.client import Message
|
||||
from chat.llm.mock import MockLLMClient
|
||||
from chat.services.skip_narration import narrate_skip
|
||||
|
||||
|
||||
_SPEAKER = {
|
||||
"id": "bot1",
|
||||
"name": "Aria",
|
||||
"persona": "thoughtful, observant",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_narrate_elision_returns_classifier_output():
|
||||
canned = (
|
||||
"She closes her laptop and slings her bag over her shoulder. "
|
||||
"The office shrinks behind her as she steps into the late "
|
||||
"afternoon light."
|
||||
)
|
||||
mock = MockLLMClient(canned=[canned])
|
||||
result = await narrate_skip(
|
||||
mock,
|
||||
narrative_model="x",
|
||||
skip_kind="elision",
|
||||
speaker_bot=_SPEAKER,
|
||||
you_name="Me",
|
||||
current_time="3:42 PM",
|
||||
new_time="5:10 PM",
|
||||
current_activity="finishing up at her desk",
|
||||
landing_state_hint="walking out into the parking lot",
|
||||
)
|
||||
assert "office" in result or result == canned
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_narrate_jump_returns_classifier_output():
|
||||
canned = (
|
||||
"Morning light spills through the kitchen window. The coffee "
|
||||
"maker hums. She's already at the table, scrolling her phone."
|
||||
)
|
||||
mock = MockLLMClient(canned=[canned])
|
||||
result = await narrate_skip(
|
||||
mock,
|
||||
narrative_model="x",
|
||||
skip_kind="jump",
|
||||
speaker_bot=_SPEAKER,
|
||||
you_name="Me",
|
||||
current_time="late evening",
|
||||
new_time="next morning",
|
||||
current_activity="winding down for the night",
|
||||
landing_state_hint="having coffee in the kitchen",
|
||||
)
|
||||
assert result
|
||||
lower = result.lower()
|
||||
assert "morning" in lower or "coffee" in lower
|
||||
|
||||
|
||||
class _RaisingMock:
|
||||
"""Mock LLMClient whose ``generate`` always raises.
|
||||
|
||||
``MockLLMClient.generate`` raises ``IndexError`` once the canned
|
||||
list is empty, but the test wants a clear, unambiguous failure
|
||||
regardless of canned-list state, so we ship a tiny dedicated mock
|
||||
instead.
|
||||
"""
|
||||
|
||||
async def generate(
|
||||
self, messages: Sequence[Message], *, model: str, **params
|
||||
) -> str:
|
||||
raise RuntimeError("LLM is down")
|
||||
|
||||
async def stream(
|
||||
self, messages: Sequence[Message], *, model: str, **params
|
||||
) -> AsyncIterator[str]:
|
||||
raise RuntimeError("LLM is down")
|
||||
yield # pragma: no cover - make this a generator
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_narrate_falls_back_on_generation_failure():
|
||||
new_time = "next morning"
|
||||
result = await narrate_skip(
|
||||
_RaisingMock(),
|
||||
narrative_model="x",
|
||||
skip_kind="jump",
|
||||
speaker_bot=_SPEAKER,
|
||||
you_name="Me",
|
||||
current_time="late evening",
|
||||
new_time=new_time,
|
||||
current_activity="winding down for the night",
|
||||
landing_state_hint="having coffee in the kitchen",
|
||||
)
|
||||
# Fallback template includes the new_time so callers can see *what*
|
||||
# we skipped to even when the LLM never answered.
|
||||
assert new_time in result
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for the synthesized-memories service (T54).
|
||||
|
||||
When the user jump-skips ("a week later") they are prompted "anything
|
||||
notable happen?" If they answer with prose, this service parses it into
|
||||
1-N synthesized memories per present bot. Each memory carries
|
||||
``source="synthesized"`` and ``reliability=0.7`` (the caller — T62 skip
|
||||
flow — applies those tags when persisting; this service just produces
|
||||
the structured digest).
|
||||
|
||||
These tests cover:
|
||||
|
||||
* The happy path: a canned classifier response parses cleanly into a
|
||||
populated :class:`SynthesizedDigest` with one memory.
|
||||
* Empty prose short-circuits before any classifier call — the mock has
|
||||
no canned responses, so an accidental call would raise
|
||||
``IndexError``.
|
||||
* Classifier failure (3 bad responses, exhausting :func:`classify`'s
|
||||
retry budget) falls back to an empty default digest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.llm.mock import MockLLMClient
|
||||
from chat.services.synthesized_memories import (
|
||||
SynthesizedDigest,
|
||||
SynthesizedMemory,
|
||||
synthesize_memories,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_synthesize_parses_canned_prose():
|
||||
canned = json.dumps(
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"text": "Maya started a new pottery class.",
|
||||
"significance": 1,
|
||||
"affinity_delta": 0,
|
||||
"trust_delta": 0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
mock = MockLLMClient(canned=[canned])
|
||||
result = await synthesize_memories(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
prose="we saw each other at her pottery class once",
|
||||
bot_name="Maya",
|
||||
bot_persona="warm potter, mid-30s",
|
||||
you_name="Sam",
|
||||
)
|
||||
assert isinstance(result, SynthesizedDigest)
|
||||
assert len(result.memories) == 1
|
||||
mem = result.memories[0]
|
||||
assert isinstance(mem, SynthesizedMemory)
|
||||
assert mem.text == "Maya started a new pottery class."
|
||||
assert mem.significance == 1
|
||||
assert mem.affinity_delta == 0
|
||||
assert mem.trust_delta == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_prose_returns_empty_digest():
|
||||
"""Empty prose short-circuits — the classifier must not be called."""
|
||||
mock = MockLLMClient(canned=[])
|
||||
result = await synthesize_memories(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
prose="",
|
||||
bot_name="Maya",
|
||||
bot_persona="warm potter, mid-30s",
|
||||
you_name="Sam",
|
||||
)
|
||||
assert result == SynthesizedDigest()
|
||||
assert result.memories == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classifier_failure_returns_empty_default():
|
||||
"""Three bad responses exhaust the classifier's retry budget; the
|
||||
service then returns the empty default digest."""
|
||||
mock = MockLLMClient(canned=["bad", "bad", "bad"])
|
||||
result = await synthesize_memories(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
prose="we saw each other at her pottery class once",
|
||||
bot_name="Maya",
|
||||
bot_persona="warm potter, mid-30s",
|
||||
you_name="Sam",
|
||||
)
|
||||
assert result == SynthesizedDigest()
|
||||
assert result.memories == []
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for the thread-detection service (T55).
|
||||
|
||||
On scene close, the transcript is classified to detect open threads
|
||||
(unresolved arcs, dangling questions, promises made). The service can
|
||||
also signal **update** to an existing thread when the scene developed
|
||||
it, or **close** when the scene resolved it.
|
||||
|
||||
These tests cover:
|
||||
|
||||
* A new thread the scene introduced — action="open" with a fresh title.
|
||||
* An update to an existing thread — action="update" with
|
||||
``existing_thread_id`` referencing the prior thread.
|
||||
* Classifier failure — three bad responses degrade to an empty
|
||||
candidates list (graceful degradation, §3.3).
|
||||
* Empty transcript short-circuits before any classifier call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.llm.mock import MockLLMClient
|
||||
from chat.services.thread_detection import (
|
||||
ThreadCandidate,
|
||||
ThreadDetectionResult,
|
||||
detect_threads,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detects_new_thread_open():
|
||||
canned = json.dumps(
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"action": "open",
|
||||
"title": "Maya's job hunt",
|
||||
"summary": "Maya is looking for a new job",
|
||||
"existing_thread_id": None,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
mock = MockLLMClient(canned=[canned])
|
||||
result = await detect_threads(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
scene_transcript=[
|
||||
{"speaker": "Maya", "text": "I need to find a new job soon."},
|
||||
{"speaker": "Sam", "text": "What kind of role are you looking for?"},
|
||||
],
|
||||
open_threads=[],
|
||||
)
|
||||
assert isinstance(result, ThreadDetectionResult)
|
||||
assert len(result.candidates) == 1
|
||||
cand = result.candidates[0]
|
||||
assert isinstance(cand, ThreadCandidate)
|
||||
assert cand.action == "open"
|
||||
assert cand.title == "Maya's job hunt"
|
||||
assert cand.summary == "Maya is looking for a new job"
|
||||
assert cand.existing_thread_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detects_update_to_existing_thread():
|
||||
canned = json.dumps(
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"action": "update",
|
||||
"title": "",
|
||||
"summary": "Maya interviewed at Acme today",
|
||||
"existing_thread_id": "thr_jobhunt",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
mock = MockLLMClient(canned=[canned])
|
||||
result = await detect_threads(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
scene_transcript=[
|
||||
{"speaker": "Maya", "text": "I had the Acme interview today."},
|
||||
{"speaker": "Sam", "text": "How did it go?"},
|
||||
],
|
||||
open_threads=[
|
||||
{
|
||||
"thread_id": "thr_jobhunt",
|
||||
"title": "Maya's job hunt",
|
||||
"summary": "Maya is looking for a new job",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert len(result.candidates) == 1
|
||||
cand = result.candidates[0]
|
||||
assert cand.action == "update"
|
||||
assert cand.existing_thread_id == "thr_jobhunt"
|
||||
assert cand.summary == "Maya interviewed at Acme today"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classifier_failure_returns_empty():
|
||||
"""Three malformed classifier responses → empty candidates list."""
|
||||
mock = MockLLMClient(canned=["not json", "still not json", "{bad"])
|
||||
result = await detect_threads(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
scene_transcript=[
|
||||
{"speaker": "Maya", "text": "Anything could happen here."},
|
||||
],
|
||||
open_threads=[],
|
||||
)
|
||||
assert result.candidates == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_transcript_short_circuits():
|
||||
"""Empty transcript short-circuits — classifier must not be called."""
|
||||
mock = MockLLMClient(canned=[])
|
||||
result = await detect_threads(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
scene_transcript=[],
|
||||
open_threads=[],
|
||||
)
|
||||
assert result.candidates == []
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.eventlog.projector import project
|
||||
import chat.state.entities # registers handlers
|
||||
import chat.state.world # registers handlers
|
||||
import chat.state.threads # registers handlers
|
||||
from chat.state.threads import get_thread, list_open_threads
|
||||
|
||||
|
||||
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 test_thread_opened_creates_row(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": "Maya's job hunt",
|
||||
"summary": "Maya is looking for a new job",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
t = get_thread(conn, "thr_abc")
|
||||
assert t is not None
|
||||
assert t["thread_id"] == "thr_abc"
|
||||
assert t["chat_id"] == "chat_bot_a"
|
||||
assert t["title"] == "Maya's job hunt"
|
||||
assert t["summary"] == "Maya is looking for a new job"
|
||||
assert t["status"] == "open"
|
||||
assert t["closed_at"] is None
|
||||
assert t["last_referenced_scene_id"] is None
|
||||
|
||||
|
||||
def test_thread_updated_changes_summary_and_last_referenced(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": "Maya's job hunt",
|
||||
"summary": "Maya is looking for a new job",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_updated",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"summary": "Maya landed an interview at a startup",
|
||||
"last_referenced_scene_id": 42,
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
t = get_thread(conn, "thr_abc")
|
||||
assert t is not None
|
||||
assert t["summary"] == "Maya landed an interview at a startup"
|
||||
assert t["last_referenced_scene_id"] == 42
|
||||
assert t["status"] == "open"
|
||||
|
||||
|
||||
def test_thread_closed_terminal(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": "Maya's job hunt",
|
||||
"summary": "Maya is looking for a new job",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_closed",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"closed_at": "2026-04-26T21:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
t = get_thread(conn, "thr_abc")
|
||||
assert t is not None
|
||||
assert t["status"] == "closed"
|
||||
assert t["closed_at"] == "2026-04-26T21:00:00+00:00"
|
||||
|
||||
# Subsequent updates to a closed thread are no-ops.
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_updated",
|
||||
payload={
|
||||
"thread_id": "thr_abc",
|
||||
"summary": "should not be applied",
|
||||
},
|
||||
)
|
||||
|
||||
t2 = get_thread(conn, "thr_abc")
|
||||
assert t2 is not None
|
||||
assert t2["summary"] == "Maya is looking for a new job"
|
||||
assert t2["status"] == "closed"
|
||||
|
||||
|
||||
def test_list_open_threads_filters_to_open_only(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
for tid, title in [
|
||||
("thr_1", "Arc 1"),
|
||||
("thr_2", "Arc 2"),
|
||||
("thr_3", "Arc 3"),
|
||||
]:
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": tid,
|
||||
"chat_id": "chat_bot_a",
|
||||
"title": title,
|
||||
"summary": "",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="thread_closed",
|
||||
payload={
|
||||
"thread_id": "thr_3",
|
||||
"closed_at": "2026-04-26T21:00:00+00:00",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
open_threads = list_open_threads(conn, "chat_bot_a")
|
||||
assert len(open_threads) == 2
|
||||
ids = {t["thread_id"] for t in open_threads}
|
||||
assert ids == {"thr_1", "thr_2"}
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
import chat.state.world # registers handlers
|
||||
from chat.state.world import get_activity, get_chat
|
||||
|
||||
|
||||
def _chat_payload(**overrides):
|
||||
payload = {
|
||||
"id": "chat_bot_a",
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": None,
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1 evening",
|
||||
"weather": "clear",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _container_payload(**overrides):
|
||||
payload = {
|
||||
"chat_id": "chat_bot_a",
|
||||
"name": "office",
|
||||
"type": "workplace",
|
||||
"properties": {
|
||||
"public": True,
|
||||
"moving": False,
|
||||
"audible_range": "normal",
|
||||
"slots": [],
|
||||
},
|
||||
"parent_id": None,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _activity_payload(**overrides):
|
||||
payload = {
|
||||
"entity_id": "bot_a",
|
||||
"container_id": 1,
|
||||
"slot": "desk_chair",
|
||||
"posture": "sitting",
|
||||
"action": {"verb": "writing email"},
|
||||
"attention": "the screen",
|
||||
"holding": ["pen"],
|
||||
"status": {"hungry": False},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _seed_events(conn):
|
||||
"""Append seed events but do NOT project — caller appends more then projects once."""
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(conn, kind="container_created", payload=_container_payload())
|
||||
append_event(conn, kind="activity_change", payload=_activity_payload())
|
||||
|
||||
|
||||
def test_elision_advances_chat_clock_only(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_events(conn)
|
||||
append_event(conn, kind="time_skip_elision", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"new_time": "2026-04-26T20:30:00+00:00",
|
||||
})
|
||||
project(conn)
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-26T20:30:00+00:00"
|
||||
|
||||
# Activity row preserved with the same fields it was seeded with.
|
||||
a = get_activity(conn, "bot_a")
|
||||
assert a is not None
|
||||
assert a["entity_id"] == "bot_a"
|
||||
assert a["container_id"] == 1
|
||||
assert a["slot"] == "desk_chair"
|
||||
assert a["posture"] == "sitting"
|
||||
assert a["action"] == {"verb": "writing email"}
|
||||
assert a["attention"] == "the screen"
|
||||
assert a["holding"] == ["pen"]
|
||||
assert a["status"] == {"hungry": False}
|
||||
|
||||
|
||||
def test_jump_with_reset_clears_activity(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_events(conn)
|
||||
append_event(conn, kind="time_skip_jump", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"new_time": "2026-04-27T08:00:00+00:00",
|
||||
"reset_activity": True,
|
||||
})
|
||||
project(conn)
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-27T08:00:00+00:00"
|
||||
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM activity "
|
||||
"WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)",
|
||||
("chat_bot_a",),
|
||||
).fetchone()[0]
|
||||
assert count == 0
|
||||
assert get_activity(conn, "bot_a") is None
|
||||
|
||||
|
||||
def test_jump_without_reset_preserves_activity(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
_seed_events(conn)
|
||||
append_event(conn, kind="time_skip_jump", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"new_time": "2026-04-27T08:00:00+00:00",
|
||||
"reset_activity": False,
|
||||
})
|
||||
project(conn)
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"] == "2026-04-27T08:00:00+00:00"
|
||||
|
||||
a = get_activity(conn, "bot_a")
|
||||
assert a is not None
|
||||
assert a["posture"] == "sitting"
|
||||
assert a["action"]["verb"] == "writing email"
|
||||
+658
-27
@@ -19,7 +19,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from chat.app import app
|
||||
from chat.db.connection import open_db
|
||||
from chat.eventlog.log import append_event
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.eventlog.projector import project
|
||||
from chat.llm.mock import MockLLMClient
|
||||
|
||||
@@ -405,14 +405,15 @@ def test_multi_bot_turn_no_interjection(app_state_setup, tmp_path):
|
||||
1 user_turn + 1 assistant_turn + 6 *post-turn* edge_updates + 2
|
||||
memory_written events. Single turn_html broadcast.
|
||||
|
||||
Canned queue (8 calls):
|
||||
Canned queue (11 calls):
|
||||
1. parse_turn
|
||||
2. narrative stream (primary, addressee = host because the prose
|
||||
2. detect_addressee (T74.1) -> host
|
||||
3. narrative stream (primary, addressee = host because the prose
|
||||
doesn't name the guest)
|
||||
3-8. 6 state-update calls (one per directed pair across {you,
|
||||
4-9. 6 state-update calls (one per directed pair across {you,
|
||||
bot_a, bot_b})
|
||||
9. detect_interjection -> should_interject=False
|
||||
10. detect_scene_close -> should_close=False
|
||||
10. detect_interjection -> should_interject=False
|
||||
11. detect_scene_close -> should_close=False
|
||||
"""
|
||||
_seed_chat_with_guest(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
@@ -420,6 +421,9 @@ def test_multi_bot_turn_no_interjection(app_state_setup, tmp_path):
|
||||
)
|
||||
canned = [
|
||||
canned_parse,
|
||||
json.dumps(
|
||||
{"addressee_id": "bot_a", "confidence": "medium", "reason": "host"}
|
||||
),
|
||||
"Greetings.",
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
@@ -474,14 +478,15 @@ def test_multi_bot_turn_with_interjection(app_state_setup, tmp_path):
|
||||
1 user_turn + 2 assistant_turns + (6 + 6) post-turn edge_updates +
|
||||
4 memory_written events.
|
||||
|
||||
Canned queue (16 calls):
|
||||
Canned queue (17 calls):
|
||||
1. parse_turn
|
||||
2. narrative stream (primary)
|
||||
3-8. 6 state-update calls (post-primary)
|
||||
9. detect_interjection -> should_interject=True
|
||||
10. narrative stream (interjection)
|
||||
11-16. 6 state-update calls (post-interjection)
|
||||
17. detect_scene_close -> should_close=False
|
||||
2. detect_addressee (T74.1) -> host
|
||||
3. narrative stream (primary)
|
||||
4-9. 6 state-update calls (post-primary)
|
||||
10. detect_interjection -> should_interject=True
|
||||
11. narrative stream (interjection)
|
||||
12-17. 6 state-update calls (post-interjection)
|
||||
18. detect_scene_close -> should_close=False
|
||||
"""
|
||||
_seed_chat_with_guest(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
@@ -489,6 +494,9 @@ def test_multi_bot_turn_with_interjection(app_state_setup, tmp_path):
|
||||
)
|
||||
canned = [
|
||||
canned_parse,
|
||||
json.dumps(
|
||||
{"addressee_id": "bot_a", "confidence": "medium", "reason": "host"}
|
||||
),
|
||||
"Primary beat.",
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
@@ -555,14 +563,15 @@ def test_multi_bot_turn_scene_close_writes_per_pov_summaries(
|
||||
rewrites fire for both bots (memory.pov_summary changes for each).
|
||||
Interjection short-circuits at False so the queue stays compact.
|
||||
|
||||
Canned queue (12 calls):
|
||||
Canned queue (13 calls):
|
||||
1. parse_turn
|
||||
2. narrative stream (primary)
|
||||
3-8. 6 state-update calls
|
||||
9. detect_interjection -> False (no follow-on stream)
|
||||
10. detect_scene_close -> True
|
||||
11. apply_scene_close_summary host POV
|
||||
12. apply_scene_close_summary guest POV
|
||||
2. detect_addressee (T74.1) -> host
|
||||
3. narrative stream (primary)
|
||||
4-9. 6 state-update calls
|
||||
10. detect_interjection -> False (no follow-on stream)
|
||||
11. detect_scene_close -> True
|
||||
12. apply_scene_close_summary host POV
|
||||
13. apply_scene_close_summary guest POV
|
||||
"""
|
||||
_seed_chat_with_guest(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
@@ -588,6 +597,9 @@ def test_multi_bot_turn_scene_close_writes_per_pov_summaries(
|
||||
)
|
||||
canned = [
|
||||
canned_parse,
|
||||
json.dumps(
|
||||
{"addressee_id": "bot_a", "confidence": "medium", "reason": "host"}
|
||||
),
|
||||
"Goodnight.",
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
@@ -639,12 +651,20 @@ def test_multi_bot_turn_scene_close_writes_per_pov_summaries(
|
||||
|
||||
|
||||
def test_addressee_detection_routes_to_named_bot(app_state_setup, tmp_path):
|
||||
"""Prose that names the guest by name routes the primary turn to the
|
||||
guest. Interjection (when fired) makes the host the silent witness
|
||||
and the second assistant_turn carries the host as speaker.
|
||||
"""T74.1: the multi-entity addressee call goes through the classifier;
|
||||
when the classifier returns the guest, the primary turn routes there.
|
||||
Interjection (when fired) makes the host the silent witness and the
|
||||
second assistant_turn carries the host as speaker.
|
||||
|
||||
Canned queue: same shape as the with-interjection test (16 calls)
|
||||
plus the trailing scene_close decision.
|
||||
Canned queue (with classifier-led addressee = guest):
|
||||
1. parse_turn
|
||||
2. detect_addressee -> bot_b (the guest)
|
||||
3. narrative stream (primary, addressee = guest)
|
||||
4-9. 6 state-update calls
|
||||
10. detect_interjection -> True
|
||||
11. interjection narrative stream
|
||||
12-17. 6 state-update calls (post-interjection)
|
||||
18. detect_scene_close -> False
|
||||
"""
|
||||
_seed_chat_with_guest(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
@@ -652,6 +672,13 @@ def test_addressee_detection_routes_to_named_bot(app_state_setup, tmp_path):
|
||||
)
|
||||
canned = [
|
||||
canned_parse,
|
||||
json.dumps(
|
||||
{
|
||||
"addressee_id": "bot_b",
|
||||
"confidence": "high",
|
||||
"reason": "user named BotB",
|
||||
}
|
||||
),
|
||||
"BotB pondering.",
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
@@ -680,9 +707,613 @@ def test_addressee_detection_routes_to_named_bot(app_state_setup, tmp_path):
|
||||
primary_payload = json.loads(rows[0][0])
|
||||
interjection_payload = json.loads(rows[1][0])
|
||||
|
||||
# Primary speaker is the guest because the prose names BotB and not
|
||||
# BotA (case-insensitive whole-word match).
|
||||
# Primary speaker is the guest because the addressee classifier
|
||||
# picked bot_b for the prose ("BotB, what do you think?").
|
||||
assert primary_payload["speaker_id"] == "bot_b"
|
||||
# Interjection follow-on goes to the silent witness — the host.
|
||||
assert interjection_payload["speaker_id"] == "bot_a"
|
||||
assert interjection_payload["interjection_of"] == "bot_b"
|
||||
|
||||
|
||||
def test_cancelled_turn_still_closes_scene_when_user_prose_signals_close(
|
||||
app_state_setup, tmp_path
|
||||
):
|
||||
"""T74.3 regression: a cancelled primary stream still triggers scene
|
||||
close when the user prose carries a hard close signal.
|
||||
|
||||
Rationale (also documented in turns.py near the close-detection
|
||||
branch): close detection only consumes the user's prose, which is
|
||||
fully appended to the event_log BEFORE streaming starts. The
|
||||
cancelled bot beat doesn't invalidate the user's intent to close.
|
||||
|
||||
Implementation: install a MockLLMClient whose ``stream`` raises
|
||||
CancelledError on the first iteration. The classifier calls (parse,
|
||||
addressee, scene_close, per-POV summaries) are still served from
|
||||
the canned queue. The post_turn route ultimately re-raises
|
||||
CancelledError after recording the partial — TestClient surfaces
|
||||
that as an exception, so we drive the request inside ``with
|
||||
pytest.raises``. Despite the exception, the scene_closed event
|
||||
must land in the event_log.
|
||||
"""
|
||||
from typing import AsyncIterator, Sequence
|
||||
|
||||
_seed_chat_with_guest(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
{"segments": [{"kind": "narration", "text": "we are done here, fade out"}]}
|
||||
)
|
||||
pov_payload = json.dumps(
|
||||
{
|
||||
"summary": "BotA noticed the day winding down.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "warmer",
|
||||
}
|
||||
)
|
||||
pov_payload_guest = json.dumps(
|
||||
{
|
||||
"summary": "BotB watched the scene close.",
|
||||
"knowledge_facts": [],
|
||||
"relationship_summary": "warmer",
|
||||
}
|
||||
)
|
||||
# Canned queue: parse + addressee + 6 state-updates +
|
||||
# scene_close=True + 2 per-POV summaries. NO interjection slot
|
||||
# because the cancel path short-circuits the interjection branch.
|
||||
canned = [
|
||||
canned_parse,
|
||||
json.dumps(
|
||||
{"addressee_id": "bot_a", "confidence": "medium", "reason": "host"}
|
||||
),
|
||||
# NOTE: no narrative slot — the stream is hijacked below to
|
||||
# raise CancelledError on first iteration; it never pulls a
|
||||
# canned response.
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
json.dumps({"should_close": True, "reason": "fade out signaled"}),
|
||||
pov_payload,
|
||||
pov_payload_guest,
|
||||
]
|
||||
|
||||
class _CancelOnStreamMock:
|
||||
"""Mock LLM client that serves ``generate`` from a canned queue
|
||||
and raises CancelledError on the FIRST iteration of ``stream``.
|
||||
|
||||
Mirrors :class:`chat.llm.mock.MockLLMClient` for ``generate`` but
|
||||
diverges on ``stream`` to simulate a mid-stream cancel.
|
||||
"""
|
||||
|
||||
def __init__(self, canned: list[str]) -> None:
|
||||
self._canned = list(canned)
|
||||
|
||||
async def generate(
|
||||
self, messages: Sequence, *, model: str, **params
|
||||
) -> str:
|
||||
return self._canned.pop(0)
|
||||
|
||||
async def stream(
|
||||
self, messages: Sequence, *, model: str, **params
|
||||
) -> AsyncIterator[str]:
|
||||
# Yield a CancelledError on first iteration to simulate the
|
||||
# /turns/cancel route firing mid-stream.
|
||||
raise asyncio.CancelledError
|
||||
yield # pragma: no cover — keeps this an async generator.
|
||||
|
||||
from chat.web.kickoff import get_llm_client
|
||||
|
||||
mock = _CancelOnStreamMock(canned=list(canned))
|
||||
app.dependency_overrides[get_llm_client] = lambda: mock
|
||||
|
||||
try:
|
||||
# FastAPI/Starlette handles the re-raised CancelledError as an
|
||||
# internal failure — TestClient surfaces it as a 500 response.
|
||||
# We don't assert on the status here; the regression is whether
|
||||
# the scene_closed event still landed in the event_log.
|
||||
try:
|
||||
app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns",
|
||||
data={"prose": "we are done here, fade out"},
|
||||
)
|
||||
except BaseException:
|
||||
# Some Starlette/asyncio versions propagate the
|
||||
# CancelledError out of the test client; that's fine — the
|
||||
# partial-record + scene-close still ran before the raise.
|
||||
pass
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
scene_close_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM event_log WHERE kind = 'scene_closed'"
|
||||
).fetchone()[0]
|
||||
assistant_payload = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
# Scene close lands despite the cancel.
|
||||
assert scene_close_count == 1
|
||||
# The cancelled assistant_turn was still recorded (truncated=True).
|
||||
assert len(assistant_payload) == 1
|
||||
assert json.loads(assistant_payload[0][0])["truncated"] is True
|
||||
|
||||
|
||||
def test_interjection_enqueues_significance_job(app_state_setup, tmp_path):
|
||||
"""T74.2: when an interjection fires, the interjection memory is
|
||||
enqueued for significance scoring just like the primary memory.
|
||||
|
||||
Capture enqueued ``SignificanceJob``s by replacing the background
|
||||
worker's ``enqueue`` method with a list-append. Without T74.2, the
|
||||
interjection memory would never be scored — only the primary's
|
||||
enqueue would land. We therefore expect TWO jobs after a turn that
|
||||
has both a primary and an interjection beat: one for the primary
|
||||
memory, one for the interjection memory.
|
||||
"""
|
||||
_seed_chat_with_guest(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
{"segments": [{"kind": "dialogue", "text": "tell me"}]}
|
||||
)
|
||||
canned = [
|
||||
canned_parse,
|
||||
json.dumps(
|
||||
{"addressee_id": "bot_a", "confidence": "medium", "reason": "host"}
|
||||
),
|
||||
"Primary beat.",
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
json.dumps({"should_interject": True, "reason": "jealous"}),
|
||||
"Interjection beat!",
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
_zero_state(), _zero_state(), _zero_state(),
|
||||
json.dumps({"should_close": False, "reason": "no signal"}),
|
||||
]
|
||||
_override_llm(canned)
|
||||
|
||||
captured_jobs: list = []
|
||||
worker = app.state.background_worker
|
||||
# Re-enable enqueue capture even though the worker's loop is disabled
|
||||
# — we want to count enqueues without the loop running classifier work.
|
||||
worker.enabled = True
|
||||
original_enqueue = worker.enqueue
|
||||
worker.enqueue = captured_jobs.append # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns", data={"prose": "tell me"}
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
worker.enqueue = original_enqueue # type: ignore[assignment]
|
||||
worker.enabled = False
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
# Expect 2 enqueues: 1 for the primary memory + 1 for the
|
||||
# interjection memory.
|
||||
assert len(captured_jobs) == 2
|
||||
|
||||
# Both jobs should reference distinct memory ids — the primary's
|
||||
# host-POV memory and the interjection's host-POV memory.
|
||||
memory_ids = [job.memory_id for job in captured_jobs]
|
||||
assert len(set(memory_ids)) == 2
|
||||
# The two narrative texts should be the two streamed beats.
|
||||
narrative_texts = sorted(job.narrative_text for job in captured_jobs)
|
||||
assert narrative_texts == ["Interjection beat!", "Primary beat."]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 (T61) — per-turn event-lifecycle detection + completion promotion.
|
||||
#
|
||||
# After the post-turn classifier passes (memory write, state update,
|
||||
# interjection check) and BEFORE scene-close detection, ``post_turn``
|
||||
# calls :func:`detect_event_transitions`. Each transition becomes one
|
||||
# of ``event_started`` / ``event_completed`` / ``event_cancelled``. A
|
||||
# completed event is followed inline by ``promote_completed_event`` so
|
||||
# the props it carries (knowledge_facts, etc.) land in state
|
||||
# synchronously.
|
||||
#
|
||||
# When no active events are seeded the classifier short-circuits without
|
||||
# an LLM call (per T52) — the canned queue therefore needs ZERO extra
|
||||
# slots in that case.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_turn_with_event_transition_appends_started_event(
|
||||
app_state_setup, tmp_path
|
||||
):
|
||||
"""A planned event becomes active when the classifier reports a
|
||||
``new_status='active'`` transition for that event_id.
|
||||
|
||||
Canned queue (5 calls — single-bot, no scene seeded):
|
||||
1. parse_turn
|
||||
2. narrative stream
|
||||
3. state-update bot_a -> you
|
||||
4. state-update you -> bot_a
|
||||
5. detect_event_transitions -> 1 transition (active)
|
||||
"""
|
||||
_seed(tmp_path / "test.db")
|
||||
# Seed a planned event so list_active_events returns 1 row. Use
|
||||
# append_and_apply so we don't re-replay the prior chat_created event
|
||||
# (whose handler is INSERT-not-IGNORE and would 409 on replay).
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": "evt_1",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "story_event",
|
||||
"props": {},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
canned_parse = json.dumps(
|
||||
{"segments": [{"kind": "dialogue", "text": "they arrived"}]}
|
||||
)
|
||||
canned_event_decision = json.dumps(
|
||||
{
|
||||
"transitions": [
|
||||
{
|
||||
"event_id": "evt_1",
|
||||
"new_status": "active",
|
||||
"reason": "they arrived",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
mock = _override_llm(
|
||||
[
|
||||
canned_parse,
|
||||
"They walk in.",
|
||||
_zero_state(),
|
||||
_zero_state(),
|
||||
canned_event_decision,
|
||||
]
|
||||
)
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns", data={"prose": "they arrived"}
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
# All 5 canned slots consumed.
|
||||
assert mock._canned == []
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
# event_started landed in event_log.
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'event_started' ORDER BY id"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
started_payload = json.loads(rows[0][0])
|
||||
assert started_payload["event_id"] == "evt_1"
|
||||
assert started_payload["started_at"] == "2026-04-26T20:00:00+00:00"
|
||||
|
||||
# The events projection row reflects the active status.
|
||||
ev_row = conn.execute(
|
||||
"SELECT status, started_at FROM events WHERE event_id = ?",
|
||||
("evt_1",),
|
||||
).fetchone()
|
||||
assert ev_row is not None
|
||||
assert ev_row[0] == "active"
|
||||
assert ev_row[1] == "2026-04-26T20:00:00+00:00"
|
||||
|
||||
|
||||
def test_turn_with_event_completion_runs_promotion(app_state_setup, tmp_path):
|
||||
"""An active event with knowledge_facts in props completes; the
|
||||
inline call to ``promote_completed_event`` emits the corresponding
|
||||
``edge_update``.
|
||||
"""
|
||||
_seed(tmp_path / "test.db")
|
||||
# Seed: planned -> started so the event is currently active. Props
|
||||
# carry a knowledge_fact that promotion will turn into an edge_update.
|
||||
# Use append_and_apply (not project) to avoid re-replaying chat_created.
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": "evt_2",
|
||||
"chat_id": "chat_bot_a",
|
||||
"kind": "story_event",
|
||||
"props": {
|
||||
"knowledge_facts": [
|
||||
{
|
||||
"owner_id": "bot_a",
|
||||
"target_id": "you",
|
||||
"fact": "Maya likes pottery",
|
||||
}
|
||||
]
|
||||
},
|
||||
"planned_for": "2026-04-30T18:00:00+00:00",
|
||||
},
|
||||
)
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": "evt_2",
|
||||
"started_at": "2026-04-30T19:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
# Snapshot the max event_log id so we can assert on rows AFTER the turn.
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
before_id = conn.execute(
|
||||
"SELECT COALESCE(MAX(id), 0) FROM event_log"
|
||||
).fetchone()[0]
|
||||
|
||||
canned_parse = json.dumps(
|
||||
{"segments": [{"kind": "dialogue", "text": "we wrap it up"}]}
|
||||
)
|
||||
canned_event_decision = json.dumps(
|
||||
{
|
||||
"transitions": [
|
||||
{
|
||||
"event_id": "evt_2",
|
||||
"new_status": "completed",
|
||||
"reason": "wrapped",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
mock = _override_llm(
|
||||
[
|
||||
canned_parse,
|
||||
"They wrap it up.",
|
||||
_zero_state(),
|
||||
_zero_state(),
|
||||
canned_event_decision,
|
||||
]
|
||||
)
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns", data={"prose": "we wrap it up"}
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert mock._canned == []
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
# event_completed landed.
|
||||
completed_rows = conn.execute(
|
||||
"SELECT id, payload_json FROM event_log "
|
||||
"WHERE kind = 'event_completed' AND id > ? ORDER BY id",
|
||||
(before_id,),
|
||||
).fetchall()
|
||||
assert len(completed_rows) == 1
|
||||
completed_payload = json.loads(completed_rows[0][1])
|
||||
assert completed_payload["event_id"] == "evt_2"
|
||||
completed_id = completed_rows[0][0]
|
||||
|
||||
# promote_completed_event ran inline AFTER event_completed: the
|
||||
# follow-on edge_update carries the knowledge fact and is tagged
|
||||
# with source=event_promotion.
|
||||
promo_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'edge_update' AND id > ? ORDER BY id",
|
||||
(completed_id,),
|
||||
).fetchall()
|
||||
promo_facts: list[str] = []
|
||||
for (payload_json,) in promo_rows:
|
||||
p = json.loads(payload_json)
|
||||
if p.get("source") == "event_promotion":
|
||||
promo_facts.extend(p.get("knowledge_facts") or [])
|
||||
|
||||
assert "Maya likes pottery" in promo_facts
|
||||
|
||||
|
||||
def test_turn_with_no_active_events_skips_classifier(app_state_setup, tmp_path):
|
||||
"""When no active events are seeded, ``detect_event_transitions``
|
||||
short-circuits without an LLM call (per T52). The canned queue must
|
||||
therefore have ZERO event-detection slots — same shape as the
|
||||
Phase 2 no-guest baseline.
|
||||
"""
|
||||
_seed(tmp_path / "test.db")
|
||||
|
||||
canned_parse = json.dumps(
|
||||
{"segments": [{"kind": "dialogue", "text": "hello"}]}
|
||||
)
|
||||
# Only 4 slots: parse + narrative + 2 state-updates. NO extra slot for
|
||||
# event-detection — non-existent active_events causes the helper to
|
||||
# short-circuit before pulling from the queue.
|
||||
mock = _override_llm(
|
||||
[canned_parse, "Hi there.", _zero_state(), _zero_state()]
|
||||
)
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns", data={"prose": "hello"}
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
# Queue fully drained — no canned slot was consumed by event detection.
|
||||
assert mock._canned == []
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
for kind in ("event_started", "event_completed", "event_cancelled"):
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM event_log WHERE kind = ?", (kind,)
|
||||
).fetchone()[0]
|
||||
assert count == 0, f"expected zero {kind} events, got {count}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 (T62) — natural-language skip-command surface.
|
||||
#
|
||||
# The classifier may flag prose as a time-skip directive via
|
||||
# ``ParsedTurn.intent``. Elision runs through the shared controller in
|
||||
# :mod:`chat.web.skip` and short-circuits the regular narrative path;
|
||||
# jump returns 422 directing the user to the drawer's structured form
|
||||
# (Phase 3 simpler path — natural-language jump time derivation is too
|
||||
# fragile for v1 without the structured surface).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_elision_skip_via_natural_language(app_state_setup, tmp_path):
|
||||
"""User prose 'skip to when we arrive at the park' classifies as
|
||||
``intent='skip_elision'``. The post_turn handler short-circuits the
|
||||
narrative path, advances the chat clock by an hour stub, appends a
|
||||
``time_skip_elision`` event AND an ``assistant_turn`` carrying the
|
||||
canned narration. No ``user_turn`` is emitted on the skip path.
|
||||
|
||||
Canned queue: 1 parse_turn (intent=skip_elision) + 1 narration
|
||||
string (consumed by ``narrate_skip``). No state-update / scene-close
|
||||
/ event-detection slots — those branches are bypassed entirely.
|
||||
"""
|
||||
_seed(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
{
|
||||
"segments": [
|
||||
{"kind": "dialogue", "text": "skip to when we arrive at the park"}
|
||||
],
|
||||
"intent": "skip_elision",
|
||||
"landing_state_hint": "we arrive at the park",
|
||||
}
|
||||
)
|
||||
canned_narration = "We pull up to the park entrance, sun low in the sky."
|
||||
mock = _override_llm([canned_parse, canned_narration])
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns",
|
||||
data={"prose": "skip to when we arrive at the park"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
# Both canned slots drained — no other classifier branches ran.
|
||||
assert mock._canned == []
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
# time_skip_elision landed.
|
||||
skip_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'time_skip_elision' ORDER BY id"
|
||||
).fetchall()
|
||||
assert len(skip_rows) == 1
|
||||
sp = json.loads(skip_rows[0][0])
|
||||
assert sp["chat_id"] == "chat_bot_a"
|
||||
# 1-hour stub from the seeded chat clock (20:00 -> 21:00).
|
||||
assert sp["new_time"].startswith("2026-04-26T21:00:00")
|
||||
|
||||
# Chat clock advanced via the projector.
|
||||
from chat.state.world import get_chat
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat["time"].startswith("2026-04-26T21:00:00")
|
||||
|
||||
# An assistant_turn carrying the canned narration was appended.
|
||||
turn_rows = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' ORDER BY id"
|
||||
).fetchall()
|
||||
assert len(turn_rows) == 1
|
||||
tp = json.loads(turn_rows[0][0])
|
||||
assert tp["chat_id"] == "chat_bot_a"
|
||||
assert tp["text"] == canned_narration
|
||||
assert tp["speaker_id"] == "bot_a"
|
||||
assert tp["truncated"] is False
|
||||
|
||||
# No user_turn lands on the skip path — the natural-language
|
||||
# skip is a command, not a beat the bots should remember.
|
||||
user_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM event_log WHERE kind = 'user_turn'"
|
||||
).fetchone()[0]
|
||||
assert user_count == 0
|
||||
|
||||
|
||||
def test_jump_skip_via_natural_language_returns_422(app_state_setup, tmp_path):
|
||||
"""User prose 'next morning' classifies as ``intent='skip_jump'``.
|
||||
The handler returns 422 with a guidance payload pointing the author
|
||||
at the drawer's structured jump form. No event is emitted — the
|
||||
drawer form is the only entry point for jump skips in Phase 3.
|
||||
"""
|
||||
_seed(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
{
|
||||
"segments": [{"kind": "dialogue", "text": "next morning"}],
|
||||
"intent": "skip_jump",
|
||||
"landing_state_hint": "",
|
||||
}
|
||||
)
|
||||
# Only one canned slot — parse — because the 422 fallback short-
|
||||
# circuits before any other classifier runs.
|
||||
mock = _override_llm([canned_parse])
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns", data={"prose": "next morning"}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
# Guidance payload mentions the drawer so the client can surface
|
||||
# the right CTA; we don't pin the exact wording.
|
||||
assert "drawer" in body.get("error", "").lower()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
# Parse slot consumed; no follow-on classifier calls.
|
||||
assert mock._canned == []
|
||||
|
||||
with open_db(tmp_path / "test.db") as conn:
|
||||
for kind in (
|
||||
"user_turn",
|
||||
"assistant_turn",
|
||||
"time_skip_elision",
|
||||
"time_skip_jump",
|
||||
):
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM event_log WHERE kind = ?", (kind,)
|
||||
).fetchone()[0]
|
||||
assert count == 0, f"expected zero {kind} on jump-via-NL, got {count}"
|
||||
|
||||
|
||||
def test_skip_command_does_not_run_narrative_classifier(
|
||||
app_state_setup, tmp_path, monkeypatch
|
||||
):
|
||||
"""The skip dispatch branch must bypass the narrative-prompt assembly
|
||||
entirely. We monkeypatch ``assemble_narrative_prompt`` (re-bound on
|
||||
the ``chat.web.turns`` module since the handler imports it by name)
|
||||
and assert the call count is zero after the elision skip lands.
|
||||
"""
|
||||
_seed(tmp_path / "test.db")
|
||||
canned_parse = json.dumps(
|
||||
{
|
||||
"segments": [
|
||||
{"kind": "dialogue", "text": "skip to when we arrive at the park"}
|
||||
],
|
||||
"intent": "skip_elision",
|
||||
"landing_state_hint": "we arrive at the park",
|
||||
}
|
||||
)
|
||||
canned_narration = "We arrive moments later."
|
||||
mock = _override_llm([canned_parse, canned_narration])
|
||||
|
||||
call_counter = {"n": 0}
|
||||
|
||||
def _spy(*args, **kwargs):
|
||||
call_counter["n"] += 1
|
||||
return []
|
||||
|
||||
# Patch the symbol at the handler's import site so we can assert
|
||||
# the skip path bypasses prompt assembly even when the symbol still
|
||||
# exists in the module namespace.
|
||||
from chat.web import turns as turns_mod
|
||||
|
||||
monkeypatch.setattr(turns_mod, "assemble_narrative_prompt", _spy)
|
||||
|
||||
try:
|
||||
response = app_state_setup.post(
|
||||
"/chats/chat_bot_a/turns",
|
||||
data={"prose": "skip to when we arrive at the park"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert mock._canned == []
|
||||
assert call_counter["n"] == 0, (
|
||||
"assemble_narrative_prompt was called on the skip path; the "
|
||||
"natural-language skip dispatch must bypass narrative assembly."
|
||||
)
|
||||
|
||||
+2
-2
@@ -324,11 +324,11 @@ def test_get_scene_returns_none_for_missing(tmp_path):
|
||||
assert active_scene(conn, "chat_missing") is None
|
||||
|
||||
|
||||
def test_schema_version_after_migration_is_8(tmp_path):
|
||||
def test_schema_version_after_migration_is_11(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM meta WHERE key = 'schema_version'"
|
||||
).fetchone()
|
||||
assert int(row[0]) == 8
|
||||
assert int(row[0]) == 11
|
||||
|
||||
Reference in New Issue
Block a user