14 Commits

Author SHA1 Message Date
Joseph Doherty e632a6247d merge: T72 drawer polish (deferred edits + first-meeting gate + witness flag editing) 2026-04-26 17:32:02 -04:00
Joseph Doherty 607d0971c4 feat: drawer witness flag inline-edit (T72.3)
Memories grow per-flag witness checkboxes (you / host / guest) that
auto-submit on change via HTMX. The new POST route emits a manual_edit
event with target_kind=memory_witness and a {flag, value} payload;
prior_value mirrors the same shape so an inverse edit restores the
flag. The drawer's recent-memories query now selects the three
witness columns alongside the existing fields so the template can
render checkbox state without a second query per row.
2026-04-26 17:28:25 -04:00
Joseph Doherty c265e4ce0f feat: first-meeting gate on drawer Add-guest form (T72.2)
When a host->candidate edge already exists from a prior chat, the
Add-guest form renders the prose textarea disabled with an "already
know each other" note. Submission without the explicit "re-seed
anyway" toggle skips seed_inter_bot_edges so existing edge content
(affinity, trust, knowledge, summaries) survives — guest_added and
group_node_initialized still fire. A small inline script enables /
disables the textarea per-option based on a pre-computed
existing_guest_edges dict surfaced by the GET handler.
2026-04-26 17:26:31 -04:00
Joseph Doherty 21404a373b feat: drawer edits for edge_trust / edge_summary / memory_pov_summary / knowledge_facts (T72.1)
Adds the four POST routes whose state-layer support was already
dispatched by the manual_edit projector (edge_trust, edge_summary,
memory_pov_summary) plus a new edge_knowledge_fact dispatch branch for
add/remove fact list manipulation. Drawer template gains editable
textareas, sliders, and add/remove fact controls. Remove semantics on
knowledge_fact match by string (not index) so concurrent edge_update
events appending facts between drawer renders don't desync the form.
2026-04-26 17:24:24 -04:00
Joseph Doherty 789b9bd042 merge: T71 prompt.py polish (witness role + ACTIVITIES + NICE trim docs) 2026-04-26 17:18:02 -04:00
Joseph Doherty 73bb8c1f17 chore: document NICE trim order rationale (T71.3)
T18 review (Phase 1) noted the NICE-tier trim drops previous-scene
FIRST while §6.3 spec lists previous-scene LAST in the NICE tier
group. Decision: keep the existing greedy order (previous-scene
first), and document why.

Rationale (now in code at the trim ladder):
  1. Cheapest-impact-first — a per-POV previous-scene summary loses
     less narrative continuity than the older dialogue turns or
     memory hits it competes with.
  2. Greedy lookahead is more expensive than the marginal narrative
     loss. Dropping previous-scene typically clears the soft-budget
     slack in one step.

Test added: test_nice_trim_order_documented pins the observed order
(previous-scene -> memories -> dialogue) so a future refactor can't
silently invert it. Sized so that all-NICE config overflows soft but
dropping just previous-scene fits — proves memories and older
dialogue turns survive while previous-scene is the FIRST drop.
2026-04-26 17:16:02 -04:00
Joseph Doherty afd1a50958 refactor: single ACTIVITIES: block with bullet-level trim (T71.2)
Phase 2 T43 added a SECOND ACTIVITIES: block to render guest activity
separately from you+speaker. Two consecutive ACTIVITIES: headers can
read as a duplicate-section bug to the LLM and bloat the prompt.

Consolidate to a single ACTIVITIES: block whose body is composed from
up to three bullets (you, speaker, guest). The block itself is
MUST-tier (always renders); bullet-level trim drops bullets in the
order guest -> group node -> you -> other edges, with the speaker
bullet as the MUST-tier floor (the speaker's own current activity is
the load-bearing slice).

Implementation chose Option B from the polish plan: pre-truncate the
bullets list at trim time before _build_activity_block runs, rather
than introduce a granular tier mode in the trim machinery. Rationale
documented in code; the existing block-level trim ladder gains a
single new toggle (include_you_activity) and the SHOULD-tier
guest_activity_block is gone.

Tests:
- test_single_activities_block_with_three_bullets_when_3_entities:
  exactly one ACTIVITIES: header with all three entity bullets.
- test_tight_budget_drops_guest_activity_bullet_first: speaker bullet
  survives, guest bullet absent under tight budget.
