4 Commits

Author SHA1 Message Date
Joseph Doherty b5175aefaa feat: per-POV summary and edge summary update on scene close 2026-04-26 13:53:12 -04:00
Joseph Doherty 0997562e75 feat: scene close on hard signals with manual override 2026-04-26 13:46:14 -04:00
Joseph Doherty db3005fc17 feat: drawer edits with manual_edit event capture 2026-04-26 13:40:40 -04:00
Joseph Doherty 5fc5b8ac23 feat: read-only drawer with scene, activity, edges, memories 2026-04-26 13:35:47 -04:00
15 changed files with 1941 additions and 3 deletions
+3
View File
@@ -12,11 +12,13 @@ from chat.services.background import BackgroundWorker
# Trigger handler registration:
import chat.state.entities # noqa: F401
import chat.state.edges # noqa: F401
import chat.state.manual_edit # noqa: F401
import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
from chat.web.bots import router as bots_router
from chat.web.chat import router as chat_router
from chat.web.drawer import router as drawer_router
from chat.web.kickoff import router as kickoff_router
from chat.web.nav import router as nav_router
from chat.web.settings import router as settings_router
@@ -62,6 +64,7 @@ app.include_router(kickoff_router)
app.include_router(settings_router)
app.include_router(nav_router)
app.include_router(chat_router)
app.include_router(drawer_router)
app.include_router(sse_router)
app.include_router(turns_router)
+100
View File
@@ -0,0 +1,100 @@
"""Scene-close hard-signal detection (T26).
A small classifier service that decides whether the user's prose narrates
a hard signal that should close the active scene. Hard signals (per
Requirements §7.2):
* Container change parsed from prose ("we drove to the park", "we stepped
outside").
* Explicit user pattern signaling end ("we're done here", "fade out",
"scene end").
NOT close signals:
* Brief activity changes within the same container ("I sit down").
* Future plans ("let's go to the park later").
The service returns a :class:`SceneCloseDecision`. The default on classifier
failure is ``should_close=False`` so the turn flow keeps moving — closing
on a misfire would be more disruptive than missing a real signal, and the
manual button in the drawer is always available as a fallback.
Phase 2/3 will introduce automatic re-opening with the new container; for
T26 the close is one-way and the next user turn operates without an active
scene (the prompt assembler already tolerates this).
"""
from __future__ import annotations
from pydantic import BaseModel
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class SceneCloseDecision(BaseModel):
"""Classifier verdict for scene-close detection.
``new_container_hint`` is captured opportunistically when the close
signal is a container change, but T26 doesn't act on it — Phase 2/3
handles automatic re-opening at the new location.
"""
should_close: bool = False
reason: str = ""
new_container_hint: str = ""
_SYSTEM = (
"You decide whether a roleplay scene should close based on the user's "
"prose.\n"
"Close signals (return should_close=true):\n"
"- The prose narrates a CONTAINER CHANGE (moving to a different place, "
'e.g. "we drove to the park", "we stepped outside").\n'
"- The prose has an EXPLICIT USER PATTERN signaling end "
'("we\'re done here", "fade out", "scene end").\n'
"\n"
"DO NOT close on:\n"
"- Brief activity changes within the same place "
'(e.g. "I sit down" — same room).\n'
"- Future plans "
'("let\'s go to the park later" — not yet).\n'
"\n"
'Reply JSON: {"should_close": bool, "reason": str (short), '
'"new_container_hint": str (optional name)}.'
)
async def detect_scene_close(
client: LLMClient,
*,
model: str,
prose: str,
current_container_name: str,
timeout_s: float = 10.0,
) -> SceneCloseDecision:
"""Run the scene-close classifier on a single user turn.
The current container name is passed in so the prompt can reason about
"different place" relative to the active scene rather than guessing.
On classifier failure (parse error twice), the returned decision is the
safe ``should_close=False`` default.
"""
user = (
f"CURRENT CONTAINER: {current_container_name}\n"
f"\n"
f"PROSE:\n{prose}\n"
f"\n"
f"Decide whether to close the scene."
)
return await classify(
client,
model=model,
system=_SYSTEM,
user=user,
schema=SceneCloseDecision,
default=SceneCloseDecision(
should_close=False, reason="fallback", new_container_hint=""
),
timeout_s=timeout_s,
)
+269
View File
@@ -0,0 +1,269 @@
"""Per-POV scene summary and edge summary update on scene close (T27).
When a scene closes — either auto-detected by the hard-signal classifier
in T26 or fired by the manual close button on the drawer — we run a
single-shot classifier per present witness that produces three signals
in one pass:
* ``summary`` — a 2-4 sentence per-POV recap of the scene from this
witness's perspective. Different from omniscient narration; focuses on
what the witness noticed/felt/remembers.
* ``knowledge_facts`` — concrete new things this witness learned about
the user during the scene. Promoted to the directed edge's
``knowledge`` list via ``edge_update``.
* ``relationship_summary`` — a 1-2 sentence delta on how the
witness's relationship to the user shifted in this scene. v1
combines this with the prior edge summary by simple concatenation —
the LLM is asked to phrase ``relationship_summary`` as a merge-ready
fragment, so the result reads naturally without a second classifier
round-trip.
Phase 1 single-bot only the host bot is summarized; "you" doesn't have
a memory store in v1 so per-POV writes for the user are deferred. The
:func:`apply_scene_close_summary` driver is intentionally tolerant: if
no memories belong to the closed scene it silently skips the rewrite,
and a flapping classifier returns the empty default so the close flow
keeps moving.
"""
from __future__ import annotations
import json
from sqlite3 import Connection
from pydantic import BaseModel, Field
from chat.eventlog.log import append_and_apply
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class ScenePOVSummary(BaseModel):
"""Classifier output: one witness's view of a closing scene.
Defaults are an inert no-op so a classifier failure is harmless —
callers can apply the result unconditionally and end up not
rewriting anything when the model misbehaves.
"""
summary: str = ""
knowledge_facts: list[str] = Field(default_factory=list)
relationship_summary: str = ""
_SYSTEM_TEMPLATE = (
"You are summarizing a roleplay scene from {bot_name}'s point of "
"view. Read the dialogue, then output JSON with exactly three "
"fields:\n"
"- summary: 2-4 sentences, in {bot_name}'s POV, of what happened "
"in the scene. This is NOT omniscient narration — focus on what "
"{bot_name} noticed, felt, and would remember.\n"
"- knowledge_facts: list of NEW factual things {bot_name} learned "
"about the user during this scene. Use specific stated content; do "
"not infer or interpret. Empty list is fine.\n"
"- relationship_summary: a SHORT (1-2 sentence) summary of how "
"{bot_name}'s relationship with the user changed or developed in "
"this scene. Phrase it so it reads as a continuation of the prior "
"summary; the caller will concatenate them.\n\n"
"Be specific. Avoid generic phrases."
)
def _format_dialogue(dialogue: list[dict]) -> str:
if not dialogue:
return "(no dialogue)"
return "\n".join(
f"{turn.get('speaker', '?')}: {turn.get('text', '')}"
for turn in dialogue
)
async def summarize_scene(
client: LLMClient,
*,
model: str,
bot_name: str,
bot_persona: str,
you_name: str,
prior_edge_summary: str,
dialogue: list[dict],
timeout_s: float = 10.0,
) -> ScenePOVSummary:
"""Run the per-POV summary classifier for one witness.
The signature mirrors :func:`compute_state_update` — passing the
bot's name and persona as separate fields lets the prompt address
the model directly ("YOU are {bot_name}") rather than handing it an
opaque id. ``prior_edge_summary`` is included so the classifier can
phrase ``relationship_summary`` as an additive fragment.
Returns the empty default on classifier failure (after one retry)
rather than raising, so the close pipeline keeps moving.
"""
system = _SYSTEM_TEMPLATE.format(bot_name=bot_name)
user = (
f"YOU are {bot_name}. {bot_persona or '(no persona on file)'}\n"
f"USER name: {you_name}\n"
f"PRIOR EDGE SUMMARY ({bot_name} -> {you_name}): "
f"{prior_edge_summary or '(empty)'}\n\n"
f"DIALOGUE:\n{_format_dialogue(dialogue)}\n\n"
f"Produce the JSON summary in {bot_name}'s POV."
)
return await classify(
client,
model=model,
system=system,
user=user,
schema=ScenePOVSummary,
default=ScenePOVSummary(),
timeout_s=timeout_s,
)
def _read_recent_dialogue(
conn: Connection, chat_id: str, *, limit: int = 50
) -> list[dict]:
"""Pull the last ``limit`` user/assistant turns for ``chat_id``.
Phase 1 ``user_turn`` / ``assistant_turn`` events don't carry a
``scene_id``, so we approximate the scene's transcript by taking
the most recent turns of the chat. Superseded and hidden rows are
filtered out so regenerated turns (T29) don't bleed into the
summary.
"""
cur = conn.execute(
"SELECT kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'assistant_turn') "
" AND superseded_by IS NULL AND hidden = 0 "
"ORDER BY id DESC LIMIT ?",
(limit,),
)
rows = list(reversed(cur.fetchall()))
out: list[dict] = []
for kind, payload_json in rows:
p = json.loads(payload_json)
if p.get("chat_id") != chat_id:
continue
if kind == "user_turn":
out.append({"speaker": "you", "text": p.get("prose", "")})
else:
out.append(
{
"speaker": p.get("speaker_id", "bot"),
"text": p.get("text", ""),
}
)
return out
async def apply_scene_close_summary(
conn: Connection,
client: LLMClient,
*,
classifier_model: str,
chat_id: str,
scene_id: int,
host_bot_id: str,
timeout_s: float = 10.0,
) -> ScenePOVSummary:
"""Drive the per-POV summary pipeline after ``scene_closed``.
Steps (Phase 1, single-bot):
1. Gather the closing scene's dialogue from the event_log.
2. Run :func:`summarize_scene` for the host bot.
3. Rewrite each scene-bound memory's ``pov_summary`` via
``manual_edit`` (target_kind ``memory_pov_summary``), capturing
the prior value for §6.4 reversibility.
4. Update the bot->you edge summary via ``manual_edit`` with the
new ``edge_summary`` target_kind. v1 combines prior + new by
concatenation — the classifier's ``relationship_summary`` is
already phrased as a continuation.
5. Append any new knowledge_facts to the same edge via
``edge_update``.
Tolerant of missing pieces: no memories -> skip step 3 silently;
no edge row -> skip step 4; empty knowledge_facts -> skip step 5.
The classifier's empty default flows through harmlessly.
"""
# Local imports to keep the module-level surface tight and avoid
# any chance of a circular dep through chat.state.*.
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you
host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id, "persona": ""}
you_entity = get_you(conn) or {"name": "you", "persona": ""}
dialogue = _read_recent_dialogue(conn, chat_id)
edge_b2y = get_edge(conn, host_bot_id, "you")
prior_summary = (edge_b2y or {}).get("summary", "") or ""
pov = await summarize_scene(
client,
model=classifier_model,
bot_name=host_bot.get("name", host_bot_id),
bot_persona=host_bot.get("persona", "") or "",
you_name=you_entity.get("name", "you") or "you",
prior_edge_summary=prior_summary,
dialogue=dialogue,
timeout_s=timeout_s,
)
# Update memories belonging to the closed scene for the host bot.
cur = conn.execute(
"SELECT id, pov_summary FROM memories "
"WHERE scene_id = ? AND owner_id = ?",
(scene_id, host_bot_id),
)
for memory_id, prior_pov in cur.fetchall():
if not pov.summary:
# Empty default -> skip the memory rewrite; the seeded
# per-turn pov_summary stays in place.
continue
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_pov_summary",
"target_id": int(memory_id),
"prior_value": prior_pov,
"new_value": pov.summary,
},
)
# Update the bot->you edge summary if we have an edge row and a
# non-empty relationship_summary to merge.
if edge_b2y is not None and pov.relationship_summary:
new_summary = (
f"{prior_summary} {pov.relationship_summary}".strip()
if prior_summary
else pov.relationship_summary
)
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "edge_summary",
"target_id": {
"source_id": host_bot_id,
"target_id": "you",
},
"prior_value": prior_summary,
"new_value": new_summary,
},
)
# Append knowledge_facts to the bot->you edge if present.
if pov.knowledge_facts:
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": host_bot_id,
"target_id": "you",
"chat_id": chat_id,
"knowledge_facts": list(pov.knowledge_facts),
},
)
return pov
+91
View File
@@ -0,0 +1,91 @@
"""Handler for ``manual_edit`` events (T25, §6.4 final paragraph).
A ``manual_edit`` event captures a user override of a projected field — its
payload snapshots both the prior value and the new value so any edit can
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:
- ``edge_affinity`` and ``edge_trust`` — slider edits on a specific edge,
clamped to 0..100.
- ``memory_significance`` — dropdown edit, clamped to 0..3.
- ``memory_pov_summary`` — textarea edit (string). Also reused by T27's
scene-close pipeline to rewrite per-turn raw narratives into a proper
per-POV scene summary.
- ``edge_summary`` — string overwrite of the directed edge's ``summary``
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.
Pin toggles intentionally use the existing ``memory_pin_changed`` event
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
the projection writes both ``pinned`` and ``auto_pinned`` atomically.
"""
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.log import Event
from chat.eventlog.projector import on
def _clamp(value: int, lo: int, hi: int) -> int:
return max(lo, min(hi, value))
@on("manual_edit")
def _apply_manual_edit(conn: Connection, e: Event) -> None:
p = e.payload
kind = p["target_kind"]
target_id = p["target_id"]
new_value = p["new_value"]
if kind == "edge_affinity":
conn.execute(
"UPDATE edges SET affinity = ? "
"WHERE source_id = ? AND target_id = ?",
(
_clamp(int(new_value), 0, 100),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "edge_trust":
conn.execute(
"UPDATE edges SET trust = ? "
"WHERE source_id = ? AND target_id = ?",
(
_clamp(int(new_value), 0, 100),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "memory_significance":
conn.execute(
"UPDATE memories SET significance = ? WHERE id = ?",
(_clamp(int(new_value), 0, 3), int(target_id)),
)
elif kind == "memory_pov_summary":
conn.execute(
"UPDATE memories SET pov_summary = ? WHERE id = ?",
(str(new_value), int(target_id)),
)
elif kind == "edge_summary":
# ``target_id`` here is a {"source_id", "target_id"} pair like
# the affinity/trust edits, since edges are keyed by the
# directed pair, not a single rowid.
conn.execute(
"UPDATE edges SET summary = ? "
"WHERE source_id = ? AND target_id = ?",
(
str(new_value),
target_id["source_id"],
target_id["target_id"],
),
)
# Unknown target_kind: silently no-op for v1. Future kinds (activity
# fields, knowledge_facts list manipulation) extend the dispatch above.
+10
View File
@@ -79,3 +79,13 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
.drawer[hidden] { display: none; }
.drawer-content { display: flex; flex-direction: column; gap: 16px; }
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 8px; border-bottom: 1px solid #e5e5e5; }
.drawer-close { border: none; background: transparent; color: #1c1c1c; font-size: 24px; padding: 0 4px; cursor: pointer; }
.drawer-section h3 { margin: 0 0 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; }
.activity-row, .edge-row { margin-bottom: 12px; }
.activity-row strong, .edge-row strong { display: block; }
.memory-list { list-style: none; padding: 0; margin: 0; }
.memory-list li { padding: 4px 0; font-size: 13px; }
.sig { display: inline-block; min-width: 16px; }
.sig-3 { color: #d4af37; }
+139
View File
@@ -0,0 +1,139 @@
<div class="drawer-content">
<header class="drawer-header">
<h2>{{ host_bot.name }}</h2>
<button class="drawer-close" type="button"
onclick="document.getElementById('drawer').setAttribute('hidden','')">&times;</button>
</header>
<section class="drawer-section">
<h3>Scene</h3>
{% if scene %}
<p>Started: {{ scene.started_at }}</p>
{% endif %}
{% if container %}
<p>Container: {{ container.name }} ({{ container.type }})</p>
{% else %}
<p class="muted">No active container.</p>
{% endif %}
<p>Time: {{ chat.time }}</p>
{% if scene %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/scene/close"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Close scene</button>
</form>
{% else %}
<p class="muted">No active scene.</p>
{% endif %}
</section>
<section class="drawer-section">
<h3>Activity</h3>
{% for label, act in [("you", you_activity), (host_bot.name, bot_activity)] %}
<div class="activity-row">
<strong>{{ label }}</strong>
{% if act %}
<p>{{ act.posture or "—" }} / {{ (act.action or {}).verb or "—" }}</p>
{% if act.attention %}<p class="muted">attention: {{ act.attention }}</p>{% endif %}
{% if act.holding %}<p class="muted">holding: {{ act.holding|join(", ") }}</p>{% endif %}
{% else %}
<p class="muted">No activity recorded.</p>
{% endif %}
</div>
{% endfor %}
</section>
<section class="drawer-section">
<h3>Edges</h3>
{% if edge_b2y %}
<div class="edge-row">
<strong>{{ host_bot.name }} &rarr; you</strong>
<p>Affinity: {{ edge_b2y.affinity }}/100 &middot; Trust: {{ edge_b2y.trust }}/100</p>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/edge/{{ host_bot.id }}/you/affinity"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Affinity:
<input type="range" name="affinity" min="0" max="100"
value="{{ edge_b2y.affinity }}"
oninput="this.nextElementSibling.value = this.value">
<output>{{ edge_b2y.affinity }}</output>
</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 %}
</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 %}
</div>
{% endif %}
{% if not edge_b2y and not edge_y2b %}
<p class="muted">No edges yet.</p>
{% endif %}
</section>
<section class="drawer-section">
<h3>Pinned memories ({{ pinned|length }} / {{ pin_cap }})</h3>
{% if pinned %}
<ul class="memory-list">
{% for m in pinned %}
<li>
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
{{ m.pov_summary }}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/pin"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="pinned" value="0">
<button type="submit">Unpin</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No pinned memories.</p>
{% endif %}
</section>
<section class="drawer-section">
<h3>Recent memories</h3>
{% if recent_memories %}
<ul class="memory-list">
{% for m in recent_memories %}
<li>
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
{{ m.pov_summary[:200] }}{% if m.pov_summary|length > 200 %}…{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/significance"
hx-target="#drawer" hx-swap="innerHTML">
<select name="significance">
{% for s in [0, 1, 2, 3] %}
<option value="{{ s }}" {% if m.significance == s %}selected{% endif %}>
{{ ['·','•','★','★★'][s] }} ({{ s }})
</option>
{% endfor %}
</select>
<button type="submit">Set</button>
</form>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/pin"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}">
<button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No memories yet.</p>
{% endif %}
</section>
</div>
+5 -2
View File
@@ -30,8 +30,11 @@
<button type="submit">Send</button>
</form>
<aside class="drawer" id="drawer" hidden>
<p class="muted">Drawer (read-only). T24 fills this in.</p>
<aside class="drawer" id="drawer" hidden
hx-get="/chats/{{ chat.id }}/drawer"
hx-trigger="revealed"
hx-swap="innerHTML">
<p class="muted">Loading drawer&hellip;</p>
</aside>
</div>
<script>
+306
View File
@@ -0,0 +1,306 @@
"""Chat drawer — read view (T24) and inline edits (T25).
The GET endpoint renders an HTML partial showing the current scene +
container, per-entity activity, host <-> you edges, pinned memories with
an ``n / cap`` counter, and recent witnessed memories from the host's
POV with significance markers.
T25 adds three POST endpoints for the most useful inline edits, each
returning the refreshed drawer partial so HTMX can swap it in:
* affinity slider on an edge (emits ``manual_edit``);
* significance dropdown on a memory (emits ``manual_edit``);
* pin toggle on a memory (emits ``memory_pin_changed`` with
``auto_pinned=0`` so a manual pin is not subject to auto-eviction).
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
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_and_apply
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
from chat.state.memory import get_pinned
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
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
# Soft cap on pinned memories per owner (§8.5). Surfaced in the drawer header
# as `pinned|length / pin_cap`; eviction logic itself lives in T22.
PIN_CAP = 8
# Recent-memories list is bounded to keep the drawer cheap to render.
RECENT_LIMIT = 10
@router.get("/chats/{chat_id}/drawer", response_class=HTMLResponse)
async def drawer(chat_id: str, request: Request, 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}")
host_bot = get_bot(conn, chat["host_bot_id"])
if host_bot is None:
raise HTTPException(
status_code=404, detail=f"host bot not found: {chat['host_bot_id']}"
)
you_entity = get_you(conn) or {"name": "you", "pronouns": "", "persona": ""}
scene = active_scene(conn, chat_id)
container = None
if scene and scene.get("container_id") is not None:
container = get_container(conn, scene["container_id"])
you_activity = get_activity(conn, "you")
bot_activity = get_activity(conn, chat["host_bot_id"])
edge_b2y = get_edge(conn, chat["host_bot_id"], "you")
edge_y2b = get_edge(conn, "you", chat["host_bot_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.
recent_rows = conn.execute(
"""
SELECT id, pov_summary, significance, pinned, created_at
FROM memories
WHERE owner_id = ? AND witness_host = 1
ORDER BY id DESC
LIMIT ?
""",
(chat["host_bot_id"], RECENT_LIMIT),
).fetchall()
recent_memories = [
{
"id": r[0],
"pov_summary": r[1],
"significance": r[2],
"pinned": r[3],
"created_at": r[4],
}
for r in recent_rows
]
pinned = get_pinned(conn, chat["host_bot_id"])
return TEMPLATES.TemplateResponse(
request,
"_drawer.html",
{
"chat": chat,
"host_bot": host_bot,
"you_entity": you_entity,
"scene": scene,
"container": container,
"you_activity": you_activity,
"bot_activity": bot_activity,
"edge_b2y": edge_b2y,
"edge_y2b": edge_y2b,
"recent_memories": recent_memories,
"pinned": pinned,
"pin_cap": PIN_CAP,
},
)
# --- T25 edit endpoints ---------------------------------------------------
#
# Each endpoint:
# 1. Loads the chat (404 if missing) and the target row (404 if missing).
# 2. Reads the prior value before mutating, so the event payload carries
# it for §6.4 reversibility.
# 3. Calls ``append_and_apply`` so the projected table updates atomically
# with the event log append; full reprojection would re-add deltas
# from earlier ``edge_update`` events.
# 4. Returns the refreshed drawer partial via ``await drawer(...)``, which
# HTMX swaps into ``#drawer``.
@router.post(
"/chats/{chat_id}/drawer/scene/close",
response_class=HTMLResponse,
)
async def close_scene_manual(
chat_id: str,
request: Request,
conn=Depends(get_conn),
client=Depends(get_llm_client),
):
"""Manual scene close from the drawer button.
Always available when there's an active scene; mirrors the auto-close
path in the turn flow but bypasses the hard-signal classifier. After
emitting ``scene_closed`` we run the T27 per-POV summary pipeline
(one classifier call) so the manual path produces the same memory /
edge updates as the auto path. Returns the refreshed drawer partial
so HTMX swaps it in. ``400`` when no scene is active — the button is
hidden in that state but a stale tab might still POST.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
scene = active_scene(conn, chat_id)
if scene is None:
raise HTTPException(
status_code=400, detail="no active scene to close"
)
append_and_apply(
conn,
kind="scene_closed",
payload={
"scene_id": scene["id"],
"ended_at": chat.get("time"),
# Significance defaults to 0; T22's significance worker
# operates on memories, not scenes.
"significance": 0,
},
)
settings = request.app.state.settings
await apply_scene_close_summary(
conn,
client,
classifier_model=settings.classifier_model,
chat_id=chat_id,
scene_id=scene["id"],
host_bot_id=chat["host_bot_id"],
timeout_s=settings.classifier_timeout_s,
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/edge/{source_id}/{target_id}/affinity",
response_class=HTMLResponse,
)
async def edit_edge_affinity(
chat_id: str,
source_id: str,
target_id: str,
request: Request,
affinity: 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}")
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["affinity"])
new_value = max(0, min(100, int(affinity)))
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "edge_affinity",
"target_id": {"source_id": source_id, "target_id": target_id},
"prior_value": prior,
"new_value": new_value,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/memory/{memory_id}/significance",
response_class=HTMLResponse,
)
async def edit_memory_significance(
chat_id: str,
memory_id: int,
request: Request,
significance: 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}")
row = conn.execute(
"SELECT significance FROM memories WHERE id = ?", (memory_id,)
).fetchone()
if row is None:
raise HTTPException(
status_code=404, detail=f"memory not found: {memory_id}"
)
prior = int(row[0])
new_value = max(0, min(3, int(significance)))
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_significance",
"target_id": int(memory_id),
"prior_value": prior,
"new_value": new_value,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/memory/{memory_id}/pin",
response_class=HTMLResponse,
)
async def toggle_memory_pin(
chat_id: str,
memory_id: int,
request: Request,
pinned: 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}")
row = conn.execute(
"SELECT pinned FROM memories WHERE id = ?", (memory_id,)
).fetchone()
if row is None:
raise HTTPException(
status_code=404, detail=f"memory not found: {memory_id}"
)
new_pinned = 1 if int(pinned) else 0
# Manual pin: ``auto_pinned=0`` so the §8.5 eviction query (which only
# touches auto-pinned rows) leaves this alone.
append_and_apply(
conn,
kind="memory_pin_changed",
payload={
"memory_id": int(memory_id),
"pinned": new_pinned,
"auto_pinned": 0,
},
)
return await drawer(chat_id, request, conn)
+56 -1
View File
@@ -42,11 +42,13 @@ from chat.eventlog.log import append_and_apply, append_event
from chat.services.background import SignificanceJob
from chat.services.memory_write import record_turn_memory
from chat.services.prompt import assemble_narrative_prompt
from chat.services.scene_close import detect_scene_close
from chat.services.scene_summarize import apply_scene_close_summary
from chat.services.state_update import compute_state_update
from chat.services.turn_parse import ParsedTurn, parse_turn
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you
from chat.state.world import active_scene, get_chat
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.pubsub import publish
@@ -331,6 +333,59 @@ async def post_turn(
)
)
# 6d. Scene-close detection (Plan §7.2, T26). Runs AFTER assistant_turn
# so the bot's response is the closing scene's final beat — closing
# before narrative would force the bot to speak "in no scene", which
# is awkward. Hard signals only in Phase 1: container change parsed
# from prose, or explicit "fade out" / "we're done here" patterns.
# On classifier failure the service returns ``should_close=False``
# so the turn flow keeps moving; the manual close button in the
# drawer is the always-available fallback.
#
# Skip empty prose — no signal to classify and no point spending a
# round-trip. Skip when there's no active scene (e.g. after a prior
# 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.
if scene is not None and prose.strip():
container = None
if scene.get("container_id") is not None:
container = get_container(conn, scene["container_id"])
container_name = container["name"] if container else "unknown"
decision = await detect_scene_close(
client,
model=settings.classifier_model,
prose=prose,
current_container_name=container_name,
)
if decision.should_close:
append_and_apply(
conn,
kind="scene_closed",
payload={
"scene_id": scene["id"],
"ended_at": chat.get("time"),
# T27 promotes the per-POV summary into ``edges.summary``
# but doesn't currently set scene significance — the
# async significance pass (T22) operates on memories.
"significance": 0,
},
)
# T27: per-POV summary + edge summary update + knowledge
# promotion. Runs synchronously after the close so the
# next turn (or a subsequent GET /chats/<id>) sees the
# rewritten memories and edge summary. Tolerates classifier
# failure (returns the empty default and skips the writes).
await apply_scene_close_summary(
conn,
client,
classifier_model=settings.classifier_model,
chat_id=chat_id,
scene_id=scene["id"],
host_bot_id=host_bot["id"],
timeout_s=settings.classifier_timeout_s,
)
# 7. Broadcast a JSON completion event (for JS consumers) and an HTML
# fragment event (for HTMX SSE swap-into-timeline).
await publish(
+190
View File
@@ -0,0 +1,190 @@
"""T25: drawer edits with manual_edit event capture.
Each editable field on the drawer is exposed as a small POST endpoint.
Edits emit either a ``manual_edit`` event (snapshotting the prior value
for §6.4 reversibility) or, for pin toggles, a ``memory_pin_changed``
event with ``auto_pinned=0`` so manual pins survive auto-eviction.
Phase 1 narrowed scope: affinity slider, significance dropdown, pin
toggle. Other §6.4 fields (activity, edge_summary, edge_trust, pov_summary,
knowledge_facts list) are deferred to a Phase 1.5 follow-up.
"""
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:
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": "",
},
)
# Edge bot_a -> you with affinity_delta=0 to materialise the row at
# default 50/50.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
"affinity_delta": 0,
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"pov_summary": "A memory",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
project(conn)
def test_edit_edge_affinity_emits_manual_edit_and_updates(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/edge/bot_a/you/affinity",
data={"affinity": "75"},
)
assert response.status_code == 200 # returns refreshed drawer partial
# Refresh shows the new affinity value.
assert "75" in response.text
with open_db(tmp_path / "test.db") as conn:
cur = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(cur) == 1
payload = json.loads(cur[0][0])
assert payload["target_kind"] == "edge_affinity"
assert payload["prior_value"] == 50
assert payload["new_value"] == 75
assert payload["target_id"]["source_id"] == "bot_a"
assert payload["target_id"]["target_id"] == "you"
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert edge["affinity"] == 75
def test_edit_memory_significance_emits_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(
f"/chats/chat_bot_a/drawer/memory/{memory_id}/significance",
data={"significance": "3"},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
sig = conn.execute(
"SELECT significance FROM memories WHERE id = ?", (memory_id,)
).fetchone()[0]
assert sig == 3
cur = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(cur) == 1
payload = json.loads(cur[0][0])
assert payload["target_kind"] == "memory_significance"
assert payload["prior_value"] == 1
assert payload["new_value"] == 3
assert payload["target_id"] == memory_id
def test_toggle_memory_pin_manual_emits_event_with_auto_pinned_0(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(
f"/chats/chat_bot_a/drawer/memory/{memory_id}/pin",
data={"pinned": "1"},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
row = conn.execute(
"SELECT pinned, auto_pinned FROM memories WHERE id = ?", (memory_id,)
).fetchone()
assert row[0] == 1
assert row[1] == 0 # NOT auto-pinned (manual pin survives auto-eviction)
# The pin toggle uses memory_pin_changed (not manual_edit).
cur = conn.execute(
"SELECT payload_json FROM event_log "
"WHERE kind = 'memory_pin_changed' ORDER BY id DESC LIMIT 1"
).fetchone()
payload = json.loads(cur[0])
assert payload["pinned"] == 1
assert payload["auto_pinned"] == 0
assert payload["memory_id"] == memory_id
def test_edit_404_when_chat_missing(client):
response = client.post(
"/chats/no_such/drawer/edge/bot_a/you/affinity",
data={"affinity": "75"},
)
assert response.status_code == 404
def test_edit_404_when_target_missing(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/memory/99999/significance",
data={"significance": "2"},
)
assert response.status_code == 404
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
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:
# Disable background worker (we won't drive turns).
if hasattr(app.state, "background_worker"):
app.state.background_worker.enabled = False
yield c
def _seed(db: Path) -> None:
with open_db(db) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": ["shy"],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
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": "",
},
)
# Activity for both you and host bot.
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "you",
"posture": "sitting",
"action": {"verb": "thinking"},
"attention": "the screen",
},
)
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "bot_a",
"posture": "standing",
"action": {"verb": "looking out the window"},
},
)
# Edge host -> you.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
"affinity_delta": 5,
"trust_delta": 2,
"knowledge_facts": ["Me likes coffee"],
},
)
# A regular memory.
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"pov_summary": "Talked about her sister",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 2,
},
)
# A pinned memory with significance 3 (★★).
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"pov_summary": "First kiss",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 3,
"pinned": 1,
"auto_pinned": 1,
},
)
project(conn)
def test_drawer_404_when_chat_missing(client):
response = client.get("/chats/no_such/drawer")
assert response.status_code == 404
def test_drawer_renders_scene_and_activity(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
# Scene/time anchor.
assert "2026-04-26" in body
# Activity verbs from both entities.
assert "thinking" in body
assert "looking out the window" in body
# Activity attention.
assert "the screen" in body
def test_drawer_renders_edges(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
assert "BotA" in body
assert "you" in body
# Default affinity 50 + delta 5 = 55.
assert "55" in body
# Knowledge fact appears.
assert "Me likes coffee" in body
def test_drawer_renders_memories_with_significance_markers(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
assert "Talked about her sister" in body
assert "First kiss" in body
# Pinned counter shows 1 / 8 (or 1/8).
assert "1 / 8" in body or "1/8" in body
# Significance star marker for the pinned, score-3 memory.
assert "" in body
def test_drawer_handles_no_state_gracefully(client, tmp_path):
db = tmp_path / "test.db"
with open_db(db) as conn:
# Just enough state for the chat to exist.
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
project(conn)
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
# Drawer renders gracefully with empty placeholders.
assert "No active container" in body or "Container:" not in body
assert "No edges yet" in body or "Edges" in body
+7
View File
@@ -156,6 +156,12 @@ def client(tmp_path, monkeypatch):
canned_state_update = json.dumps(
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
)
# T26 scene-close detection runs after the state-update pass. ``_seed_full``
# below doesn't open a scene so the classifier call is short-circuited in
# turns.py — but the canned slot stays in place to document the order.
canned_scene_close = json.dumps(
{"should_close": False, "reason": "no signal"}
)
from chat.web.kickoff import get_llm_client
@@ -165,6 +171,7 @@ def client(tmp_path, monkeypatch):
canned_response,
canned_state_update,
canned_state_update,
canned_scene_close,
]
)
app.dependency_overrides[get_llm_client] = lambda: mock
+260
View File
@@ -0,0 +1,260 @@
"""Per-POV summary and edge summary update on scene close (T27).
When a scene closes (via the auto-close path in the turn flow or the
manual button in the drawer), we run a classifier that produces a
per-POV summary for each present witness — Phase 1 single-bot only the
host bot, since "you" doesn't have a memory store in v1. The output
drives three projected updates:
1. Each ``memories`` row for the closed scene owned by the host bot has
its ``pov_summary`` rewritten via ``manual_edit`` events
(``target_kind="memory_pov_summary"``) so the field carries a proper
scene-level summary instead of the per-turn raw narrative seeded by
T21.
2. The directed bot->you ``edges.summary`` is updated via a new
``manual_edit`` target_kind ``edge_summary``. v1 strategy combines
the prior summary with the classifier's ``relationship_summary``
field; the LLM is the one phrasing the merge.
3. Newly-learned facts from the classifier's ``knowledge_facts`` field
are appended via the existing ``edge_update`` event handler.
"""
from __future__ import annotations
import json
from pathlib import Path
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.projector import project
from chat.llm.mock import MockLLMClient
from chat.services.scene_summarize import (
ScenePOVSummary,
apply_scene_close_summary,
summarize_scene,
)
# Importing for handler-registration side effects so the freshly-migrated
# DB created in each test below has the projector ready.
import chat.state.edges # noqa: F401
import chat.state.entities # noqa: F401
import chat.state.manual_edit # noqa: F401
import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
# ---------------------------------------------------------------------------
# Service-level tests — no FastAPI involvement.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_summarize_scene_parses_classifier_output():
canned = json.dumps(
{
"summary": "BotA shared a quiet moment with you in the office.",
"knowledge_facts": ["You like coffee black."],
"relationship_summary": "BotA feels closer to you after this conversation.",
}
)
mock = MockLLMClient(canned=[canned])
result = await summarize_scene(
mock,
model="x",
bot_name="BotA",
bot_persona="thoughtful",
you_name="Me",
prior_edge_summary="",
dialogue=[
{"speaker": "Me", "text": "hi"},
{"speaker": "BotA", "text": "Hello!"},
],
)
assert isinstance(result, ScenePOVSummary)
assert result.summary.startswith("BotA shared")
assert result.knowledge_facts == ["You like coffee black."]
assert "closer" in result.relationship_summary
@pytest.mark.asyncio
async def test_summarize_scene_default_on_failure():
"""Two consecutive non-JSON returns trip the classifier's retry-then-default
path; we should get the empty fallback rather than crashing the close
flow."""
mock = MockLLMClient(canned=["bad", "still bad"])
result = await summarize_scene(
mock,
model="x",
bot_name="BotA",
bot_persona="",
you_name="Me",
prior_edge_summary="",
dialogue=[],
)
assert result.summary == ""
assert result.knowledge_facts == []
assert result.relationship_summary == ""
@pytest.mark.asyncio
async def test_apply_scene_close_summary_updates_memories_and_edge(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA reassured you about the project deadline.",
"knowledge_facts": ["You are nervous about the deadline."],
"relationship_summary": "BotA showed quiet support.",
}
)
with open_db(db) as conn:
# Seed bot, you, chat, container, scene, edge, memory, dialogue.
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="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="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "I'm nervous 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,
},
)
project(conn)
client = MockLLMClient(canned=[canned])
result = await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Returned summary plumbs through.
assert "reassured" in result.summary
assert result.knowledge_facts == ["You are nervous about the deadline."]
# Memory pov_summary updated.
new_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_a' AND scene_id = 1"
).fetchone()[0]
assert "reassured" in new_pov
# And the manual_edit event was logged with prior_value capture.
edits = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert any(
json.loads(p[0]).get("target_kind") == "memory_pov_summary"
for p in edits
)
mem_edit = next(
json.loads(p[0])
for p in edits
if json.loads(p[0]).get("target_kind") == "memory_pov_summary"
)
assert mem_edit["prior_value"] == "Original raw narrative"
# Edge summary updated via manual_edit (target_kind="edge_summary").
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert "support" in edge["summary"]
assert any(
json.loads(p[0]).get("target_kind") == "edge_summary"
for p in edits
)
# Knowledge fact appended via edge_update.
assert any("deadline" in fact for fact in edge["knowledge"])
+287
View File
@@ -0,0 +1,287 @@
"""Scene close on hard signals + manual override (T26).
A small classifier service decides whether the user's prose narrates a
"hard signal" that should close the active scene (container change,
explicit "fade out" / "we're done here" patterns). Wired into the turn
flow AFTER the assistant_turn so the bot's response is the final beat in
the closing scene. The drawer also exposes a manual "Close scene" button
that always fires a ``scene_closed`` event.
Per Task 26 we DO NOT auto-open a new scene on close — the next
interaction either lives in a fresh chat or operates without an active
scene; the prompt assembler already tolerates ``active_scene == None``.
"""
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
from chat.services.scene_close import detect_scene_close
# ---------------------------------------------------------------------------
# Service-level tests (no FastAPI involvement).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_detect_scene_close_returns_decision():
canned = json.dumps(
{
"should_close": True,
"reason": "container change",
"new_container_hint": "park",
}
)
mock = MockLLMClient(canned=[canned])
decision = await detect_scene_close(
mock,
model="x",
prose="we drove to the park",
current_container_name="office",
)
assert decision.should_close is True
assert "container" in decision.reason
@pytest.mark.asyncio
async def test_detect_scene_close_default_on_failure():
"""Two consecutive non-JSON returns trip the classifier's retry-then-default
path; we should get the safe ``should_close=False`` fallback rather than
crashing the turn flow."""
mock = MockLLMClient(canned=["nope", "still nope"])
decision = await detect_scene_close(
mock,
model="x",
prose="anything",
current_container_name="office",
)
assert decision.should_close is False
# ---------------------------------------------------------------------------
# HTTP integration: turn flow + manual close.
# ---------------------------------------------------------------------------
@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))
# Order of canned responses for one POST /turns:
# 1. parse_turn classifier
# 2. narrative streamer
# 3. state_update bot->you
# 4. state_update you->bot
# 5. detect_scene_close (runs AFTER assistant_turn — see turns.py)
# 6. summarize_scene (T27, runs only when scene-close fires)
parse_canned = json.dumps(
{"segments": [{"kind": "dialogue", "text": "hello"}]}
)
narrative_canned = "BotA grins."
state_update_canned = json.dumps(
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
)
scene_close_canned = json.dumps(
{
"should_close": True,
"reason": "container change",
"new_container_hint": "park",
}
)
pov_summary_canned = json.dumps(
{
"summary": "BotA noticed you leaving the office.",
"knowledge_facts": [],
"relationship_summary": "BotA wonders where you're headed.",
}
)
from chat.web.kickoff import get_llm_client
mock = MockLLMClient(
canned=[
parse_canned,
narrative_canned,
state_update_canned,
state_update_canned,
scene_close_canned,
pov_summary_canned,
]
)
app.dependency_overrides[get_llm_client] = lambda: mock
with TestClient(app) as c:
# Same as other turn-flow tests: keep the async significance worker
# off so it doesn't try to call Featherless with the test API key.
app.state.background_worker.enabled = False
yield c
app.dependency_overrides.clear()
def _seed(db_path: Path, *, with_scene: bool = True) -> None:
"""Seed enough state for a full turn flow plus an active scene."""
with open_db(db_path) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "thoughtful, observant",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "you",
"posture": "sitting",
"action": {
"verb": "thinking",
"interruptible": True,
"required_attention": "low",
"expected_duration": "ongoing",
},
"attention": "",
"holding": [],
"status": {},
},
)
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "bot_a",
"posture": "standing",
"action": {
"verb": "watching",
"interruptible": True,
"required_attention": "low",
"expected_duration": "ongoing",
},
"attention": "",
"holding": [],
"status": {},
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
"knowledge_facts": ["coworker"],
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "you",
"target_id": "bot_a",
"chat_id": "chat_bot_a",
"knowledge_facts": [],
},
)
if with_scene:
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"],
},
)
project(conn)
def test_post_turn_closes_scene_on_container_change(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/turns", data={"prose": "we drove to the park"}
)
assert response.status_code == 204
with open_db(tmp_path / "test.db") as conn:
# scene_closed event present.
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'scene_closed'"
)
assert cur.fetchone()[0] == 1
# Active scene cleared by the projector.
from chat.state.world import active_scene
assert active_scene(conn, "chat_bot_a") is None
# Order: assistant_turn lands BEFORE scene_closed (the bot's reply is
# the closing scene's final beat).
cur = conn.execute(
"SELECT kind FROM event_log "
"WHERE kind IN ('assistant_turn', 'scene_closed') ORDER BY id"
)
kinds = [r[0] for r in cur.fetchall()]
assert kinds == ["assistant_turn", "scene_closed"]
def test_manual_close_scene_button(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post("/chats/chat_bot_a/drawer/scene/close")
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'scene_closed'"
)
assert cur.fetchone()[0] == 1
from chat.state.world import active_scene
assert active_scene(conn, "chat_bot_a") is None
def test_manual_close_400_when_no_active_scene(client, tmp_path):
_seed(tmp_path / "test.db", with_scene=False)
response = client.post("/chats/chat_bot_a/drawer/scene/close")
assert response.status_code == 400
+8
View File
@@ -43,6 +43,13 @@ def client(tmp_path, monkeypatch):
canned_state_update = json.dumps(
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
)
# T26 scene-close detection runs after the state-update pass. These
# tests don't seed an active scene so the classifier is short-circuited
# in turns.py — but the canned slot is harmless to leave in place,
# and adding it documents the order even when the call doesn't fire.
canned_scene_close = json.dumps(
{"should_close": False, "reason": "no signal"}
)
# Import here so env vars are visible to the dependency lookup.
from chat.web.kickoff import get_llm_client
@@ -53,6 +60,7 @@ def client(tmp_path, monkeypatch):
canned_response,
canned_state_update,
canned_state_update,
canned_scene_close,
]
)
app.dependency_overrides[get_llm_client] = lambda: mock