- Existing test_assemble_with_tight_budget_drops_guest_activity_first
  still passes (asserts on bullet absence, not block-header absence).
2026-04-26 17:13:24 -04:00
Joseph Doherty 428438b223 fix: witness role parametric in prompt assembly (T71.1)
Phase 2 T46 pinned the witness mask contract on search_memories with a
witness_role parameter (host/guest/you). The prompt-assembly call site
in assemble_narrative_prompt was hardcoded to "host", which silently
returned the wrong rows when the speaker was the guest bot.

Derive the witness role from chat membership via a new private helper
_witness_role_for(speaker_bot_id, host_bot_id), and apply it at the
search_memories call. Behaviour is identical when the speaker is the
host (or when no guest is present); the fix is load-bearing only when
the guest bot is the speaker — exactly the scenario Phase 2 T43 added
support for.

Tests: pin both directions (host-as-speaker and guest-as-speaker) by
patching the imported search_memories reference and asserting the
witness_role argument the call site emits.
2026-04-26 17:11:20 -04:00
Joseph Doherty b13f3b4e47 merge: T70 LLM-merged group meta-summary 2026-04-26 17:09:16 -04:00
Joseph Doherty f701f9d7dd merge: T69 bot_reset purges orphaned 'you' activity rows 2026-04-26 17:09:16 -04:00
Joseph Doherty 1b9144442a merge: T68 open_db with check_same_thread parameter 2026-04-26 17:09:16 -04:00
Joseph Doherty 13c23fd898 feat: LLM-merged group meta-summary (T70) 2026-04-26 17:07:12 -04:00
Joseph Doherty c1e419e012 fix: bot_reset purges orphaned 'you' activity rows (T69) 2026-04-26 17:06:21 -04:00
Joseph Doherty 994728b5ed refactor: open_db with check_same_thread parameter (T68) 2026-04-26 17:05:29 -04:00
14 changed files with 2005 additions and 101 deletions
+2 -2
View File
@@ -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:
+130 -41
View File
@@ -273,6 +273,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]:
@@ -356,34 +368,57 @@ 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] = []
# 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
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)
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
@@ -433,7 +468,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 +492,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,6 +528,7 @@ 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,
) -> tuple[str, int, list[dict]]:
@@ -503,13 +551,20 @@ 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,
prev_block,
memories_block,
@@ -527,16 +582,18 @@ 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
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) -> 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,
)
@@ -544,8 +601,8 @@ def assemble_narrative_prompt(
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,
)
# If under soft, we're done.
@@ -554,12 +611,32 @@ 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,
)
if total <= budget_soft:
return _emit(body, user_turn_prose)
@@ -568,8 +645,8 @@ 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,
)
if total <= budget_soft:
return _emit(body, user_turn_prose)
@@ -578,8 +655,8 @@ 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,
)
if total <= budget_soft:
return _emit(body, user_turn_prose)
@@ -589,35 +666,47 @@ 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,
)
# 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.)
# Drop SHOULD-tier extras in order:
# 1. guest activity bullet (T71.2: bullet-level trim within the
# single ACTIVITIES: block — guest goes first per Task 43 spec)
# 2. group node block
# 3. you activity bullet (still SHOULD-tier; speaker bullet is the
# MUST-tier floor and never dropped)
# 4. other edges
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,
)
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,
)
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,
)
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,
)
if total > budget_hard:
+74 -8
View File
@@ -334,26 +334,92 @@ async def apply_scene_close_summary(
timeout_s=timeout_s,
)
# 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,
},
)
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."
)
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,
)
+11 -2
View File
@@ -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:
+55 -4
View File
@@ -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.
+160 -10
View File
@@ -100,24 +100,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 +203,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 &rarr; {{ host_bot.name }}</strong>
<p>Affinity: {{ edge_y2b.affinity }}/100 &middot; 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 +347,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
View File
@@ -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]:
+335 -20
View File
@@ -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,14 +13,16 @@ 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
@@ -55,6 +57,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 +113,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,6 +146,9 @@ 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
]
@@ -152,6 +175,7 @@ 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,
@@ -342,6 +366,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 +680,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 +712,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 +752,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",
+403
View File
@@ -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
+156
View File
@@ -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
+57
View File
@@ -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,)]
+170 -5
View File
@@ -636,8 +636,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 +662,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 +681,167 @@ 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 == []
+267
View File
@@ -452,6 +452,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."""
+183
View File
@@ -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(