Compare commits
221 Commits
f0594c24d2
...
phase-4
| Author | SHA1 | Date | |
|---|---|---|---|
| 51a12afbec | |||
| fc3020a0ee | |||
| 228f9abb19 | |||
| b6119879e5 | |||
| 3b4c7b9cef | |||
| 36d75fa6e7 | |||
| 0a2c5924f9 | |||
| a5f0e69d44 | |||
| 3dbe1a01ff | |||
| 4546bc0d9c | |||
| c4fa11fe78 | |||
| 461d441078 | |||
| b25007eb44 | |||
| d39d31479d | |||
| 7899c50b6c | |||
| 177e39d59c | |||
| d85ed8aaa6 | |||
| 9c63d6b24c | |||
| 64a07aa87f | |||
| 6674f9475c | |||
| 50448b72f8 | |||
| b8b4aed6d9 | |||
| 5ff107574c | |||
| 915d625d7f | |||
| 28e13d416f | |||
| 296e8fdddd | |||
| 013b563f21 | |||
| 62d5cdd826 | |||
| a25c166174 | |||
| 8f66e1123a | |||
| caa17b4174 | |||
| c7cb0eb01e | |||
| 1d6768e980 | |||
| 8b086d4bb8 | |||
| 6c7ac8f69f | |||
| fe34d4f4c0 | |||
| 0d76a6b2d6 | |||
| cc71fb4d01 | |||
| c06a32767b | |||
| 0ba374b790 | |||
| 77f1636086 | |||
| bffd9a2f38 | |||
| 1b66a2821c | |||
| 74bb42397d | |||
| 3be8ed8915 | |||
| 097073ede5 | |||
| 4a2617565b | |||
| 73625c0ac4 | |||
| aea20a2c83 | |||
| 9493d24a53 | |||
| da7aa88b8e | |||
| 82701d3c18 | |||
| 0de4d1252c | |||
| b667a21c99 | |||
| a1e2d9a24d | |||
| d833bbc3e7 | |||
| f2fd30c5a9 | |||
| 9e7c16de40 | |||
| 71245fb85a | |||
| be92691f9a | |||
| 6f50ce5b7a | |||
| f816d44438 | |||
| 6f0716201f | |||
| 0d3bbf4272 | |||
| b91a5e9293 | |||
| 9d06eaf57a | |||
| dae481eb92 | |||
| d123684f9a | |||
| 29e6f346ef | |||
| cb570a5adc | |||
| ce4f56adfa | |||
| bdc93b4b67 | |||
| 9c9d71eb31 | |||
| 4199038b8b | |||
| e3dfe18811 | |||
| d759b90aa1 | |||
| fb7e97260b | |||
| 753cec327f | |||
| 70a5ad3ecc | |||
| 6709cf46a7 | |||
| c3947bbb68 | |||
| f865ac2ee2 | |||
| af6c54dd05 | |||
| dc35833534 | |||
| 0cd41636b3 | |||
| 2c7aa68af9 | |||
| cf43ba0993 | |||
| a781732ee6 | |||
| c9d58b8229 | |||
| c463dc70b2 | |||
| 819803da84 | |||
| a7eedb8037 | |||
| e236bcadcd | |||
| 3678bcaca6 | |||
| b582567521 | |||
| 21c4ffa63c | |||
| 83f94a4325 | |||
| 2d14197553 | |||
| 8efbcdf6c3 | |||
| 8aeadfd0e4 | |||
| 88350d7d2e | |||
| 343f305587 | |||
| 021587b3df | |||
| 5e6b29e0c5 | |||
| a34931375c | |||
| 959fe11410 | |||
| 2959e1ac2a | |||
| afe940259a | |||
| c2144cd9df | |||
| 7857da4112 | |||
| adbbd32873 | |||
| 98250644ad | |||
| da1f67fb6a | |||
| 03ba34272b | |||
| e26885b011 | |||
| 5b7a195cf5 | |||
| 25bcbac055 | |||
| ab2b494c21 | |||
| b6888ff36a | |||
| e4fd888b53 | |||
| 079774dce5 | |||
| 3be7920f41 | |||
| e61bd9cb08 | |||
| c6e0130e59 | |||
| 67d6f3fe68 | |||
| dbc9690358 | |||
| 6d98728a2e | |||
| bfb2ffb6f6 | |||
| bd13b64959 | |||
| f2a57005e5 | |||
| 88fae33152 | |||
| c874883a84 | |||
| 6f22e86f54 | |||
| e632a6247d | |||
| 607d0971c4 | |||
| c265e4ce0f | |||
| 21404a373b | |||
| 789b9bd042 | |||
| 73bb8c1f17 | |||
| afd1a50958 | |||
| 428438b223 | |||
| b13f3b4e47 | |||
| f701f9d7dd | |||
| 1b9144442a | |||
| 13c23fd898 | |||
| c1e419e012 | |||
| 994728b5ed | |||
| e05f28e9d5 | |||
| 379054755a | |||
| bb87fcbd4a | |||
| f6b75b25eb | |||
| 9f35669936 | |||
| 321810fa54 | |||
| fb17ba0657 | |||
| d40313063c | |||
| 60ac33a787 | |||
| c86b0df411 | |||
| 44c8735b27 | |||
| 9b601650fb | |||
| fcb111310a | |||
| 4e240347b4 | |||
| a90647dddb | |||
| bb83d97088 | |||
| f24ffb8e4f | |||
| 9d80b9ae2b | |||
| 77b42f1ea5 | |||
| e7793f2441 | |||
| 4ec56dd475 | |||
| 6a92253ae7 | |||
| 22db9f3554 | |||
| 6b726b2a4a | |||
| e58cdbd527 | |||
| b69a74e69b | |||
| c6b3531c64 | |||
| a0d7debce5 | |||
| a1b4e251c5 | |||
| b8335895e1 | |||
| d161e7b8e9 | |||
| f0742dd4f9 | |||
| 52555e0455 | |||
| 5c039c8e56 | |||
| 5aab98e4d7 | |||
| 12502d6ec7 | |||
| 365dacc0d0 | |||
| a302ed427a | |||
| 0353d592cd | |||
| 330077afcf | |||
| 8390703b73 | |||
| b9644fad31 | |||
| 82be8b3f51 | |||
| 46062973c2 | |||
| aa0563b4fa | |||
| b5175aefaa | |||
| 0997562e75 | |||
| db3005fc17 | |||
| 5fc5b8ac23 | |||
| 3995a8671b | |||
| eb4cdf9cbb | |||
| a45dabb6ae | |||
| e8d24a0875 | |||
| 9b45710cb1 | |||
| 73d8b0c092 | |||
| a0f5e818ec | |||
| 656c2558cb | |||
| e79f4d8d22 | |||
| 0c08745194 | |||
| fbb16c86b3 | |||
| e44e2bf93f | |||
| 44ea627a8a | |||
| a5339fc1d2 | |||
| ec344064f1 | |||
| 30e6648122 | |||
| bc97d425ef | |||
| 7e6c2985dd | |||
| 5e6bbb586c | |||
| 517fe49aef | |||
| c2aceffda1 | |||
| e627356168 | |||
| 67517926aa | |||
| 01e6975d20 | |||
| 4a60171035 |
@@ -2,3 +2,10 @@
|
||||
|
||||
# v1 runtime data (DB, backups, snapshots, exports, config with secrets)
|
||||
data/
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -50,6 +50,10 @@ The 3-entity cap is load-bearing: it makes the relationship graph fully enumerab
|
||||
- **Snapshots**: periodic every 100 events / 30 min; pre-rewind always. 5 periodic retained; pre-rewind retained 14 days.
|
||||
- **Streaming**: Stop button on streaming row; mid-stream disconnect commits partial with `truncated: true`; Send disabled mid-stream; multi-tab streaming via per-chat SSE channel.
|
||||
- **Display**: lightweight markdown; `*action*` italic; OOC `((parens))` shown dimmed/italic, never sent to bot.
|
||||
- **Multi-entity defaults (Phase 2)**: when `chat.guest_bot_id is None`, behavior matches Phase 1 single-bot 1:1. With a guest, all 3 entities are present in the prompt, witness writes, and state-update fan-out (6 directed pairs).
|
||||
- **Addressee detection**: simple substring match (whole-word, case-insensitive) over the user turn's body. If both bot names match or neither does, the host gets the floor.
|
||||
- **Interjection**: classifier-driven, conservative bias (default false on classifier failure / refusal / parse error). When the classifier returns true, the addressee speaks first, then the non-addressee may interject in a follow-up turn.
|
||||
- **Per-POV summaries (multi-entity)**: each present witness with a memory store gets their own per-POV summary on scene close. The summary differs per bot based on persona + their edge to "you". The group node summary is updated alongside.
|
||||
|
||||
## Core concepts (vocabulary)
|
||||
|
||||
@@ -149,3 +153,222 @@ Don't jump phases. Phase 1 must work end-to-end before Phase 2 lands.
|
||||
- Inference hosting (start with a cloud API, re-evaluate later)
|
||||
- Character template format (during Phase 1)
|
||||
- Multi-session / multi-character casts: **out of scope for v1**. Leave cheap schema hooks only.
|
||||
|
||||
## Phase 1 status
|
||||
|
||||
Phase 1 shipped end-to-end across **35 tasks** (T0–T35). The single-bot core loop is functional: event log + projector, schema + migrations, settings/bot authoring, kickoff confirm, streaming turns, drawer rendering, regenerate/rewind, scene close + per-POV summaries, significance classifier, snapshots/backups, first-run navigation, and friendly 404/500 pages. **168 tests passing.**
|
||||
|
||||
Deferred to Phase 2: second bot, group node, scene configurations, witness filtering across multi-entity scenes, activity/containers, scene-transition compression. Phase 3: event queue + triggers, time skips, active threads. Phase 4: vector retrieval, branching, surgical delete + regenerate, impact-preview UI.
|
||||
|
||||
### Known v1 limitations (read before extending)
|
||||
|
||||
- **Drawer edits scope**: only affinity, significance, and pin can be hand-edited from the drawer. Other v1 fields (knowledge, summary text, traits) are deferred to Phase 1.5.
|
||||
- **Cold-load snapshot path** is wired and unit-tested but rarely exercised in dev — long-running sessions are the only realistic trigger.
|
||||
- **WAL sidecar files** (`-wal`, `-shm`) are not captured in nightly backups; the nightly snapshot is a fresh `.backup()` so this is fine for restore but worth knowing if you copy the db file by hand.
|
||||
- **HTMX SSE event names** may need a version check if you bump the htmx CDN URL in `base.html` — the swap targets are name-coupled.
|
||||
- **"You" activity rows** can linger after `bot_reset` (the reset purges the bot's chats and the bot's own activity row but not the "you" row that was associated with those chats). Cosmetic, fixed in Phase 1.5.
|
||||
- **Projector replay is non-idempotent** for plain `INSERT` events. After appending, call `apply_event(conn, event)` for the new row only — calling `project(conn)` re-runs every handler from scratch and will trip uniqueness or duplicate inserts.
|
||||
- **8-pin auto-cap eviction** is FIFO over the auto-pinned set only. Manual pins survive the eviction; this is by design (manual intent > auto-pin signal).
|
||||
- **Regenerate (T29) does not broadcast `turn_html` over SSE** — the page must refresh to show the regenerated turn. Acceptable for v1 single-tab usage; Phase 1.5 should wire the SSE event.
|
||||
- **First-run middleware** fires only on bare `/` and `/chats`. Sub-paths like `/chats/<id>` and `/chats/<id>/drawer` pass through (correct: HTMX partials should not page-redirect, and a deep-link to a missing chat should 404, not redirect mid-setup).
|
||||
|
||||
### Phase 1.5 cleanup backlog
|
||||
|
||||
All items shipped — see Phase 2.5 status below.
|
||||
|
||||
## Phase 2 status
|
||||
|
||||
Phase 2 shipped end-to-end across **13 tasks** (T36–T48 wave). The multi-entity surface is functional: chats can host a guest bot, the prompt assembly is guest-aware, post-turn fans out across all directed pairs, and scene close writes a per-POV summary per present witness plus a group_node summary.
|
||||
|
||||
- **Multi-entity scene support**: chats can now have a guest bot (you + host + guest). The 3-entity cap holds. New event kinds: `guest_added`, `guest_removed`, `group_node_initialized`, `group_node_updated`. New table: `group_node` (members, summary, dynamic, threads).
|
||||
- **Drawer guest UX**: add/remove guest from the drawer side panel. The "have they met?" prose seed is parsed by the `relationship_seed` classifier into inter-bot directed edges (host↔guest).
|
||||
- **Multi-entity turn flow**: `post_turn` assembles narrative with the guest-aware prompt; writes memories for **all** present bot witnesses; runs state updates for **all** directed pairs (6 with 3 entities); detects interjections via classifier (default false; the addressee gets the floor first).
|
||||
- **Per-POV scene close summaries**: each present witness with a memory store gets their own per-POV summary on close; `group_node` summary updated alongside.
|
||||
- **Bot reset cascade**: resetting a bot now also clears `chats.guest_bot_id` references in other chats (root-cause fix for stale-guest references after T47).
|
||||
|
||||
### Phase 2.5 / 3 backlog
|
||||
|
||||
All items shipped — see Phase 2.5 status below.
|
||||
|
||||
## Phase 2.5 status
|
||||
|
||||
Phase 2.5 cleanup shipped end-to-end across 8 tasks (T68–T75). Two CLAUDE.md backlogs (Phase 1.5 cleanup, Phase 2.5/3) are now empty; deferred follow-ups discovered during execution are tracked in a new "Phase 2.6 / 3 backlog" section below.
|
||||
|
||||
- **`open_db` with check_same_thread parameter (T68)**: refactored `chat/db/connection.py` so `chat/web/bots.py:get_conn` no longer duplicates the PRAGMA setup. Default behavior preserved.
|
||||
- **`bot_reset` cross-chat cleanup (T69)**: now purges orphaned "you" activity rows. Note: this also fixed a latent FK constraint crash that was lurking in the projector — `activity.container_id` is FK-referenced and the prior code would have crashed on any reset of a bot whose chat had a non-NULL `container_id` "you" activity row. The bug was masked because no prior test seeded such a row.
|
||||
- **LLM-merged group meta-summary (T70)**: replaces Phase 2 T45's naive concat with a classifier merge call. Falls back to the naive concat on classifier failure.
|
||||
- **`prompt.py` polish (T71)**: witness role parametric (`host` vs `guest` derived from chat membership); single `ACTIVITIES:` block with bullet-level trim; NICE trim order kept with documented rationale (greedy cheapest-impact-first beats spec-listing order in practice).
|
||||
- **Drawer polish (T72)**: deferred v1 edits (edge_trust slider, edge_summary textarea, memory pov_summary textarea, knowledge_facts add/remove) + first-meeting gate (Add-guest form disables prose textarea when host→guest edge already exists; "re-seed anyway" toggle re-enables) + witness flag inline-edit (per-memory checkboxes for [you, host, guest] flags). Two new `manual_edit` projector branches: `edge_knowledge_fact` and `memory_witness`.
|
||||
- **Regenerate polish (T73)**: regenerate now broadcasts `turn_html_replace` over SSE (NEW event distinct from `turn_html` to avoid breaking the existing append-semantic consumer); regenerate covers interjection turns (re-detects + re-streams or supersedes); defensive stale-guest degrade removed.
|
||||
- **Turn-flow polish + addressee service (T74)**: classifier-based addressee detection (substring helper kept as no-guest fast path); SignificanceJob enqueued for interjection memories; scene-close-on-cancel pinned with comment + regression test (close detection is genuinely user-prose-only); defensive stale-guest degrade removed.
|
||||
|
||||
### Phase 2.6 / 3 backlog
|
||||
|
||||
All items shipped — see Phase 3.5 status below.
|
||||
|
||||
## Phase 3 status
|
||||
|
||||
Phase 3 shipped end-to-end across 19 tasks (T49–T67). Events with full lifecycle, time skips, active threads, significance refinements, and meanwhile scenes are functional. Schema baseline is now version 11 (migrations 0009 events, 0010 threads, 0011 meanwhile_scenes). Test count grew from ~247 (Phase 2) to ~315 (+68 new tests across the wave).
|
||||
|
||||
- **Wave 1 — schema + lifecycle handlers (parallel)**:
|
||||
- **T49** `events` table + lifecycle handlers (`event_planned`, `event_started`, `event_completed`, `event_cancelled`, `event_expired`).
|
||||
- **T50** `time_skip` event handlers (elision and jump variants).
|
||||
- **T51** `threads` table + handlers (`thread_opened`, `thread_updated`, `thread_closed`).
|
||||
- **Wave 2 — detection / narration services (parallel)**:
|
||||
- **T52** event-lifecycle detection service (planned→active→completed transitions inferred from narration).
|
||||
- **T53** skip narration service (elision + jump prose).
|
||||
- **T54** synthesized-memories service for jump skips (LLM-summarized intervening time).
|
||||
- **T55** thread-detection service (open/update/close inferred from recent dialogue).
|
||||
- **Wave 3 — promotion + ranking (parallel)**:
|
||||
- **T56** event-completion promotion service (objects → inventory, knowledge → edge knowledge, relationship deltas → edge summary; everything else stays in the closed event).
|
||||
- **T57** significance-aware retrieval ranking — SQL-side `SIGNIFICANCE_RANK_BIAS` plus the existing Python composite re-rank.
|
||||
- **T58** scene compression keeps key quotes when significance ≥ 2; thread emission piggybacks on scene close.
|
||||
- **Wave 4 — drawer UX (single)**:
|
||||
- **T59** drawer additions: events panel, threads panel, skip controls.
|
||||
- **Wave 5a — prompt + turn flow integration (parallel)**:
|
||||
- **T60** prompt assembly includes active events + open threads in the speaker's prompt.
|
||||
- **T61** turn flow invokes event-detection + completion promotion alongside existing post-turn fan-out.
|
||||
- **Wave 5b — natural-language skip surface (single)**:
|
||||
- **T62** classifier-driven skip command at the user-input layer; shared skip controllers extracted into `chat/web/skip.py`.
|
||||
- **Wave 6a — meanwhile schema (single)**:
|
||||
- **T63** meanwhile-scene schema + state (scene config 4: host+guest, no "you").
|
||||
- **Wave 6b — meanwhile turn flow (parallel)**:
|
||||
- **T64** meanwhile turn flow (host+guest, no "you" in the prompt or witness writes).
|
||||
- **T65** meanwhile summary digest surfaces to the next "you"-present scene.
|
||||
- **Wave 7 — integration + docs (parallel)**:
|
||||
- **T66** cross-feature integration tests covering events × skips × threads × meanwhile.
|
||||
- **T67** documentation (this section).
|
||||
|
||||
### Phase 3.5 / 4 backlog
|
||||
|
||||
All items shipped — see Phase 3.5 status below.
|
||||
|
||||
## Phase 3.5 status
|
||||
|
||||
Phase 3.5 cleanup shipped end-to-end across 12 tasks (T76–T87). Two CLAUDE.md backlogs (Phase 2.6/3, Phase 3.5/4) are now empty; deferred follow-ups discovered during execution are tracked in a new "Phase 3.6 / 4 backlog" section below. Test count grew from 315 (Phase 3) to 343 (+28 new tests).
|
||||
|
||||
- **Wave 1 — trivial polish (parallel)**:
|
||||
- **T76** `narrate_skip` `timeout_s` plumbed through to `client.generate`.
|
||||
- **T77** `AddresseeDecision.confidence` typed as `Literal["high","medium","low"]`.
|
||||
- **T78** `search_memories` docstring notes SQL-side significance bias (`SIGNIFICANCE_RANK_BIAS`).
|
||||
- **T79** `_witness_role_for` defensive `host_bot_id is None` handling (returns `"host"` for Phase-1 chats).
|
||||
- **Wave 2 — scene_summarize polish (single)**:
|
||||
- **T80** five T58 follow-ups: re-close suffix bloat guard, transcript scoping by scene, swallowed-exception logging in `detect_threads`, chat-clock `closed_at`, and three new tests covering T58 gaps (200-char truncation, `thread_updated`/`thread_closed` candidate paths, try/except fallback).
|
||||
- **Wave 3 — typed exception (single)**:
|
||||
- **T81** `ChatNotFoundError` replaces string-prefix sniff in skip routes; mapped to 404 (vs 400 for other `ValueError` cases).
|
||||
- **Wave 4 — turn-flow wiring (single)**:
|
||||
- **T82** `consume_pending_meanwhile_digests` wired into `post_turn` (closes T66 gap; meanwhile digests no longer pile up); natural-language skip dispatch now runs scene close detection first.
|
||||
- **Wave 5 — regenerate polish (single)**:
|
||||
- **T83** five sub-fixes — cancel/stop hook (regenerate registers stream task in `_in_flight_tasks`); DRY extraction of `read_recent_dialogue` and `gather_prior_edges` into `chat/services/turn_common.py`; chat-scoped sibling-assistant-turn lookup; lifecycle-rollback warning log on regenerate; ordering-symmetry comment between post_turn and regenerate event-detection paths.
|
||||
- **Wave 6 — final polish (parallel)**:
|
||||
- **T84** unified `record_turn_memory` API with `you_present` kwarg; `record_meanwhile_memory` becomes a thin wrapper.
|
||||
- **T85** JSON-build audit (no findings) + meanwhile cancel route-level test.
|
||||
- **T86** frontend `turn_html_replace` SSE handler + turn_id stamping on rendered HTML so the in-place swap actually works.
|
||||
|
||||
### Phase 3.6 / 4 backlog
|
||||
|
||||
New follow-ups discovered during Phase 3.5 reviews and execution. None are blocking; pick up at any time.
|
||||
|
||||
#### From T80 review
|
||||
|
||||
- **`read_recent_dialogue` chat-id pushdown**: helper filters `chat_id` post-fetch in Python. Could push the `json_extract(payload_json, '$.chat_id') = ?` predicate into SQL (matching T83.3's pattern) for tighter LIMIT semantics. Currently a chat-with-many-other-chats can have its 50-row LIMIT consumed by foreign rows.
|
||||
- **Lifecycle warning wording in regenerate**: T83.4's warning log lists ALL lifecycle event ids that exist after the original `assistant_turn` id, not just ones produced by the superseded turn. For the typical "regenerate the most recent" flow these are identical, but if a user regenerates an OLDER turn, the warning will list intervening-turn lifecycle events that legitimately stand. Tighten warning wording to "lifecycle transitions at-or-after turn X" (operator-friendly); a code-level fix would require a schema change to add explicit back-reference from lifecycle events to their producing turn.
|
||||
|
||||
#### From T84 review
|
||||
|
||||
- **`record_turn_memory` legacy single-bot function** still exists alongside the unified `record_turn_memory_for_present`. Could be consolidated in a follow-up.
|
||||
|
||||
#### From T86 fix-up
|
||||
|
||||
- **Test fixtures + `tests/test_phase3_integration.py`** that seed turns directly via `append_event`+`project` may need updating once any new test asserts the rendered HTML carries the new turn ids end-to-end. Existing tests pass because they don't read the stamped attribute, but they're brittle if the contract evolves.
|
||||
|
||||
#### Deferred items (carry-over)
|
||||
|
||||
- **Scene-close-on-cancel UX revisit** (Phase 2.5 carry-over): T74.3 pinned the existing behavior; revisit if real play-testing surfaces a regression.
|
||||
- **Cross-feature canned-queue brittleness**: meanwhile-scene close test required a canned response for T65's digest call after T64+T65 merge. Future close-path additions will keep extending the queue. Consider a structured fixture builder rather than positional canned arrays. NOT addressed in Phase 3.5.
|
||||
- **Lifecycle-transition rollback in regenerate**: T83.4 added a warning log; actual rollback (with proper schema linkage from lifecycle event back to producing turn) is Phase 4 work.
|
||||
|
||||
## Phase 4 status
|
||||
|
||||
Phase 4 polish shipped end-to-end across 15 tasks (T88–T102). Vector retrieval is functional via pure-Python cosine over a JSON-blob embeddings table (sqlite-vec deferred — host Python lacks loadable extensions). Branching is data-model + drawer UI. Surgical delete with cascade preview, hide-from-view soft delete, significance review panel, snapshot UX, and cross-chat search all surface from the drawer or top-bar. Test count grew from 343 (Phase 3.5) to ~413 (+70 new tests).
|
||||
|
||||
- **Wave 1 — schema + Phase 3.6 carry-overs (parallel)**:
|
||||
- **T88** `embeddings` table + projector handlers (pure-Python cosine, JSON-blob storage; sqlite-vec deferred).
|
||||
- **T89** `branches` table + handlers (main bootstrapped; `is_active` flag; partial unique index).
|
||||
- **T90** Phase 3.6 carry-overs trio — `read_recent_dialogue` chat-id SQL pushdown, lifecycle warning wording tightening, legacy `record_turn_memory` removed.
|
||||
- **Wave 2 — services (parallel)**:
|
||||
- **T91** embedding generation service (Phase 4 ships a deterministic SHA-256-derived pseudo-embedding; real model swap is Phase 4.5+).
|
||||
- **T92** vector search service via pure-Python cosine.
|
||||
- **T93** cross-chat search service (FTS5 across all owners, no witness filter — admin-style).
|
||||
- **Wave 3 — services (parallel)**:
|
||||
- **T94** branching service (`branch_from_event`, `switch_active_branch`, `list_branches_with_metadata`).
|
||||
- **T95** delete-impact computation service (cascade preview, no DB mutation).
|
||||
- **Wave 4 — combined retrieval (single)**:
|
||||
- **T96** combined FTS + vector retrieval ranking via reciprocal-rank fusion (RRF, `RRF_CONST=60`); existing significance/recency boost applied as final pass.
|
||||
- **Wave 5 — memory write hook + backfill (single)**:
|
||||
- **T97** `EmbeddingWorker` drains queue and emits `embedding_indexed` events; `memory_write` enqueues per `memory_written`; `backfill_embeddings` script for existing memories; ALL 4 production call sites wired (turns, regenerate, meanwhile, drawer).
|
||||
- **Wave 6 — drawer Phase 4 bundle (single, 5 sub-features)**:
|
||||
- **T98.1** branching UI (Branches panel + 3 routes).
|
||||
- **T98.2** significance review panel (distribution bar chart + per-memory edit).
|
||||
- **T98.3** hide-from-view toggle + `turn_hidden` `manual_edit` branch.
|
||||
- **T98.4** surgical delete with cascade preview (reuses existing rewind path; pre-rewind snapshot preserved).
|
||||
- **T98.5** remaining v1 edits — `narrative_anchor` + weather drawer affordances + 2 new `manual_edit` branches.
|
||||
- **Wave 7 — UX surfaces (parallel)**:
|
||||
- **T99** snapshot UX (manual trigger, list, restore with hard-confirm, preview).
|
||||
- **T100** cross-chat search UX (top-bar form + results page).
|
||||
- **Wave 8 — polish (parallel)**:
|
||||
- **T101** cross-feature integration tests (5 multi-feature scenarios).
|
||||
- **T102** documentation (this section).
|
||||
|
||||
### Phase 4.5 / 5 backlog
|
||||
|
||||
New follow-ups discovered during Phase 4 reviews and execution. None are blocking; pick up at any time.
|
||||
|
||||
#### From T88 review
|
||||
|
||||
- **`embeddings` FK lacks `ON DELETE CASCADE`**: deindex events are the only deletion path; if memories ever get deleted directly (raw SQL), embedding rows orphan. Defensible since projector model uses explicit deindex events, but worth a comment or `ON DELETE CASCADE` addition.
|
||||
|
||||
#### From T89 review
|
||||
|
||||
- **`list_branches(chat_id=...)` filter leaks global branches** (`chat_id IS NULL`) into every chat scope. Intentional? Document.
|
||||
- **Branch-switch to nonexistent silently leaves zero active branches** — log a warning when this would happen.
|
||||
|
||||
#### From T91 review
|
||||
|
||||
- **Real embedding model swap**: Phase 4 ships pseudo-embedding (deterministic SHA-256 hash). Phase 4.5+ should swap to a real model (Featherless `bge-small-en-v1.5` if available; or local `sentence-transformers/all-MiniLM-L6-v2`). The 384-dim is hardcoded in `0012_embeddings.sql`; if dim changes, migrate first.
|
||||
- **`timeout_s` unused on pseudo path** — fine, but log when non-default model falls through to fallback so misconfigured callers don't silently degrade.
|
||||
|
||||
#### From T96 review
|
||||
|
||||
- **Duplicate `MAX(id)` lookup** between `_composite_rerank` and the fused-path tail — DRY follow-up.
|
||||
- **`fts_rank=None` for vector-only rows** — document downstream contract.
|
||||
|
||||
#### From T98 review
|
||||
|
||||
- **`event_id <= 0` guard in `delete_turn`** — currently silently rewinds everything if `event_id` is 0. Add `if event_id <= 0: 400`.
|
||||
- **`html.escape()` on `compute_delete_impact` output rendered into the modal** — defense in depth (currently model-controlled strings, but if event payload fields ever appear in descriptions, autoescape needed).
|
||||
- **Extract delete-impact modal HTML to a Jinja partial** — testability + autoescape inheritance.
|
||||
|
||||
#### From T99 review
|
||||
|
||||
- **Hoist `datetime`/`timezone` imports to module level** in `chat/web/snapshots.py`.
|
||||
- **`kind` defaulting in restore/preview** — reject missing `kind` rather than silent 404.
|
||||
- **`created_at` from file mtime** vs filename-encoded timestamp — small drift if files copied; document.
|
||||
|
||||
#### From T100 review
|
||||
|
||||
- **Hardcoded `k=50`** — extract to module constant.
|
||||
- **N+1 lookups (`get_bot`/`get_chat`/`get_scene` per row)** — fine at `k=50`, revisit if `k` grows.
|
||||
- **FTS highlighting via `snippet()`** — Phase 4 skipped this; UX nice-to-have.
|
||||
- **Result links chat-level only** — `memories` table has no `event_id` column; deep-linking to specific turn requires schema addition.
|
||||
|
||||
#### Deferred items
|
||||
|
||||
- **sqlite-vec swap** when host Python supports `enable_load_extension`.
|
||||
- **Real embedding model** with proper semantic similarity.
|
||||
- **Branching read-side filter**: T89 ships data-model + UI but event readers don't yet consult `is_active`. Each branch is metadata-only labeled ranges. Consult-on-read is Phase 4.5+ work.
|
||||
- **Bulk significance re-rate** in drawer (T98.2 deferred — only per-memory edit shipped).
|
||||
- **Vector index optimization** (HNSW) — only relevant if memory counts grow past pure-Python feasibility.
|
||||
- **`scene-close-on-cancel` UX revisit** (Phase 2.5 carry-over).
|
||||
- **Cross-feature canned-queue brittleness fixture builder** (Phase 3 carry-over).
|
||||
- **Full lifecycle-rollback in regenerate** — Phase 3.5 T83.4 shipped a warning log; proper rollback needs schema-level back-references (`triggered_by_assistant_turn_id` payload field).
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from chat.config import load_settings
|
||||
from chat.db.connection import open_db
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.eventlog.log import read_events
|
||||
from chat.eventlog.projector import apply_event
|
||||
from chat.services.background import BackgroundWorker
|
||||
from chat.services.embedding_worker import EmbeddingWorker
|
||||
from chat.services.snapshot import latest_snapshot_path, restore_from_snapshot
|
||||
|
||||
# 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.middleware import FirstRunRedirectMiddleware
|
||||
from chat.web.nav import router as nav_router
|
||||
from chat.web.search import router as search_router
|
||||
from chat.web.settings import router as settings_router
|
||||
from chat.web.snapshots import router as snapshots_router
|
||||
from chat.web.sse import router as sse_router
|
||||
from chat.web.turns import router as turns_router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = load_settings()
|
||||
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
apply_migrations(settings.db_path)
|
||||
|
||||
# T31 cold-load fast-path: if a periodic snapshot exists, restore
|
||||
# projected tables from it and replay only events past its
|
||||
# ``last_event_id``. Migrations already ran above, so any new tables
|
||||
# introduced after the snapshot was taken are present and empty —
|
||||
# the replay-forward step refills them from the event log.
|
||||
snapshot_path = latest_snapshot_path(settings.data_dir, kind="periodic")
|
||||
if snapshot_path is not None:
|
||||
with open_db(settings.db_path) as conn:
|
||||
last_event_id = restore_from_snapshot(conn, snapshot_path)
|
||||
for event in read_events(
|
||||
conn, branch_id=1, after_id=last_event_id
|
||||
):
|
||||
apply_event(conn, event)
|
||||
log.info(
|
||||
"cold-load restored from %s, replayed events past id %d",
|
||||
snapshot_path,
|
||||
last_event_id,
|
||||
)
|
||||
|
||||
app.state.settings = settings
|
||||
|
||||
# Cap concurrent Featherless connections to the account's limit
|
||||
# (free / lower paid tiers cap at 2). Shared across all
|
||||
# FeatherlessClient instances in the process.
|
||||
from chat.llm.featherless import FeatherlessClient
|
||||
|
||||
FeatherlessClient.configure_concurrency(settings.featherless_max_concurrent)
|
||||
|
||||
# Background worker for the async significance pass (T22). Each job
|
||||
# constructs a fresh FeatherlessClient via the factory; tests can
|
||||
# disable enqueue by toggling ``app.state.background_worker.enabled``.
|
||||
def _factory():
|
||||
return FeatherlessClient(
|
||||
api_key=settings.featherless_api_key,
|
||||
base_url=settings.featherless_base_url,
|
||||
)
|
||||
|
||||
worker = BackgroundWorker(settings, llm_client_factory=_factory)
|
||||
await worker.start()
|
||||
app.state.background_worker = worker
|
||||
|
||||
# T97: separate worker for the async embedding pass. Each
|
||||
# ``memory_written`` enqueues an EmbeddingJob; the worker drains the
|
||||
# queue, calls ``generate_embedding``, and emits ``embedding_indexed``.
|
||||
# Phase 4's pseudo-embedding path is local so the worker doesn't need
|
||||
# an LLM client; we still pass one so the Phase 4.5 swap to a real
|
||||
# model is a one-line change.
|
||||
embedding_worker = EmbeddingWorker(
|
||||
conn_factory=lambda: open_db(settings.db_path),
|
||||
client=_factory(),
|
||||
)
|
||||
await embedding_worker.start()
|
||||
app.state.embedding_worker = embedding_worker
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await embedding_worker.stop()
|
||||
await worker.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="chat", lifespan=lifespan)
|
||||
app.add_middleware(FirstRunRedirectMiddleware)
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
ERROR_TEMPLATES = Jinja2Templates(
|
||||
directory=str(Path(__file__).resolve().parent / "templates")
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||
"""Render a friendly HTML page for 404/500; JSON for everything else."""
|
||||
if exc.status_code in (404, 500):
|
||||
return ERROR_TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"errors.html",
|
||||
{
|
||||
"status_code": exc.status_code,
|
||||
"detail": exc.detail or "Something went wrong.",
|
||||
"active_nav": "chats",
|
||||
},
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
|
||||
app.include_router(bots_router)
|
||||
app.include_router(kickoff_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(snapshots_router)
|
||||
app.include_router(nav_router)
|
||||
app.include_router(chat_router)
|
||||
app.include_router(drawer_router)
|
||||
app.include_router(search_router)
|
||||
app.include_router(sse_router)
|
||||
app.include_router(turns_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_CONFIG = REPO_ROOT / "data" / "config.toml"
|
||||
DEFAULT_DB = REPO_ROOT / "data" / "chat.db"
|
||||
|
||||
class Settings(BaseModel):
|
||||
featherless_api_key: str
|
||||
featherless_base_url: str = "https://api.featherless.ai/v1"
|
||||
narrative_model: str = "dphn/Dolphin-Mistral-24B-Venice-Edition"
|
||||
classifier_model: str = "NousResearch/Hermes-3-Llama-3.1-8B"
|
||||
classifier_fallbacks: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"cognitivecomputations/dolphin-2.9.4-llama3-8b",
|
||||
"mlabonne/Meta-Llama-3.1-8B-Instruct-abliterated",
|
||||
]
|
||||
)
|
||||
ooc_marker: str = "(("
|
||||
retrieval_k: int = 4
|
||||
narrative_budget_hard: int = 8000
|
||||
narrative_budget_soft: int = 6000
|
||||
# Cap on each generated bot response. ~400 tokens ≈ 1–2 short paragraphs.
|
||||
# Bump if you want longer scenes; drop to 200 for terse banter.
|
||||
narrative_max_tokens: int = 400
|
||||
# Sampling temperature for narrative generation. 0.7 = grounded /
|
||||
# consistent; 0.85 = creative-but-in-character (default); 1.0 = wide
|
||||
# variety, can drift; >1.0 = often off-the-rails.
|
||||
narrative_temperature: float = 0.85
|
||||
classifier_budget_hard: int = 4000
|
||||
classifier_timeout_s: float = 30.0
|
||||
# Featherless free tier and lower paid tiers cap concurrent connections.
|
||||
# Set this to your account's max-concurrent-connections limit.
|
||||
featherless_max_concurrent: int = 2
|
||||
db_path: Path = DEFAULT_DB
|
||||
data_dir: Path = REPO_ROOT / "data"
|
||||
bind_host: str = "127.0.0.1"
|
||||
bind_port: int = 8000
|
||||
|
||||
def load_settings() -> Settings:
|
||||
config_path = Path(os.environ.get("CHAT_CONFIG_PATH", DEFAULT_CONFIG))
|
||||
raw: dict = {}
|
||||
if config_path.exists():
|
||||
raw = tomllib.loads(config_path.read_text())
|
||||
if "CHAT_DB_PATH" in os.environ:
|
||||
raw["db_path"] = Path(os.environ["CHAT_DB_PATH"])
|
||||
if "CHAT_DATA_DIR" in os.environ:
|
||||
raw["data_dir"] = Path(os.environ["CHAT_DATA_DIR"])
|
||||
elif "data_dir" not in raw and "db_path" in raw:
|
||||
# T31: when ``CHAT_DB_PATH`` is overridden (typical in tests) but
|
||||
# ``data_dir`` isn't, derive ``data_dir`` from the db's parent so
|
||||
# snapshot/auxiliary files stay alongside the test db rather than
|
||||
# leaking into the real repo data dir.
|
||||
raw["data_dir"] = Path(raw["db_path"]).parent
|
||||
return Settings(**raw)
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_db(path: Path, *, check_same_thread: bool = True):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, check_same_thread=check_same_thread)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from chat.db.connection import open_db
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
|
||||
|
||||
def apply_migrations(db_path: Path) -> None:
|
||||
with open_db(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)"
|
||||
)
|
||||
cur = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'")
|
||||
row = cur.fetchone()
|
||||
current = int(row[0]) if row else 0
|
||||
for path in sorted(MIGRATIONS_DIR.glob("*.sql")):
|
||||
version = int(path.stem.split("_", 1)[0])
|
||||
if version <= current:
|
||||
continue
|
||||
sql = path.read_text()
|
||||
conn.executescript(sql)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
|
||||
(str(version),),
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
-- meta table is created by the migrate runner; this migration is a marker.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE classifier_failures (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
raw_text TEXT,
|
||||
attempt_count INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE event_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
branch_id INTEGER NOT NULL DEFAULT 1,
|
||||
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
kind TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
superseded_by INTEGER REFERENCES event_log(id),
|
||||
hidden INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_event_log_branch_kind ON event_log(branch_id, kind);
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE bots (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
persona TEXT NOT NULL,
|
||||
voice_samples_json TEXT NOT NULL DEFAULT '[]',
|
||||
traits_json TEXT NOT NULL DEFAULT '[]',
|
||||
backstory TEXT NOT NULL DEFAULT '',
|
||||
initial_relationship_to_you TEXT NOT NULL DEFAULT '',
|
||||
kickoff_prose TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE you_entity (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT NOT NULL,
|
||||
pronouns TEXT NOT NULL DEFAULT '',
|
||||
persona TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chat_id TEXT,
|
||||
source_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
affinity INTEGER NOT NULL DEFAULT 50,
|
||||
trust INTEGER NOT NULL DEFAULT 50,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
knowledge_json TEXT NOT NULL DEFAULT '[]',
|
||||
last_interaction_chat_id TEXT,
|
||||
last_interaction_at TEXT,
|
||||
UNIQUE (source_id, target_id)
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE memories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
scene_id INTEGER,
|
||||
pov_summary TEXT NOT NULL,
|
||||
witness_you INTEGER NOT NULL,
|
||||
witness_host INTEGER NOT NULL,
|
||||
witness_guest INTEGER NOT NULL,
|
||||
chat_clock_at TEXT,
|
||||
source TEXT,
|
||||
reliability REAL NOT NULL DEFAULT 1.0,
|
||||
significance INTEGER NOT NULL DEFAULT 1,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
auto_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX idx_memories_owner ON memories(owner_id);
|
||||
|
||||
CREATE VIRTUAL TABLE memories_fts USING fts5(
|
||||
pov_summary, content='memories', content_rowid='id'
|
||||
);
|
||||
|
||||
CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
|
||||
INSERT INTO memories_fts(rowid, pov_summary) VALUES (new.id, new.pov_summary);
|
||||
END;
|
||||
CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, pov_summary)
|
||||
VALUES('delete', old.id, old.pov_summary);
|
||||
INSERT INTO memories_fts(rowid, pov_summary) VALUES (new.id, new.pov_summary);
|
||||
END;
|
||||
CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, pov_summary)
|
||||
VALUES('delete', old.id, old.pov_summary);
|
||||
END;
|
||||
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE chats (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_bot_id TEXT NOT NULL,
|
||||
guest_bot_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE chat_state (
|
||||
chat_id TEXT PRIMARY KEY,
|
||||
time TEXT NOT NULL,
|
||||
weather TEXT NOT NULL DEFAULT '',
|
||||
active_scene_id INTEGER,
|
||||
narrative_anchor TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE containers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chat_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
properties_json TEXT NOT NULL DEFAULT '{}',
|
||||
parent_id INTEGER REFERENCES containers(id)
|
||||
);
|
||||
|
||||
CREATE TABLE scenes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chat_id TEXT NOT NULL,
|
||||
container_id INTEGER REFERENCES containers(id),
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
significance INTEGER NOT NULL DEFAULT 0,
|
||||
participants_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE activity (
|
||||
entity_id TEXT PRIMARY KEY,
|
||||
container_id INTEGER REFERENCES containers(id),
|
||||
slot TEXT,
|
||||
posture TEXT NOT NULL DEFAULT '',
|
||||
action_json TEXT NOT NULL DEFAULT '{}',
|
||||
attention TEXT NOT NULL DEFAULT '',
|
||||
holding_json TEXT NOT NULL DEFAULT '[]',
|
||||
status_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE group_node (
|
||||
chat_id TEXT PRIMARY KEY,
|
||||
members_json TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
dynamic TEXT NOT NULL DEFAULT '',
|
||||
threads_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
chat_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'planned',
|
||||
props_json TEXT NOT NULL DEFAULT '{}',
|
||||
planned_for TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX events_chat_idx ON events(chat_id, status);
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE threads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL UNIQUE,
|
||||
chat_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
closed_at TEXT,
|
||||
last_referenced_scene_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX threads_chat_status_idx ON threads(chat_id, status);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- T63: Meanwhile scene support — extend scenes with a present-set discriminator
|
||||
-- and a parent link (you-scene -> meanwhile child), plus a pending-digest queue.
|
||||
--
|
||||
-- Existing scenes table (0007) has columns:
|
||||
-- id, chat_id, container_id, started_at, ended_at, significance,
|
||||
-- participants_json
|
||||
-- It has no `status` / `closed_at` columns. We treat `ended_at IS NULL` as
|
||||
-- "active" and `ended_at IS NOT NULL` as "closed" — consistent with the
|
||||
-- existing scene_opened/scene_closed handlers.
|
||||
|
||||
ALTER TABLE scenes ADD COLUMN present_set_kind TEXT NOT NULL DEFAULT 'you_host';
|
||||
ALTER TABLE scenes ADD COLUMN parent_scene_id INTEGER;
|
||||
|
||||
CREATE INDEX scenes_present_set_idx
|
||||
ON scenes(chat_id, present_set_kind, ended_at);
|
||||
|
||||
CREATE TABLE meanwhile_digest_pending (
|
||||
id INTEGER PRIMARY KEY,
|
||||
scene_id INTEGER NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
consumed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX meanwhile_digest_chat_idx
|
||||
ON meanwhile_digest_pending(chat_id) WHERE consumed_at IS NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Embeddings stored as JSON arrays (pure-Python cosine at query time).
|
||||
-- Phase 4.5+ may swap to sqlite-vec when the host Python supports
|
||||
-- loadable extensions; the schema is intentionally simple to make that
|
||||
-- migration straightforward.
|
||||
CREATE TABLE embeddings (
|
||||
memory_id INTEGER PRIMARY KEY,
|
||||
vector_json TEXT NOT NULL, -- JSON array of floats, length = dim
|
||||
model TEXT NOT NULL,
|
||||
dim INTEGER NOT NULL,
|
||||
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (memory_id) REFERENCES memories(id)
|
||||
);
|
||||
|
||||
CREATE INDEX embeddings_model_idx ON embeddings(model);
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE branches (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
origin_event_id INTEGER NOT NULL,
|
||||
head_event_id INTEGER NOT NULL,
|
||||
chat_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
is_active INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Exactly one row may have is_active = 1 at any time.
|
||||
CREATE UNIQUE INDEX branches_active_idx ON branches(is_active) WHERE is_active = 1;
|
||||
|
||||
-- Bootstrap the main branch. origin_event_id=0 + head_event_id=0 are
|
||||
-- placeholder seeds; the orchestrator updates head as new events land.
|
||||
INSERT INTO branches (name, origin_event_id, head_event_id, is_active)
|
||||
VALUES ('main', 0, 0, 1);
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterator
|
||||
from sqlite3 import Connection
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
id: int
|
||||
branch_id: int
|
||||
ts: str
|
||||
kind: str
|
||||
payload: dict[str, Any]
|
||||
superseded_by: int | None
|
||||
hidden: bool
|
||||
|
||||
|
||||
def append_event(conn: Connection, *, kind: str, payload: dict[str, Any], branch_id: int = 1) -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO event_log (branch_id, kind, payload_json) VALUES (?, ?, ?)",
|
||||
(branch_id, kind, json.dumps(payload)),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def append_and_apply(
|
||||
conn: Connection,
|
||||
*,
|
||||
kind: str,
|
||||
payload: dict[str, Any],
|
||||
branch_id: int = 1,
|
||||
) -> int:
|
||||
"""Append an event AND immediately apply just that event's handler.
|
||||
|
||||
Calling :func:`chat.eventlog.projector.project` after an append
|
||||
re-runs every prior event, which is fine for idempotent inserts but
|
||||
catastrophic for delta-shaped events like ``edge_update`` whose
|
||||
handler is *not* replay-safe (each pass would re-add the same
|
||||
``affinity_delta``). This helper runs only the brand-new event
|
||||
through the registered handler, leaving prior state untouched.
|
||||
|
||||
No-ops cleanly when ``kind`` has no registered handler — useful for
|
||||
transcript-only events like ``user_turn`` / ``assistant_turn`` where
|
||||
callers may swap ``append_event`` for ``append_and_apply`` without
|
||||
side effects.
|
||||
"""
|
||||
# Local import to avoid a circular dependency at module import: the
|
||||
# projector imports from .log to define ``Event``.
|
||||
from chat.eventlog.projector import apply_event
|
||||
|
||||
eid = append_event(conn, kind=kind, payload=payload, branch_id=branch_id)
|
||||
event = Event(
|
||||
id=eid,
|
||||
branch_id=branch_id,
|
||||
ts="",
|
||||
kind=kind,
|
||||
payload=payload,
|
||||
superseded_by=None,
|
||||
hidden=False,
|
||||
)
|
||||
apply_event(conn, event)
|
||||
return eid
|
||||
|
||||
|
||||
def read_events(conn: Connection, branch_id: int = 1, after_id: int = 0) -> Iterator[Event]:
|
||||
cur = conn.execute(
|
||||
"SELECT id, branch_id, ts, kind, payload_json, superseded_by, hidden "
|
||||
"FROM event_log WHERE branch_id = ? AND id > ? AND hidden = 0 "
|
||||
"AND superseded_by IS NULL ORDER BY id",
|
||||
(branch_id, after_id),
|
||||
)
|
||||
for row in cur:
|
||||
yield Event(
|
||||
id=row[0], branch_id=row[1], ts=row[2], kind=row[3],
|
||||
payload=json.loads(row[4]), superseded_by=row[5], hidden=bool(row[6]),
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from sqlite3 import Connection
|
||||
from .log import Event, read_events
|
||||
|
||||
Handler = Callable[[Connection, Event], None]
|
||||
_REGISTRY: dict[str, Handler] = {}
|
||||
|
||||
|
||||
def on(kind: str):
|
||||
def deco(fn: Handler) -> Handler:
|
||||
_REGISTRY[kind] = fn
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
def project(conn: Connection, branch_id: int = 1) -> None:
|
||||
for event in read_events(conn, branch_id=branch_id):
|
||||
h = _REGISTRY.get(event.kind)
|
||||
if h:
|
||||
h(conn, event)
|
||||
|
||||
|
||||
def apply_event(conn: Connection, event: Event) -> None:
|
||||
h = _REGISTRY.get(event.kind)
|
||||
if h:
|
||||
h(conn, event)
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import asyncio
|
||||
from typing import TypeVar
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from .client import LLMClient, Message
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
REFUSAL_PATTERNS = ("i can't", "i cannot", "i'm sorry, but", "as an ai")
|
||||
|
||||
|
||||
def _strip_json_fences(text: str) -> str:
|
||||
"""Strip ```json ... ``` markdown fences if the model wraps its JSON output."""
|
||||
s = text.strip()
|
||||
if s.startswith("```"):
|
||||
# Drop the first fence line (which may be ``` or ```json)
|
||||
s = s.split("\n", 1)[1] if "\n" in s else s[3:]
|
||||
# Drop the trailing fence
|
||||
if s.rstrip().endswith("```"):
|
||||
s = s.rstrip()[:-3]
|
||||
return s.strip()
|
||||
|
||||
|
||||
async def classify(
|
||||
client: LLMClient,
|
||||
*,
|
||||
model: str,
|
||||
system: str,
|
||||
user: str,
|
||||
schema: type[T],
|
||||
default: T | None = None,
|
||||
timeout_s: float = 10.0,
|
||||
) -> T:
|
||||
schema_json = json.dumps(schema.model_json_schema(), indent=2)
|
||||
schema_block = (
|
||||
f"\n\nRespond with a single JSON object matching this exact schema. "
|
||||
f"Use these field names exactly; do not invent your own keys:\n```json\n{schema_json}\n```"
|
||||
)
|
||||
msgs = [
|
||||
Message(role="system", content=system + schema_block),
|
||||
Message(role="user", content=user),
|
||||
]
|
||||
for attempt in range(3):
|
||||
try:
|
||||
text = await asyncio.wait_for(
|
||||
client.generate(msgs, model=model, response_format={"type": "json_object"}),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
cleaned = _strip_json_fences(text)
|
||||
if any(p in cleaned.lower()[:80] for p in REFUSAL_PATTERNS) and not cleaned.lstrip().startswith("{"):
|
||||
raise ValueError("refusal-shaped response")
|
||||
return schema.model_validate_json(cleaned)
|
||||
except (ValidationError, ValueError, json.JSONDecodeError, asyncio.TimeoutError):
|
||||
msgs[0] = Message(
|
||||
role="system",
|
||||
content=system + schema_block + "\n\nRespond with valid JSON ONLY. No prose, no markdown fences.",
|
||||
)
|
||||
continue
|
||||
if default is None:
|
||||
raise RuntimeError(f"classify failed for schema {schema.__name__} with no default")
|
||||
return default
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, AsyncIterator, Sequence
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
role: str # "system" | "user" | "assistant"
|
||||
content: str
|
||||
|
||||
|
||||
class LLMClient(Protocol):
|
||||
async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str: ...
|
||||
def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]: ...
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import AsyncIterator, Sequence
|
||||
from openai import AsyncOpenAI
|
||||
from .client import Message
|
||||
|
||||
|
||||
class FeatherlessClient:
|
||||
"""Client for Featherless's OpenAI-compatible API.
|
||||
|
||||
Featherless caps concurrent connections per account (2 on free / lower
|
||||
paid tiers). A class-level semaphore gates every ``generate`` and
|
||||
``stream`` call so the orchestrator never exceeds the configured cap,
|
||||
regardless of how many ``FeatherlessClient`` instances are alive.
|
||||
|
||||
Configure once at app startup via :meth:`configure_concurrency`. The
|
||||
default is 2.
|
||||
"""
|
||||
|
||||
_semaphore: asyncio.Semaphore | None = None
|
||||
|
||||
@classmethod
|
||||
def configure_concurrency(cls, max_concurrent: int) -> None:
|
||||
cls._semaphore = asyncio.Semaphore(max(1, int(max_concurrent)))
|
||||
|
||||
@classmethod
|
||||
def _sem(cls) -> asyncio.Semaphore:
|
||||
if cls._semaphore is None:
|
||||
cls._semaphore = asyncio.Semaphore(2)
|
||||
return cls._semaphore
|
||||
|
||||
def __init__(self, api_key: str, base_url: str = "https://api.featherless.ai/v1"):
|
||||
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
|
||||
async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str:
|
||||
async with self._sem():
|
||||
resp = await self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": m.role, "content": m.content} for m in messages],
|
||||
**params,
|
||||
)
|
||||
return resp.choices[0].message.content or ""
|
||||
|
||||
async def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]:
|
||||
async with self._sem():
|
||||
stream = await self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": m.role, "content": m.content} for m in messages],
|
||||
stream=True,
|
||||
**params,
|
||||
)
|
||||
async for chunk in stream:
|
||||
delta = chunk.choices[0].delta.content or ""
|
||||
if delta:
|
||||
yield delta
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
from typing import AsyncIterator, Sequence
|
||||
from .client import Message
|
||||
|
||||
|
||||
class MockLLMClient:
|
||||
def __init__(self, canned: list[str]):
|
||||
self._canned = list(canned)
|
||||
|
||||
async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str:
|
||||
return self._canned.pop(0)
|
||||
|
||||
async def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]:
|
||||
text = self._canned.pop(0)
|
||||
for ch in text:
|
||||
yield ch
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Addressee classifier service (T74.1).
|
||||
|
||||
Phase 2 (T44) detected the addressee — host vs. guest — with a simple
|
||||
case-insensitive whole-word substring match against the bots' names.
|
||||
That worked for the obvious case ("BotB, what do you think?") but lost
|
||||
the long tail: pronouns, paraphrases, indirect address, narrative
|
||||
focus on a particular party. T74.1 swaps the substring helper for a
|
||||
classifier call that reads the prose holistically.
|
||||
|
||||
The substring helper in :mod:`chat.web.turns` is kept as a fast-path
|
||||
for the no-guest case (only one bot present means there is nothing to
|
||||
classify) and as a non-breaking fallback for the regenerate path. The
|
||||
multi-entity branch in :func:`chat.web.turns.post_turn` calls
|
||||
:func:`detect_addressee` from this module.
|
||||
|
||||
Failure mode: classifier flake or low-confidence response degrades to
|
||||
the host (the default speaker per Phase 2's host-keeps-the-floor
|
||||
bias). The decision carries ``confidence`` and ``reason`` so callers
|
||||
that want to log degraded decisions can distinguish a real "host" call
|
||||
from a fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class AddresseeDecision(BaseModel):
|
||||
"""Which present bot the user is addressing.
|
||||
|
||||
``addressee_id`` is the chosen bot's id. ``confidence`` is one of
|
||||
``"high"`` / ``"medium"`` / ``"low"`` — callers may treat ``"low"``
|
||||
as a soft fallback to the host. ``reason`` is a short free-form
|
||||
string. The classifier-failure fallback uses ``reason="fallback"``
|
||||
so it's distinguishable from a real low-confidence call.
|
||||
"""
|
||||
|
||||
addressee_id: str
|
||||
confidence: Literal["high", "medium", "low"] = "medium"
|
||||
reason: str = ""
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"Given a user's turn prose and the names of present bots, decide "
|
||||
"which bot the user is addressing. If the user is speaking to no "
|
||||
"specific bot (descriptive narration, action without dialogue), "
|
||||
"default to the host. Output strict JSON matching the schema. "
|
||||
"The addressee_id MUST be one of the ids supplied in the user "
|
||||
"message — do not invent ids."
|
||||
)
|
||||
|
||||
|
||||
async def detect_addressee(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
user_prose: str,
|
||||
host_id: str,
|
||||
host_name: str,
|
||||
guest_id: str | None,
|
||||
guest_name: str | None,
|
||||
timeout_s: float = 30.0,
|
||||
) -> AddresseeDecision:
|
||||
"""Classify which present bot the user is addressing.
|
||||
|
||||
Defaults to host on classifier failure or when the classifier picks
|
||||
an id that isn't one of the supplied ids. The caller is expected to
|
||||
only invoke this in the multi-entity case (a guest is present);
|
||||
when no guest is present the substring fast-path in
|
||||
:mod:`chat.web.turns` is used instead and this function is not
|
||||
called.
|
||||
"""
|
||||
fallback = AddresseeDecision(
|
||||
addressee_id=host_id, confidence="low", reason="fallback"
|
||||
)
|
||||
user = (
|
||||
f"Host: {host_name} (id={host_id})\n"
|
||||
+ (
|
||||
f"Guest: {guest_name} (id={guest_id})\n"
|
||||
if guest_id is not None
|
||||
else ""
|
||||
)
|
||||
+ f"\nUser prose:\n{user_prose}"
|
||||
)
|
||||
decision = await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=AddresseeDecision,
|
||||
default=fallback,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
# Defensive: if the classifier returned an id outside the supplied
|
||||
# set, treat it as a fallback to the host. This catches pathological
|
||||
# outputs that pass schema validation but pick a phantom id.
|
||||
valid_ids = {host_id}
|
||||
if guest_id is not None:
|
||||
valid_ids.add(guest_id)
|
||||
if decision.addressee_id not in valid_ids:
|
||||
return fallback
|
||||
return decision
|
||||
|
||||
|
||||
__all__ = ["AddresseeDecision", "detect_addressee"]
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Async background worker for post-turn jobs (T22).
|
||||
|
||||
The turn flow records a ``memory_written`` event synchronously on the
|
||||
request path so the timeline updates immediately. Significance scoring is
|
||||
a separate classifier round-trip that we don't want to block on, so the
|
||||
turn handler enqueues a :class:`SignificanceJob` here and the worker
|
||||
drains the queue out-of-band.
|
||||
|
||||
A single :class:`BackgroundWorker` is started/stopped via FastAPI lifespan
|
||||
in :mod:`chat.app`. The worker owns its own ``asyncio.Queue`` and runs
|
||||
exactly one task that pulls jobs off the queue, calls
|
||||
:func:`chat.services.significance.compute_significance`, and writes
|
||||
``memory_significance_set`` (and on score 3, ``memory_pin_changed``)
|
||||
events. Each job opens its own DB connection — workers and request
|
||||
handlers don't share connections.
|
||||
|
||||
Failures inside ``_process`` are logged and swallowed: a flaky classifier
|
||||
shouldn't take down the worker. Tests can disable enqueue() by setting
|
||||
``BackgroundWorker.enabled = False`` (e.g. in the existing turn-flow
|
||||
fixture, which doesn't have a usable LLM key for the lifespan-managed
|
||||
factory).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.db.connection import open_db
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.llm.client import LLMClient
|
||||
from chat.services.backup import (
|
||||
prune_backups,
|
||||
should_take_backup,
|
||||
take_backup,
|
||||
)
|
||||
from chat.services.significance import compute_significance
|
||||
from chat.services.snapshot import (
|
||||
prune_periodic_snapshots,
|
||||
should_take_periodic_snapshot,
|
||||
take_snapshot,
|
||||
)
|
||||
|
||||
# T32: tick-loop wake interval. 60s gives a single backup window per
|
||||
# target hour with plenty of slack: should_take_backup's 23h freshness
|
||||
# guard prevents back-to-back runs.
|
||||
BACKUP_TICK_INTERVAL_SECONDS = 60.0
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignificanceJob:
|
||||
"""One unit of work for the background worker.
|
||||
|
||||
``host_bot_id`` is the memory's owner — used both for the auto-pin
|
||||
soft cap query and as the eventual scope for the soft-cap eviction.
|
||||
"""
|
||||
|
||||
memory_id: int
|
||||
narrative_text: str
|
||||
prior_dialogue: list[dict]
|
||||
host_bot_id: str
|
||||
|
||||
|
||||
class BackgroundWorker:
|
||||
"""asyncio.Queue-backed single-worker task.
|
||||
|
||||
Started on app startup; ``stop()`` enqueues a sentinel and awaits the
|
||||
task so any in-flight job has a chance to finish. Pending jobs after
|
||||
the sentinel are dropped on shutdown — Phase 1 simplification.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
llm_client_factory: Callable[[], LLMClient],
|
||||
*,
|
||||
enabled: bool = True,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._llm_client_factory = llm_client_factory
|
||||
self._queue: asyncio.Queue[SignificanceJob | None] = asyncio.Queue()
|
||||
self._task: asyncio.Task | None = None
|
||||
# T32: nightly-backup tick loop runs alongside the job loop. The
|
||||
# event is set by stop() to wake the loop early so shutdown is
|
||||
# snappy even mid-tick.
|
||||
self._tick_task: asyncio.Task | None = None
|
||||
self._tick_stop: asyncio.Event = asyncio.Event()
|
||||
self.enabled = enabled
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is not None:
|
||||
return
|
||||
self._task = asyncio.create_task(self._run())
|
||||
self._tick_task = asyncio.create_task(self._tick_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
# Stop the tick loop first — it has no in-flight work to drain,
|
||||
# so signalling early lets it exit while the job loop is still
|
||||
# finishing its sentinel handoff.
|
||||
self._tick_stop.set()
|
||||
if self._tick_task is not None:
|
||||
await self._tick_task
|
||||
self._tick_task = None
|
||||
if self._task is None:
|
||||
return
|
||||
await self._queue.put(None) # sentinel
|
||||
await self._task
|
||||
self._task = None
|
||||
|
||||
def enqueue(self, job: SignificanceJob) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
self._queue.put_nowait(job)
|
||||
|
||||
async def _run(self) -> None:
|
||||
while True:
|
||||
job = await self._queue.get()
|
||||
if job is None:
|
||||
return
|
||||
try:
|
||||
await self._process(job)
|
||||
except Exception as exc: # noqa: BLE001 — worker must not die
|
||||
log.exception("significance job failed: %s", exc)
|
||||
|
||||
async def _tick_loop(self) -> None:
|
||||
"""Periodic-operations loop (T32 nightly backup).
|
||||
|
||||
Wakes every :data:`BACKUP_TICK_INTERVAL_SECONDS` seconds and
|
||||
asks :func:`should_take_backup` whether a backup is due. The
|
||||
scheduling decision lives in the backup module so we don't
|
||||
duplicate the "is it 03:00?" logic here. Failures are caught
|
||||
and logged so a flaky disk doesn't kill the loop — the next
|
||||
tick will retry.
|
||||
|
||||
Wait uses :func:`asyncio.wait_for` on ``_tick_stop`` so that
|
||||
:meth:`stop` can interrupt a sleeping tick instead of having to
|
||||
wait the full interval.
|
||||
"""
|
||||
while not self._tick_stop.is_set():
|
||||
try:
|
||||
if should_take_backup(self._settings.data_dir):
|
||||
take_backup(
|
||||
db_path=self._settings.db_path,
|
||||
data_dir=self._settings.data_dir,
|
||||
)
|
||||
prune_backups(self._settings.data_dir, keep=14)
|
||||
log.info("nightly backup taken")
|
||||
except Exception as exc: # noqa: BLE001 — never break the loop
|
||||
log.exception("backup tick failed: %s", exc)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._tick_stop.wait(),
|
||||
timeout=BACKUP_TICK_INTERVAL_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Normal path: timed out waiting for stop, run another tick.
|
||||
pass
|
||||
|
||||
async def _process(self, job: SignificanceJob) -> None:
|
||||
client = self._llm_client_factory()
|
||||
score = await compute_significance(
|
||||
client,
|
||||
model=self._settings.classifier_model,
|
||||
narrative_text=job.narrative_text,
|
||||
prior_dialogue=job.prior_dialogue,
|
||||
)
|
||||
with open_db(self._settings.db_path) as conn:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="memory_significance_set",
|
||||
payload={
|
||||
"memory_id": job.memory_id,
|
||||
"significance": score,
|
||||
},
|
||||
)
|
||||
if score >= 3:
|
||||
_auto_pin_with_cap(
|
||||
conn,
|
||||
owner_id=job.host_bot_id,
|
||||
memory_id=job.memory_id,
|
||||
)
|
||||
|
||||
# T31: piggy-back the periodic snapshot check on the background
|
||||
# worker so we don't need a separate timer task. The classifier
|
||||
# pass already runs out-of-band, so snapshot I/O on the same
|
||||
# worker is a natural fit. Each snapshot opens its own
|
||||
# connection so we don't conflate the snapshot's read-only view
|
||||
# with the significance-write transaction above. Failures are
|
||||
# caught and logged: a flaky disk shouldn't take down the
|
||||
# significance pipeline.
|
||||
try:
|
||||
with open_db(self._settings.db_path) as conn:
|
||||
if should_take_periodic_snapshot(
|
||||
conn, self._settings.data_dir
|
||||
):
|
||||
snapshot_path = take_snapshot(
|
||||
conn,
|
||||
data_dir=self._settings.data_dir,
|
||||
kind="periodic",
|
||||
)
|
||||
prune_periodic_snapshots(
|
||||
self._settings.data_dir, keep=5
|
||||
)
|
||||
log.info(
|
||||
"periodic snapshot taken: %s", snapshot_path
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — never break the worker
|
||||
log.exception("periodic snapshot failed: %s", exc)
|
||||
|
||||
|
||||
def _auto_pin_with_cap(
|
||||
conn,
|
||||
*,
|
||||
owner_id: str,
|
||||
memory_id: int,
|
||||
cap: int = 8,
|
||||
) -> None:
|
||||
"""Auto-pin ``memory_id`` and evict the oldest auto-pin if over ``cap``.
|
||||
|
||||
Per §8.5: pivotal turns are auto-pinned, with a soft cap of 8 pins per
|
||||
bot. When the cap is exceeded the oldest auto-pin is unpinned (manual
|
||||
pins are never auto-evicted — we filter on ``auto_pinned = 1``).
|
||||
"""
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="memory_pin_changed",
|
||||
payload={
|
||||
"memory_id": memory_id,
|
||||
"pinned": 1,
|
||||
"auto_pinned": 1,
|
||||
},
|
||||
)
|
||||
cur = conn.execute(
|
||||
"SELECT COUNT(*) FROM memories WHERE owner_id = ? AND pinned = 1",
|
||||
(owner_id,),
|
||||
)
|
||||
count = cur.fetchone()[0]
|
||||
if count <= cap:
|
||||
return
|
||||
cur = conn.execute(
|
||||
"SELECT id FROM memories "
|
||||
"WHERE owner_id = ? AND pinned = 1 AND auto_pinned = 1 AND id != ? "
|
||||
"ORDER BY created_at ASC, id ASC LIMIT 1",
|
||||
(owner_id, memory_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="memory_pin_changed",
|
||||
payload={
|
||||
"memory_id": row[0],
|
||||
"pinned": 0,
|
||||
"auto_pinned": 0,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Nightly DB backup service (T32, Requirements §12).
|
||||
|
||||
A simple in-process scheduler: at 03:00 local time daily, copy
|
||||
``chat.db`` to ``data/backups/chat-<utc-timestamp>.db`` and prune to the
|
||||
14 most recent. The BackgroundWorker tick loop calls
|
||||
:func:`should_take_backup` every 60 seconds; when it returns True the
|
||||
worker calls :func:`take_backup` then :func:`prune_backups`.
|
||||
|
||||
The launchd plist suggested in §12 can replace this later by invoking a
|
||||
small script that calls :func:`take_backup` directly. For v1 the
|
||||
in-process loop is enough — the daemon already runs continuously to
|
||||
serve requests, so there's no extra moving part to install.
|
||||
|
||||
Backups capture the live ``.db`` file via :func:`shutil.copy2`. SQLite's
|
||||
WAL mode means an in-flight transaction's pages might live in the
|
||||
``-wal`` sidecar rather than the main file, but our codebase commits
|
||||
every write transaction synchronously, so the .db alone is sufficient
|
||||
for v1. A truly safe online backup would use
|
||||
``sqlite3.Connection.backup()``; deferred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# 03:00 local time per Requirements §12. Hardcoded for v1 — making this
|
||||
# configurable via Settings is straightforward but not needed yet.
|
||||
DEFAULT_BACKUP_HOUR = 3
|
||||
|
||||
# Retention window per Requirements §12 ("Last 14 retained").
|
||||
DEFAULT_KEEP = 14
|
||||
|
||||
# Wake interval for should_take_backup's freshness check. We wake the
|
||||
# tick loop every 60s, so a backup taken in the previous tick within the
|
||||
# same target hour must NOT trigger another. 23h gives us a generous
|
||||
# safety margin against scheduling jitter while still allowing a single
|
||||
# backup per day.
|
||||
FRESHNESS_HOURS = 23
|
||||
|
||||
|
||||
def take_backup(*, db_path: Path, data_dir: Path) -> Path:
|
||||
"""Copy ``db_path`` to ``data_dir/backups/chat-<utc-timestamp>.db``.
|
||||
|
||||
Returns the new file path. Creates the backup directory if missing.
|
||||
Uses :func:`shutil.copy2` so the destination's mtime is preserved —
|
||||
:func:`should_take_backup` reads mtime to gate fresh backups.
|
||||
"""
|
||||
backup_dir = data_dir / "backups"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
backup_path = backup_dir / f"chat-{timestamp}.db"
|
||||
shutil.copy2(db_path, backup_path)
|
||||
return backup_path
|
||||
|
||||
|
||||
def prune_backups(data_dir: Path, *, keep: int = DEFAULT_KEEP) -> int:
|
||||
"""Remove all but the most recent ``keep`` backup files.
|
||||
|
||||
Returns the number of files removed. Safe when the directory is
|
||||
missing (returns 0). Sorting is by filename, which is the UTC
|
||||
timestamp embedded in the name — lexicographic order matches
|
||||
chronological order.
|
||||
"""
|
||||
backup_dir = data_dir / "backups"
|
||||
if not backup_dir.exists():
|
||||
return 0
|
||||
files = sorted(backup_dir.glob("chat-*.db"))
|
||||
to_remove = files[:-keep] if len(files) > keep else []
|
||||
for f in to_remove:
|
||||
f.unlink()
|
||||
return len(to_remove)
|
||||
|
||||
|
||||
def should_take_backup(
|
||||
data_dir: Path, *, target_hour: int = DEFAULT_BACKUP_HOUR
|
||||
) -> bool:
|
||||
"""Decide whether a nightly backup is due.
|
||||
|
||||
Two conditions must hold:
|
||||
|
||||
* The current local hour matches ``target_hour``.
|
||||
* No backup file in ``data_dir/backups/`` has an mtime within the
|
||||
last :data:`FRESHNESS_HOURS` (23h). The 23h window prevents a
|
||||
double-backup within the same target hour while still allowing
|
||||
the next day's run to fire on time.
|
||||
|
||||
Local time (not UTC) is used for the hour comparison per the
|
||||
requirements ("03:00 local time"). The filename embeds a UTC stamp
|
||||
so file ordering remains unambiguous across DST transitions.
|
||||
"""
|
||||
now = datetime.now()
|
||||
if now.hour != target_hour:
|
||||
return False
|
||||
backup_dir = data_dir / "backups"
|
||||
if not backup_dir.exists():
|
||||
return True
|
||||
files = list(backup_dir.glob("chat-*.db"))
|
||||
if not files:
|
||||
return True
|
||||
most_recent = max(files, key=lambda f: f.stat().st_mtime)
|
||||
age_hours = (
|
||||
datetime.now().timestamp() - most_recent.stat().st_mtime
|
||||
) / 3600
|
||||
return age_hours >= FRESHNESS_HOURS
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Branching service (T94, Phase 4).
|
||||
|
||||
Wraps branches state with validation + event emission. Phase 4 ships
|
||||
the data model and creation/switching APIs; the read-side filter
|
||||
(event readers consulting is_active) is a Phase 4.5+ follow-up — for
|
||||
now branches are metadata-only and the existing event readers remain
|
||||
branch-agnostic. The drawer UI (T98) drives create/switch via these
|
||||
helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.state.branches import get_branch, list_branches, active_branch # noqa: F401
|
||||
|
||||
|
||||
def branch_from_event(
|
||||
conn: Connection,
|
||||
*,
|
||||
name: str,
|
||||
origin_event_id: int,
|
||||
chat_id: str | None = None,
|
||||
) -> int:
|
||||
"""Create a new named branch forking from origin_event_id.
|
||||
|
||||
Emits a branch_created event. Returns the new branch's row id.
|
||||
Raises ValueError if name already exists or origin_event_id doesn't
|
||||
correspond to a real event."""
|
||||
if not name or not name.strip():
|
||||
raise ValueError("branch name must be non-empty")
|
||||
|
||||
if get_branch(conn, name) is not None:
|
||||
raise ValueError(f"branch {name!r} already exists")
|
||||
|
||||
# Validate origin_event_id is a real event id (or 0 for the bootstrap case
|
||||
# which only main uses).
|
||||
if origin_event_id < 0:
|
||||
raise ValueError(f"origin_event_id must be >= 0, got {origin_event_id}")
|
||||
if origin_event_id > 0:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM event_log WHERE id = ?", (origin_event_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError(
|
||||
f"origin_event_id {origin_event_id} does not exist in event_log"
|
||||
)
|
||||
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="branch_created",
|
||||
payload={
|
||||
"name": name,
|
||||
"origin_event_id": origin_event_id,
|
||||
"head_event_id": origin_event_id, # head starts at origin
|
||||
"chat_id": chat_id,
|
||||
},
|
||||
)
|
||||
|
||||
branch = get_branch(conn, name)
|
||||
if branch is None:
|
||||
# Should be unreachable if append_and_apply worked.
|
||||
raise RuntimeError(f"branch {name!r} not found after creation")
|
||||
return branch["id"]
|
||||
|
||||
|
||||
def switch_active_branch(conn: Connection, *, name: str) -> None:
|
||||
"""Make the named branch active. Emits branch_switched."""
|
||||
if get_branch(conn, name) is None:
|
||||
raise ValueError(f"branch {name!r} does not exist")
|
||||
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="branch_switched",
|
||||
payload={"name": name},
|
||||
)
|
||||
|
||||
|
||||
def list_branches_with_metadata(
|
||||
conn: Connection, chat_id: str | None = None
|
||||
) -> list[dict]:
|
||||
"""List branches with computed event_count metadata.
|
||||
|
||||
event_count = head_event_id - origin_event_id + 1 (when both are set)
|
||||
OR head_event_id (when origin is 0, e.g., main branch)
|
||||
OR 0 (when head <= origin, which is the bootstrap state)
|
||||
"""
|
||||
branches = list_branches(conn, chat_id)
|
||||
enriched = []
|
||||
for b in branches:
|
||||
origin = b["origin_event_id"]
|
||||
head = b["head_event_id"]
|
||||
if head < origin:
|
||||
event_count = 0
|
||||
elif origin == 0:
|
||||
event_count = head
|
||||
else:
|
||||
event_count = head - origin + 1
|
||||
enriched.append({**b, "event_count": event_count})
|
||||
return enriched
|
||||
|
||||
|
||||
__all__ = [
|
||||
"branch_from_event",
|
||||
"switch_active_branch",
|
||||
"list_branches_with_metadata",
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Cross-chat search service (T93, Phase 4).
|
||||
|
||||
FTS5-based search across ALL owners and ALL chats. Used by the
|
||||
top-bar search UX (T100) for "where did I last see this character
|
||||
mention X?" queries. NO witness filter -- this is intentionally a
|
||||
power-user surface that surfaces memories across POVs.
|
||||
|
||||
Mirrors the FTS5 access pattern of ``chat.state.memory.search_memories``
|
||||
but drops both the ``owner_id = ?`` and the per-witness predicates so a
|
||||
single query can sweep every chat in the database. The composite
|
||||
re-rank is also dropped: callers want raw BM25 ordering for the
|
||||
"highest match strength wins" semantics expected of a global search box.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlite3 import Connection
|
||||
|
||||
|
||||
def search_all_memories(
|
||||
conn: Connection,
|
||||
*,
|
||||
query: str,
|
||||
k: int = 20,
|
||||
) -> list[dict]:
|
||||
"""Search FTS5 across all owners and chats.
|
||||
|
||||
Returns rows with ``{memory_id, owner_id, chat_id, scene_id,
|
||||
pov_summary, significance, ts, fts_rank}``, sorted by FTS5 BM25
|
||||
rank ascending (lower rank = stronger match, surfaced first).
|
||||
|
||||
The ``memories`` table has no ``ts`` column; we expose ``created_at``
|
||||
(the projector-side row insertion timestamp) under that key so the
|
||||
UI does not have to know the storage name.
|
||||
|
||||
An empty / whitespace-only ``query`` short-circuits to ``[]`` to
|
||||
avoid an FTS5 ``MATCH ''`` syntax error and to keep the top-bar
|
||||
"no input yet" state from triggering a full-table scan.
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
|
||||
# FTS5 MATCH against the same ``memories_fts`` virtual table that
|
||||
# backs ``state.memory.search_memories``; the JOIN pulls metadata
|
||||
# from the content table because the FTS index only stores
|
||||
# ``pov_summary``. ORDER BY rank ASC because BM25 in FTS5 returns
|
||||
# negative scores where lower is better.
|
||||
rows = conn.execute(
|
||||
"SELECT m.id, m.owner_id, m.chat_id, m.scene_id, "
|
||||
" m.pov_summary, m.significance, m.created_at, "
|
||||
" memories_fts.rank "
|
||||
"FROM memories_fts "
|
||||
"JOIN memories m ON m.id = memories_fts.rowid "
|
||||
"WHERE memories_fts MATCH ? "
|
||||
"ORDER BY memories_fts.rank ASC "
|
||||
"LIMIT ?",
|
||||
(query.strip(), k),
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"memory_id": r[0],
|
||||
"owner_id": r[1],
|
||||
"chat_id": r[2],
|
||||
"scene_id": r[3],
|
||||
"pov_summary": r[4],
|
||||
"significance": r[5],
|
||||
"ts": r[6],
|
||||
"fts_rank": r[7],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["search_all_memories"]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Delete-impact computation service (T95, Phase 4).
|
||||
|
||||
Walks event_log forward from a target event_id and produces an ImpactReport
|
||||
describing what would be removed if rewind-to-target were invoked. Pure
|
||||
computation — does NOT mutate the database. Used by T98's drawer surgical-
|
||||
delete UI to render an 'are you sure?' modal before invoking the actual
|
||||
rewind path (chat/services/rewind.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DeletedItem(BaseModel):
|
||||
kind: str
|
||||
description: str
|
||||
target_id: int | str | None = None
|
||||
|
||||
|
||||
class ImpactReport(BaseModel):
|
||||
target_event_id: int
|
||||
cascading: list[DeletedItem] = Field(default_factory=list)
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _excerpt(text: str, n: int = 60) -> str:
|
||||
text = (text or "").strip().replace("\n", " ")
|
||||
return text if len(text) <= n else text[: n - 1] + "…"
|
||||
|
||||
|
||||
def compute_delete_impact(
|
||||
conn: Connection,
|
||||
*,
|
||||
target_event_id: int,
|
||||
) -> ImpactReport:
|
||||
"""Compute the cascading impact of rewinding to target_event_id."""
|
||||
# Verify target exists.
|
||||
target_row = conn.execute(
|
||||
"SELECT id, kind, payload_json FROM event_log WHERE id = ?",
|
||||
(target_event_id,),
|
||||
).fetchone()
|
||||
if target_row is None:
|
||||
return ImpactReport(
|
||||
target_event_id=target_event_id,
|
||||
cascading=[],
|
||||
notes=[f"target event_id {target_event_id} not found"],
|
||||
)
|
||||
|
||||
# Walk forward: every event with id >= target_event_id is in scope.
|
||||
rows = conn.execute(
|
||||
"SELECT id, kind, payload_json FROM event_log "
|
||||
"WHERE id >= ? ORDER BY id ASC",
|
||||
(target_event_id,),
|
||||
).fetchall()
|
||||
|
||||
cascading: list[DeletedItem] = []
|
||||
notes: list[str] = []
|
||||
scene_close_present = False
|
||||
regenerated_from = None
|
||||
|
||||
for row_id, kind, payload_json in rows:
|
||||
try:
|
||||
payload = json.loads(payload_json) if payload_json else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {}
|
||||
|
||||
if kind == "memory_written":
|
||||
cascading.append(
|
||||
DeletedItem(
|
||||
kind=kind,
|
||||
description=f"memory: {_excerpt(payload.get('pov_summary', ''))}",
|
||||
target_id=payload.get("memory_id"),
|
||||
)
|
||||
)
|
||||
elif kind == "edge_update":
|
||||
src = payload.get("source_id", "?")
|
||||
tgt = payload.get("target_id", "?")
|
||||
cascading.append(
|
||||
DeletedItem(
|
||||
kind=kind,
|
||||
description=f"edge update: {src} -> {tgt}",
|
||||
target_id=f"{src}->{tgt}",
|
||||
)
|
||||
)
|
||||
elif kind == "scene_closed":
|
||||
scene_close_present = True
|
||||
cascading.append(
|
||||
DeletedItem(
|
||||
kind=kind,
|
||||
description=f"scene close at {payload.get('closed_at', '?')}",
|
||||
target_id=payload.get("scene_id"),
|
||||
)
|
||||
)
|
||||
elif kind in ("user_turn", "user_turn_edit", "assistant_turn"):
|
||||
speaker = payload.get("speaker_id") or ("you" if kind.startswith("user") else "?")
|
||||
prose = payload.get("prose") or payload.get("text") or ""
|
||||
cascading.append(
|
||||
DeletedItem(
|
||||
kind=kind,
|
||||
description=f"turn {row_id} ({speaker}: {_excerpt(prose, 50)})",
|
||||
target_id=row_id,
|
||||
)
|
||||
)
|
||||
if regenerated_from is None and payload.get("regenerated_from"):
|
||||
regenerated_from = payload["regenerated_from"]
|
||||
elif kind == "manual_edit":
|
||||
target_kind = payload.get("target_kind", "?")
|
||||
cascading.append(
|
||||
DeletedItem(
|
||||
kind=kind,
|
||||
description=f"manual edit: {target_kind}",
|
||||
target_id=payload.get("target_id"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
cascading.append(
|
||||
DeletedItem(
|
||||
kind=kind,
|
||||
description=f"{kind} event",
|
||||
target_id=row_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Notes / warnings.
|
||||
notes.append(f"{len(rows)} events would be discarded total")
|
||||
if scene_close_present:
|
||||
notes.append(
|
||||
"scene close events are in scope — closing-scene per-POV summaries "
|
||||
"and group_node updates will be reverted"
|
||||
)
|
||||
if regenerated_from is not None:
|
||||
notes.append(
|
||||
f"target turn was regenerated from event_id {regenerated_from}; "
|
||||
f"the original turn remains intact"
|
||||
)
|
||||
|
||||
return ImpactReport(
|
||||
target_event_id=target_event_id,
|
||||
cascading=cascading,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DeletedItem", "ImpactReport", "compute_delete_impact"]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Embedding worker (T97, Phase 4).
|
||||
|
||||
Drains a queue of embedding jobs. Each job carries a memory id and the
|
||||
narrative text to embed; the worker calls
|
||||
:func:`chat.services.embeddings.generate_embedding` and emits an
|
||||
``embedding_indexed`` event so the projector lands the vector in the
|
||||
``embeddings`` table.
|
||||
|
||||
Mirrors the :class:`chat.services.background.BackgroundWorker` pattern:
|
||||
single asyncio task, sentinel-based shutdown, exceptions are caught and
|
||||
logged so a flaky embedding call doesn't take down the worker. Each job
|
||||
opens its own SQLite connection via ``conn_factory`` — the request path
|
||||
and the worker do not share connections.
|
||||
|
||||
Featherless concurrency (the 2-conn cap) is respected by virtue of the
|
||||
single-task design: jobs run strictly serially. Phase 4's pseudo-embedding
|
||||
path is local and synchronous so this is largely moot, but the pattern
|
||||
is in place for the Phase 4.5+ real-embedding swap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from sqlite3 import Connection
|
||||
from typing import Callable
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.services.embeddings import (
|
||||
DEFAULT_EMBEDDING_DIM,
|
||||
DEFAULT_EMBEDDING_MODEL,
|
||||
FALLBACK_EMBEDDING_MODEL,
|
||||
generate_embedding,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingJob:
|
||||
"""One unit of work for the embedding worker.
|
||||
|
||||
``memory_id`` is the row to attach the vector to; ``text`` is the
|
||||
narrative text to embed (typically ``memories.pov_summary``).
|
||||
"""
|
||||
|
||||
memory_id: int
|
||||
text: str
|
||||
|
||||
|
||||
class EmbeddingWorker:
|
||||
"""asyncio.Queue-backed single-worker task for embedding generation.
|
||||
|
||||
Started on app startup; ``stop()`` enqueues a sentinel and awaits
|
||||
the task so any in-flight job has a chance to finish. Pending jobs
|
||||
after the sentinel are dropped on shutdown.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
conn_factory: Callable[[], Connection],
|
||||
client, # LLMClient | None — unused on the pseudo path.
|
||||
model: str = DEFAULT_EMBEDDING_MODEL,
|
||||
dim: int = DEFAULT_EMBEDDING_DIM,
|
||||
enabled: bool = True,
|
||||
) -> None:
|
||||
self._queue: asyncio.Queue[EmbeddingJob | None] = asyncio.Queue()
|
||||
self._conn_factory = conn_factory
|
||||
self._client = client
|
||||
self._model = model
|
||||
self._dim = dim
|
||||
self._task: asyncio.Task | None = None
|
||||
self.enabled = enabled
|
||||
|
||||
def enqueue(self, job: EmbeddingJob) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
self._queue.put_nowait(job)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is None:
|
||||
self._task = asyncio.create_task(self._run())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is None:
|
||||
return
|
||||
self._queue.put_nowait(None) # sentinel
|
||||
await self._task
|
||||
self._task = None
|
||||
|
||||
async def _run(self) -> None:
|
||||
while True:
|
||||
job = await self._queue.get()
|
||||
if job is None:
|
||||
return
|
||||
try:
|
||||
await self._process(job)
|
||||
except Exception as exc: # noqa: BLE001 — worker must not die
|
||||
log.warning(
|
||||
"embedding worker failed for memory_id=%s: %s",
|
||||
job.memory_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _process(self, job: EmbeddingJob) -> None:
|
||||
result = await generate_embedding(
|
||||
self._client,
|
||||
text=job.text,
|
||||
model=self._model,
|
||||
dim=self._dim,
|
||||
)
|
||||
if result.model == FALLBACK_EMBEDDING_MODEL:
|
||||
# Don't index a fallback (zero) vector — the backfill script
|
||||
# can retry later once a real embedding is available.
|
||||
log.debug(
|
||||
"embedding worker skipping fallback result for memory_id=%s",
|
||||
job.memory_id,
|
||||
)
|
||||
return
|
||||
with self._conn_factory() as conn:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="embedding_indexed",
|
||||
payload={
|
||||
"memory_id": job.memory_id,
|
||||
"model": result.model,
|
||||
"dim": result.dim,
|
||||
"vector": result.vector,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["EmbeddingJob", "EmbeddingWorker"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Embedding generation service (T91, Phase 4).
|
||||
|
||||
Wraps the embedding API call. For Phase 4's first cut we ship a
|
||||
deterministic local pseudo-embedding (hash-derived) so the vector
|
||||
retrieval pipeline can land without an external embedding endpoint
|
||||
or heavy local dependency. Phase 4.5+ swaps to a real model — the
|
||||
EmbeddingResult shape stays the same, only the generator changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import struct
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
DEFAULT_EMBEDDING_DIM = 384
|
||||
DEFAULT_EMBEDDING_MODEL = "pseudo-sha256-384"
|
||||
FALLBACK_EMBEDDING_MODEL = "fallback"
|
||||
|
||||
|
||||
class EmbeddingResult(BaseModel):
|
||||
vector: list[float]
|
||||
model: str
|
||||
dim: int
|
||||
|
||||
|
||||
def _pseudo_embed(text: str, dim: int = DEFAULT_EMBEDDING_DIM) -> list[float]:
|
||||
"""Deterministic pseudo-embedding for Phase 4 first cut.
|
||||
|
||||
Hashes the text with SHA-256, then expands by re-hashing each
|
||||
successive block with the previous block + a counter — this gives
|
||||
``dim * 4`` bytes of fresh entropy per input rather than naively
|
||||
repeating the 32-byte digest (which would collapse the vector onto
|
||||
only 8 unique floats and make distinct inputs cosine-similar).
|
||||
|
||||
Bytes are unpacked as little-endian int32s and rescaled to [-1, 1]
|
||||
so we sidestep the float32 NaN/denormal values that ``struct.unpack
|
||||
'f'`` would otherwise produce on raw hash bytes. The result is
|
||||
unit-normalized so cosine similarity reduces to a dot product.
|
||||
|
||||
NOT semantically meaningful — just consistent for testing the
|
||||
pipeline. Phase 4.5 should swap to a real embedding model.
|
||||
"""
|
||||
needed = dim * 4 # 4 bytes per int32
|
||||
seed = text.encode("utf-8")
|
||||
chunks: list[bytes] = []
|
||||
counter = 0
|
||||
while sum(len(c) for c in chunks) < needed:
|
||||
block = hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()
|
||||
chunks.append(block)
|
||||
counter += 1
|
||||
full = b"".join(chunks)[:needed]
|
||||
ints = struct.unpack(f"<{dim}i", full)
|
||||
# Map int32 to roughly [-1, 1] — exact bound doesn't matter since we
|
||||
# normalize, but keeps values numerically tame.
|
||||
raw = [x / 2147483648.0 for x in ints]
|
||||
norm = math.sqrt(sum(x * x for x in raw)) or 1.0
|
||||
return [x / norm for x in raw]
|
||||
|
||||
|
||||
async def generate_embedding(
|
||||
client: LLMClient,
|
||||
*,
|
||||
text: str,
|
||||
model: str = DEFAULT_EMBEDDING_MODEL,
|
||||
dim: int = DEFAULT_EMBEDDING_DIM,
|
||||
timeout_s: float = 30.0,
|
||||
) -> EmbeddingResult:
|
||||
"""Generate an embedding for the given text.
|
||||
|
||||
Phase 4 default uses a deterministic local pseudo-embedding. If
|
||||
the LLMClient grows an ``embed(...)`` method in Phase 4.5, this
|
||||
wrapper will route to it when ``model != "pseudo-sha256-384"``.
|
||||
|
||||
Falls back to a zero vector with ``model="fallback"`` on any
|
||||
failure (callers detect the sentinel and skip indexing). For the
|
||||
pseudo path, failure is structurally impossible — it's pure local
|
||||
computation.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
# Empty input — return fallback so caller doesn't index empty rows.
|
||||
return EmbeddingResult(
|
||||
vector=[0.0] * dim, model=FALLBACK_EMBEDDING_MODEL, dim=dim
|
||||
)
|
||||
|
||||
if model == DEFAULT_EMBEDDING_MODEL:
|
||||
# Pure-local pseudo path — no LLMClient call.
|
||||
return EmbeddingResult(vector=_pseudo_embed(text, dim), model=model, dim=dim)
|
||||
|
||||
# Future: real embedding via client.embed(...). Phase 4.5 work.
|
||||
# For Phase 4, any non-default model falls through to fallback.
|
||||
return EmbeddingResult(
|
||||
vector=[0.0] * dim, model=FALLBACK_EMBEDDING_MODEL, dim=dim
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_EMBEDDING_DIM",
|
||||
"DEFAULT_EMBEDDING_MODEL",
|
||||
"FALLBACK_EMBEDDING_MODEL",
|
||||
"EmbeddingResult",
|
||||
"generate_embedding",
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Event-lifecycle detection (T52).
|
||||
|
||||
After each turn, classify whether any active events transitioned
|
||||
(started, completed, cancelled). Conservative bias — most turns
|
||||
return empty. T61 turn flow appends one event_started/completed/
|
||||
cancelled per transition via append_and_apply.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class EventTransition(BaseModel):
|
||||
event_id: str
|
||||
new_status: str # "active" | "completed" | "cancelled"
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class EventLifecycleDecision(BaseModel):
|
||||
transitions: list[EventTransition] = Field(default_factory=list)
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"You decide whether any active events transitioned this turn. "
|
||||
"STRONGLY default to empty transitions — most turns do NOT resolve "
|
||||
"or start a known event. Output only transitions that the narrative "
|
||||
"text clearly resolves or starts. Each transition MUST reference an "
|
||||
"event_id from the active_events list. new_status is one of "
|
||||
"'active' (planned -> active), 'completed', or 'cancelled'. "
|
||||
"Output strict JSON matching the schema."
|
||||
)
|
||||
|
||||
|
||||
async def detect_event_transitions(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
narrative_text: str,
|
||||
active_events: list[dict], # [{event_id, kind, status, props}, ...]
|
||||
timeout_s: float = 30.0,
|
||||
) -> EventLifecycleDecision:
|
||||
"""Classify event transitions for the latest turn. Empty active_events
|
||||
short-circuits without an LLM call."""
|
||||
if not active_events:
|
||||
return EventLifecycleDecision()
|
||||
|
||||
user_lines = ["Active events:"]
|
||||
for ev in active_events:
|
||||
user_lines.append(
|
||||
f"- event_id={ev['event_id']} kind={ev['kind']} "
|
||||
f"status={ev['status']} props={ev.get('props', {})}"
|
||||
)
|
||||
user_lines.append("")
|
||||
user_lines.append("Latest narrative:")
|
||||
user_lines.append(narrative_text.strip())
|
||||
user = "\n".join(user_lines)
|
||||
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=EventLifecycleDecision,
|
||||
default=EventLifecycleDecision(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["EventTransition", "EventLifecycleDecision", "detect_event_transitions"]
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Event-completion promotion (T56).
|
||||
|
||||
When an event reaches ``status='completed'``, read its ``props_json``
|
||||
and emit promotion events into the appropriate state stores.
|
||||
Synchronous, no LLM. Skips when the event status is not ``completed``
|
||||
(cancelled / expired terminate the event without promoting).
|
||||
|
||||
Props recognized:
|
||||
|
||||
- ``acquired_objects: list[str]`` — emits a ``manual_edit`` with
|
||||
``target_kind="memory_pov_summary"`` per object on the host's memory
|
||||
row, recording the acquisition. Phase 3 is a stub: it requires both
|
||||
``host_bot_id`` and ``host_memory_id`` (an existing memories.id) to
|
||||
be present in props; missing either skips that object cleanly.
|
||||
Phase 4 will introduce a real inventory schema.
|
||||
|
||||
- ``knowledge_facts: list[{owner_id, target_id, fact}]`` — emits an
|
||||
``edge_update`` event on the directed ``owner_id -> target_id`` edge
|
||||
with the fact appended to ``knowledge_facts``. The ``edge_update``
|
||||
projector accepts ``knowledge_facts`` as a list and extends the
|
||||
edge's stored ``knowledge_json``.
|
||||
|
||||
- ``relationship_change: {summary, source_id, target_id}`` — emits a
|
||||
``manual_edit`` with ``target_kind="edge_summary"`` overwriting the
|
||||
edge's ``summary`` field on the directed pair.
|
||||
|
||||
Anything else stays in the closed event record (the projector kept
|
||||
the row; no further promotion).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.state.events import get_event
|
||||
|
||||
|
||||
def promote_completed_event(
|
||||
conn: Connection,
|
||||
*,
|
||||
event_id: str,
|
||||
chat_id: str,
|
||||
chat_clock_at: str | None,
|
||||
) -> dict:
|
||||
"""Read the completed event's props and emit promotion events.
|
||||
|
||||
Returns a dict of counts keyed by promoted artifact:
|
||||
``{"acquired_objects", "knowledge_facts", "relationship_change"}``.
|
||||
Skips silently if the event row is missing or its status is not
|
||||
``completed`` — cancelled / expired events terminate without any
|
||||
promotion.
|
||||
"""
|
||||
counts = {
|
||||
"acquired_objects": 0,
|
||||
"knowledge_facts": 0,
|
||||
"relationship_change": 0,
|
||||
}
|
||||
|
||||
event = get_event(conn, event_id)
|
||||
if event is None or event["status"] != "completed":
|
||||
return counts
|
||||
|
||||
props = event.get("props") or {}
|
||||
|
||||
# acquired_objects: each becomes a memory_pov_summary edit (Phase 3
|
||||
# stub). The manual_edit projector requires a valid memory rowid as
|
||||
# ``target_id`` (it does ``int(target_id)``), so skip cleanly when
|
||||
# neither a host_bot_id nor a host_memory_id is supplied.
|
||||
host_bot_id = props.get("host_bot_id")
|
||||
host_memory_id = props.get("host_memory_id")
|
||||
for obj in props.get("acquired_objects", []) or []:
|
||||
if host_bot_id is None or host_memory_id is None:
|
||||
continue
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "memory_pov_summary",
|
||||
"target_id": host_memory_id,
|
||||
"owner_id": host_bot_id,
|
||||
"chat_id": chat_id,
|
||||
"prior_value": "",
|
||||
"new_value": f"Acquired: {obj}",
|
||||
"source": "event_promotion",
|
||||
"event_id": event_id,
|
||||
"chat_clock_at": chat_clock_at,
|
||||
},
|
||||
)
|
||||
counts["acquired_objects"] += 1
|
||||
|
||||
# knowledge_facts: each becomes an edge_update appending the fact.
|
||||
for fact_entry in props.get("knowledge_facts", []) or []:
|
||||
owner_id = fact_entry.get("owner_id")
|
||||
target_id = fact_entry.get("target_id")
|
||||
fact = fact_entry.get("fact", "")
|
||||
if not owner_id or not target_id or not fact:
|
||||
continue
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": owner_id,
|
||||
"target_id": target_id,
|
||||
"chat_id": chat_id,
|
||||
"affinity_delta": 0,
|
||||
"trust_delta": 0,
|
||||
"knowledge_facts": [fact],
|
||||
"last_interaction_at": chat_clock_at,
|
||||
"last_interaction_chat_id": chat_id,
|
||||
"source": "event_promotion",
|
||||
"event_id": event_id,
|
||||
},
|
||||
)
|
||||
counts["knowledge_facts"] += 1
|
||||
|
||||
# relationship_change: edge_summary manual_edit on the directed pair.
|
||||
# The manual_edit projector for ``edge_summary`` keys on a
|
||||
# ``target_id`` dict ``{source_id, target_id}`` (see
|
||||
# chat/state/manual_edit.py); we shape the payload to match.
|
||||
rc = props.get("relationship_change") or {}
|
||||
if rc:
|
||||
source_id = rc.get("source_id")
|
||||
rc_target_id = rc.get("target_id")
|
||||
summary = rc.get("summary", "")
|
||||
if source_id and rc_target_id and summary:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "edge_summary",
|
||||
"target_id": {
|
||||
"source_id": source_id,
|
||||
"target_id": rc_target_id,
|
||||
},
|
||||
"chat_id": chat_id,
|
||||
"prior_value": "",
|
||||
"new_value": summary,
|
||||
"source": "event_promotion",
|
||||
"event_id": event_id,
|
||||
"chat_clock_at": chat_clock_at,
|
||||
},
|
||||
)
|
||||
counts["relationship_change"] += 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
__all__ = ["promote_completed_event"]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Interjection classifier service (T39).
|
||||
|
||||
Per Requirements §6.2, when a guest is present and the addressee bot has
|
||||
just spoken, the *non-addressee* bot may follow on with a brief
|
||||
interjection beat. This service decides whether that interjection
|
||||
fires. Conservative bias: most turns return ``should_interject=False``
|
||||
— the addressee has the floor and an interjection is the exception.
|
||||
Trigger ``True`` only when the silent witness's character, given their
|
||||
persona and edges, would plausibly speak up: jealousy, surprise, strong
|
||||
agreement worth voicing, correcting a factual falsehood, urgency.
|
||||
|
||||
T44 (turn flow) calls this and, on ``True``, generates the brief
|
||||
follow-on response as the silent witness. Classifier failure falls back
|
||||
to ``should_interject=False`` with ``reason="fallback"`` so the chat
|
||||
keeps moving (§3.3 graceful-degradation rule); callers that care can
|
||||
distinguish a real "no" from a degraded "no" by the reason string.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class InterjectionDecision(BaseModel):
|
||||
"""Whether the silent witness interjects, plus a short reason.
|
||||
|
||||
Defaults are a deliberate no-op: ``should_interject=False`` with an
|
||||
empty reason. The classifier-failure fallback uses
|
||||
``reason="fallback"`` so it's distinguishable from a real "no".
|
||||
"""
|
||||
|
||||
should_interject: bool = False
|
||||
reason: str = ""
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"You decide whether a silent witness character interjects after the "
|
||||
"addressee character finishes speaking. STRONGLY default to false — "
|
||||
"the addressee has the floor and most turns should NOT have an "
|
||||
"interjection. Only return true when the silent witness's character, "
|
||||
"given their persona and edges, would plausibly speak up: jealousy, "
|
||||
"surprise, strong agreement worth voicing, correcting a factual "
|
||||
"falsehood, urgency. Output strict JSON matching the schema."
|
||||
)
|
||||
|
||||
|
||||
async def detect_interjection(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
addressee_name: str,
|
||||
addressee_just_said: str,
|
||||
silent_witness_name: str,
|
||||
silent_witness_persona: str,
|
||||
silent_witness_edge_to_addressee: dict, # {affinity, trust, summary}
|
||||
silent_witness_edge_to_you: dict,
|
||||
you_just_said: str,
|
||||
timeout_s: float = 30.0,
|
||||
) -> InterjectionDecision:
|
||||
"""Decide whether the silent witness bot interjects after the addressee
|
||||
finishes speaking.
|
||||
|
||||
The two ``silent_witness_edge_*`` dicts carry the silent witness's
|
||||
directed edges toward the addressee and toward the user ("you"),
|
||||
each shaped ``{affinity: int, trust: int, summary: str}``. Missing
|
||||
keys fall back to a 50/50 baseline with an empty summary so this
|
||||
function tolerates partially-populated edge state without raising.
|
||||
"""
|
||||
user = (
|
||||
f"You said: {you_just_said}\n\n"
|
||||
f"{addressee_name} just said: {addressee_just_said}\n\n"
|
||||
f"Silent witness: {silent_witness_name}\n"
|
||||
f"Persona: {silent_witness_persona}\n"
|
||||
f"Edge {silent_witness_name} -> {addressee_name}: "
|
||||
f"affinity={silent_witness_edge_to_addressee.get('affinity', 50)}, "
|
||||
f"trust={silent_witness_edge_to_addressee.get('trust', 50)}, "
|
||||
f"summary={silent_witness_edge_to_addressee.get('summary', '')}\n"
|
||||
f"Edge {silent_witness_name} -> you: "
|
||||
f"affinity={silent_witness_edge_to_you.get('affinity', 50)}, "
|
||||
f"trust={silent_witness_edge_to_you.get('trust', 50)}, "
|
||||
f"summary={silent_witness_edge_to_you.get('summary', '')}\n\n"
|
||||
f"Should {silent_witness_name} interject?"
|
||||
)
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=InterjectionDecision,
|
||||
default=InterjectionDecision(
|
||||
should_interject=False, reason="fallback"
|
||||
),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["InterjectionDecision", "detect_interjection"]
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Kickoff prose parser.
|
||||
|
||||
Service-layer function that converts a bot's authored kickoff prose into a
|
||||
structured ``KickoffParse`` for the kickoff confirm-and-edit step (T13 will
|
||||
wire this into the UI flow).
|
||||
|
||||
The classifier prompt includes only the bot context that's load-bearing for
|
||||
parsing the opening scene: name, persona, the authored
|
||||
``initial_relationship_to_you`` blurb, the ``you`` entity name, and the
|
||||
kickoff prose itself. Other identity fields (traits, backstory, ...) are
|
||||
intentionally left out — they would be noise for this extraction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class ActivityShape(BaseModel):
|
||||
"""Per-entity activity at scene start.
|
||||
|
||||
Maps onto Requirements §6.5: ``current_action.{verb,interruptible,
|
||||
required_attention,expected_duration}`` plus posture, attention, holding.
|
||||
``action_required_attention`` is left as a free-form string ("low" /
|
||||
"medium" / "high" expected) rather than a Literal so the classifier has
|
||||
room to vary phrasing in v1.
|
||||
"""
|
||||
|
||||
posture: str
|
||||
action_verb: str
|
||||
action_interruptible: bool
|
||||
action_required_attention: str # low | medium | high
|
||||
action_expected_duration: str
|
||||
attention: str = ""
|
||||
holding: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KickoffParse(BaseModel):
|
||||
"""Structured opening-scene state extracted from kickoff prose.
|
||||
|
||||
``container_properties`` is loose ``dict``: the classifier may emit
|
||||
``moving`` / ``public`` / ``audible_range`` keys, but downstream
|
||||
consumers (T13's confirm form) handle missing keys gracefully.
|
||||
``initial_time_iso`` is stored as text — not validated as a datetime
|
||||
here; ``chat_state.time`` stores it as a plain string.
|
||||
"""
|
||||
|
||||
container_name: str
|
||||
container_type: str
|
||||
container_properties: dict
|
||||
you_activity: ActivityShape
|
||||
bot_activity: ActivityShape
|
||||
initial_time_iso: str
|
||||
edge_seed_summary: str
|
||||
edge_seed_knowledge_facts: list[str]
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are extracting structured scene state from a roleplay kickoff "
|
||||
"scene description. The user provides bot context and a prose "
|
||||
"description of the opening scene; you output JSON conforming to the "
|
||||
"schema. Be concrete: pick a single container, single activity per "
|
||||
"entity, and a sensible initial in-fiction time. Anything not stated "
|
||||
"explicitly should be inferred reasonably from the prose."
|
||||
)
|
||||
|
||||
|
||||
def _build_user_prompt(
|
||||
*,
|
||||
bot_name: str,
|
||||
bot_persona: str,
|
||||
initial_relationship_to_you: str,
|
||||
kickoff_prose: str,
|
||||
you_name: str,
|
||||
) -> str:
|
||||
return (
|
||||
f"BOT NAME: {bot_name}\n"
|
||||
f"BOT PERSONA: {bot_persona}\n"
|
||||
f"INITIAL RELATIONSHIP TO {you_name}: {initial_relationship_to_you}\n"
|
||||
f"YOU NAME: {you_name}\n"
|
||||
f"KICKOFF PROSE:\n{kickoff_prose}"
|
||||
)
|
||||
|
||||
|
||||
def _empty_activity() -> ActivityShape:
|
||||
return ActivityShape(
|
||||
posture="",
|
||||
action_verb="",
|
||||
action_interruptible=True,
|
||||
action_required_attention="low",
|
||||
action_expected_duration="brief",
|
||||
)
|
||||
|
||||
|
||||
def _empty_kickoff_parse() -> KickoffParse:
|
||||
"""Default returned when the classifier can't produce a valid parse.
|
||||
|
||||
The user gets a mostly-empty confirm form they can fill in by hand
|
||||
instead of a 500. ``initial_time_iso`` is left as the current UTC.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return KickoffParse(
|
||||
container_name="",
|
||||
container_type="",
|
||||
container_properties={},
|
||||
you_activity=_empty_activity(),
|
||||
bot_activity=_empty_activity(),
|
||||
initial_time_iso=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
edge_seed_summary="",
|
||||
edge_seed_knowledge_facts=[],
|
||||
)
|
||||
|
||||
|
||||
async def parse_kickoff(
|
||||
client: LLMClient,
|
||||
*,
|
||||
model: str,
|
||||
bot_name: str,
|
||||
bot_persona: str,
|
||||
initial_relationship_to_you: str,
|
||||
kickoff_prose: str,
|
||||
you_name: str,
|
||||
timeout_s: float = 10.0,
|
||||
) -> KickoffParse:
|
||||
"""Parse authored kickoff prose into a structured ``KickoffParse``.
|
||||
|
||||
Falls back to a mostly-empty default if the classifier fails — the
|
||||
confirm-and-edit form is the human-in-the-loop, so a degraded form
|
||||
that the user can fill in is preferable to a 500.
|
||||
"""
|
||||
user_prompt = _build_user_prompt(
|
||||
bot_name=bot_name,
|
||||
bot_persona=bot_persona,
|
||||
initial_relationship_to_you=initial_relationship_to_you,
|
||||
kickoff_prose=kickoff_prose,
|
||||
you_name=you_name,
|
||||
)
|
||||
return await classify(
|
||||
client,
|
||||
model=model,
|
||||
system=_SYSTEM_PROMPT,
|
||||
user=user_prompt,
|
||||
schema=KickoffParse,
|
||||
default=_empty_kickoff_parse(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Per-turn memory writes (T21).
|
||||
|
||||
After ``assistant_turn`` lands, the turn flow records a ``memory_written``
|
||||
event for each present POV owner. Phase 1 single-bot turns only have the
|
||||
host bot as a memory-store owner — ``you`` doesn't have a memory store in
|
||||
v1 — so we write exactly one row per turn.
|
||||
|
||||
Phase 1 simplifications (per plan §11.1, T27 will refine):
|
||||
|
||||
- ``pov_summary`` is the assistant's raw narrative text. T27 rewrites at
|
||||
scene close into per-POV summary form.
|
||||
- ``significance`` defaults to ``1`` (Notable). T22's async significance
|
||||
pass overwrites via a follow-up event.
|
||||
- Witness flags are hard-coded ``[you=1, host=1, guest=0]``. Phase 2 will
|
||||
derive them from ``chat.guest_bot_id`` once a guest can be present.
|
||||
|
||||
T97 (Phase 4): each successful memory write also enqueues an
|
||||
:class:`~chat.services.embedding_worker.EmbeddingJob` on the
|
||||
lifespan-managed embedding worker, so the just-written memory gets a
|
||||
vector indexed out-of-band. The hook is opt-in via the ``app`` kwarg —
|
||||
callers without a FastAPI app handle (e.g. one-off scripts, isolated
|
||||
unit tests) simply don't enqueue, and the backfill script can pick up
|
||||
those rows later.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.services.embedding_worker import EmbeddingJob
|
||||
|
||||
|
||||
def _write_one_memory(
|
||||
conn: Connection,
|
||||
*,
|
||||
owner_id: str,
|
||||
chat_id: str,
|
||||
narrative_text: str,
|
||||
witness_you: int,
|
||||
witness_host: int,
|
||||
witness_guest: int,
|
||||
scene_id: int | None,
|
||||
chat_clock_at: str | None,
|
||||
source: str,
|
||||
significance: int,
|
||||
app=None,
|
||||
) -> tuple[int, int | None]:
|
||||
"""Append a single ``memory_written`` event for ``owner_id`` and return
|
||||
``(event_id, memory_id)`` for the projected row.
|
||||
|
||||
When ``app`` is provided and ``app.state.embedding_worker`` exists,
|
||||
enqueue an :class:`EmbeddingJob` for the freshly-projected memory id
|
||||
(T97). Skipped silently if the worker is absent or the projected row
|
||||
can't be located — the backfill script handles missing-vector rows.
|
||||
"""
|
||||
payload: dict = {
|
||||
"owner_id": owner_id,
|
||||
"chat_id": chat_id,
|
||||
"pov_summary": narrative_text,
|
||||
"witness_you": witness_you,
|
||||
"witness_host": witness_host,
|
||||
"witness_guest": witness_guest,
|
||||
"source": source,
|
||||
"reliability": 1.0,
|
||||
"significance": significance,
|
||||
"pinned": 0,
|
||||
"auto_pinned": 0,
|
||||
}
|
||||
if scene_id is not None:
|
||||
payload["scene_id"] = scene_id
|
||||
if chat_clock_at is not None:
|
||||
payload["chat_clock_at"] = chat_clock_at
|
||||
|
||||
event_id = append_and_apply(conn, kind="memory_written", payload=payload)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM memories "
|
||||
"WHERE owner_id = ? AND chat_id = ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(owner_id, chat_id),
|
||||
).fetchone()
|
||||
memory_id = row[0] if row else None
|
||||
|
||||
# T97: enqueue an embedding job for the just-written memory. The
|
||||
# worker drains the queue out-of-band and emits an
|
||||
# ``embedding_indexed`` event when the vector is ready. ``getattr``
|
||||
# keeps this a no-op for callers without a wired-up app (scripts,
|
||||
# tests) — the backfill script handles those rows.
|
||||
if memory_id is not None and narrative_text and narrative_text.strip():
|
||||
worker = (
|
||||
getattr(app.state, "embedding_worker", None)
|
||||
if app is not None
|
||||
else None
|
||||
)
|
||||
if worker is not None:
|
||||
worker.enqueue(
|
||||
EmbeddingJob(memory_id=memory_id, text=narrative_text)
|
||||
)
|
||||
|
||||
return event_id, memory_id
|
||||
|
||||
|
||||
def record_turn_memory_for_present(
|
||||
conn: Connection,
|
||||
*,
|
||||
chat_id: str,
|
||||
host_bot_id: str,
|
||||
guest_bot_id: str | None,
|
||||
narrative_text: str,
|
||||
scene_id: int | None = None,
|
||||
chat_clock_at: str | None = None,
|
||||
source: str = "direct",
|
||||
significance: int = 1,
|
||||
you_present: bool = True,
|
||||
app=None,
|
||||
) -> dict[str, tuple[int, int | None]]:
|
||||
"""Single entry-point for per-turn memory writes (T84).
|
||||
|
||||
Writes one ``memory_written`` event per present bot witness. Host is
|
||||
always written. Guest is written iff ``guest_bot_id is not None``.
|
||||
|
||||
Witness flags depend on ``you_present``:
|
||||
|
||||
- ``you_present=True`` (default — Phase 1/2/3 you-scenes): the user
|
||||
is a witness. Mask is ``[you=1, host=1, guest=1]`` when a guest is
|
||||
present, ``[you=1, host=1, guest=0]`` otherwise.
|
||||
- ``you_present=False`` (Phase 3 meanwhile scenes): the user is
|
||||
absent. Mask is ``[you=0, host=1, guest=1]`` for both bots. Both
|
||||
``host_bot_id`` and ``guest_bot_id`` are required — a meanwhile
|
||||
scene by definition has both bots, so passing ``guest_bot_id=None``
|
||||
with ``you_present=False`` is a programming error and raises
|
||||
:class:`ValueError`.
|
||||
|
||||
When ``app`` is provided, each per-witness write also enqueues an
|
||||
:class:`EmbeddingJob` on ``app.state.embedding_worker`` (T97).
|
||||
|
||||
Returns a mapping ``{bot_id: (event_id, memory_id)}`` so callers can
|
||||
look up the freshly-projected memory id per owner without re-querying
|
||||
the database.
|
||||
"""
|
||||
if not you_present and guest_bot_id is None:
|
||||
raise ValueError("you_present=False requires guest_bot_id")
|
||||
|
||||
witness_you = 1 if you_present else 0
|
||||
witness_host = 1
|
||||
witness_guest = 1 if guest_bot_id is not None else 0
|
||||
|
||||
result: dict[str, tuple[int, int | None]] = {}
|
||||
result[host_bot_id] = _write_one_memory(
|
||||
conn,
|
||||
owner_id=host_bot_id,
|
||||
chat_id=chat_id,
|
||||
narrative_text=narrative_text,
|
||||
witness_you=witness_you,
|
||||
witness_host=witness_host,
|
||||
witness_guest=witness_guest,
|
||||
scene_id=scene_id,
|
||||
chat_clock_at=chat_clock_at,
|
||||
source=source,
|
||||
significance=significance,
|
||||
app=app,
|
||||
)
|
||||
if guest_bot_id is not None:
|
||||
result[guest_bot_id] = _write_one_memory(
|
||||
conn,
|
||||
owner_id=guest_bot_id,
|
||||
chat_id=chat_id,
|
||||
narrative_text=narrative_text,
|
||||
witness_you=witness_you,
|
||||
witness_host=witness_host,
|
||||
witness_guest=1,
|
||||
scene_id=scene_id,
|
||||
chat_clock_at=chat_clock_at,
|
||||
source=source,
|
||||
significance=significance,
|
||||
app=app,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def record_meanwhile_memory(
|
||||
conn: Connection,
|
||||
*,
|
||||
chat_id: str,
|
||||
host_bot_id: str,
|
||||
guest_bot_id: str,
|
||||
narrative_text: str,
|
||||
scene_id: int | None = None,
|
||||
chat_clock_at: str | None = None,
|
||||
source: str = "direct",
|
||||
significance: int = 1,
|
||||
app=None,
|
||||
) -> dict[str, tuple[int, int | None]]:
|
||||
"""Backward-compat thin wrapper for meanwhile memory writes (T64, T84).
|
||||
|
||||
Equivalent to calling :func:`record_turn_memory_for_present` with
|
||||
``you_present=False``. Kept so existing call sites in
|
||||
:mod:`chat.web.meanwhile` continue to work without churn. New code
|
||||
should prefer the unified entry-point directly.
|
||||
"""
|
||||
return record_turn_memory_for_present(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=host_bot_id,
|
||||
guest_bot_id=guest_bot_id,
|
||||
narrative_text=narrative_text,
|
||||
scene_id=scene_id,
|
||||
chat_clock_at=chat_clock_at,
|
||||
source=source,
|
||||
significance=significance,
|
||||
you_present=False,
|
||||
app=app,
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Multi-entity state-update coordinator (T40).
|
||||
|
||||
Wraps single-pair compute_state_update to run state updates for ALL
|
||||
directed pairs of present entities. With 3 present entities (you, host,
|
||||
guest) that's 6 directed pairs. With 2 present (you, host) it's 2 pairs.
|
||||
|
||||
Calls run sequentially to respect Featherless's 2-connection cap (the
|
||||
client-level semaphore would serialize them anyway, but doing it here
|
||||
keeps the failure surface clean — a hung pair doesn't queue behind
|
||||
itself).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chat.llm.client import LLMClient
|
||||
from chat.services.state_update import StateUpdate, compute_state_update
|
||||
|
||||
|
||||
async def compute_state_updates_for_present(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
present_ids: list[str],
|
||||
present_names: dict[str, str],
|
||||
personas: dict[str, str],
|
||||
prior_edges: dict[tuple[str, str], dict],
|
||||
recent_dialogue: list[dict],
|
||||
timeout_s: float = 30.0,
|
||||
) -> list[tuple[str, str, StateUpdate]]:
|
||||
"""Run compute_state_update for every directed pair (src != tgt) over
|
||||
``present_ids``. Returns list of ``(source_id, target_id, update)``
|
||||
tuples in the natural iteration order over ``present_ids x present_ids``.
|
||||
|
||||
A single failing pair falls back to the schema-default StateUpdate
|
||||
(zero deltas, empty facts) inside ``compute_state_update``; the batch
|
||||
keeps going.
|
||||
"""
|
||||
out: list[tuple[str, str, StateUpdate]] = []
|
||||
for src in present_ids:
|
||||
for tgt in present_ids:
|
||||
if src == tgt:
|
||||
continue
|
||||
edge = prior_edges.get((src, tgt), {})
|
||||
update = await compute_state_update(
|
||||
client,
|
||||
model=classifier_model,
|
||||
source_id=src,
|
||||
target_id=tgt,
|
||||
source_name=present_names.get(src, src),
|
||||
source_persona=personas.get(src, "") or "",
|
||||
target_name=present_names.get(tgt, tgt),
|
||||
prior_affinity=int(edge.get("affinity", 50)),
|
||||
prior_trust=int(edge.get("trust", 50)),
|
||||
prior_summary=edge.get("summary", "") or "",
|
||||
recent_dialogue=recent_dialogue,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
out.append((src, tgt, update))
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["compute_state_updates_for_present"]
|
||||
@@ -0,0 +1,998 @@
|
||||
"""Narrative-prompt assembly with must/should/nice trim tiers.
|
||||
|
||||
Implements Task 18 (Phase 1D). See Requirements §3.2 (token budgets and
|
||||
trim tiers) and §6.3 (speaker prompt assembly order). The function
|
||||
:func:`assemble_narrative_prompt` returns a list of
|
||||
:class:`chat.llm.client.Message` objects ready to feed to
|
||||
``LLMClient.generate``.
|
||||
|
||||
Trim policy when the assembled prompt exceeds the soft target:
|
||||
|
||||
- **MUST-include** (never trimmed): system / speaker identity, the
|
||||
speaker→addressee edge, the activity snapshot for all present
|
||||
entities, the current scene description, and the last 4 turns of
|
||||
dialogue.
|
||||
- **SHOULD-include** (trim when over budget): other edges of the
|
||||
speaker. (Group nodes, active threads, and active events / props are
|
||||
Phase 3 — skipped here.)
|
||||
- **NICE-include** (trim first): retrieved memories beyond top-2,
|
||||
dialogue turns beyond the last 4 (replaced with a one-line elision
|
||||
placeholder), per-POV summary of the previous scene.
|
||||
|
||||
Token counting uses ``tiktoken.get_encoding("cl100k_base")`` per the
|
||||
requirements. Mistral / Llama tokenizers diverge ~5%; we accept the
|
||||
drift.
|
||||
|
||||
The function is intentionally deterministic (no LLM call) so it is
|
||||
testable with synthetic state and so T29's regenerate flow can rebuild
|
||||
prompts without re-running classifiers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlite3 import Connection
|
||||
|
||||
import tiktoken
|
||||
|
||||
from chat.llm.client import Message
|
||||
from chat.state.edges import get_edge, list_edges_for
|
||||
from chat.state.entities import get_bot, get_you
|
||||
from chat.state.events import list_active_events
|
||||
from chat.state.group_node import get_group_node
|
||||
from chat.state.meanwhile import list_pending_meanwhile_digests
|
||||
from chat.state.memory import search_memories
|
||||
from chat.state.threads import list_open_threads
|
||||
from chat.state.world import (
|
||||
active_scene,
|
||||
get_activity,
|
||||
get_chat,
|
||||
get_container,
|
||||
get_scene,
|
||||
)
|
||||
|
||||
|
||||
# Cache the encoder once at import-time. tiktoken's encoder load is
|
||||
# non-trivial (~tens of ms) and the encoding is process-wide stable.
|
||||
_ENCODER = tiktoken.get_encoding("cl100k_base")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _count_tokens(text: str, encoding=_ENCODER) -> int:
|
||||
"""Return the cl100k_base token count for ``text`` (0 for falsy)."""
|
||||
if not text:
|
||||
return 0
|
||||
return len(encoding.encode(text))
|
||||
|
||||
|
||||
def _build_speaker_identity(bot: dict) -> str:
|
||||
"""Render the bot identity block. Skips empty optional fields."""
|
||||
lines = [f"You are {bot['name']}."]
|
||||
if bot.get("persona"):
|
||||
lines.append("")
|
||||
lines.append("PERSONA:")
|
||||
lines.append(bot["persona"])
|
||||
voice_samples = bot.get("voice_samples") or []
|
||||
if voice_samples:
|
||||
lines.append("")
|
||||
lines.append("VOICE REFERENCE:")
|
||||
lines.append("\n---\n".join(voice_samples))
|
||||
traits = bot.get("traits") or []
|
||||
if traits:
|
||||
lines.append("")
|
||||
lines.append(f"TRAITS: {', '.join(traits)}")
|
||||
if bot.get("backstory"):
|
||||
lines.append("")
|
||||
lines.append("BACKSTORY:")
|
||||
lines.append(bot["backstory"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_edge_block(edge: dict | None, addressee_name: str) -> str | None:
|
||||
"""Render the speaker → addressee edge. Returns None when no edge exists."""
|
||||
if edge is None:
|
||||
return None
|
||||
lines = [f"YOUR EDGE TO {addressee_name}:"]
|
||||
lines.append(f"- Affinity: {edge.get('affinity', 50)}/100")
|
||||
lines.append(f"- Trust: {edge.get('trust', 50)}/100")
|
||||
summary = edge.get("summary") or ""
|
||||
if summary:
|
||||
lines.append(f"- Summary: {summary}")
|
||||
knowledge = edge.get("knowledge") or []
|
||||
if knowledge:
|
||||
lines.append(f"- What you know about {addressee_name}:")
|
||||
for fact in knowledge:
|
||||
lines.append(f" * {fact}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_activity_block(activities: list[dict]) -> str | None:
|
||||
"""Render the activity snapshot for all present entities."""
|
||||
rendered: list[str] = []
|
||||
for a in activities:
|
||||
if a is None:
|
||||
continue
|
||||
label = a.get("_display_name") or a.get("entity_id", "?")
|
||||
parts: list[str] = []
|
||||
posture = a.get("posture") or ""
|
||||
if posture:
|
||||
parts.append(posture)
|
||||
action = a.get("action") or {}
|
||||
verb = action.get("verb") if isinstance(action, dict) else None
|
||||
if verb:
|
||||
parts.append(verb)
|
||||
attention = a.get("attention") or ""
|
||||
if attention:
|
||||
parts.append(f"attention: {attention}")
|
||||
holding = a.get("holding") or []
|
||||
if holding:
|
||||
parts.append(f"holding: {', '.join(holding)}")
|
||||
if parts:
|
||||
rendered.append(f"- {label}: " + ", ".join(parts))
|
||||
else:
|
||||
rendered.append(f"- {label}: (no activity recorded)")
|
||||
if not rendered:
|
||||
return None
|
||||
return "ACTIVITIES:\n" + "\n".join(rendered)
|
||||
|
||||
|
||||
def _build_scene_block(chat: dict, container: dict | None, scene: dict | None) -> str | None:
|
||||
"""Render the current-scene block. Always present when chat exists."""
|
||||
lines = ["CURRENT SCENE:"]
|
||||
if container is not None:
|
||||
lines.append(f"- Container: {container['name']} ({container['type']})")
|
||||
chat_time = chat.get("time") if chat else None
|
||||
if chat_time:
|
||||
lines.append(f"- Time: {chat_time}")
|
||||
if scene is not None and scene.get("started_at"):
|
||||
lines.append(f"- Active scene started: {scene['started_at']}")
|
||||
if len(lines) == 1:
|
||||
return None
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_dialogue_turn(turn: dict) -> str:
|
||||
speaker = turn.get("speaker") or "?"
|
||||
text = turn.get("text") or ""
|
||||
return f"{speaker}: {text}"
|
||||
|
||||
|
||||
def _build_dialogue_block(
|
||||
recent: list[dict],
|
||||
earlier_summary: str | None,
|
||||
) -> str | None:
|
||||
"""Render the recent-dialogue block. The ``recent`` list is the
|
||||
*kept* tail of the dialogue (already trimmed to the last-N turns).
|
||||
``earlier_summary``, when non-None, is rendered as the first line as
|
||||
``earlier: <text>`` to flag elided context.
|
||||
"""
|
||||
if not recent and not earlier_summary:
|
||||
return None
|
||||
lines = ["RECENT DIALOGUE:"]
|
||||
if earlier_summary:
|
||||
lines.append(f"earlier: {earlier_summary}")
|
||||
for turn in recent:
|
||||
lines.append(_format_dialogue_turn(turn))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_memories_block(memory_summaries: list[str]) -> str | None:
|
||||
if not memory_summaries:
|
||||
return None
|
||||
lines = ["RELEVANT MEMORIES:"]
|
||||
for m in memory_summaries:
|
||||
lines.append(f"- {m}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_other_edges_block(edges: list[dict]) -> str | None:
|
||||
"""Render edges to entities other than the addressee."""
|
||||
if not edges:
|
||||
return None
|
||||
lines = ["OTHER EDGES:"]
|
||||
for e in edges:
|
||||
target = e.get("_display_name") or e.get("target_id", "?")
|
||||
affinity = e.get("affinity", 50)
|
||||
trust = e.get("trust", 50)
|
||||
lines.append(f"- {target}: affinity {affinity}/100, trust {trust}/100")
|
||||
summary = e.get("summary") or ""
|
||||
if summary:
|
||||
lines.append(f" summary: {summary}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_previous_scene_block(pov_summary: str | None) -> str | None:
|
||||
if not pov_summary:
|
||||
return None
|
||||
return "PREVIOUS SCENE SUMMARY:\n" + pov_summary
|
||||
|
||||
|
||||
def _build_group_node_block(group_node: dict | None) -> str | None:
|
||||
"""Render the group-node summary + dynamic as a SHOULD-tier block.
|
||||
|
||||
Used only in 3-entity scenes (you + host + guest). Returns None when
|
||||
the row is missing or both summary and dynamic are empty.
|
||||
"""
|
||||
if not group_node:
|
||||
return None
|
||||
summary = (group_node.get("summary") or "").strip()
|
||||
dynamic = (group_node.get("dynamic") or "").strip()
|
||||
if not summary and not dynamic:
|
||||
return None
|
||||
lines = ["Group dynamic:"]
|
||||
if summary:
|
||||
lines.append(f"- Summary: {summary}")
|
||||
if dynamic:
|
||||
lines.append(f"- Dynamic: {dynamic}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _props_excerpt(props: dict | None, limit: int = 80) -> str:
|
||||
"""Return a one-line excerpt of an event's ``props`` dict.
|
||||
|
||||
Renders ``key=value`` pairs separated by ", " (deterministic by dict
|
||||
insertion order) and truncates to ~``limit`` characters with a
|
||||
trailing ellipsis. Returns empty string for falsy/empty props so the
|
||||
caller can omit the line entirely.
|
||||
"""
|
||||
if not props:
|
||||
return ""
|
||||
pieces: list[str] = []
|
||||
for k, v in props.items():
|
||||
pieces.append(f"{k}={v}")
|
||||
rendered = ", ".join(pieces)
|
||||
if len(rendered) > limit:
|
||||
# Reserve 1 char for the ellipsis so the total never exceeds limit.
|
||||
rendered = rendered[: max(0, limit - 1)] + "…"
|
||||
return rendered
|
||||
|
||||
|
||||
def _build_active_events_block(events: list[dict]) -> str | None:
|
||||
"""Render the ``Active events:`` block for Phase 3 Task 60.
|
||||
|
||||
One bullet per event. The sub-label depends on status:
|
||||
- ``planned`` → ``(planned for {planned_for})``
|
||||
- ``active`` → ``(active, started_at={started_at})``
|
||||
A second indented line carries a one-line excerpt of the event's
|
||||
``props`` (truncated ~80 chars) when non-empty. Returns ``None`` when
|
||||
there are no active events so the caller can omit the entire block.
|
||||
"""
|
||||
if not events:
|
||||
return None
|
||||
lines = ["Active events:"]
|
||||
for ev in events:
|
||||
kind = ev.get("kind") or "?"
|
||||
status = ev.get("status") or ""
|
||||
if status == "active":
|
||||
started = ev.get("started_at") or ""
|
||||
lines.append(f"- {kind} (active, started_at={started})")
|
||||
else:
|
||||
planned = ev.get("planned_for") or ""
|
||||
lines.append(f"- {kind} (planned for {planned})")
|
||||
excerpt = _props_excerpt(ev.get("props"))
|
||||
if excerpt:
|
||||
lines.append(f" {excerpt}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_meanwhile_digests_block(digests: list[dict]) -> str | None:
|
||||
"""Render the ``Meanwhile while you were away:`` block for T65.
|
||||
|
||||
One bullet per pending digest, formatted as ``- {summary}`` with the
|
||||
summary truncated to ~200 characters per spec. Returns ``None`` when
|
||||
there are no pending digests so the caller can omit the entire block.
|
||||
|
||||
The block is rendered ONLY when the prompt is for a regular you-scene
|
||||
(``present_set_kind != "host_guest"``); the caller filters before
|
||||
constructing the digests list.
|
||||
"""
|
||||
if not digests:
|
||||
return None
|
||||
lines = ["Meanwhile while you were away:"]
|
||||
for d in digests:
|
||||
summary = d.get("summary") or ""
|
||||
if len(summary) > 200:
|
||||
summary = summary[:199] + "…"
|
||||
if summary:
|
||||
lines.append(f"- {summary}")
|
||||
if len(lines) == 1:
|
||||
return None
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_open_threads_block(threads: list[dict]) -> str | None:
|
||||
"""Render the ``Open threads:`` block for Phase 3 Task 60.
|
||||
|
||||
One bullet per thread, formatted as ``- {title}: {summary}`` with the
|
||||
summary truncated to ~120 characters. Returns ``None`` when there are
|
||||
no open threads so the caller can omit the entire block.
|
||||
"""
|
||||
if not threads:
|
||||
return None
|
||||
lines = ["Open threads:"]
|
||||
for t in threads:
|
||||
title = t.get("title") or "?"
|
||||
summary = t.get("summary") or ""
|
||||
if len(summary) > 120:
|
||||
summary = summary[:119] + "…"
|
||||
if summary:
|
||||
lines.append(f"- {title}: {summary}")
|
||||
else:
|
||||
lines.append(f"- {title}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _closing_instruction(speaker_name: str, addressee_name: str) -> str:
|
||||
return (
|
||||
f"Continue the scene as {speaker_name}, in their voice, responding "
|
||||
"naturally. Use *asterisks* for actions and quotes for dialogue. "
|
||||
f"Stay in character. Do not narrate {addressee_name}'s actions or "
|
||||
"thoughts. "
|
||||
"Keep your response to a single beat — one or two short paragraphs "
|
||||
"at most. Don't monologue; leave room for the other person to react."
|
||||
)
|
||||
|
||||
|
||||
def _join_blocks(blocks: list[str | None]) -> str:
|
||||
"""Join non-empty blocks with double newlines."""
|
||||
return "\n\n".join(b for b in blocks if b)
|
||||
|
||||
|
||||
def _earlier_summary_placeholder(elided_count: int) -> str:
|
||||
"""Phase 1 placeholder. Real summarization is a downstream concern."""
|
||||
plural = "turn" if elided_count == 1 else "turns"
|
||||
return f"{elided_count} earlier {plural} elided for brevity"
|
||||
|
||||
|
||||
def _resolve_previous_scene_summary(
|
||||
conn: Connection, chat_id: str, speaker_bot_id: str
|
||||
) -> str | None:
|
||||
"""Return ``pov_summary`` of the most recent ended scene, owned by
|
||||
the speaker. None if no closed scene exists or no matching memory.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT id FROM scenes WHERE chat_id = ? AND ended_at IS NOT NULL "
|
||||
"ORDER BY ended_at DESC LIMIT 1",
|
||||
(chat_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
scene_id = row[0]
|
||||
mem = conn.execute(
|
||||
"SELECT pov_summary FROM memories WHERE scene_id = ? AND owner_id = ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(scene_id, speaker_bot_id),
|
||||
).fetchone()
|
||||
if not mem:
|
||||
return None
|
||||
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.
|
||||
|
||||
When ``host_bot_id`` is ``None`` (degenerate case from a half-seeded
|
||||
chat or Phase-1 path), the speaker is treated as the host so the
|
||||
query falls back to the host POV mask rather than silently masking
|
||||
the speaker's own memories as a guest.
|
||||
"""
|
||||
if host_bot_id is None or speaker_bot_id == host_bot_id:
|
||||
return "host"
|
||||
return "guest"
|
||||
|
||||
|
||||
def _resolve_addressee(
|
||||
conn: Connection, addressee: str, you: dict | None
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(addressee_id, addressee_display_name)``.
|
||||
|
||||
The function is permissive: ``addressee="you"`` resolves to the
|
||||
you-entity (display name is its authored name, falling back to
|
||||
"you" if no entity exists yet). Other ids resolve as bot ids.
|
||||
"""
|
||||
if addressee == "you":
|
||||
name = (you or {}).get("name") or "you"
|
||||
return "you", name
|
||||
bot = get_bot(conn, addressee)
|
||||
if bot is not None:
|
||||
return addressee, bot["name"]
|
||||
return addressee, addressee
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assemble_narrative_prompt(
|
||||
conn: Connection,
|
||||
*,
|
||||
chat_id: str,
|
||||
speaker_bot_id: str,
|
||||
addressee: str = "you",
|
||||
user_turn_prose: str | None = None,
|
||||
recent_dialogue: list[dict] | None = None,
|
||||
retrieved_memory_summaries: list[str] | None = None,
|
||||
budget_soft: int = 6000,
|
||||
budget_hard: int = 8000,
|
||||
encoding_name: str = "cl100k_base",
|
||||
guest_id: str | None = None,
|
||||
present_set_kind: str = "you_host",
|
||||
) -> list[Message]:
|
||||
"""Assemble the narrative prompt for ``speaker_bot_id`` to respond.
|
||||
|
||||
Returns a list of :class:`Message` objects: one ``system`` message
|
||||
carrying the assembled context, optionally followed by a single
|
||||
``user`` message containing ``user_turn_prose`` (when provided).
|
||||
|
||||
Trimming proceeds in tiers (NICE → SHOULD) once the total token
|
||||
count exceeds ``budget_soft``; the function refuses to exceed
|
||||
``budget_hard``. If the MUST-include block alone is already over
|
||||
``budget_hard``, :class:`ValueError` is raised — the caller should
|
||||
surface the failure rather than ship a malformed prompt.
|
||||
"""
|
||||
encoding = (
|
||||
_ENCODER if encoding_name == "cl100k_base"
|
||||
else tiktoken.get_encoding(encoding_name)
|
||||
)
|
||||
|
||||
bot = get_bot(conn, speaker_bot_id)
|
||||
if bot is None:
|
||||
raise ValueError(f"speaker_bot_id {speaker_bot_id!r} not found")
|
||||
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise ValueError(f"chat_id {chat_id!r} not found")
|
||||
|
||||
# Auto-detect guest from chat state when caller didn't pass one.
|
||||
# Phase 1 chats have ``guest_bot_id is None``; the auto-detect is a
|
||||
# no-op there and the function behaves exactly as before.
|
||||
if guest_id is None:
|
||||
guest_id = chat.get("guest_bot_id")
|
||||
# A speaker addressing themself as guest doesn't add a third party.
|
||||
if guest_id is not None and guest_id == speaker_bot_id:
|
||||
guest_id = None
|
||||
|
||||
you = get_you(conn)
|
||||
addressee_id, addressee_name = _resolve_addressee(conn, addressee, you)
|
||||
|
||||
# T64: meanwhile-mode marker. When present_set_kind == "host_guest"
|
||||
# the user ("you") is NOT a witness in the scene — bots speak only to
|
||||
# each other. The local flag below is consumed by the activity-block
|
||||
# builder (skip the "you" bullet entirely) and the other-edges filter
|
||||
# (drop any speaker -> "you" rendering). Default "you_host" preserves
|
||||
# the Phase 1/2/3 behavior for normal turns.
|
||||
_exclude_you = present_set_kind == "host_guest"
|
||||
|
||||
# ---- Build all components as text strings ------------------------------
|
||||
|
||||
speaker_identity = _build_speaker_identity(bot)
|
||||
|
||||
edge_to_addressee = _build_edge_block(
|
||||
get_edge(conn, speaker_bot_id, addressee_id),
|
||||
addressee_name,
|
||||
)
|
||||
|
||||
# Activity for present entities — single ACTIVITIES: block with up
|
||||
# to three bullets (you, speaker, guest). The block itself is
|
||||
# MUST-tier and survives all trims, but bullet-level trim drops
|
||||
# bullets in the order guest -> you, keeping the speaker bullet
|
||||
# (the speaker's own current activity is the load-bearing slice).
|
||||
#
|
||||
# T71.2 chose Option B from the polish plan: pre-truncate the
|
||||
# bullets list at trim time before _build_activity_block runs,
|
||||
# rather than introducing a granular tier mode in the trim
|
||||
# machinery. The single-block render avoids the dual-ACTIVITIES:
|
||||
# header that Phase 2 T43 introduced (read by some LLMs as a
|
||||
# duplicate-section bug).
|
||||
you_activity: dict | None = None
|
||||
if not _exclude_you:
|
||||
you_act = get_activity(conn, "you")
|
||||
if you_act is not None:
|
||||
you_activity = dict(you_act)
|
||||
you_activity["_display_name"] = (you or {}).get("name") or "you"
|
||||
|
||||
speaker_activity: dict | None = None
|
||||
bot_act = get_activity(conn, speaker_bot_id)
|
||||
if bot_act is not None:
|
||||
speaker_activity = dict(bot_act)
|
||||
speaker_activity["_display_name"] = bot["name"]
|
||||
|
||||
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_activity = dict(guest_act)
|
||||
guest_bot = get_bot(conn, guest_id)
|
||||
guest_activity["_display_name"] = (
|
||||
guest_bot["name"] if guest_bot else guest_id
|
||||
)
|
||||
|
||||
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
|
||||
# you + host + guest (per the Task 43 spec).
|
||||
group_node_block: str | None = None
|
||||
if guest_id is not None:
|
||||
gn = get_group_node(conn, chat_id)
|
||||
if gn is not None:
|
||||
members = set(gn.get("members") or [])
|
||||
host_id = chat.get("host_bot_id")
|
||||
required = {"you"}
|
||||
if host_id is not None:
|
||||
required.add(host_id)
|
||||
required.add(guest_id)
|
||||
if required.issubset(members):
|
||||
group_node_block = _build_group_node_block(gn)
|
||||
|
||||
# SHOULD-tier active events + open threads (Phase 3 / Task 60).
|
||||
# Auto-detect both from the chat_id per the Phase 2 T43 precedent —
|
||||
# no new function parameter. Both blocks are omit-when-empty so a
|
||||
# Phase 1 chat with no events/threads renders identically to before.
|
||||
active_events_block = _build_active_events_block(
|
||||
list_active_events(conn, chat_id)
|
||||
)
|
||||
open_threads_block = _build_open_threads_block(
|
||||
list_open_threads(conn, chat_id)
|
||||
)
|
||||
|
||||
# SHOULD-tier meanwhile digest (Phase 3 / Task 65). Only surfaces
|
||||
# when the prompt is for a regular you-scene (NOT for a meanwhile
|
||||
# child scene — the absent player is the audience, not the bots
|
||||
# currently mid-meanwhile). We distinguish via the chat's active
|
||||
# scene's ``present_set_kind``; a missing scene row defaults to a
|
||||
# you-scene render so the block can still surface during the
|
||||
# post-meanwhile-close transition before the next scene opens.
|
||||
#
|
||||
# Consumption is INTENTIONALLY left to the post_turn flow (a
|
||||
# ``consume_pending_meanwhile_digests`` helper, see below) rather
|
||||
# than emitted inline here. Surfacing has no side-effects; the
|
||||
# caller appends ``meanwhile_digest_consumed`` after the response
|
||||
# streams. This keeps prompt assembly pure and deterministic — the
|
||||
# Phase 1 invariant T29's regenerate flow relies on.
|
||||
meanwhile_digests_block: str | None = None
|
||||
active_scene_kind: str | None = None
|
||||
if chat.get("active_scene_id"):
|
||||
active_sc = get_scene(conn, chat["active_scene_id"])
|
||||
if active_sc:
|
||||
active_scene_kind = active_sc.get("present_set_kind")
|
||||
if active_scene_kind != "host_guest":
|
||||
pending_digests = list_pending_meanwhile_digests(conn, chat_id)
|
||||
meanwhile_digests_block = _build_meanwhile_digests_block(
|
||||
pending_digests
|
||||
)
|
||||
|
||||
container = None
|
||||
if chat.get("active_scene_id"):
|
||||
scene = get_scene(conn, chat["active_scene_id"])
|
||||
if scene and scene.get("container_id"):
|
||||
container = get_container(conn, scene["container_id"])
|
||||
else:
|
||||
scene = active_scene(conn, chat_id)
|
||||
if container is None and scene and scene.get("container_id"):
|
||||
container = get_container(conn, scene["container_id"])
|
||||
scene_block = _build_scene_block(chat, container, scene)
|
||||
|
||||
# Other edges: speaker → non-addressee. In meanwhile mode (host_guest)
|
||||
# the speaker -> "you" edge is filtered out as well — "you" isn't
|
||||
# part of the present set, so surfacing the speaker's relationship
|
||||
# to the user inside a private bot-to-bot beat would leak context
|
||||
# the bots aren't supposed to be drawing on right now.
|
||||
all_outgoing = list_edges_for(conn, speaker_bot_id)
|
||||
other_edges_raw = [e for e in all_outgoing if e.get("target_id") != addressee_id]
|
||||
if _exclude_you:
|
||||
other_edges_raw = [e for e in other_edges_raw if e.get("target_id") != "you"]
|
||||
for e in other_edges_raw:
|
||||
tid = e.get("target_id")
|
||||
if tid == "you":
|
||||
e["_display_name"] = (you or {}).get("name") or "you"
|
||||
else:
|
||||
tb = get_bot(conn, tid) if tid else None
|
||||
e["_display_name"] = tb["name"] if tb else (tid or "?")
|
||||
other_edges_block = _build_other_edges_block(other_edges_raw)
|
||||
|
||||
# Memories: caller override wins; otherwise FTS5 search keyed on the
|
||||
# scene's container/posture as a coarse query proxy.
|
||||
if retrieved_memory_summaries is not None:
|
||||
memory_summaries = list(retrieved_memory_summaries)
|
||||
else:
|
||||
query = (container or {}).get("name") or chat.get("narrative_anchor") or ""
|
||||
memory_summaries = []
|
||||
if query:
|
||||
try:
|
||||
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 = []
|
||||
|
||||
# Dialogue: caller override only (no event_log read in Phase 1).
|
||||
dialogue_full = list(recent_dialogue or [])
|
||||
|
||||
previous_scene_summary = _resolve_previous_scene_summary(
|
||||
conn, chat_id, speaker_bot_id
|
||||
)
|
||||
|
||||
closing = _closing_instruction(bot["name"], addressee_name)
|
||||
|
||||
# ---- Build the MUST core ----------------------------------------------
|
||||
|
||||
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,
|
||||
must_activity_block,
|
||||
must_dialogue_block,
|
||||
closing,
|
||||
]
|
||||
must_text = _join_blocks(must_blocks)
|
||||
must_tokens = _count_tokens(must_text, encoding)
|
||||
if must_tokens > budget_hard:
|
||||
raise ValueError(
|
||||
f"MUST-include block ({must_tokens} tokens) exceeds budget_hard "
|
||||
f"({budget_hard}). Cannot assemble prompt."
|
||||
)
|
||||
|
||||
# ---- Stage SHOULD additions, then NICE additions -----------------------
|
||||
|
||||
# We carry a running "components" list and rebuild the body as we go
|
||||
# so token accounting reflects join-overhead. Order in the final
|
||||
# prompt follows §6.3: identity → edge → other edges → scene →
|
||||
# activities → previous scene summary → memories → dialogue → close.
|
||||
|
||||
def assemble(
|
||||
*,
|
||||
include_other_edges: bool,
|
||||
include_previous_scene: bool,
|
||||
include_memories_top_k: int,
|
||||
dialogue_keep: int,
|
||||
include_you_activity: bool = True,
|
||||
include_guest_activity: bool = True,
|
||||
include_group_node: bool = True,
|
||||
include_active_events: bool = True,
|
||||
include_open_threads: bool = True,
|
||||
include_meanwhile_digests: bool = True,
|
||||
) -> tuple[str, int, list[dict]]:
|
||||
# dialogue: keep the last `dialogue_keep` turns verbatim; older
|
||||
# turns become an "earlier:" placeholder line.
|
||||
kept_dialogue = (
|
||||
dialogue_full[-dialogue_keep:] if dialogue_keep > 0 else []
|
||||
)
|
||||
elided = max(0, len(dialogue_full) - len(kept_dialogue))
|
||||
earlier_summary = (
|
||||
_earlier_summary_placeholder(elided) if elided > 0 else None
|
||||
)
|
||||
dialogue_block = _build_dialogue_block(kept_dialogue, earlier_summary)
|
||||
|
||||
memories_subset = memory_summaries[:include_memories_top_k]
|
||||
memories_block = _build_memories_block(memories_subset)
|
||||
|
||||
prev_block = (
|
||||
_build_previous_scene_block(previous_scene_summary)
|
||||
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,
|
||||
group_node_block if include_group_node else None,
|
||||
active_events_block if include_active_events else None,
|
||||
open_threads_block if include_open_threads else None,
|
||||
(
|
||||
meanwhile_digests_block
|
||||
if include_meanwhile_digests else None
|
||||
),
|
||||
prev_block,
|
||||
memories_block,
|
||||
dialogue_block,
|
||||
closing,
|
||||
])
|
||||
return body, _count_tokens(body, encoding), kept_dialogue
|
||||
|
||||
# Start with the MUST baseline: last 4 turns of dialogue, no
|
||||
# SHOULD/NICE extras.
|
||||
baseline_keep = min(4, len(dialogue_full))
|
||||
|
||||
# Try the most generous configuration first; trim greedily.
|
||||
nice_dialogue_keep = len(dialogue_full) # all turns, no elision
|
||||
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_you_activity = you_activity is not None
|
||||
include_guest_activity = guest_activity is not None
|
||||
include_group_node = group_node_block is not None
|
||||
include_active_events = active_events_block is not None
|
||||
include_open_threads = open_threads_block is not None
|
||||
include_meanwhile_digests = meanwhile_digests_block is not None
|
||||
|
||||
def _build(*, prev: bool, mem_k: int, dlg: int, other: bool,
|
||||
you_act: bool, guest_act: bool, group: bool,
|
||||
events: bool, threads: bool,
|
||||
digests: bool) -> tuple[str, int]:
|
||||
body, total, _ = assemble(
|
||||
include_other_edges=other,
|
||||
include_previous_scene=prev,
|
||||
include_memories_top_k=mem_k,
|
||||
dialogue_keep=dlg,
|
||||
include_you_activity=you_act,
|
||||
include_guest_activity=guest_act,
|
||||
include_group_node=group,
|
||||
include_active_events=events,
|
||||
include_open_threads=threads,
|
||||
include_meanwhile_digests=digests,
|
||||
)
|
||||
return body, total
|
||||
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
# If under soft, we're done.
|
||||
if total <= budget_soft:
|
||||
return _emit(body, user_turn_prose)
|
||||
|
||||
# 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, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
if total <= budget_soft:
|
||||
return _emit(body, user_turn_prose)
|
||||
|
||||
if nice_memories_k > 2:
|
||||
nice_memories_k = 2
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
if total <= budget_soft:
|
||||
return _emit(body, user_turn_prose)
|
||||
|
||||
if nice_dialogue_keep > baseline_keep:
|
||||
nice_dialogue_keep = baseline_keep
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
if total <= budget_soft:
|
||||
return _emit(body, user_turn_prose)
|
||||
|
||||
# Drop more NICE until we're under hard: memories all the way to 0.
|
||||
while nice_memories_k > 0 and total > budget_hard:
|
||||
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, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
# Drop SHOULD-tier extras in order:
|
||||
# 1. meanwhile digests block (T65: SHOULD-tier; refers to a past
|
||||
# meanwhile scene — least critical to the speaker's immediate
|
||||
# voice, so dropped first among SHOULD)
|
||||
# 2. open threads block (T60: SHOULD-tier; least critical to the
|
||||
# speaker's immediate voice — drop next among SHOULD)
|
||||
# 3. active events block (T60: same tier, drops next)
|
||||
# 4. guest activity bullet (T71.2: bullet-level trim within the
|
||||
# single ACTIVITIES: block — guest goes first per Task 43 spec)
|
||||
# 5. group node block
|
||||
# 6. you activity bullet (still SHOULD-tier; speaker bullet is the
|
||||
# MUST-tier floor and never dropped)
|
||||
# 7. other edges
|
||||
if include_meanwhile_digests and total > budget_hard:
|
||||
include_meanwhile_digests = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_open_threads and total > budget_hard:
|
||||
include_open_threads = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_active_events and total > budget_hard:
|
||||
include_active_events = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
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, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_group_node and total > budget_hard:
|
||||
include_group_node = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_you_activity and total > budget_hard:
|
||||
include_you_activity = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if include_other and total > budget_hard:
|
||||
include_other = False
|
||||
body, total = _build(
|
||||
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
|
||||
other=include_other, you_act=include_you_activity,
|
||||
guest_act=include_guest_activity, group=include_group_node,
|
||||
events=include_active_events, threads=include_open_threads,
|
||||
digests=include_meanwhile_digests,
|
||||
)
|
||||
|
||||
if total > budget_hard:
|
||||
# We've stripped everything optional and we still overflow.
|
||||
# MUST alone fits (we checked at the top), so this means our
|
||||
# last-4 dialogue + must blocks together exceed hard. Fall back
|
||||
# to the bare MUST core.
|
||||
body = must_text
|
||||
total = must_tokens
|
||||
if total > budget_hard:
|
||||
raise ValueError(
|
||||
f"Prompt cannot fit budget_hard={budget_hard}; MUST core "
|
||||
f"is {total} tokens"
|
||||
)
|
||||
|
||||
return _emit(body, user_turn_prose)
|
||||
|
||||
|
||||
def _emit(system_body: str, user_turn_prose: str | None) -> list[Message]:
|
||||
msgs: list[Message] = [Message(role="system", content=system_body)]
|
||||
if user_turn_prose is not None:
|
||||
msgs.append(Message(role="user", content=user_turn_prose))
|
||||
return msgs
|
||||
|
||||
|
||||
def consume_pending_meanwhile_digests(conn: Connection, chat_id: str) -> int:
|
||||
"""Mark every pending meanwhile digest for ``chat_id`` as consumed.
|
||||
|
||||
Called by the post_turn flow AFTER the assistant response streams,
|
||||
once for the first you-turn that surfaced any pending digests. We
|
||||
keep this side-effect out of :func:`assemble_narrative_prompt` so
|
||||
prompt assembly stays pure (T29's regenerate flow rebuilds prompts
|
||||
repeatedly without state mutation).
|
||||
|
||||
Returns the number of digests consumed (0 when none were pending).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
|
||||
pending = list_pending_meanwhile_digests(conn, chat_id)
|
||||
if not pending:
|
||||
return 0
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for d in pending:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="meanwhile_digest_consumed",
|
||||
payload={
|
||||
"digest_id": d["id"],
|
||||
"consumed_at": now,
|
||||
},
|
||||
)
|
||||
return len(pending)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"assemble_narrative_prompt",
|
||||
"consume_pending_meanwhile_digests",
|
||||
]
|
||||
@@ -0,0 +1,771 @@
|
||||
"""Regenerate flow (T29).
|
||||
|
||||
The user clicks "Regenerate" on the latest ``assistant_turn``. The UI
|
||||
puts the prior ``user_turn`` into inline edit mode and submits to
|
||||
:func:`regenerate_assistant_turn` either:
|
||||
|
||||
- with **no edit** — we re-run the narrative against the original user
|
||||
prose and append a fresh ``assistant_turn`` superseding the old one;
|
||||
- with **edited prose** — we additionally append a ``user_turn_edit``
|
||||
event capturing the new prose, mark the original ``user_turn`` as
|
||||
superseded by the edit, then run the narrative against the edited
|
||||
prose.
|
||||
|
||||
Per Requirements §10.2 superseded events are *kept in the log* — the
|
||||
display layer hides them. This is what makes rewinding to before a
|
||||
regenerate cheap: we just clear ``superseded_by`` on the old row.
|
||||
|
||||
The supersede update is one of the rare "direct DB write" exceptions
|
||||
documented in the plan: we manipulate metadata fields on the canonical
|
||||
event_log row itself rather than projecting through a handler.
|
||||
|
||||
Phase 1 simplifications (per the plan's "bound it" guidance):
|
||||
|
||||
- Significance pass is *not* re-run on regenerate. The original score
|
||||
remains attached to the prior memory. The state-update pass *is* re-run
|
||||
so affinity/trust/knowledge reflect the new output.
|
||||
- The route does not broadcast a fresh ``turn_html`` SSE event; T34
|
||||
polishes UI swaps. The user refreshes the page to see the new turn.
|
||||
*(T73.1 closed this gap — see Phase 2.5 changes below.)*
|
||||
|
||||
Phase 2 changes (T44):
|
||||
|
||||
- Multi-entity prompt assembly: ``guest_id`` is forwarded to the
|
||||
prompt assembler so the regenerated narrative sees the same
|
||||
guest-aware context the original turn did.
|
||||
- Multi-witness memory write: ``record_turn_memory_for_present`` fans
|
||||
out one ``memory_written`` event per witness when a guest is present.
|
||||
- Multi-pair state-update: ``compute_state_updates_for_present`` emits
|
||||
one ``edge_update`` per directed pair across present entities. With
|
||||
three present that's six edges instead of two.
|
||||
- Interjection regeneration is **deferred to Phase 2.5**. Regenerate
|
||||
only re-streams the addressee turn for v2; ``detect_interjection``
|
||||
is not invoked here. If the prior turn fired an interjection it
|
||||
remains attached to the original assistant_turn (which is superseded
|
||||
alongside the regenerated turn) — Phase 2.5 will revisit.
|
||||
|
||||
Phase 2.5 changes:
|
||||
|
||||
- T73.1: After the new ``assistant_turn`` lands we publish a
|
||||
``turn_html_replace`` SSE event carrying the rendered HTML for the
|
||||
regenerated turn plus the original assistant_turn's event_id as
|
||||
``supersedes_id`` so connected tabs can swap the prior DOM node
|
||||
in-place. We use a NEW event name (rather than re-using ``turn_html``)
|
||||
because the existing HTMX ``sse-swap="turn_html"`` consumer expects a
|
||||
raw-HTML body and an *append* semantic; ``turn_html_replace`` is a
|
||||
JSON payload (sse.py auto-serialises when extra keys accompany
|
||||
``data``) so the front-end JS can read ``supersedes_id`` and replace
|
||||
the right node.
|
||||
- T73.2: Interjection regeneration. When the original assistant_turn
|
||||
group included an interjection beat we redo BOTH the primary and the
|
||||
interjection — re-running ``detect_interjection`` against the new
|
||||
primary text. If the classifier returns False this time we supersede
|
||||
the original interjection without appending a replacement.
|
||||
- T73.3: The defensive degrade-to-1:1 for stale ``guest_bot_id``
|
||||
references was removed — Phase 2 T47 fixed the root cause (resets
|
||||
clear the reference) so the guard is dead code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.services.event_lifecycle import detect_event_transitions
|
||||
from chat.services.event_promotion import promote_completed_event
|
||||
from chat.services.interjection import detect_interjection
|
||||
from chat.services.memory_write import record_turn_memory_for_present
|
||||
from chat.services.multi_state_update import compute_state_updates_for_present
|
||||
from chat.services.prompt import assemble_narrative_prompt
|
||||
from chat.services.turn_common import (
|
||||
gather_prior_edges,
|
||||
read_recent_dialogue,
|
||||
)
|
||||
from chat.state.edges import get_edge
|
||||
from chat.state.entities import get_bot, get_you
|
||||
from chat.state.events import list_active_events
|
||||
from chat.state.world import active_scene, get_chat
|
||||
from chat.web.pubsub import publish
|
||||
from chat.web.render import render_turn_html
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def regenerate_assistant_turn(
|
||||
conn: Connection,
|
||||
client,
|
||||
*,
|
||||
settings: Settings,
|
||||
chat_id: str,
|
||||
original_assistant_event_id: int,
|
||||
edited_user_prose: str | None = None,
|
||||
app=None,
|
||||
) -> str:
|
||||
"""Regenerate the assistant turn linked to ``original_assistant_event_id``.
|
||||
|
||||
When ``edited_user_prose`` is provided the original user_turn is also
|
||||
superseded by a fresh ``user_turn_edit`` event capturing the new
|
||||
prose. Returns the new assistant text.
|
||||
|
||||
Raises :class:`ValueError` when the chat or the assistant_turn event
|
||||
cannot be found — the FastAPI route translates this to 404.
|
||||
|
||||
.. note::
|
||||
**Lifecycle-rollback limitation (T83.4, Phase 4 follow-up).**
|
||||
When the superseded turn already produced lifecycle transitions
|
||||
(``event_started`` / ``event_completed`` / ``event_cancelled``),
|
||||
this function does NOT roll those rows back before re-running
|
||||
``detect_event_transitions`` against the regenerated text. A
|
||||
regenerate-after-completion can therefore double-emit promotion
|
||||
artifacts if the new text re-completes the same event. Phase 3.5
|
||||
only documents the gap and emits a WARNING log naming the
|
||||
affected event_log ids; the actual undo pass is invasive
|
||||
(re-projection / inverse-handler dispatch) and is deferred to
|
||||
Phase 4. See the ``# T83.4`` block below for the warning emit.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise ValueError("chat not found")
|
||||
host_bot_id = chat["host_bot_id"]
|
||||
host_bot = get_bot(conn, host_bot_id) or {
|
||||
"id": host_bot_id,
|
||||
"name": "bot",
|
||||
"persona": "",
|
||||
}
|
||||
|
||||
# Phase 2: surface the guest (if any) so the prompt assembler and
|
||||
# downstream multi-entity passes see the same shape post_turn does.
|
||||
# Phase 2 T47 made bot_reset cascade-clear ``chat.guest_bot_id`` when
|
||||
# the referenced bot is purged (verified by tests/test_reset.py), so
|
||||
# we trust the column here: it's either a valid bot id or NULL.
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
guest_bot: dict | None = (
|
||||
get_bot(conn, guest_bot_id) if guest_bot_id is not None else None
|
||||
)
|
||||
|
||||
# 1. Locate the original assistant_turn event.
|
||||
row = conn.execute(
|
||||
"SELECT payload_json FROM event_log "
|
||||
"WHERE id = ? AND kind = 'assistant_turn'",
|
||||
(original_assistant_event_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("assistant_turn event not found")
|
||||
original_assistant_payload = json.loads(row[0])
|
||||
original_user_turn_id = original_assistant_payload.get("user_turn_id")
|
||||
|
||||
# T83.4: scan for downstream lifecycle transitions emitted by the
|
||||
# superseded turn — they're not being rolled back (see method
|
||||
# docstring). Heuristic: any ``event_started`` / ``event_completed``
|
||||
# / ``event_cancelled`` event_log row with id strictly greater than
|
||||
# the original assistant_turn's id was emitted as part of (or after)
|
||||
# that turn's processing. Lifecycle events don't carry ``chat_id``
|
||||
# in their payload (their payload references an ``event_id`` FK to
|
||||
# the ``events`` table, which holds chat_id), so we join through
|
||||
# ``events`` to scope to this chat.
|
||||
#
|
||||
# A WARNING log surfaces the affected event ids so operators can
|
||||
# spot double-emit cases until the Phase 4 rollback pass lands.
|
||||
unrolled_lifecycle = conn.execute(
|
||||
"SELECT el.id, el.kind FROM event_log AS el "
|
||||
"JOIN events AS ev "
|
||||
" ON ev.event_id = json_extract(el.payload_json, '$.event_id') "
|
||||
"WHERE el.kind IN ("
|
||||
" 'event_started', 'event_completed', 'event_cancelled'"
|
||||
" ) "
|
||||
" AND ev.chat_id = ? "
|
||||
" AND el.id > ? "
|
||||
"ORDER BY el.id ASC",
|
||||
(chat_id, original_assistant_event_id),
|
||||
).fetchall()
|
||||
if unrolled_lifecycle:
|
||||
# T90.2: phrased as "at-or-after turn <id>" rather than "from
|
||||
# superseded turn" because regenerating an OLDER turn lists
|
||||
# intervening-turn transitions that legitimately stand on their
|
||||
# own — those weren't authored by the superseded turn itself.
|
||||
_log.warning(
|
||||
"regenerate_assistant_turn: %d lifecycle transition(s) "
|
||||
"at-or-after turn %s are NOT being rolled back (Phase 4 "
|
||||
"follow-up). Affected event ids: %s",
|
||||
len(unrolled_lifecycle),
|
||||
original_assistant_event_id,
|
||||
[r[0] for r in unrolled_lifecycle],
|
||||
)
|
||||
|
||||
# 1a. Look up any sibling interjection beat in the same turn group
|
||||
# (T73.2). The original group is (primary + optional interjection),
|
||||
# both pinned to the same ``user_turn_id``. The interjection has a
|
||||
# populated ``interjection_of`` field in its payload — its speaker is
|
||||
# the silent witness (the bot that wasn't the primary addressee).
|
||||
# Filter on ``superseded_by IS NULL`` so prior regenerates of this
|
||||
# group don't reappear as siblings.
|
||||
#
|
||||
# T83.3: push the chat_id filter into SQL via ``json_extract`` so
|
||||
# the query doesn't scan every assistant_turn row across the whole
|
||||
# database. ``LIMIT 50`` bounds worst-case work even when chat_id
|
||||
# isn't selective (e.g. a single chat with many turns) — we only
|
||||
# need the one matching sibling. Mirrors the SQL pattern in
|
||||
# ``chat.web.meanwhile._last_meanwhile_speaker``.
|
||||
original_interjection_event_id: int | None = None
|
||||
original_interjection_payload: dict | None = None
|
||||
if original_user_turn_id is not None:
|
||||
sibling_cur = conn.execute(
|
||||
"SELECT id, payload_json FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' "
|
||||
" AND id != ? "
|
||||
" AND superseded_by IS NULL "
|
||||
" AND json_extract(payload_json, '$.chat_id') = ? "
|
||||
"ORDER BY id DESC "
|
||||
"LIMIT 50",
|
||||
(original_assistant_event_id, chat_id),
|
||||
)
|
||||
for sib_id, sib_payload_json in sibling_cur.fetchall():
|
||||
sib_payload = json.loads(sib_payload_json)
|
||||
if sib_payload.get("user_turn_id") != original_user_turn_id:
|
||||
continue
|
||||
if not sib_payload.get("interjection_of"):
|
||||
continue
|
||||
original_interjection_event_id = sib_id
|
||||
original_interjection_payload = sib_payload
|
||||
break
|
||||
# Phase 2 v2 regenerates only the addressee turn — preserve whichever
|
||||
# bot the original turn was attributed to, falling back to the host
|
||||
# for legacy rows that pre-date multi-entity support.
|
||||
speaker_bot_id = original_assistant_payload.get("speaker_id") or host_bot_id
|
||||
if speaker_bot_id == host_bot_id:
|
||||
speaker_bot = host_bot
|
||||
elif guest_bot is not None and speaker_bot_id == guest_bot.get("id"):
|
||||
speaker_bot = guest_bot
|
||||
else:
|
||||
speaker_bot = get_bot(conn, speaker_bot_id) or host_bot
|
||||
speaker_bot_id = speaker_bot.get("id", host_bot_id)
|
||||
|
||||
# 2. Determine the prose for the new prompt and (when edited) capture
|
||||
# the user_turn_edit event up front so the new event ids exist before
|
||||
# we link them from the assistant_turn payload.
|
||||
new_user_event_id: int | None = None
|
||||
if edited_user_prose is not None:
|
||||
new_user_event_id = append_event(
|
||||
conn,
|
||||
kind="user_turn_edit",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"prose": edited_user_prose,
|
||||
"supersedes_user_turn_id": original_user_turn_id,
|
||||
},
|
||||
)
|
||||
if original_user_turn_id is not None:
|
||||
conn.execute(
|
||||
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
||||
(new_user_event_id, original_user_turn_id),
|
||||
)
|
||||
prose_for_prompt = edited_user_prose
|
||||
else:
|
||||
original_user_row = conn.execute(
|
||||
"SELECT payload_json FROM event_log WHERE id = ?",
|
||||
(original_user_turn_id,),
|
||||
).fetchone() if original_user_turn_id is not None else None
|
||||
if original_user_row is not None:
|
||||
prose_for_prompt = json.loads(original_user_row[0]).get("prose", "")
|
||||
else:
|
||||
prose_for_prompt = ""
|
||||
|
||||
# 3. Build the recent-dialogue slice. Exclude the original
|
||||
# assistant_turn explicitly (we haven't superseded it yet — that
|
||||
# update lands at the end so the new event_id is known) and use the
|
||||
# standard ``superseded_by IS NULL AND hidden = 0`` filter so any
|
||||
# prior regenerates also drop out. T83.2: shared helper handles the
|
||||
# SQL + filtering; we post-process to map speaker ids to display
|
||||
# names for the prompt.
|
||||
you_entity = get_you(conn) or {"name": "you", "persona": ""}
|
||||
you_name = you_entity.get("name", "you")
|
||||
raw_recent = read_recent_dialogue(
|
||||
conn,
|
||||
chat_id,
|
||||
limit=20,
|
||||
exclude_event_id=original_assistant_event_id,
|
||||
)
|
||||
recent: list[dict] = []
|
||||
for entry in raw_recent:
|
||||
spk = entry.get("speaker", "bot")
|
||||
if spk == "you":
|
||||
recent.append({"speaker": you_name, "text": entry.get("text", "")})
|
||||
continue
|
||||
if spk == host_bot_id:
|
||||
spk_name = host_bot.get("name", "bot")
|
||||
elif guest_bot is not None and spk == guest_bot.get("id"):
|
||||
spk_name = guest_bot.get("name", "bot")
|
||||
else:
|
||||
spk_name = host_bot.get("name", "bot")
|
||||
recent.append({"speaker": spk_name, "text": entry.get("text", "")})
|
||||
|
||||
# 4. Assemble the narrative prompt. ``recent`` already excludes the
|
||||
# current user prose, which we pass through ``user_turn_prose``.
|
||||
# Phase 2: forward ``guest_id`` so the prompt sees the third party.
|
||||
messages = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
speaker_bot_id=speaker_bot_id,
|
||||
user_turn_prose=prose_for_prompt or None,
|
||||
recent_dialogue=recent,
|
||||
budget_soft=settings.narrative_budget_soft,
|
||||
budget_hard=settings.narrative_budget_hard,
|
||||
guest_id=guest_bot_id,
|
||||
)
|
||||
|
||||
# 5. Stream the new narrative. T83.1: register the streaming Task in
|
||||
# the chat-keyed in-flight registry so POST /chats/<id>/turns/cancel
|
||||
# can call ``.cancel()`` on a mid-regenerate stream. We import the
|
||||
# underscore name from turns.py deliberately — same single-process
|
||||
# registry the cancel route reads, mirrors the meanwhile registration
|
||||
# pattern in chat/web/meanwhile.py.
|
||||
from chat.web.turns import _in_flight_tasks # noqa: PLC0415
|
||||
|
||||
accumulated: list[str] = []
|
||||
|
||||
async def _stream_primary() -> None:
|
||||
async for chunk in client.stream(
|
||||
messages,
|
||||
model=settings.narrative_model,
|
||||
max_tokens=settings.narrative_max_tokens,
|
||||
temperature=settings.narrative_temperature,
|
||||
):
|
||||
accumulated.append(chunk)
|
||||
await publish(
|
||||
chat_id,
|
||||
{"event": "token", "text": chunk, "speaker_id": speaker_bot_id},
|
||||
)
|
||||
|
||||
stream_task = asyncio.create_task(_stream_primary())
|
||||
_in_flight_tasks[chat_id] = stream_task
|
||||
try:
|
||||
await stream_task
|
||||
finally:
|
||||
# Always unregister so a subsequent turn / regenerate can register
|
||||
# a fresh task. Mirrors the cleanup in turns.py::post_turn.
|
||||
_in_flight_tasks.pop(chat_id, None)
|
||||
new_text = "".join(accumulated)
|
||||
|
||||
# 6. Append the new assistant_turn event. ``user_turn_id`` points at
|
||||
# the edit event when one was created, otherwise the original. The
|
||||
# ``regenerated_from`` field is the back-pointer the UI uses for an
|
||||
# "originally said …" affordance.
|
||||
new_assistant_event_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": speaker_bot_id,
|
||||
"text": new_text,
|
||||
"truncated": False,
|
||||
"user_turn_id": (
|
||||
new_user_event_id
|
||||
if new_user_event_id is not None
|
||||
else original_user_turn_id
|
||||
),
|
||||
"regenerated_from": original_assistant_event_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 7. Mark the original assistant_turn as superseded by the new one.
|
||||
conn.execute(
|
||||
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
||||
(new_assistant_event_id, original_assistant_event_id),
|
||||
)
|
||||
|
||||
# 7a. Broadcast a turn_html_replace SSE event so connected tabs can
|
||||
# swap the prior assistant_turn DOM node in-place (T73.1, Phase 1.5
|
||||
# backlog #2). Uses a separate event name from post_turn's
|
||||
# ``turn_html`` (which is append-only) because regenerate is a
|
||||
# *replace* operation — see module docstring for the rationale.
|
||||
speaker_name_for_render = (
|
||||
speaker_bot.get("name", "bot") if speaker_bot is not None else "bot"
|
||||
)
|
||||
new_turn_html = render_turn_html(
|
||||
speaker_name_for_render,
|
||||
new_text,
|
||||
role="bot",
|
||||
event_id=new_assistant_event_id,
|
||||
)
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "turn_html_replace",
|
||||
"data": new_turn_html,
|
||||
"turn_id": new_assistant_event_id,
|
||||
"supersedes_id": original_assistant_event_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 8. Re-run downstream classifier passes (memory write + state update
|
||||
# for every directed pair across present entities). Significance is
|
||||
# intentionally skipped on regenerate (the prior score remains
|
||||
# attached to the prior memory). Phase 2.5 will add interjection
|
||||
# regeneration; v2 leaves any prior interjection beat in place.
|
||||
scene = active_scene(conn, chat_id)
|
||||
record_turn_memory_for_present(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=host_bot_id,
|
||||
guest_bot_id=guest_bot_id,
|
||||
narrative_text=new_text,
|
||||
scene_id=scene["id"] if scene else None,
|
||||
chat_clock_at=chat.get("time"),
|
||||
app=app,
|
||||
)
|
||||
|
||||
last_at = chat.get("time")
|
||||
speaker_name = (
|
||||
speaker_bot.get("name", "bot") if speaker_bot is not None else "bot"
|
||||
)
|
||||
recent_for_update = recent + [
|
||||
{"speaker": speaker_name, "text": new_text}
|
||||
]
|
||||
|
||||
# Build present-entity inputs for the multi-pair state-update pass.
|
||||
# Host first preserves the Phase 1 directed-pair order (host->you,
|
||||
# then you->host) so existing canned-response fixtures still line up.
|
||||
present_ids: list[str] = [host_bot_id, "you"]
|
||||
present_names: dict[str, str] = {
|
||||
host_bot_id: host_bot.get("name", "bot"),
|
||||
"you": you_name,
|
||||
}
|
||||
personas: dict[str, str] = {
|
||||
host_bot_id: host_bot.get("persona") or "",
|
||||
"you": you_entity.get("persona") or "",
|
||||
}
|
||||
if guest_bot is not None and guest_bot_id is not None:
|
||||
present_ids.append(guest_bot_id)
|
||||
present_names[guest_bot_id] = guest_bot.get("name", "bot")
|
||||
personas[guest_bot_id] = guest_bot.get("persona") or ""
|
||||
|
||||
# T83.2: shared helper builds the directed-pair edge dict.
|
||||
prior_edges = gather_prior_edges(conn, present_ids)
|
||||
|
||||
state_updates = await compute_state_updates_for_present(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
present_ids=present_ids,
|
||||
present_names=present_names,
|
||||
personas=personas,
|
||||
prior_edges=prior_edges,
|
||||
recent_dialogue=recent_for_update,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for src_id, tgt_id, update in state_updates:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": src_id,
|
||||
"target_id": tgt_id,
|
||||
"chat_id": chat_id,
|
||||
"affinity_delta": update.affinity_delta,
|
||||
"trust_delta": update.trust_delta,
|
||||
"knowledge_facts": update.knowledge_facts,
|
||||
"last_interaction_at": last_at,
|
||||
"last_interaction_chat_id": chat_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 9. Interjection regenerate branch (T73.2). When the original
|
||||
# assistant_turn group included a follow-on interjection beat we need
|
||||
# to revisit that beat against the regenerated primary. Three outcomes:
|
||||
#
|
||||
# - No original interjection: nothing to do; we already short-circuit
|
||||
# above by leaving ``original_interjection_event_id`` as None.
|
||||
# - Original interjection + classifier returns True: stream a fresh
|
||||
# interjection from the silent witness, append it (with
|
||||
# ``interjection_of`` linking to the new primary speaker), and
|
||||
# supersede the original interjection's row. Also re-run memory
|
||||
# + state-update so the second beat moves edges + writes memories.
|
||||
# - Original interjection + classifier returns False: supersede the
|
||||
# original interjection without appending a replacement. The
|
||||
# regenerated group becomes "primary only" because the new primary
|
||||
# no longer warrants a follow-on. No memory / state work needed
|
||||
# for the absent beat.
|
||||
#
|
||||
# ``superseded_by`` on the original interjection's row points at the
|
||||
# *new primary* in the no-replacement case (rather than NULL or a
|
||||
# nonexistent id) so the row is consistently hidden by the standard
|
||||
# ``superseded_by IS NULL`` timeline filter and the back-pointer
|
||||
# leads somewhere meaningful for an "originally said …" affordance.
|
||||
if original_interjection_event_id is not None and guest_bot is not None:
|
||||
# Identify the silent witness from the original interjection's
|
||||
# speaker_id (which is the bot that interjected last time). When
|
||||
# we regenerate we keep the *same pair of present entities*, so
|
||||
# the silent witness is whichever bot isn't the new primary
|
||||
# speaker — derive it from present rather than reusing the prior
|
||||
# speaker_id verbatim, in case the regenerated primary swapped
|
||||
# who held the floor.
|
||||
if speaker_bot_id == host_bot_id:
|
||||
silent_witness = guest_bot
|
||||
else:
|
||||
silent_witness = host_bot
|
||||
silent_witness_id = silent_witness.get("id")
|
||||
|
||||
edge_w_to_addr = get_edge(conn, silent_witness_id, speaker_bot_id) or {
|
||||
"affinity": 50,
|
||||
"trust": 50,
|
||||
"summary": "",
|
||||
}
|
||||
edge_w_to_you = get_edge(conn, silent_witness_id, "you") or {
|
||||
"affinity": 50,
|
||||
"trust": 50,
|
||||
"summary": "",
|
||||
}
|
||||
|
||||
decision = await detect_interjection(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
addressee_name=speaker_bot.get("name", "bot"),
|
||||
addressee_just_said=new_text,
|
||||
silent_witness_name=silent_witness.get("name", "bot"),
|
||||
silent_witness_persona=silent_witness.get("persona") or "",
|
||||
silent_witness_edge_to_addressee=edge_w_to_addr,
|
||||
silent_witness_edge_to_you=edge_w_to_you,
|
||||
you_just_said=prose_for_prompt or "",
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
|
||||
if decision.should_interject:
|
||||
# Re-read recent so the just-appended primary is in the
|
||||
# prompt. T83.2: shared helper + the same id->name mapping
|
||||
# as the primary read above.
|
||||
raw_interject = read_recent_dialogue(conn, chat_id, limit=20)
|
||||
interject_recent: list[dict] = []
|
||||
for entry in raw_interject:
|
||||
spk = entry.get("speaker", "bot")
|
||||
if spk == "you":
|
||||
interject_recent.append(
|
||||
{"speaker": you_name, "text": entry.get("text", "")}
|
||||
)
|
||||
continue
|
||||
if spk == host_bot_id:
|
||||
spk_name = host_bot.get("name", "bot")
|
||||
elif spk == guest_bot.get("id"):
|
||||
spk_name = guest_bot.get("name", "bot")
|
||||
else:
|
||||
spk_name = "bot"
|
||||
interject_recent.append(
|
||||
{"speaker": spk_name, "text": entry.get("text", "")}
|
||||
)
|
||||
if interject_recent and interject_recent[-1].get("speaker") == you_name:
|
||||
interject_recent = interject_recent[:-1]
|
||||
|
||||
interject_messages = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
speaker_bot_id=silent_witness_id,
|
||||
addressee=speaker_bot_id,
|
||||
user_turn_prose=prose_for_prompt or None,
|
||||
recent_dialogue=interject_recent,
|
||||
budget_soft=settings.narrative_budget_soft,
|
||||
budget_hard=settings.narrative_budget_hard,
|
||||
guest_id=guest_bot_id,
|
||||
)
|
||||
|
||||
interject_accumulated: list[str] = []
|
||||
|
||||
async def _stream_interjection() -> None:
|
||||
async for chunk in client.stream(
|
||||
interject_messages,
|
||||
model=settings.narrative_model,
|
||||
max_tokens=settings.narrative_max_tokens,
|
||||
temperature=settings.narrative_temperature,
|
||||
):
|
||||
interject_accumulated.append(chunk)
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "token",
|
||||
"text": chunk,
|
||||
"speaker_id": silent_witness_id,
|
||||
},
|
||||
)
|
||||
|
||||
# T83.1: register the interjection sub-stream in the same
|
||||
# in-flight registry so /turns/cancel collapses it too.
|
||||
interject_task = asyncio.create_task(_stream_interjection())
|
||||
_in_flight_tasks[chat_id] = interject_task
|
||||
try:
|
||||
await interject_task
|
||||
finally:
|
||||
_in_flight_tasks.pop(chat_id, None)
|
||||
interject_text = "".join(interject_accumulated)
|
||||
|
||||
new_interjection_event_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": silent_witness_id,
|
||||
"text": interject_text,
|
||||
"truncated": False,
|
||||
"user_turn_id": (
|
||||
new_user_event_id
|
||||
if new_user_event_id is not None
|
||||
else original_user_turn_id
|
||||
),
|
||||
"regenerated_from": original_interjection_event_id,
|
||||
"interjection_of": speaker_bot_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Supersede the original interjection by the new one.
|
||||
conn.execute(
|
||||
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
||||
(new_interjection_event_id, original_interjection_event_id),
|
||||
)
|
||||
|
||||
# Broadcast a replace event so connected tabs swap the prior
|
||||
# interjection node in-place (mirrors T73.1's primary swap).
|
||||
interject_html = render_turn_html(
|
||||
silent_witness.get("name", "bot"),
|
||||
interject_text,
|
||||
role="bot",
|
||||
event_id=new_interjection_event_id,
|
||||
)
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "turn_html_replace",
|
||||
"data": interject_html,
|
||||
"turn_id": new_interjection_event_id,
|
||||
"supersedes_id": original_interjection_event_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Memory write for the new interjection beat (one event per
|
||||
# present witness).
|
||||
record_turn_memory_for_present(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=host_bot_id,
|
||||
guest_bot_id=guest_bot_id,
|
||||
narrative_text=interject_text,
|
||||
scene_id=scene["id"] if scene else None,
|
||||
chat_clock_at=chat.get("time"),
|
||||
app=app,
|
||||
)
|
||||
|
||||
# Re-run the multi-pair state-update with the post-interjection
|
||||
# dialogue tail so deltas land on the post-primary baseline.
|
||||
recent_post_interject = recent_for_update + [
|
||||
{
|
||||
"speaker": silent_witness.get("name", "bot"),
|
||||
"text": interject_text,
|
||||
}
|
||||
]
|
||||
# T83.2: shared helper handles the directed-pair edge dict.
|
||||
prior_edges_post = gather_prior_edges(conn, present_ids)
|
||||
|
||||
state_updates_post = await compute_state_updates_for_present(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
present_ids=present_ids,
|
||||
present_names=present_names,
|
||||
personas=personas,
|
||||
prior_edges=prior_edges_post,
|
||||
recent_dialogue=recent_post_interject,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for src_id, tgt_id, update in state_updates_post:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": src_id,
|
||||
"target_id": tgt_id,
|
||||
"chat_id": chat_id,
|
||||
"affinity_delta": update.affinity_delta,
|
||||
"trust_delta": update.trust_delta,
|
||||
"knowledge_facts": update.knowledge_facts,
|
||||
"last_interaction_at": last_at,
|
||||
"last_interaction_chat_id": chat_id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Classifier said "no follow-on this time" — supersede the
|
||||
# original interjection without a replacement. Point the
|
||||
# back-pointer at the new primary so the row is consistently
|
||||
# hidden by the standard timeline filter.
|
||||
conn.execute(
|
||||
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
||||
(new_assistant_event_id, original_interjection_event_id),
|
||||
)
|
||||
|
||||
# 9a. Event-lifecycle detection (Phase 3, T61). T83.5 cosmetic
|
||||
# ordering: mirrors ``chat.web.turns.post_turn``'s 8a block — runs
|
||||
# AFTER the interjection branch (and AFTER the post-interjection
|
||||
# state-update + memory passes) so the classifier sees the same
|
||||
# narrative-text input post_turn does. Numbering uses ``9a`` to
|
||||
# match post_turn's ``8a`` shape (the interjection branch is step 9
|
||||
# in regenerate vs step 8 in post_turn; lifecycle is the immediate
|
||||
# follow-on in both). Behaviour identical to the prior ``step 10``
|
||||
# placement — the block was already structurally last in regenerate
|
||||
# because there's no scene-close pass here.
|
||||
#
|
||||
# Classify whether any active events transitioned in the regenerated
|
||||
# narrative and append the corresponding event_started /
|
||||
# event_completed / event_cancelled. ``promote_completed_event``
|
||||
# runs inline after a completion so promotion artifacts land in the
|
||||
# same regenerate path.
|
||||
#
|
||||
# T83.4 follow-up: when a regenerate replaces a turn that had
|
||||
# already produced event transitions, those original transitions
|
||||
# are NOT undone here (Phase 4 work). A WARNING log earlier in this
|
||||
# function names the affected event_log ids — see the T83.4 block
|
||||
# near the function entry.
|
||||
new_active_events = list_active_events(conn, chat_id)
|
||||
if new_active_events:
|
||||
lifecycle_decision = await detect_event_transitions(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
narrative_text=new_text,
|
||||
active_events=new_active_events,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for transition in lifecycle_decision.transitions:
|
||||
if transition.new_status == "active":
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_started",
|
||||
payload={
|
||||
"event_id": transition.event_id,
|
||||
"started_at": chat.get("time"),
|
||||
},
|
||||
)
|
||||
elif transition.new_status == "completed":
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_completed",
|
||||
payload={
|
||||
"event_id": transition.event_id,
|
||||
"completed_at": chat.get("time"),
|
||||
},
|
||||
)
|
||||
promote_completed_event(
|
||||
conn,
|
||||
event_id=transition.event_id,
|
||||
chat_id=chat_id,
|
||||
chat_clock_at=chat.get("time"),
|
||||
)
|
||||
elif transition.new_status == "cancelled":
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": transition.event_id,
|
||||
"completed_at": chat.get("time"),
|
||||
},
|
||||
)
|
||||
|
||||
return new_text
|
||||
|
||||
|
||||
__all__ = ["regenerate_assistant_turn"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Parse user-supplied "have they met?" prose into per-direction seed
|
||||
content for two bots' edges (T38).
|
||||
|
||||
Per Requirements §5.2, when two bots first co-appear in a chat, the user
|
||||
is offered a small drawer asking "Have they met before? If yes, write a
|
||||
short prose seed describing how." That prose lands here and is parsed
|
||||
into a :class:`RelationshipSeed` whose two halves populate the
|
||||
``botA -> botB`` and ``botB -> botA`` edges respectively (summary,
|
||||
initial knowledge facts, and small affinity/trust deltas around the
|
||||
default 50/50 baseline).
|
||||
|
||||
The two directions can differ — A may know more about B than B knows
|
||||
about A, or A may trust B less than the reverse — so the schema carries
|
||||
both halves independently.
|
||||
|
||||
Empty/whitespace-only prose short-circuits to a default
|
||||
``RelationshipSeed`` (all zeroes, empty strings); the caller treats
|
||||
that as "they haven't met" and writes no edge content. The wrapper uses
|
||||
:func:`chat.llm.classify.classify` with ``default=RelationshipSeed()``
|
||||
so a flapping classifier degrades to the same no-op rather than
|
||||
blocking the chat-creation flow (§3.3 graceful-degradation rule).
|
||||
|
||||
T42 (the inter-bot relationship drawer) calls this from the route layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class RelationshipSeed(BaseModel):
|
||||
"""Structured per-direction seed for two bots' edges.
|
||||
|
||||
Defaults are a deliberate no-op: empty summaries, empty knowledge
|
||||
lists, zero deltas. Both the empty-prose short-circuit and the
|
||||
classifier-failure fallback return this default so the caller can
|
||||
treat them identically.
|
||||
"""
|
||||
|
||||
a_to_b_summary: str = ""
|
||||
a_to_b_knowledge_facts: list[str] = Field(default_factory=list)
|
||||
a_to_b_affinity_delta: int = 0 # signed, -10..+10 typical
|
||||
a_to_b_trust_delta: int = 0
|
||||
b_to_a_summary: str = ""
|
||||
b_to_a_knowledge_facts: list[str] = Field(default_factory=list)
|
||||
b_to_a_affinity_delta: int = 0
|
||||
b_to_a_trust_delta: int = 0
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"You parse a short prose seed describing how two characters know each "
|
||||
"other into structured per-direction edge content. For each direction "
|
||||
"(A -> B, B -> A) extract: summary (one sentence from that POV), "
|
||||
"knowledge_facts (list of factual claims that direction can carry "
|
||||
"into future scenes), affinity_delta (-10..+10 — small adjustments to "
|
||||
"the default 50/50 baseline), trust_delta (-10..+10). Default deltas "
|
||||
"to 0 when prose is neutral. The two directions can differ — A may "
|
||||
"trust B more than B trusts A. Output strict JSON matching the schema."
|
||||
)
|
||||
|
||||
|
||||
async def seed_inter_bot_edges(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
bot_a_id: str,
|
||||
bot_a_name: str,
|
||||
bot_b_id: str,
|
||||
bot_b_name: str,
|
||||
relationship_prose: str,
|
||||
timeout_s: float = 30.0,
|
||||
) -> RelationshipSeed:
|
||||
"""Parse user-supplied prose into structured edge content for both
|
||||
directed pairs.
|
||||
|
||||
Empty/whitespace prose short-circuits to an empty
|
||||
:class:`RelationshipSeed` (the caller treats this as "they haven't
|
||||
met" and writes no edge content). Classifier failure also returns
|
||||
the default — see module docstring for the rationale.
|
||||
|
||||
The ``bot_a_id`` / ``bot_b_id`` arguments are accepted for symmetry
|
||||
with the caller (T42's drawer route uses them when emitting
|
||||
``edge_update`` events); they're embedded in the prompt alongside
|
||||
the names so the classifier can disambiguate when names collide.
|
||||
"""
|
||||
if not relationship_prose or not relationship_prose.strip():
|
||||
return RelationshipSeed()
|
||||
user = (
|
||||
f"Bot A: {bot_a_name} (id={bot_a_id})\n"
|
||||
f"Bot B: {bot_b_name} (id={bot_b_id})\n\n"
|
||||
f"Prose seed:\n{relationship_prose.strip()}"
|
||||
)
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=RelationshipSeed,
|
||||
default=RelationshipSeed(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RelationshipSeed", "seed_inter_bot_edges"]
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.state.entities import get_bot
|
||||
|
||||
|
||||
def reset_bot(conn: Connection, bot_id: str, *, confirm_name: str) -> None:
|
||||
"""Reset a bot's runtime state via a ``bot_reset`` event.
|
||||
|
||||
Validates that ``confirm_name`` matches the bot's stored ``name``
|
||||
exactly (case-sensitive, no trim). Raises:
|
||||
|
||||
- ``ValueError("bot {bot_id} not found")`` when the bot is missing.
|
||||
- ``ValueError("confirm_name does not match bot name")`` on mismatch.
|
||||
"""
|
||||
bot = get_bot(conn, bot_id)
|
||||
if bot is None:
|
||||
raise ValueError(f"bot {bot_id} not found")
|
||||
if confirm_name != bot["name"]:
|
||||
raise ValueError("confirm_name does not match bot name")
|
||||
append_and_apply(conn, kind="bot_reset", payload={"bot_id": bot_id})
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Rewind service — truncate the event log past a chosen turn and re-project.
|
||||
|
||||
Per Requirements §10.1 and Plan Task 28, "rewind to here" must:
|
||||
|
||||
1. Take a snapshot of the current state so the user can recover (handed
|
||||
off to :mod:`chat.services.snapshot`).
|
||||
2. Truncate the event log past ``after_event_id`` — physical DELETE for
|
||||
v1 simplicity; the spec says rewind should be a hard truncation, not
|
||||
the soft ``hidden=1`` mechanism used by edits/regenerate.
|
||||
3. Clear projected tables and re-project from the truncated log so live
|
||||
state matches "what the world looked like at turn N". Without the
|
||||
re-projection, projected tables would carry forward stale rows from
|
||||
rewound events (e.g. an ``edge_update`` that bumped affinity past the
|
||||
rewind point would still show in ``edges``).
|
||||
|
||||
Re-projection is a full replay rather than a "revert delta" because most
|
||||
projector handlers are idempotent inserts, but the edge handler is a
|
||||
delta-shaped accumulator — there's no clean way to invert a single
|
||||
``edge_update`` against ``edges.affinity`` without replay. Wiping +
|
||||
replaying is straightforward and correct.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.eventlog.projector import project
|
||||
from chat.services.snapshot import take_snapshot
|
||||
|
||||
|
||||
def compute_rewind_preview(
|
||||
conn: Connection, after_event_id: int
|
||||
) -> dict:
|
||||
"""Return counts of each event kind that would be removed by rewinding.
|
||||
|
||||
Used by the preview modal so the user sees the impact (e.g. "this
|
||||
will remove 8 events: 4 user_turn, 4 assistant_turn") before
|
||||
confirming. Counts include hidden/superseded rows — they're still
|
||||
physically deleted.
|
||||
"""
|
||||
cur = conn.execute(
|
||||
"SELECT kind, COUNT(*) FROM event_log WHERE id > ? GROUP BY kind "
|
||||
"ORDER BY kind",
|
||||
(after_event_id,),
|
||||
)
|
||||
counts = {kind: count for kind, count in cur.fetchall()}
|
||||
total = sum(counts.values())
|
||||
return {
|
||||
"after_event_id": after_event_id,
|
||||
"total_events": total,
|
||||
"by_kind": counts,
|
||||
}
|
||||
|
||||
|
||||
def execute_rewind(
|
||||
*, db_path: Path, data_dir: Path, after_event_id: int
|
||||
) -> Path:
|
||||
"""Take a snapshot, truncate, and re-project. Returns the snapshot path.
|
||||
|
||||
The snapshot is taken inside the same connection scope as the
|
||||
truncate + reproject so all three commit together — if any step
|
||||
fails the connection's commit-on-exit is bypassed by the exception
|
||||
and the database stays untouched. The snapshot file is on disk
|
||||
regardless, which is the desired behaviour: even if the truncate
|
||||
aborts, the user has a recovery point.
|
||||
"""
|
||||
with open_db(db_path) as conn:
|
||||
# 1. Snapshot first — we want this on disk before any destructive
|
||||
# operation runs.
|
||||
snapshot_path = take_snapshot(
|
||||
conn, data_dir=data_dir, kind="rewind"
|
||||
)
|
||||
|
||||
# 2. Truncate the event log past the chosen id. Foreign keys are
|
||||
# ON, but ``event_log.superseded_by`` self-references and the
|
||||
# rows we're deleting are the only ones that could point
|
||||
# forward — there's nothing to cascade.
|
||||
conn.execute(
|
||||
"DELETE FROM event_log WHERE id > ?", (after_event_id,)
|
||||
)
|
||||
|
||||
# 3. Clear projected tables in topological order so FK ON DELETE
|
||||
# constraints don't fire on referenced rows. ``activity`` and
|
||||
# ``scenes`` reference ``containers``; ``chat_state`` references
|
||||
# ``chats`` by id-convention only (no FK declared). ``memories``,
|
||||
# ``edges``, ``bots``, ``you_entity``, and ``classifier_failures``
|
||||
# have no incoming FKs from other projected tables.
|
||||
#
|
||||
# ``executescript`` is intentionally avoided so foreign_keys=ON
|
||||
# stays in effect for each statement — executescript would
|
||||
# implicitly commit and reset some pragmas on certain SQLite
|
||||
# builds.
|
||||
conn.execute("DELETE FROM memories")
|
||||
conn.execute("DELETE FROM activity")
|
||||
conn.execute("DELETE FROM scenes")
|
||||
conn.execute("DELETE FROM containers")
|
||||
conn.execute("DELETE FROM chat_state")
|
||||
conn.execute("DELETE FROM chats")
|
||||
conn.execute("DELETE FROM edges")
|
||||
conn.execute("DELETE FROM bots")
|
||||
conn.execute("DELETE FROM you_entity")
|
||||
conn.execute("DELETE FROM classifier_failures")
|
||||
|
||||
# 4. Re-project from the truncated event log. Handler registry
|
||||
# is module-level state populated by importing chat.state.* —
|
||||
# callers (the route, tests) need to have those modules
|
||||
# imported for this to do anything useful.
|
||||
project(conn)
|
||||
|
||||
return snapshot_path
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,714 @@
|
||||
"""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
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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,
|
||||
since_event_id: int | None = None,
|
||||
) -> 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.
|
||||
|
||||
T80.2: ``since_event_id`` clamps the result to event_log rows whose
|
||||
``id >= since_event_id`` so callers needing a scene-scoped view (e.g.
|
||||
thread detection on close) don't pull turns that landed before the
|
||||
closing scene's ``scene_opened`` event.
|
||||
"""
|
||||
if since_event_id is None:
|
||||
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,),
|
||||
)
|
||||
else:
|
||||
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 "
|
||||
" AND id >= ? "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(since_event_id, 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
|
||||
|
||||
|
||||
def _scene_opened_event_id(
|
||||
conn: Connection, chat_id: str, scene_id: int
|
||||
) -> int | None:
|
||||
"""Return the event_log id of the ``scene_opened`` (or
|
||||
``meanwhile_scene_started``) event that created scene row
|
||||
``scene_id``. Used by T80.2 to lower-bound dialogue reads to a
|
||||
single scene's transcript.
|
||||
|
||||
``meanwhile_scene_started`` carries an explicit ``scene_id`` so we
|
||||
match on that directly. ``scene_opened`` doesn't, so we walk the
|
||||
chat's scene rows in id order and zip against the chat's scene-open
|
||||
events in id order — the projector creates one scene row per
|
||||
scene-open event, so positions correspond.
|
||||
|
||||
Returns ``None`` when no matching event is found; callers should
|
||||
treat that as "fall back to chat-wide" rather than over-filter.
|
||||
"""
|
||||
# Fast path for meanwhile children (explicit scene_id in payload).
|
||||
for ev_id, payload_json in conn.execute(
|
||||
"SELECT id, payload_json FROM event_log "
|
||||
"WHERE kind = 'meanwhile_scene_started' "
|
||||
" AND superseded_by IS NULL AND hidden = 0",
|
||||
).fetchall():
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if p.get("chat_id") == chat_id and p.get("scene_id") == scene_id:
|
||||
return ev_id
|
||||
# Fallback for parent you-scenes: zip chat-scoped scene-open events
|
||||
# against chat-scoped scene rows in id order.
|
||||
chat_scene_ids = [
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM scenes WHERE chat_id = ? ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
]
|
||||
if scene_id not in chat_scene_ids:
|
||||
return None
|
||||
chat_open_evs: list[int] = []
|
||||
for ev_id, _kind, payload_json in conn.execute(
|
||||
"SELECT id, kind, payload_json FROM event_log "
|
||||
"WHERE kind IN ('scene_opened', 'meanwhile_scene_started') "
|
||||
" AND superseded_by IS NULL AND hidden = 0 "
|
||||
"ORDER BY id ASC",
|
||||
).fetchall():
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if p.get("chat_id") == chat_id:
|
||||
chat_open_evs.append(ev_id)
|
||||
idx = chat_scene_ids.index(scene_id)
|
||||
if idx < len(chat_open_evs):
|
||||
return chat_open_evs[idx]
|
||||
return None
|
||||
|
||||
|
||||
async def _summarize_and_apply_for_witness(
|
||||
conn: Connection,
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
chat_id: str,
|
||||
scene_id: int,
|
||||
bot_id: str,
|
||||
you_name: str,
|
||||
dialogue: list[dict],
|
||||
timeout_s: float,
|
||||
key_quotes_suffix: str = "",
|
||||
) -> ScenePOVSummary:
|
||||
"""Run :func:`summarize_scene` for one bot witness and apply the
|
||||
three projected updates (memory pov_summary rewrite, edge summary
|
||||
overwrite, edge knowledge_facts append).
|
||||
|
||||
Tolerant of missing pieces in the same way Phase 1 was: no memory
|
||||
row -> skip the rewrite; no edge row -> skip the edge_summary write
|
||||
(the empty-default classifier output simply yields no rewrites).
|
||||
|
||||
``key_quotes_suffix`` is appended verbatim to the per-POV summary
|
||||
text before the rewrite lands (T58.1) — empty string is the no-op
|
||||
default for low-significance scenes.
|
||||
"""
|
||||
from chat.state.edges import get_edge
|
||||
from chat.state.entities import get_bot
|
||||
|
||||
bot = get_bot(conn, bot_id) or {"name": bot_id, "persona": ""}
|
||||
|
||||
edge_b2y = get_edge(conn, bot_id, "you")
|
||||
prior_summary = (edge_b2y or {}).get("summary", "") or ""
|
||||
|
||||
pov = await summarize_scene(
|
||||
client,
|
||||
model=classifier_model,
|
||||
bot_name=bot.get("name", bot_id),
|
||||
bot_persona=bot.get("persona", "") or "",
|
||||
you_name=you_name,
|
||||
prior_edge_summary=prior_summary,
|
||||
dialogue=dialogue,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
# Update memories belonging to the closed scene for this witness.
|
||||
cur = conn.execute(
|
||||
"SELECT id, pov_summary FROM memories "
|
||||
"WHERE scene_id = ? AND owner_id = ?",
|
||||
(scene_id, 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
|
||||
# T80.1: a prior close may have already appended a Key quotes
|
||||
# suffix to this row's pov_summary. Strip it here so the fresh
|
||||
# rewrite replaces the existing suffix rather than stacking a
|
||||
# second one on top.
|
||||
new_value = _strip_key_quotes_suffix(pov.summary) + key_quotes_suffix
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="manual_edit",
|
||||
payload={
|
||||
"target_kind": "memory_pov_summary",
|
||||
"target_id": int(memory_id),
|
||||
"prior_value": prior_pov,
|
||||
"new_value": new_value,
|
||||
},
|
||||
)
|
||||
|
||||
# Update this 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": bot_id,
|
||||
"target_id": "you",
|
||||
},
|
||||
"prior_value": prior_summary,
|
||||
"new_value": new_summary,
|
||||
},
|
||||
)
|
||||
|
||||
# Append knowledge_facts to this bot->you edge if present.
|
||||
if pov.knowledge_facts:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": bot_id,
|
||||
"target_id": "you",
|
||||
"chat_id": chat_id,
|
||||
"knowledge_facts": list(pov.knowledge_facts),
|
||||
},
|
||||
)
|
||||
|
||||
return pov
|
||||
|
||||
|
||||
# T80.1: header marker shared by the suffix builder and the
|
||||
# witness-write strip step. Any text starting with this marker is treated
|
||||
# as a previously-appended Key quotes suffix and stripped before reuse so
|
||||
# repeated scene closes don't compose recursive bloat.
|
||||
_KEY_QUOTES_HEADER = "\n\nKey quotes:\n"
|
||||
|
||||
|
||||
def _strip_key_quotes_suffix(text: str) -> str:
|
||||
"""Remove a previously-appended Key quotes suffix from ``text``.
|
||||
|
||||
Returns ``text`` unchanged when the marker is absent, or the prefix
|
||||
up to (but not including) the marker when present. Used in two
|
||||
places: (1) when sourcing quote text from a memory row that may
|
||||
already carry the suffix from a prior close, and (2) when computing
|
||||
the per-POV rewrite's prior_value so the new write replaces — rather
|
||||
than stacks on — the old suffix.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
idx = text.find(_KEY_QUOTES_HEADER)
|
||||
if idx >= 0:
|
||||
return text[:idx]
|
||||
return text
|
||||
|
||||
|
||||
def _build_key_quotes_suffix(conn: Connection, scene_id: int) -> str:
|
||||
"""If the scene's max-turn-significance is >= 2, build the
|
||||
"Key quotes:" suffix from the top-3 highest-significance memory rows
|
||||
(per requirements §11.1). Otherwise return the empty string so the
|
||||
per-POV summaries collapse fully (low-significance scenes lose all
|
||||
raw text in favor of the classifier rewrite).
|
||||
|
||||
Quote source is each memory's current ``pov_summary`` — the raw
|
||||
per-turn narrative seeded by T21, since this helper is called BEFORE
|
||||
the per-POV rewrite. Texts are truncated to 200 chars to bound
|
||||
memory row growth across many witnesses.
|
||||
|
||||
T80.1: candidate text is run through :func:`_strip_key_quotes_suffix`
|
||||
first so a re-close (whose source memories already carry a suffix from
|
||||
the prior close) doesn't quote a quote.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT MAX(significance) FROM memories WHERE scene_id = ?",
|
||||
(scene_id,),
|
||||
).fetchone()
|
||||
max_sig = (row[0] if row else None) or 0
|
||||
if max_sig < 2:
|
||||
return ""
|
||||
cur = conn.execute(
|
||||
"SELECT pov_summary FROM memories WHERE scene_id = ? "
|
||||
"ORDER BY significance DESC, id ASC LIMIT 3",
|
||||
(scene_id,),
|
||||
)
|
||||
quotes = [
|
||||
_strip_key_quotes_suffix(r[0] or "")[:200]
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
if not quotes:
|
||||
return ""
|
||||
lines = "\n".join(f'- "{q}"' for q in quotes)
|
||||
return f"\n\nKey quotes:\n{lines}"
|
||||
|
||||
|
||||
async def apply_scene_close_summary(
|
||||
conn: Connection,
|
||||
client: LLMClient,
|
||||
*,
|
||||
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``.
|
||||
|
||||
Phase 1 (single-bot) behavior — the host bot is summarized once and
|
||||
the result drives memory + edge rewrites — is preserved exactly when
|
||||
the chat has no guest. T45 extends this to fan out across each
|
||||
present bot witness when a guest is also in the room:
|
||||
|
||||
1. Gather the closing scene's dialogue from the event_log.
|
||||
2. For each present witness (host + guest if any), run
|
||||
:func:`summarize_scene` once with that witness's persona and
|
||||
their own prior ``bot -> you`` edge summary.
|
||||
3. For each witness independently:
|
||||
a. Rewrite each scene-bound memory's ``pov_summary`` via
|
||||
``manual_edit`` (target_kind ``memory_pov_summary``).
|
||||
b. Update that witness's ``bot -> you`` edge summary via
|
||||
``manual_edit`` (target_kind ``edge_summary``). v2 combines
|
||||
prior + classifier ``relationship_summary`` by simple
|
||||
concatenation.
|
||||
c. Append any ``knowledge_facts`` to the same edge via
|
||||
``edge_update``.
|
||||
4. If a ``group_node`` row exists for this chat, append a
|
||||
``group_node_updated`` event whose ``summary`` is the naive
|
||||
per-POV concat ``f"{name}: {summary}\\n\\n..."``. A true
|
||||
LLM-merged group view is deferred to Phase 2.5; ``dynamic``
|
||||
is left empty here for v2 (Phase 3 polishes it).
|
||||
|
||||
The host's :class:`ScenePOVSummary` is returned to preserve the
|
||||
Phase 1 callers' contract.
|
||||
"""
|
||||
# Local imports to keep the module-level surface tight and avoid
|
||||
# any chance of a circular dep through chat.state.*.
|
||||
from chat.services.thread_detection import detect_threads
|
||||
from chat.state.entities import get_bot, get_you
|
||||
from chat.state.group_node import get_group_node
|
||||
from chat.state.threads import list_open_threads
|
||||
from chat.state.world import get_chat, get_scene
|
||||
|
||||
you_entity = get_you(conn) or {"name": "you", "persona": ""}
|
||||
you_name = you_entity.get("name", "you") or "you"
|
||||
|
||||
chat = get_chat(conn, chat_id) or {}
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
|
||||
# T65: detect meanwhile child scenes via the migration-0011
|
||||
# ``present_set_kind`` column. The mechanism is a single field read
|
||||
# — meanwhile scenes carry ``"host_guest"``, regular you-scenes
|
||||
# carry the default ``"you_host"``. We read this once up front so
|
||||
# both the dialogue source and the post-summary digest emission
|
||||
# branches can reference it.
|
||||
closing_scene = get_scene(conn, scene_id) or {}
|
||||
is_meanwhile = closing_scene.get("present_set_kind") == "host_guest"
|
||||
|
||||
dialogue = _read_recent_dialogue(conn, chat_id)
|
||||
|
||||
# T58.1: build the "Key quotes:" suffix BEFORE the per-POV rewrites
|
||||
# land — quote source is the raw seeded pov_summary text on each
|
||||
# memory row, which the rewrite about to fire would clobber.
|
||||
key_quotes_suffix = _build_key_quotes_suffix(conn, scene_id)
|
||||
|
||||
host_pov = await _summarize_and_apply_for_witness(
|
||||
conn,
|
||||
client,
|
||||
classifier_model=classifier_model,
|
||||
chat_id=chat_id,
|
||||
scene_id=scene_id,
|
||||
bot_id=host_bot_id,
|
||||
you_name=you_name,
|
||||
dialogue=dialogue,
|
||||
timeout_s=timeout_s,
|
||||
key_quotes_suffix=key_quotes_suffix,
|
||||
)
|
||||
|
||||
guest_pov: ScenePOVSummary | None = None
|
||||
if guest_bot_id is not None:
|
||||
guest_pov = await _summarize_and_apply_for_witness(
|
||||
conn,
|
||||
client,
|
||||
classifier_model=classifier_model,
|
||||
chat_id=chat_id,
|
||||
scene_id=scene_id,
|
||||
bot_id=guest_bot_id,
|
||||
you_name=you_name,
|
||||
dialogue=dialogue,
|
||||
timeout_s=timeout_s,
|
||||
key_quotes_suffix=key_quotes_suffix,
|
||||
)
|
||||
|
||||
# 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
|
||||
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": merged.summary,
|
||||
"dynamic": merged.dynamic,
|
||||
},
|
||||
)
|
||||
|
||||
# T65: when the closing scene was a meanwhile child (host_guest
|
||||
# present set), generate an omniscient briefing for the absent
|
||||
# "you" and queue it as a pending digest. We reuse summarize_scene
|
||||
# with a narrator persona so the digest text is shaped by the same
|
||||
# classifier — only the ``summary`` field is consumed downstream.
|
||||
# Emitted AFTER per-POV summaries land so witness memories carry
|
||||
# their own POV text first; this mirrors how group_node_updated is
|
||||
# ordered relative to the per-POV writes above.
|
||||
if is_meanwhile:
|
||||
digest_pov = await summarize_scene(
|
||||
client,
|
||||
model=classifier_model,
|
||||
bot_name="Narrator",
|
||||
bot_persona=_MEANWHILE_DIGEST_PERSONA,
|
||||
you_name=you_name,
|
||||
prior_edge_summary="",
|
||||
dialogue=dialogue,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if digest_pov.summary:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="meanwhile_digest_created",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"scene_id": scene_id,
|
||||
"summary": digest_pov.summary,
|
||||
},
|
||||
)
|
||||
|
||||
# T58.2: thread detection on close. Failure-tolerant: classify()
|
||||
# returns the empty default on retry-exhaustion, and the broad except
|
||||
# below protects the close pipeline from any other classifier/mock
|
||||
# flap.
|
||||
#
|
||||
# T80.2: thread detection runs against a SCENE-SCOPED transcript,
|
||||
# not the chat-wide last-50 turns used by the per-POV summaries.
|
||||
# Mis-attributing threads when scene boundaries fall inside the last
|
||||
# 50 turns would otherwise close threads opened in a prior scene.
|
||||
scene_open_ev_id = _scene_opened_event_id(conn, chat_id, scene_id)
|
||||
if scene_open_ev_id is not None:
|
||||
scene_dialogue = _read_recent_dialogue(
|
||||
conn, chat_id, since_event_id=scene_open_ev_id
|
||||
)
|
||||
else:
|
||||
scene_dialogue = dialogue
|
||||
try:
|
||||
thread_result = await detect_threads(
|
||||
client,
|
||||
classifier_model=classifier_model,
|
||||
scene_transcript=scene_dialogue,
|
||||
open_threads=list_open_threads(conn, chat_id),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
except Exception as exc:
|
||||
# T80.3: log the swallowed exception at DEBUG so a
|
||||
# programmer-error flap (e.g. wrong kwarg name) surfaces in
|
||||
# local logs without breaking the close pipeline.
|
||||
_log.debug("detect_threads failed: %s", exc, exc_info=True)
|
||||
from chat.services.thread_detection import ThreadDetectionResult
|
||||
|
||||
thread_result = ThreadDetectionResult()
|
||||
for cand in thread_result.candidates:
|
||||
if cand.action == "open":
|
||||
new_thread_id = f"thr_{uuid.uuid4().hex[:12]}"
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_opened",
|
||||
payload={
|
||||
"thread_id": new_thread_id,
|
||||
"chat_id": chat_id,
|
||||
"title": cand.title,
|
||||
"summary": cand.summary,
|
||||
},
|
||||
)
|
||||
elif cand.action == "update" and cand.existing_thread_id:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_updated",
|
||||
payload={
|
||||
"thread_id": cand.existing_thread_id,
|
||||
"summary": cand.summary,
|
||||
"last_referenced_scene_id": scene_id,
|
||||
},
|
||||
)
|
||||
elif cand.action == "close" and cand.existing_thread_id:
|
||||
# T80.4: chat-clock time, not wall clock — the rest of the
|
||||
# close pipeline (memories, edges, scene_closed payloads)
|
||||
# uses chat["time"] so threads must agree. Falls back to
|
||||
# UTC now only when the chat row has no clock yet (defensive
|
||||
# — chat_state always seeds "time" via chat_created).
|
||||
chat_clock_at = chat.get("time") or datetime.now(
|
||||
timezone.utc
|
||||
).isoformat()
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_closed",
|
||||
payload={
|
||||
"thread_id": cand.existing_thread_id,
|
||||
"closed_at": chat_clock_at,
|
||||
},
|
||||
)
|
||||
|
||||
return host_pov
|
||||
|
||||
|
||||
class GroupMetaSummary(BaseModel):
|
||||
"""Classifier output: a merged group-level view of a closed scene.
|
||||
|
||||
Defaults are an empty no-op so callers can use the schema's default
|
||||
as a sentinel; in practice :func:`merge_group_summary` builds an
|
||||
explicit naive-concat fallback rather than returning these defaults
|
||||
directly so existing Phase 2 behavior is preserved on classifier
|
||||
failure.
|
||||
"""
|
||||
|
||||
summary: str = ""
|
||||
dynamic: str = ""
|
||||
|
||||
|
||||
_GROUP_MERGE_SYSTEM = (
|
||||
"Given two per-POV scene summaries from a 3-entity scene (you + "
|
||||
"host + guest), produce a coherent group-level summary capturing "
|
||||
"the shared events as both witnesses experienced them, plus a "
|
||||
"brief 'dynamic' note describing the trio's group dynamic during "
|
||||
"the scene. Output strict JSON matching schema."
|
||||
)
|
||||
|
||||
|
||||
# T65: meanwhile-scene digest. The "you" player was absent during this
|
||||
# scene; the digest is a short neutral briefing they'll read on the next
|
||||
# you-scene resume. Reuses the ScenePOVSummary schema so the same
|
||||
# `summarize_scene` helper can be called with a different persona — only
|
||||
# the ``summary`` field is used downstream.
|
||||
_MEANWHILE_DIGEST_PERSONA = (
|
||||
"an omniscient narrator briefing the absent player in 2-3 neutral "
|
||||
"sentences on what happened while they were away — no editorializing, "
|
||||
"no second-person address"
|
||||
)
|
||||
|
||||
|
||||
async def merge_group_summary(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
host_name: str,
|
||||
host_pov_summary: str,
|
||||
guest_name: str,
|
||||
guest_pov_summary: str,
|
||||
timeout_s: float = 30.0,
|
||||
) -> GroupMetaSummary:
|
||||
"""Merge two per-POV scene summaries into a coherent group-level
|
||||
summary + group-dynamic note. Falls back to the naive concat (the
|
||||
existing behavior) on classifier failure."""
|
||||
user = (
|
||||
f"{host_name} (host) POV summary:\n{host_pov_summary}\n\n"
|
||||
f"{guest_name} (guest) POV summary:\n{guest_pov_summary}"
|
||||
)
|
||||
fallback = GroupMetaSummary(
|
||||
summary=(
|
||||
f"{host_name}: {host_pov_summary}\n\n"
|
||||
f"{guest_name}: {guest_pov_summary}"
|
||||
),
|
||||
dynamic="",
|
||||
)
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_GROUP_MERGE_SYSTEM,
|
||||
user=user,
|
||||
schema=GroupMetaSummary,
|
||||
default=fallback,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Turn-level significance scorer (T22).
|
||||
|
||||
Per Requirements §11.1, each turn is scored on a 0-3 scale:
|
||||
|
||||
- 0 = Routine: small talk, ordinary action.
|
||||
- 1 = Notable: a specific detail or beat worth remembering.
|
||||
- 2 = Significant: a scene-level moment, real disagreement, confided secret.
|
||||
- 3 = Pivotal: a relationship-altering event (first kiss, betrayal, "I love
|
||||
you").
|
||||
|
||||
The scorer is conservative: pivotal (3) requires a clear signal because the
|
||||
auto-pin rule (§8.5) gives those memories permanent shelf space. The
|
||||
classifier returns a strict-JSON ``SignificanceVerdict``; a malformed or
|
||||
refusal-shaped response falls back to ``score=1`` (Notable) — a safe
|
||||
middle-of-the-road default that won't trigger auto-pin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class SignificanceVerdict(BaseModel):
|
||||
score: int = Field(ge=0, le=3)
|
||||
reason: str = ""
|
||||
|
||||
|
||||
_SYSTEM = """You score the significance of a roleplay turn 0-3:
|
||||
0 = Routine: small talk, ordinary action.
|
||||
1 = Notable: a specific detail or beat worth remembering.
|
||||
2 = Significant: a scene-level moment, real disagreement, confided secret.
|
||||
3 = Pivotal: a relationship-altering event (first kiss, betrayal, "I love you").
|
||||
|
||||
Be conservative — pivotal (3) requires a clear signal. Reply with JSON: {"score": int 0-3, "reason": str}."""
|
||||
|
||||
|
||||
async def compute_significance(
|
||||
client: LLMClient,
|
||||
*,
|
||||
model: str,
|
||||
narrative_text: str,
|
||||
prior_dialogue: list[dict],
|
||||
timeout_s: float = 10.0,
|
||||
) -> int:
|
||||
"""Score the significance of ``narrative_text`` (the just-written turn).
|
||||
|
||||
``prior_dialogue`` is a list of ``{"speaker", "text"}`` dicts ordered
|
||||
oldest-first; the last 6 entries are stitched into the user prompt as
|
||||
context so the classifier can recognize escalation. Returns an int in
|
||||
``[0, 3]`` — clamped defensively in case the classifier slips a value
|
||||
past the schema validator.
|
||||
"""
|
||||
user_prompt = "PRIOR DIALOGUE:\n"
|
||||
for turn in prior_dialogue[-6:]:
|
||||
speaker = turn.get("speaker", "?")
|
||||
text = turn.get("text", "")
|
||||
user_prompt += f"{speaker}: {text}\n"
|
||||
user_prompt += (
|
||||
f"\nNEW TURN:\n{narrative_text}\n\n"
|
||||
"Score the significance of the NEW TURN."
|
||||
)
|
||||
|
||||
result = await classify(
|
||||
client,
|
||||
model=model,
|
||||
system=_SYSTEM,
|
||||
user=user_prompt,
|
||||
schema=SignificanceVerdict,
|
||||
default=SignificanceVerdict(score=1, reason="fallback"),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
return max(0, min(3, result.score))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Skip narration service (T53).
|
||||
|
||||
Generates brief transition prose for elision and jump skips.
|
||||
|
||||
Skips come in two flavors that read very differently:
|
||||
|
||||
* **Elision** — collapses an in-progress activity into its expected
|
||||
end-state in 1-2 sentences, narrated from the speaker bot's POV.
|
||||
Example: "skip ahead to when we arrive" while the characters are
|
||||
driving — output describes pulling into the lot.
|
||||
* **Jump** — bridges a longer fiction-time delta ("next morning", "a
|
||||
week later") in 2-3 sentences, setting the scene at the new time.
|
||||
|
||||
Output is free-form prose, not structured JSON, so this service calls
|
||||
``client.generate`` directly rather than going through the classifier
|
||||
path used by, e.g., :mod:`chat.services.scene_summarize`. A
|
||||
deterministic template fallback fires on any LLM failure so the skip
|
||||
flow keeps moving even when the model is down — important because
|
||||
skips are a UI-blocking operation; we'd rather show a parenthetical
|
||||
sentence than hang the chat indefinitely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chat.llm.client import LLMClient, Message
|
||||
|
||||
|
||||
_ELISION_SYSTEM = (
|
||||
"You write a brief 1-2 sentence transition that elides the time "
|
||||
"between an in-progress activity and its expected end-state, "
|
||||
"narrated from the speaker's POV. Keep it grounded and concrete. "
|
||||
"Do not invent new events or characters."
|
||||
)
|
||||
|
||||
_JUMP_SYSTEM = (
|
||||
"You write a brief 2-3 sentence transition narrating a jump in "
|
||||
"fiction time (e.g., 'next morning', 'a week later'), narrated "
|
||||
"from the speaker's POV. Set the scene at the new time. Keep it "
|
||||
"grounded — no invented major events. If a landing-state hint is "
|
||||
"provided, weave it in naturally."
|
||||
)
|
||||
|
||||
|
||||
async def narrate_skip(
|
||||
client: LLMClient,
|
||||
*,
|
||||
narrative_model: str,
|
||||
skip_kind: str,
|
||||
speaker_bot: dict,
|
||||
you_name: str,
|
||||
current_time: str,
|
||||
new_time: str,
|
||||
current_activity: str,
|
||||
landing_state_hint: str = "",
|
||||
timeout_s: float = 60.0,
|
||||
) -> str:
|
||||
"""Generate brief transition prose for a time skip.
|
||||
|
||||
``skip_kind`` is ``"elision"`` or ``"jump"``; any other value short-
|
||||
circuits to the deterministic fallback (defensive — callers
|
||||
shouldn't be inventing new kinds without updating this service).
|
||||
|
||||
Returns plain text. Never raises: any LLM error, an empty/blank
|
||||
result, or an unknown ``skip_kind`` falls back to a parenthetical
|
||||
template like ``"(next morning: having coffee in the kitchen.)"``
|
||||
so the skip UI always has *something* to render.
|
||||
"""
|
||||
fallback = _build_fallback(
|
||||
skip_kind=skip_kind,
|
||||
new_time=new_time,
|
||||
current_activity=current_activity,
|
||||
landing_state_hint=landing_state_hint,
|
||||
)
|
||||
|
||||
if skip_kind not in ("elision", "jump"):
|
||||
return fallback
|
||||
|
||||
system = _ELISION_SYSTEM if skip_kind == "elision" else _JUMP_SYSTEM
|
||||
user = (
|
||||
f"Speaker: {speaker_bot.get('name', 'speaker')}\n"
|
||||
f"Persona: {speaker_bot.get('persona', '')}\n"
|
||||
f"Other party: {you_name}\n"
|
||||
f"Current time: {current_time}\n"
|
||||
f"New time: {new_time}\n"
|
||||
f"Current activity: {current_activity}\n"
|
||||
)
|
||||
if landing_state_hint:
|
||||
user += f"Landing state hint: {landing_state_hint}\n"
|
||||
|
||||
try:
|
||||
result = await client.generate(
|
||||
[
|
||||
Message(role="system", content=system),
|
||||
Message(role="user", content=user),
|
||||
],
|
||||
model=narrative_model,
|
||||
max_tokens=200,
|
||||
temperature=0.7,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
text = (result or "").strip()
|
||||
if not text:
|
||||
return fallback
|
||||
return text
|
||||
except Exception:
|
||||
# Any failure — network blip, timeout, mock raising in tests —
|
||||
# collapses to the deterministic template so the skip pipeline
|
||||
# is never blocked on the LLM being available.
|
||||
return fallback
|
||||
|
||||
|
||||
def _build_fallback(
|
||||
*,
|
||||
skip_kind: str,
|
||||
new_time: str,
|
||||
current_activity: str,
|
||||
landing_state_hint: str,
|
||||
) -> str:
|
||||
"""Deterministic parenthetical narration used when the LLM fails.
|
||||
|
||||
Both flavors render the same shape today: ``(<new_time>:
|
||||
<detail>.)``. They're separated as branches to make it easy to
|
||||
diverge later (e.g. an elision-specific template) without churning
|
||||
the call site or the public signature.
|
||||
"""
|
||||
detail = landing_state_hint or current_activity or "moments later"
|
||||
if skip_kind == "elision":
|
||||
return f"({new_time}: {detail}.)"
|
||||
return f"({new_time}: {detail}.)"
|
||||
|
||||
|
||||
__all__ = ["narrate_skip"]
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Snapshot service — write a JSON dump of all projected tables to disk.
|
||||
|
||||
Two snapshot kinds, both covered by this module:
|
||||
|
||||
* ``rewind`` (T28, Requirements §10.1): pre-rewind safety snapshot so the
|
||||
user can recover if a rewind was a mistake. Retention: 14 days.
|
||||
* ``periodic`` (T31, Requirements §10.4): full-state checkpoint taken
|
||||
every 100 events OR every 30 minutes since the last one. Retention:
|
||||
the most recent 5 are kept; older ones are pruned on write.
|
||||
|
||||
Both kinds live under ``data/snapshots/{kind}/`` with a UTC timestamp
|
||||
filename so chronological listing matches creation order.
|
||||
|
||||
The dump captures the event log (so the original event sequence is
|
||||
preserved verbatim), every projected table, and a top-level
|
||||
``last_event_id`` recording the highest ``event_log.id`` at snapshot
|
||||
time. The ``last_event_id`` is what the cold-load fast-path uses to
|
||||
replay only events past the snapshot rather than the entire log.
|
||||
|
||||
The FTS shadow table ``memories_fts`` is intentionally skipped — it's a
|
||||
virtual table maintained by the ``memories_ai/au/ad`` triggers, so it
|
||||
rebuilds itself on a memories re-load. Snapshotting it would also fail
|
||||
``PRAGMA table_info`` cleanly since FTS5 reports its columns differently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from sqlite3 import Connection
|
||||
|
||||
# Periodic snapshot triggers (Requirements §10.4): "every 100 events OR
|
||||
# every 30 minutes since last snapshot". Module-level so tests can read
|
||||
# them and so the values stay together with the policy that uses them.
|
||||
EVENT_COUNT_THRESHOLD = 100
|
||||
TIME_THRESHOLD_SECONDS = 30 * 60 # 30 minutes
|
||||
|
||||
# Order doesn't affect correctness for snapshotting (we read, not write),
|
||||
# but listing tables explicitly keeps the snapshot stable across schema
|
||||
# evolution: a new table won't silently change the dump shape until it's
|
||||
# added here.
|
||||
PROJECTED_TABLES = [
|
||||
"bots",
|
||||
"you_entity",
|
||||
"edges",
|
||||
"memories",
|
||||
"memories_fts",
|
||||
"chats",
|
||||
"chat_state",
|
||||
"containers",
|
||||
"scenes",
|
||||
"activity",
|
||||
"classifier_failures",
|
||||
]
|
||||
|
||||
|
||||
def take_snapshot(
|
||||
conn: Connection, *, data_dir: Path, kind: str = "rewind"
|
||||
) -> Path:
|
||||
"""Write a JSON dump of the event log and projected tables.
|
||||
|
||||
Returns the path to the written snapshot file. Creates parent
|
||||
directories as needed. Filename is a UTC timestamp in
|
||||
``YYYYMMDDTHHMMSSZ`` form so chronological listing matches creation
|
||||
order.
|
||||
|
||||
The dump's top-level ``last_event_id`` is the highest ``event_log.id``
|
||||
at snapshot time (0 if the log is empty). This is what the cold-load
|
||||
fast-path uses to know which suffix of the log to replay.
|
||||
"""
|
||||
snapshot_dir = data_dir / "snapshots" / kind
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
path = snapshot_dir / f"{timestamp}.json"
|
||||
|
||||
dump: dict = {}
|
||||
|
||||
# Record the high-water-mark id up front so cold-load can replay
|
||||
# only events past it. ``MAX(id)`` is None on an empty log; treat
|
||||
# that as 0 (i.e. "replay everything").
|
||||
cur = conn.execute("SELECT MAX(id) FROM event_log")
|
||||
max_id_row = cur.fetchone()
|
||||
dump["last_event_id"] = max_id_row[0] if max_id_row[0] is not None else 0
|
||||
|
||||
# Event log: pull every column we care about. ``ts`` and the
|
||||
# superseded/hidden flags are needed to faithfully reconstruct the
|
||||
# log on restore.
|
||||
cur = conn.execute(
|
||||
"SELECT id, branch_id, ts, kind, payload_json, superseded_by, hidden "
|
||||
"FROM event_log ORDER BY id"
|
||||
)
|
||||
dump["event_log"] = [
|
||||
{
|
||||
"id": r[0],
|
||||
"branch_id": r[1],
|
||||
"ts": r[2],
|
||||
"kind": r[3],
|
||||
"payload_json": r[4],
|
||||
"superseded_by": r[5],
|
||||
"hidden": r[6],
|
||||
}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
for table in PROJECTED_TABLES:
|
||||
if table == "memories_fts":
|
||||
# Virtual FTS5 table — rebuilt by triggers on insert, no need
|
||||
# to snapshot it (and ``PRAGMA table_info`` reports its
|
||||
# columns differently).
|
||||
continue
|
||||
cur = conn.execute(f"PRAGMA table_info({table})")
|
||||
cols = [c[1] for c in cur.fetchall()]
|
||||
if not cols:
|
||||
# Table not present in this schema version — leave an empty
|
||||
# list rather than raising, so older snapshots can survive.
|
||||
dump[table] = []
|
||||
continue
|
||||
cur = conn.execute(f"SELECT {', '.join(cols)} FROM {table}")
|
||||
dump[table] = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
|
||||
# ``default=str`` covers Path-like or datetime values that might
|
||||
# sneak through if a column ever stored them; the projected tables
|
||||
# all use TEXT so this is mostly defensive.
|
||||
path.write_text(json.dumps(dump, default=str))
|
||||
return path
|
||||
|
||||
|
||||
def latest_snapshot_path(data_dir: Path, kind: str = "periodic") -> Path | None:
|
||||
"""Return the most recent snapshot file for ``kind``, or None if none exist.
|
||||
|
||||
Sorting by filename works because :func:`take_snapshot` uses a UTC
|
||||
timestamp in ``YYYYMMDDTHHMMSSZ`` form — lexicographic order matches
|
||||
chronological order.
|
||||
"""
|
||||
snapshot_dir = data_dir / "snapshots" / kind
|
||||
if not snapshot_dir.exists():
|
||||
return None
|
||||
files = sorted(snapshot_dir.glob("*.json"))
|
||||
return files[-1] if files else None
|
||||
|
||||
|
||||
def should_take_periodic_snapshot(
|
||||
conn: Connection, data_dir: Path
|
||||
) -> bool:
|
||||
"""Decide whether a periodic snapshot is due per Requirements §10.4.
|
||||
|
||||
The policy:
|
||||
|
||||
* No prior snapshot and at least one event in the log → take one.
|
||||
* Time since last snapshot ≥ ``TIME_THRESHOLD_SECONDS`` → take one.
|
||||
* New events since last snapshot's ``last_event_id`` ≥
|
||||
``EVENT_COUNT_THRESHOLD`` → take one.
|
||||
|
||||
"Time since last snapshot" is measured by the file's mtime — we
|
||||
don't trust the timestamp embedded in the filename for clock drift
|
||||
reasons.
|
||||
"""
|
||||
latest = latest_snapshot_path(data_dir, kind="periodic")
|
||||
if latest is None:
|
||||
# No prior snapshot; take one if there are any events to capture.
|
||||
cur = conn.execute("SELECT COUNT(*) FROM event_log")
|
||||
return cur.fetchone()[0] > 0
|
||||
|
||||
age_seconds = time.time() - latest.stat().st_mtime
|
||||
if age_seconds >= TIME_THRESHOLD_SECONDS:
|
||||
return True
|
||||
|
||||
# Count events appended since the last snapshot was written. Reading
|
||||
# ``last_event_id`` from the dump is cheap (a few KB at most for the
|
||||
# header) but we still avoid loading the full file by parsing once.
|
||||
last_dump = json.loads(latest.read_text())
|
||||
last_event_id = last_dump.get("last_event_id", 0)
|
||||
cur = conn.execute(
|
||||
"SELECT COUNT(*) FROM event_log WHERE id > ?", (last_event_id,)
|
||||
)
|
||||
new_event_count = cur.fetchone()[0]
|
||||
return new_event_count >= EVENT_COUNT_THRESHOLD
|
||||
|
||||
|
||||
def prune_periodic_snapshots(data_dir: Path, keep: int = 5) -> int:
|
||||
"""Delete all but the most recent ``keep`` periodic snapshots.
|
||||
|
||||
Returns the number of files removed. Safe to call when the directory
|
||||
doesn't exist (returns 0). Sorting is by filename, which is the UTC
|
||||
timestamp — same ordering :func:`latest_snapshot_path` uses.
|
||||
"""
|
||||
snapshot_dir = data_dir / "snapshots" / "periodic"
|
||||
if not snapshot_dir.exists():
|
||||
return 0
|
||||
files = sorted(snapshot_dir.glob("*.json"))
|
||||
to_remove = files[:-keep] if len(files) > keep else []
|
||||
for f in to_remove:
|
||||
f.unlink()
|
||||
return len(to_remove)
|
||||
|
||||
|
||||
def restore_from_snapshot(conn: Connection, snapshot_path: Path) -> int:
|
||||
"""Restore projected tables from ``snapshot_path``.
|
||||
|
||||
Returns the snapshot's ``last_event_id`` so callers (the cold-load
|
||||
fast-path in :func:`chat.app.lifespan`) know what suffix of the
|
||||
event log still needs replaying.
|
||||
|
||||
Projected tables are cleared in the same FK-respecting order as
|
||||
:func:`chat.services.rewind.execute_rewind`, then re-populated from
|
||||
the dump. ``memories_fts`` is skipped — it's a virtual FTS5 table
|
||||
that rebuilds itself when rows hit ``memories``. The event log
|
||||
itself is *not* touched: cold-load assumes the on-disk log is the
|
||||
source of truth and the snapshot is just a fast-forward to skip
|
||||
re-projecting old events.
|
||||
"""
|
||||
dump = json.loads(snapshot_path.read_text())
|
||||
|
||||
# Same delete order as rewind: child tables before parents so FK
|
||||
# ON DELETE doesn't fire on referenced rows.
|
||||
conn.execute("DELETE FROM memories")
|
||||
conn.execute("DELETE FROM activity")
|
||||
conn.execute("DELETE FROM scenes")
|
||||
conn.execute("DELETE FROM containers")
|
||||
conn.execute("DELETE FROM chat_state")
|
||||
conn.execute("DELETE FROM chats")
|
||||
conn.execute("DELETE FROM edges")
|
||||
conn.execute("DELETE FROM bots")
|
||||
conn.execute("DELETE FROM you_entity")
|
||||
conn.execute("DELETE FROM classifier_failures")
|
||||
|
||||
for table in PROJECTED_TABLES:
|
||||
if table == "memories_fts":
|
||||
# Rebuilt by triggers when memories rows are inserted below.
|
||||
continue
|
||||
rows = dump.get(table, [])
|
||||
if not rows:
|
||||
continue
|
||||
cols = list(rows[0].keys())
|
||||
placeholders = ", ".join("?" * len(cols))
|
||||
col_list = ", ".join(cols)
|
||||
for row in rows:
|
||||
conn.execute(
|
||||
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
|
||||
tuple(row[c] for c in cols),
|
||||
)
|
||||
|
||||
return dump.get("last_event_id", 0)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Post-turn state-update pass.
|
||||
|
||||
Per Requirements §3.4, after every utterance we run a classifier on each
|
||||
present entity (silent witnesses included) to extract directed-edge
|
||||
deltas — what changed in *source*'s view of *target*. The classifier
|
||||
returns three signals:
|
||||
|
||||
- ``affinity_delta`` — signed change in how warmly source feels (typical
|
||||
range -3..+3; the edge handler clamps the running total to 0..100).
|
||||
- ``trust_delta`` — signed change in source's trust of target (same
|
||||
shape).
|
||||
- ``knowledge_facts`` — concrete things source learned about target
|
||||
during this exchange. Stored verbatim and appended to ``edge.knowledge``.
|
||||
|
||||
The wrapper deliberately uses :func:`chat.llm.classify.classify` with a
|
||||
``default=StateUpdate()`` so a flapping classifier never blocks the turn
|
||||
flow — at worst the edge sits unchanged and the next turn tries again
|
||||
(§3.3 "graceful degradation" rule).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class StateUpdate(BaseModel):
|
||||
"""One directed-edge update from a single classifier call.
|
||||
|
||||
Defaults are deliberately a no-op (zero deltas, empty facts) so a
|
||||
failing classifier produces a benign event rather than a disruption.
|
||||
"""
|
||||
|
||||
affinity_delta: int = 0
|
||||
trust_delta: int = 0
|
||||
knowledge_facts: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are reading a recent slice of dialogue from a roleplay scene. "
|
||||
"You assess how SOURCE's view of TARGET shifted based on what was "
|
||||
"said — including silent witnessing (SOURCE may not have spoken).\n\n"
|
||||
"Output a JSON object with exactly three fields:\n"
|
||||
"- affinity_delta: signed integer in [-3, 3]. How much warmer "
|
||||
"(positive) or cooler (negative) SOURCE now feels toward TARGET.\n"
|
||||
"- trust_delta: signed integer in [-3, 3]. How much more (positive) "
|
||||
"or less (negative) SOURCE now trusts TARGET.\n"
|
||||
"- knowledge_facts: list of short strings. New, concrete facts "
|
||||
"SOURCE learned about TARGET in this exchange. Use TARGET's actual "
|
||||
"stated content; do not infer or interpret. Empty list is fine.\n\n"
|
||||
"Be conservative. Most turns produce small deltas (-1, 0, +1). "
|
||||
"Reserve +/-2 or +/-3 for moments that materially shift the "
|
||||
"relationship. Knowledge_facts should be specific things stated in "
|
||||
"dialogue (e.g. \"works at the bakery\"), not interpretations "
|
||||
"(\"seems lonely\")."
|
||||
)
|
||||
|
||||
|
||||
def _format_dialogue(recent_dialogue: list[dict]) -> str:
|
||||
"""Render the recent-dialogue slice as plain ``Speaker: text`` lines."""
|
||||
if not recent_dialogue:
|
||||
return "(no dialogue yet)"
|
||||
lines = []
|
||||
for turn in recent_dialogue:
|
||||
speaker = turn.get("speaker", "?")
|
||||
text = turn.get("text", "")
|
||||
lines.append(f"{speaker}: {text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_user_prompt(
|
||||
*,
|
||||
source_name: str,
|
||||
source_persona: str,
|
||||
target_name: str,
|
||||
prior_affinity: int,
|
||||
prior_trust: int,
|
||||
prior_summary: str,
|
||||
recent_dialogue: list[dict],
|
||||
) -> str:
|
||||
return (
|
||||
f"SOURCE: {source_name}\n"
|
||||
f"SOURCE_PERSONA: {source_persona or '(none)'}\n"
|
||||
f"TARGET: {target_name}\n"
|
||||
f"PRIOR_AFFINITY (0-100): {prior_affinity}\n"
|
||||
f"PRIOR_TRUST (0-100): {prior_trust}\n"
|
||||
f"PRIOR_SUMMARY: {prior_summary or '(none)'}\n\n"
|
||||
f"RECENT_DIALOGUE:\n{_format_dialogue(recent_dialogue)}\n\n"
|
||||
"How did SOURCE's view of TARGET shift? Respond with JSON only."
|
||||
)
|
||||
|
||||
|
||||
async def compute_state_update(
|
||||
client: LLMClient,
|
||||
*,
|
||||
model: str,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
source_name: str,
|
||||
source_persona: str,
|
||||
target_name: str,
|
||||
prior_affinity: int,
|
||||
prior_trust: int,
|
||||
prior_summary: str,
|
||||
recent_dialogue: list[dict],
|
||||
timeout_s: float = 10.0,
|
||||
) -> StateUpdate:
|
||||
"""Run a classifier pass and return the directed-edge update.
|
||||
|
||||
On classifier failure (after retry) returns the schema default — a
|
||||
no-op ``StateUpdate`` — so the turn flow can keep moving. The
|
||||
``source_id`` / ``target_id`` arguments are accepted for symmetry
|
||||
with the caller (T20's POST flow uses them when emitting the
|
||||
``edge_update`` event); they're not currently embedded in the
|
||||
prompt because the classifier reasons about names, not opaque ids.
|
||||
"""
|
||||
# ``source_id``/``target_id`` are kept on the signature even though
|
||||
# the prompt only quotes the names: callers in turns.py thread the
|
||||
# ids straight from this function's args into the appended event.
|
||||
del source_id, target_id # silence unused-arg lint cleanly
|
||||
|
||||
user_prompt = _build_user_prompt(
|
||||
source_name=source_name,
|
||||
source_persona=source_persona,
|
||||
target_name=target_name,
|
||||
prior_affinity=prior_affinity,
|
||||
prior_trust=prior_trust,
|
||||
prior_summary=prior_summary,
|
||||
recent_dialogue=recent_dialogue,
|
||||
)
|
||||
|
||||
return await classify(
|
||||
client,
|
||||
model=model,
|
||||
system=_SYSTEM_PROMPT,
|
||||
user=user_prompt,
|
||||
schema=StateUpdate,
|
||||
default=StateUpdate(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Synthesized-memories service (T54).
|
||||
|
||||
When the user jump-skips with 'anything notable happen?' prose, parse
|
||||
that prose into 1-N synthesized memories per present bot. Each memory
|
||||
carries source="synthesized" and reliability=0.7 (lower than direct).
|
||||
Caller (T62 skip flow) writes the memories via record_turn_memory_for_present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class SynthesizedMemory(BaseModel):
|
||||
text: str
|
||||
significance: int = 1 # 0..3, default 1
|
||||
affinity_delta: int = 0
|
||||
trust_delta: int = 0
|
||||
|
||||
|
||||
class SynthesizedDigest(BaseModel):
|
||||
memories: list[SynthesizedMemory] = Field(default_factory=list)
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"You parse a short user-supplied prose describing 'anything notable' "
|
||||
"that happened during a time skip into 1-N synthesized memories from "
|
||||
"a single bot's POV. Each memory has: text (one factual sentence "
|
||||
"from that bot's perspective), significance (0-3, default 1; only "
|
||||
"use 2 or 3 for genuinely scene-level or relationship-altering "
|
||||
"events), affinity_delta and trust_delta (-10..+10, default 0; "
|
||||
"use small adjustments only when prose explicitly describes a shift). "
|
||||
"Empty/whitespace prose returns an empty memories list. Output "
|
||||
"strict JSON matching the schema."
|
||||
)
|
||||
|
||||
|
||||
async def synthesize_memories(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
prose: str,
|
||||
bot_name: str,
|
||||
bot_persona: str,
|
||||
you_name: str,
|
||||
timeout_s: float = 30.0,
|
||||
) -> SynthesizedDigest:
|
||||
"""Parse 'anything notable' prose into structured memories from a
|
||||
single bot's POV. Empty/whitespace prose short-circuits to an
|
||||
empty digest (no LLM call)."""
|
||||
if not prose or not prose.strip():
|
||||
return SynthesizedDigest()
|
||||
|
||||
user = (
|
||||
f"Bot: {bot_name}\n"
|
||||
f"Persona: {bot_persona}\n"
|
||||
f"Other party: {you_name}\n\n"
|
||||
f"Prose:\n{prose.strip()}"
|
||||
)
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=SynthesizedDigest,
|
||||
default=SynthesizedDigest(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SynthesizedMemory", "SynthesizedDigest", "synthesize_memories"]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Thread-detection service (T55).
|
||||
|
||||
On scene close, classify the transcript into thread open/update/close
|
||||
candidates. Returns ThreadCandidate list; caller (T58 scene compression)
|
||||
emits one thread_opened/thread_updated/thread_closed event per candidate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class ThreadCandidate(BaseModel):
|
||||
action: str # "open" | "update" | "close"
|
||||
title: str = "" # required for "open"; ignored otherwise
|
||||
summary: str = ""
|
||||
existing_thread_id: str | None = None # required for "update" / "close"
|
||||
|
||||
|
||||
class ThreadDetectionResult(BaseModel):
|
||||
candidates: list[ThreadCandidate] = Field(default_factory=list)
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"You analyze a closed scene's transcript to identify narrative "
|
||||
"threads (unresolved arcs, dangling questions, promises made, "
|
||||
"open obligations). Choose actions:\n"
|
||||
"- 'open': a NEW thread the scene introduced. Provide title (short "
|
||||
"noun phrase) + summary (one sentence).\n"
|
||||
"- 'update': an EXISTING open thread that the scene developed. "
|
||||
"Provide existing_thread_id + new summary.\n"
|
||||
"- 'close': an EXISTING open thread that the scene resolved. "
|
||||
"Provide existing_thread_id; summary may capture the resolution.\n"
|
||||
"Conservative bias: most scenes do NOT open new threads. Only "
|
||||
"produce candidates when the transcript clearly justifies them. "
|
||||
"Output strict JSON matching the schema."
|
||||
)
|
||||
|
||||
|
||||
async def detect_threads(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
scene_transcript: list[dict], # [{speaker, text}, ...]
|
||||
open_threads: list[dict], # [{thread_id, title, summary}, ...]
|
||||
timeout_s: float = 30.0,
|
||||
) -> ThreadDetectionResult:
|
||||
"""Classify scene close into thread open/update/close candidates."""
|
||||
if not scene_transcript:
|
||||
return ThreadDetectionResult()
|
||||
|
||||
transcript_lines = [
|
||||
f"{turn.get('speaker', 'unknown')}: {turn.get('text', '')}"
|
||||
for turn in scene_transcript
|
||||
]
|
||||
threads_lines = []
|
||||
if open_threads:
|
||||
threads_lines.append("Currently open threads:")
|
||||
for t in open_threads:
|
||||
threads_lines.append(
|
||||
f"- thread_id={t['thread_id']} "
|
||||
f"title={t.get('title', '')} "
|
||||
f"summary={t.get('summary', '')}"
|
||||
)
|
||||
else:
|
||||
threads_lines.append("No currently open threads.")
|
||||
|
||||
user = (
|
||||
"Scene transcript:\n"
|
||||
+ "\n".join(transcript_lines)
|
||||
+ "\n\n"
|
||||
+ "\n".join(threads_lines)
|
||||
)
|
||||
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=ThreadDetectionResult,
|
||||
default=ThreadDetectionResult(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ThreadCandidate", "ThreadDetectionResult", "detect_threads"]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Shared helpers for turn flows (T83.2).
|
||||
|
||||
Both ``chat.web.turns.post_turn`` and
|
||||
``chat.services.regenerate.regenerate_assistant_turn`` need to:
|
||||
|
||||
1. Pull a chronological tail of user-side and assistant_turn events for
|
||||
prompt assembly + state-update inputs.
|
||||
2. Build a directed-edge dict over a fixed set of "present" entity ids
|
||||
for the multi-pair state-update pass (with the schema 50/50 default
|
||||
filled in for missing rows).
|
||||
|
||||
Before T83.2 each call site had its own copy of these blocks. The two
|
||||
copies drifted on details (T73.1 added ``user_turn_edit`` handling to
|
||||
turns.py; regenerate.py had a slightly different recent-window query).
|
||||
This module is the single source so a future change to either lands in
|
||||
both flows by construction.
|
||||
|
||||
Note on overlap with ``chat.services.scene_summarize._read_recent_dialogue``:
|
||||
that helper has a ``since_event_id`` clamp (T80.2 thread-detection
|
||||
scope) and intentionally does NOT include ``user_turn_edit`` events —
|
||||
its callers want the *original* prose, not edits. Deduplicating it
|
||||
into here would either (a) require a new flag on the shared helper for
|
||||
``user_turn_edit`` inclusion, or (b) silently change scene_summarize's
|
||||
read shape. Both feel more invasive than the duplication is bad, so
|
||||
that helper is left alone for now.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.state.edges import get_edge
|
||||
|
||||
|
||||
def read_recent_dialogue(
|
||||
conn: Connection,
|
||||
chat_id: str,
|
||||
*,
|
||||
limit: int = 50,
|
||||
exclude_event_id: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""Pull the last ``limit`` user-side / assistant_turn events for
|
||||
``chat_id`` as ``[{"speaker": <id-or-"you">, "text": <prose>}]``,
|
||||
chronologically ordered (oldest first).
|
||||
|
||||
Filters: ``superseded_by IS NULL AND hidden = 0`` — regenerated
|
||||
rows drop out so the timeline reflects the current state. Includes
|
||||
``user_turn``, ``user_turn_edit`` (T29 edited prose substitutes for
|
||||
the original — the original is marked superseded above), and
|
||||
``assistant_turn`` rows.
|
||||
|
||||
``exclude_event_id`` is an optional event_log id to skip — used by
|
||||
regenerate to drop the original assistant_turn from its prompt
|
||||
context window before that row has been marked superseded (the
|
||||
supersede UPDATE lands at the end so the new event_id is known).
|
||||
|
||||
T90.1: the chat_id filter is pushed into SQL via ``json_extract`` so
|
||||
``LIMIT N`` always returns N rows scoped to the requested chat. The
|
||||
previous implementation filtered chat_id post-fetch in Python, which
|
||||
let foreign-chat rows fill the LIMIT and yield fewer than N relevant
|
||||
rows in busy multi-chat databases.
|
||||
"""
|
||||
if exclude_event_id is None:
|
||||
cur = conn.execute(
|
||||
"SELECT id, kind, payload_json FROM event_log "
|
||||
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
|
||||
" AND superseded_by IS NULL AND hidden = 0 "
|
||||
" AND json_extract(payload_json, '$.chat_id') = ? "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(chat_id, limit),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"SELECT id, kind, payload_json FROM event_log "
|
||||
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
|
||||
" AND id != ? "
|
||||
" AND superseded_by IS NULL AND hidden = 0 "
|
||||
" AND json_extract(payload_json, '$.chat_id') = ? "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(exclude_event_id, chat_id, limit),
|
||||
)
|
||||
rows = list(reversed(cur.fetchall()))
|
||||
out: list[dict] = []
|
||||
for row_id, kind, payload_json in rows:
|
||||
p = json.loads(payload_json)
|
||||
if kind in ("user_turn", "user_turn_edit"):
|
||||
out.append(
|
||||
{
|
||||
"speaker": "you",
|
||||
"text": p.get("prose", ""),
|
||||
"event_id": row_id,
|
||||
}
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
{
|
||||
"speaker": p.get("speaker_id", "bot"),
|
||||
"text": p.get("text", ""),
|
||||
"event_id": row_id,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def gather_prior_edges(
|
||||
conn: Connection, present_ids: list[str]
|
||||
) -> dict[tuple[str, str], dict]:
|
||||
"""Build ``{(src, tgt): {affinity, trust, summary}}`` for every
|
||||
directed pair where both ``src`` and ``tgt`` are in ``present_ids``
|
||||
and ``src != tgt``.
|
||||
|
||||
Missing rows fall back to the schema default 50/50 baseline (mirrors
|
||||
the Phase 1 single-pair flow). Used by post_turn and regenerate to
|
||||
seed the multi-pair state-update classifier.
|
||||
"""
|
||||
prior_edges: dict[tuple[str, str], dict] = {}
|
||||
for src in present_ids:
|
||||
for tgt in present_ids:
|
||||
if src == tgt:
|
||||
continue
|
||||
edge = get_edge(conn, src, tgt) or {
|
||||
"affinity": 50,
|
||||
"trust": 50,
|
||||
"summary": "",
|
||||
}
|
||||
prior_edges[(src, tgt)] = edge
|
||||
return prior_edges
|
||||
|
||||
|
||||
__all__ = ["read_recent_dialogue", "gather_prior_edges"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Turn input parser.
|
||||
|
||||
Service-layer function that splits a user's authored turn into typed
|
||||
segments — ``dialogue``, ``action``, or ``ooc`` (out-of-character).
|
||||
|
||||
Per Requirements §6.1 a turn is mixed prose with three conventions:
|
||||
|
||||
- ``*action*`` (single asterisks around prose) → action segment.
|
||||
- Quoted text, or bare prose between the conventions → dialogue.
|
||||
- ``((double parens))`` → OOC, the author talking to the system rather
|
||||
than the bot. Downstream (T19) strips OOC from the prompt sent to the
|
||||
bot but keeps it in the transcript display.
|
||||
|
||||
A regex-based splitter would brittle on edge cases (unclosed asterisks,
|
||||
nested quotes, mixed punctuation), so v1 delegates the segmentation to
|
||||
the classifier. The configurable ``Settings.ooc_marker`` is *not* read
|
||||
here: the classifier figures OOC out from ``((`` ``))`` regardless of
|
||||
config-time choice; marker-based stripping is a downstream concern.
|
||||
|
||||
T62 extends the parser with an ``intent`` field so the turn flow can
|
||||
short-circuit time-skip phrases before the regular narrative path.
|
||||
``intent`` defaults to ``"narrative"``; the classifier may set it to
|
||||
``"skip_elision"`` when prose like "skip to when we arrive" or
|
||||
``"skip_jump"`` when prose like "next morning" / "a week later" is
|
||||
detected. ``landing_state_hint`` carries the residual descriptor for
|
||||
elision skips (the "to when we ..." phrase). Existing callers that
|
||||
don't read ``intent`` continue to work because the default keeps the
|
||||
narrative path intact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class TurnSegment(BaseModel):
|
||||
"""One classified piece of a turn.
|
||||
|
||||
``kind`` is kept as a plain ``str`` (not a ``Literal``) so an
|
||||
unexpected classifier output doesn't crash parsing — callers that
|
||||
care about specific values can check defensively.
|
||||
"""
|
||||
|
||||
kind: str # "dialogue" | "action" | "ooc"
|
||||
text: str
|
||||
|
||||
|
||||
class ParsedTurn(BaseModel):
|
||||
"""A turn split into ordered, typed segments.
|
||||
|
||||
``intent`` distinguishes a regular narrative beat (the default) from
|
||||
a natural-language time-skip command (T62). ``landing_state_hint``
|
||||
captures the descriptor following "skip to when we ..." for elision
|
||||
skips so the downstream skip controller can pass it to the
|
||||
narration helper. Both fields are optional and default-empty so
|
||||
older fixtures and tests that don't supply them keep working.
|
||||
"""
|
||||
|
||||
segments: list[TurnSegment]
|
||||
intent: str = "narrative" # "narrative" | "skip_elision" | "skip_jump"
|
||||
landing_state_hint: str = ""
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are splitting a roleplay turn into typed segments. The input "
|
||||
"is mixed prose with three conventions:\n"
|
||||
"- *text in single asterisks* is an ACTION segment.\n"
|
||||
"- \"quoted text\" or bare prose between conventions is a DIALOGUE segment.\n"
|
||||
"- ((text in double parens)) is an OOC (out-of-character) segment — "
|
||||
"the author talking to the system, not the in-fiction bot.\n\n"
|
||||
"Output a JSON object with shape "
|
||||
'{"segments": [{"kind": "...", "text": "..."}, ...], '
|
||||
'"intent": "...", "landing_state_hint": "..."} '
|
||||
"where each segment ``kind`` is exactly one of: dialogue, action, "
|
||||
"ooc. Preserve the original substring text as ``text``: do not "
|
||||
"rewrite, translate, or normalize punctuation — strip only the "
|
||||
"marker characters (asterisks, surrounding quotes, double parens) "
|
||||
"so ``text`` is the inner content. Emit segments in the order they "
|
||||
"appear in the input.\n\n"
|
||||
"``intent`` is exactly one of: narrative, skip_elision, skip_jump. "
|
||||
"Default to ``narrative``. Use ``skip_elision`` when the prose is a "
|
||||
"directive to fast-forward an in-progress activity to a near-term "
|
||||
"landing state — e.g. 'skip to when we arrive', 'fast-forward to "
|
||||
"after dinner'. Use ``skip_jump`` when the prose denotes a longer "
|
||||
"fiction-time bridge — e.g. 'next morning', 'a week later', 'the "
|
||||
"following day'.\n"
|
||||
"``landing_state_hint`` is a short descriptor of the landing state "
|
||||
"for ``skip_elision`` (e.g. 'we arrive at the park'). Empty string "
|
||||
"for ``skip_jump`` and ``narrative``."
|
||||
)
|
||||
|
||||
|
||||
async def parse_turn(
|
||||
client: LLMClient,
|
||||
*,
|
||||
model: str,
|
||||
prose: str,
|
||||
timeout_s: float = 10.0,
|
||||
) -> ParsedTurn:
|
||||
"""Parse a user turn into typed segments.
|
||||
|
||||
Calls :func:`chat.llm.classify.classify` under the hood. Empty or
|
||||
whitespace-only prose short-circuits to an empty ``ParsedTurn``
|
||||
without an LLM call (the classifier would error on empty input
|
||||
anyway, and the result is unambiguous).
|
||||
|
||||
Raises ``RuntimeError`` if the classifier fails twice — no default
|
||||
is supplied, since the caller (T19's turn flow) is responsible for
|
||||
surfacing the error to the user.
|
||||
"""
|
||||
if not prose.strip():
|
||||
return ParsedTurn(segments=[])
|
||||
|
||||
user_prompt = f"INPUT:\n{prose}"
|
||||
return await classify(
|
||||
client,
|
||||
model=model,
|
||||
system=_SYSTEM_PROMPT,
|
||||
user=user_prompt,
|
||||
schema=ParsedTurn,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Vector search service (T92, Phase 4).
|
||||
|
||||
Pure-Python cosine similarity over the embeddings table. Phase 4 ships
|
||||
this without sqlite-vec because the host Python build doesn't support
|
||||
loadable extensions. For single-user scale (< few thousand memories
|
||||
per owner), iterating in Python is sub-millisecond.
|
||||
|
||||
Phase 4.5+ may swap to sqlite-vec when the host Python supports
|
||||
enable_load_extension; the public API stays stable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import math
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.state.embeddings import list_embeddings_for_owner
|
||||
|
||||
|
||||
_VALID_WITNESS_ROLES = {"you", "host", "guest"}
|
||||
|
||||
|
||||
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Cosine similarity. Assumes both vectors are non-zero."""
|
||||
if len(a) != len(b):
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = math.sqrt(sum(x * x for x in a)) or 1.0
|
||||
norm_b = math.sqrt(sum(x * x for x in b)) or 1.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
def vector_search(
|
||||
conn: Connection,
|
||||
*,
|
||||
owner_id: str,
|
||||
witness_role: str, # "you" | "host" | "guest"
|
||||
query_vector: list[float],
|
||||
k: int = 4,
|
||||
) -> list[dict]:
|
||||
"""Return top-K memories by cosine similarity to query_vector,
|
||||
witness-filtered for the viewer's POV. Returns rows with
|
||||
{memory_id, pov_summary, significance, score} sorted by score
|
||||
DESC. Empty list if no embeddings indexed for this owner.
|
||||
"""
|
||||
if witness_role not in _VALID_WITNESS_ROLES:
|
||||
raise ValueError(
|
||||
f"witness_role must be one of {_VALID_WITNESS_ROLES}, got {witness_role!r}"
|
||||
)
|
||||
|
||||
rows = list_embeddings_for_owner(conn, owner_id)
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Witness-filter by the requesting role.
|
||||
witness_key = f"witness_{witness_role}"
|
||||
filtered = [r for r in rows if r.get(witness_key) == 1]
|
||||
if not filtered:
|
||||
return []
|
||||
|
||||
scored: list[tuple[float, dict]] = []
|
||||
for row in filtered:
|
||||
score = _cosine_similarity(query_vector, row["vector"])
|
||||
scored.append(
|
||||
(
|
||||
score,
|
||||
{
|
||||
"memory_id": row["memory_id"],
|
||||
"pov_summary": row["pov_summary"],
|
||||
"significance": row["significance"],
|
||||
"score": score,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
scored.sort(key=lambda t: t[0], reverse=True)
|
||||
return [item for _, item in scored[:k]]
|
||||
|
||||
|
||||
__all__ = ["vector_search"]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Branches projector + readers (T89, Phase 4).
|
||||
|
||||
A branch is a named fork of the event log. The 'main' branch is bootstrapped
|
||||
by migration 0013 with is_active=1. Subsequent branches reference an
|
||||
origin_event_id (the event they forked from). Phase 4 enables creation
|
||||
and switching; the read-side filter (event readers consulting is_active)
|
||||
is a Phase 4.5 follow-up — for now branches are metadata-only and the
|
||||
existing event readers remain branch-agnostic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("branch_created")
|
||||
def _apply_branch_created(conn: Connection, e: Event) -> None:
|
||||
"""Insert a new branch row with is_active=0. Idempotent via INSERT OR IGNORE."""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO branches "
|
||||
"(name, origin_event_id, head_event_id, chat_id, is_active) "
|
||||
"VALUES (?, ?, ?, ?, 0)",
|
||||
(
|
||||
p["name"],
|
||||
int(p["origin_event_id"]),
|
||||
int(p.get("head_event_id", p["origin_event_id"])),
|
||||
p.get("chat_id"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("branch_switched")
|
||||
def _apply_branch_switched(conn: Connection, e: Event) -> None:
|
||||
"""Set is_active=1 on the named branch and is_active=0 on all others.
|
||||
|
||||
Atomic via two UPDATEs ordered to avoid the unique-active-index race.
|
||||
"""
|
||||
p = e.payload
|
||||
name = p["name"]
|
||||
# Clear ALL is_active flags first (avoids the unique-index trip).
|
||||
conn.execute("UPDATE branches SET is_active = 0 WHERE is_active = 1")
|
||||
conn.execute(
|
||||
"UPDATE branches SET is_active = 1 WHERE name = ?",
|
||||
(name,),
|
||||
)
|
||||
|
||||
|
||||
@on("branch_head_updated")
|
||||
def _apply_branch_head_updated(conn: Connection, e: Event) -> None:
|
||||
"""Update head_event_id on the named branch."""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE branches SET head_event_id = ? WHERE name = ?",
|
||||
(int(p["head_event_id"]), p["name"]),
|
||||
)
|
||||
|
||||
|
||||
def get_branch(conn: Connection, name: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
|
||||
" created_at, is_active "
|
||||
"FROM branches WHERE name = ?",
|
||||
(name,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"origin_event_id": row[2],
|
||||
"head_event_id": row[3],
|
||||
"chat_id": row[4],
|
||||
"created_at": row[5],
|
||||
"is_active": bool(row[6]),
|
||||
}
|
||||
|
||||
|
||||
def list_branches(conn: Connection, chat_id: str | None = None) -> list[dict]:
|
||||
if chat_id is None:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
|
||||
" created_at, is_active "
|
||||
"FROM branches ORDER BY id ASC"
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
|
||||
" created_at, is_active "
|
||||
"FROM branches WHERE chat_id = ? OR chat_id IS NULL "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"name": r[1],
|
||||
"origin_event_id": r[2],
|
||||
"head_event_id": r[3],
|
||||
"chat_id": r[4],
|
||||
"created_at": r[5],
|
||||
"is_active": bool(r[6]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def active_branch(conn: Connection) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
|
||||
" created_at, is_active "
|
||||
"FROM branches WHERE is_active = 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"origin_event_id": row[2],
|
||||
"head_event_id": row[3],
|
||||
"chat_id": row[4],
|
||||
"created_at": row[5],
|
||||
"is_active": bool(row[6]),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_branch",
|
||||
"list_branches",
|
||||
"active_branch",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
def _clamp(value: int, lo: int = 0, hi: int = 100) -> int:
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
@on("edge_update")
|
||||
def _apply_edge_update(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
source_id = p["source_id"]
|
||||
target_id = p["target_id"]
|
||||
chat_id = p.get("chat_id")
|
||||
|
||||
# Upsert: ensure a row exists with defaults, then apply deltas.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO edges (chat_id, source_id, target_id) VALUES (?, ?, ?)",
|
||||
(chat_id, source_id, target_id),
|
||||
)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT affinity, trust, knowledge_json, last_interaction_chat_id, last_interaction_at "
|
||||
"FROM edges WHERE source_id = ? AND target_id = ?",
|
||||
(source_id, target_id),
|
||||
).fetchone()
|
||||
affinity, trust, knowledge_json, last_chat_id, last_at = row
|
||||
|
||||
affinity_delta = int(p.get("affinity_delta", 0))
|
||||
trust_delta = int(p.get("trust_delta", 0))
|
||||
new_affinity = _clamp(affinity + affinity_delta)
|
||||
new_trust = _clamp(trust + trust_delta)
|
||||
|
||||
new_facts = p.get("knowledge_facts") or []
|
||||
if new_facts:
|
||||
knowledge = json.loads(knowledge_json)
|
||||
knowledge.extend(new_facts)
|
||||
knowledge_json = json.dumps(knowledge)
|
||||
|
||||
payload_at = p.get("last_interaction_at")
|
||||
payload_chat_id = p.get("last_interaction_chat_id")
|
||||
if payload_at is not None:
|
||||
last_at = payload_at
|
||||
if payload_chat_id is not None:
|
||||
last_chat_id = payload_chat_id
|
||||
|
||||
conn.execute(
|
||||
"UPDATE edges SET affinity = ?, trust = ?, knowledge_json = ?, "
|
||||
"last_interaction_chat_id = ?, last_interaction_at = ? "
|
||||
"WHERE source_id = ? AND target_id = ?",
|
||||
(new_affinity, new_trust, knowledge_json, last_chat_id, last_at,
|
||||
source_id, target_id),
|
||||
)
|
||||
|
||||
|
||||
def get_edge(conn: Connection, source_id: str, target_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM edges WHERE source_id = ? AND target_id = ?",
|
||||
(source_id, target_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
cols = [c[1] for c in conn.execute("PRAGMA table_info(edges)").fetchall()]
|
||||
d = dict(zip(cols, row))
|
||||
d["knowledge"] = json.loads(d.pop("knowledge_json"))
|
||||
return d
|
||||
|
||||
|
||||
def list_edges_for(conn: Connection, source_id: str) -> list[dict]:
|
||||
cur = conn.execute(
|
||||
"SELECT * FROM edges WHERE source_id = ? ORDER BY target_id",
|
||||
(source_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cols = [c[1] for c in conn.execute("PRAGMA table_info(edges)").fetchall()]
|
||||
out: list[dict] = []
|
||||
for row in rows:
|
||||
d = dict(zip(cols, row))
|
||||
d["knowledge"] = json.loads(d.pop("knowledge_json"))
|
||||
out.append(d)
|
||||
return out
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Embeddings projector + readers (T88, Phase 4).
|
||||
|
||||
Embeddings are stored as JSON-serialized float arrays in a regular
|
||||
SQLite table. Cosine similarity is computed in Python at query time
|
||||
(see chat/services/vector_search.py / T92). This deliberately avoids
|
||||
the sqlite-vec extension dependency — the host Python build doesn't
|
||||
support enable_load_extension. Phase 4.5+ may revisit if memory counts
|
||||
grow beyond pure-Python feasibility (~few thousand per query).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("embedding_indexed")
|
||||
def _apply_embedding_indexed(conn: Connection, e: Event) -> None:
|
||||
"""Insert or replace the embedding for a memory.
|
||||
|
||||
Idempotent: re-projection or re-indexing replaces the prior vector.
|
||||
"""
|
||||
p = e.payload
|
||||
vector = p["vector"]
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO embeddings "
|
||||
"(memory_id, vector_json, model, dim, indexed_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(
|
||||
int(p["memory_id"]),
|
||||
json.dumps(list(vector)),
|
||||
p["model"],
|
||||
int(p.get("dim") or len(vector)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("embedding_deindexed")
|
||||
def _apply_embedding_deindexed(conn: Connection, e: Event) -> None:
|
||||
"""Remove the embedding for a memory (used by reset cascade)."""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"DELETE FROM embeddings WHERE memory_id = ?",
|
||||
(int(p["memory_id"]),),
|
||||
)
|
||||
|
||||
|
||||
def get_embedding(conn: Connection, memory_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT memory_id, vector_json, model, dim, indexed_at "
|
||||
"FROM embeddings WHERE memory_id = ?",
|
||||
(memory_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"memory_id": row[0],
|
||||
"vector": json.loads(row[1]),
|
||||
"model": row[2],
|
||||
"dim": row[3],
|
||||
"indexed_at": row[4],
|
||||
}
|
||||
|
||||
|
||||
def list_embeddings_for_owner(conn: Connection, owner_id: str) -> list[dict]:
|
||||
"""Return all embeddings for memories owned by ``owner_id``.
|
||||
|
||||
Used by vector search at query time (T92). The join carries the
|
||||
fields the cosine ranker needs to assemble result rows without a
|
||||
second round-trip: the POV summary text, significance, and witness
|
||||
flags. The ``memories`` table has no separate ``text`` column —
|
||||
``pov_summary`` is the canonical narrative text per
|
||||
``chat/services/memory_write.py``.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT e.memory_id, e.vector_json, e.model, e.dim, "
|
||||
" m.pov_summary, m.significance, "
|
||||
" m.witness_you, m.witness_host, m.witness_guest "
|
||||
"FROM embeddings e "
|
||||
"JOIN memories m ON m.id = e.memory_id "
|
||||
"WHERE m.owner_id = ?",
|
||||
(owner_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"memory_id": r[0],
|
||||
"vector": json.loads(r[1]),
|
||||
"model": r[2],
|
||||
"dim": r[3],
|
||||
"pov_summary": r[4],
|
||||
"significance": r[5],
|
||||
"witness_you": r[6],
|
||||
"witness_host": r[7],
|
||||
"witness_guest": r[8],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_embedding",
|
||||
"list_embeddings_for_owner",
|
||||
]
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("bot_authored")
|
||||
def _apply_bot_authored(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO bots "
|
||||
"(id, name, persona, voice_samples_json, traits_json, backstory, "
|
||||
" initial_relationship_to_you, kickoff_prose) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(p["id"], p["name"], p["persona"],
|
||||
json.dumps(p.get("voice_samples", [])),
|
||||
json.dumps(p.get("traits", [])),
|
||||
p.get("backstory", ""),
|
||||
p.get("initial_relationship_to_you", ""),
|
||||
p.get("kickoff_prose", "")),
|
||||
)
|
||||
|
||||
|
||||
@on("you_authored")
|
||||
def _apply_you_authored(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO you_entity (id, name, pronouns, persona) VALUES (1, ?, ?, ?)",
|
||||
(p["name"], p.get("pronouns", ""), p.get("persona", "")),
|
||||
)
|
||||
|
||||
|
||||
@on("bot_reset")
|
||||
def _apply_bot_reset(conn: Connection, e: Event) -> None:
|
||||
"""Purge per-bot runtime state while preserving the bot's identity row.
|
||||
|
||||
Wipes chats hosted by this bot (with cascading chat-scoped tables),
|
||||
memories owned by this bot, edges involving this bot, and the bot's own
|
||||
activity row. The ``bots`` row itself is preserved so identity,
|
||||
initial-relationship, and kickoff prose remain authored.
|
||||
"""
|
||||
bot_id = e.payload["bot_id"]
|
||||
|
||||
chat_ids = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"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,))
|
||||
conn.execute("DELETE FROM chat_state WHERE chat_id = ?", (chat_id,))
|
||||
conn.execute("DELETE FROM chats WHERE id = ?", (chat_id,))
|
||||
|
||||
# Activity for this bot's entity row (independent of chat_id since the
|
||||
# ``activity`` table is keyed on entity_id).
|
||||
conn.execute("DELETE FROM activity WHERE entity_id = ?", (bot_id,))
|
||||
|
||||
# Memories authored by this bot.
|
||||
conn.execute("DELETE FROM memories WHERE owner_id = ?", (bot_id,))
|
||||
|
||||
# Edges in either direction involving this bot.
|
||||
conn.execute(
|
||||
"DELETE FROM edges WHERE source_id = ? OR target_id = ?",
|
||||
(bot_id, bot_id),
|
||||
)
|
||||
|
||||
# Phase 2 cascade: clear guest references in other bots' chats so the host
|
||||
# doesn't see a stale guest_bot_id pointing at this (now-purged) bot.
|
||||
conn.execute(
|
||||
"UPDATE chats SET guest_bot_id = NULL WHERE guest_bot_id = ?",
|
||||
(bot_id,),
|
||||
)
|
||||
# NOTE: bots row itself is preserved (identity, kickoff_prose intact).
|
||||
|
||||
|
||||
def get_bot(conn: Connection, bot_id: str) -> dict | None:
|
||||
row = conn.execute("SELECT * FROM bots WHERE id = ?", (bot_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
cols = [c[1] for c in conn.execute("PRAGMA table_info(bots)").fetchall()]
|
||||
d = dict(zip(cols, row))
|
||||
d["voice_samples"] = json.loads(d.pop("voice_samples_json"))
|
||||
d["traits"] = json.loads(d.pop("traits_json"))
|
||||
return d
|
||||
|
||||
|
||||
def list_bots(conn: Connection) -> list[dict]:
|
||||
cur = conn.execute("SELECT id, name FROM bots ORDER BY name")
|
||||
return [{"id": r[0], "name": r[1]} for r in cur]
|
||||
|
||||
|
||||
def get_you(conn: Connection) -> dict | None:
|
||||
row = conn.execute("SELECT name, pronouns, persona FROM you_entity WHERE id = 1").fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {"name": row[0], "pronouns": row[1], "persona": row[2]}
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
_TERMINAL_STATUSES = {"completed", "cancelled", "expired"}
|
||||
|
||||
|
||||
@on("event_planned")
|
||||
def _apply_event_planned(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO events "
|
||||
"(event_id, chat_id, kind, status, props_json, planned_for) "
|
||||
"VALUES (?, ?, ?, 'planned', ?, ?)",
|
||||
(
|
||||
p["event_id"],
|
||||
p["chat_id"],
|
||||
p["kind"],
|
||||
json.dumps(p.get("props", {})),
|
||||
p.get("planned_for"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("event_started")
|
||||
def _apply_event_started(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
# Idempotent: only transition from non-terminal status.
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'active', started_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("started_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("event_completed")
|
||||
def _apply_event_completed(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'completed', completed_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("completed_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("event_cancelled")
|
||||
def _apply_event_cancelled(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'cancelled', completed_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("completed_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("event_expired")
|
||||
def _apply_event_expired(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE events SET status = 'expired', completed_at = ?, updated_at = datetime('now') "
|
||||
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
|
||||
(p.get("completed_at"), p["event_id"]),
|
||||
)
|
||||
|
||||
|
||||
def get_event(conn: Connection, event_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
||||
"started_at, completed_at, created_at, updated_at "
|
||||
"FROM events WHERE event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"event_id": row[0],
|
||||
"chat_id": row[1],
|
||||
"kind": row[2],
|
||||
"status": row[3],
|
||||
"props": json.loads(row[4]),
|
||||
"planned_for": row[5],
|
||||
"started_at": row[6],
|
||||
"completed_at": row[7],
|
||||
"created_at": row[8],
|
||||
"updated_at": row[9],
|
||||
}
|
||||
|
||||
|
||||
def list_active_events(conn: Connection, chat_id: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
||||
"started_at, completed_at, created_at, updated_at "
|
||||
"FROM events WHERE chat_id = ? AND status IN ('planned','active') "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3],
|
||||
"props": json.loads(r[4]),
|
||||
"planned_for": r[5], "started_at": r[6], "completed_at": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def list_events_in_status(conn: Connection, chat_id: str, status: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
||||
"started_at, completed_at, created_at, updated_at "
|
||||
"FROM events WHERE chat_id = ? AND status = ? ORDER BY id ASC",
|
||||
(chat_id, status),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3],
|
||||
"props": json.loads(r[4]),
|
||||
"planned_for": r[5], "started_at": r[6], "completed_at": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("group_node_initialized")
|
||||
def _apply_group_node_initialized(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO group_node "
|
||||
"(chat_id, members_json, summary, dynamic, threads_json) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
p["chat_id"],
|
||||
json.dumps(p["members"]),
|
||||
p.get("summary", ""),
|
||||
p.get("dynamic", ""),
|
||||
json.dumps(p.get("threads", [])),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("group_node_updated")
|
||||
def _apply_group_node_updated(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE group_node SET summary = ?, dynamic = ?, updated_at = datetime('now') "
|
||||
"WHERE chat_id = ?",
|
||||
(p.get("summary", ""), p.get("dynamic", ""), p["chat_id"]),
|
||||
)
|
||||
|
||||
|
||||
def get_group_node(conn: Connection, chat_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT chat_id, members_json, summary, dynamic, threads_json, updated_at "
|
||||
"FROM group_node WHERE chat_id = ?",
|
||||
(chat_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"chat_id": row[0],
|
||||
"members": json.loads(row[1]),
|
||||
"summary": row[2],
|
||||
"dynamic": row[3],
|
||||
"threads": json.loads(row[4]),
|
||||
"updated_at": row[5],
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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 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.
|
||||
- ``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.
|
||||
|
||||
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.
|
||||
|
||||
T98.3 adds a hide-from-view toggle:
|
||||
- ``turn_hidden`` — flip ``event_log.hidden`` on a single turn row.
|
||||
Hidden turns are filtered by ``read_recent_dialogue`` (see
|
||||
:mod:`chat.services.turn_common`) so they vanish from the prompt
|
||||
without being deleted from the log. ``target_id`` is the integer
|
||||
``event_log.id`` of the turn; ``new_value`` is ``{"hidden": 0|1}``
|
||||
and ``prior_value`` mirrors the shape so an inverse edit restores it.
|
||||
|
||||
T98.5 finishes the v1 drawer surface with two chat-scope text edits:
|
||||
- ``chat_narrative_anchor`` and ``chat_weather`` — string overwrites of
|
||||
the matching ``chat_state`` columns. ``target_id`` is the chat id
|
||||
(``chats.id``); ``new_value`` is the new string and ``prior_value``
|
||||
carries the previous content for §6.4 reversibility.
|
||||
|
||||
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
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@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"],
|
||||
),
|
||||
)
|
||||
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)),
|
||||
)
|
||||
elif kind == "turn_hidden":
|
||||
# T98.3: hide-from-view toggle on a turn (event_log row). Sets
|
||||
# ``event_log.hidden`` so :func:`read_recent_dialogue` (which
|
||||
# filters ``hidden = 0``) drops the row from the prompt window
|
||||
# without deleting it from the log. ``new_value`` is
|
||||
# ``{"hidden": 0|1}``.
|
||||
hidden_int = 1 if int(new_value.get("hidden", 0)) else 0
|
||||
conn.execute(
|
||||
"UPDATE event_log SET hidden = ? WHERE id = ?",
|
||||
(hidden_int, int(target_id)),
|
||||
)
|
||||
elif kind == "chat_narrative_anchor":
|
||||
# T98.5: string overwrite of ``chat_state.narrative_anchor`` for
|
||||
# the chat keyed by ``target_id``.
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET narrative_anchor = ? WHERE chat_id = ?",
|
||||
(str(new_value), str(target_id)),
|
||||
)
|
||||
elif kind == "chat_weather":
|
||||
# T98.5: string overwrite of ``chat_state.weather``.
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET weather = ? WHERE chat_id = ?",
|
||||
(str(new_value), str(target_id)),
|
||||
)
|
||||
# Unknown target_kind: silently no-op for v1. Future kinds (activity
|
||||
# fields, etc.) extend the dispatch above.
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Meanwhile-scene projection (T63).
|
||||
|
||||
A "meanwhile" scene is a 2-bot scene where ``present_set = {host_bot_id,
|
||||
guest_bot_id}`` and "you" is absent. It runs alongside an active you-scene
|
||||
(its parent) so bots can have private interactions whose outcome later
|
||||
surfaces back to the you-scene as a pending digest.
|
||||
|
||||
The underlying ``scenes`` table (migration 0007) has no explicit ``status``
|
||||
column; "active" is encoded as ``ended_at IS NULL`` and "closed" as
|
||||
``ended_at IS NOT NULL``. This module preserves that convention and adds
|
||||
two new columns introduced by migration 0011:
|
||||
|
||||
- ``present_set_kind`` — ``'you_host'`` (default) for normal scenes,
|
||||
``'host_guest'`` for meanwhile child scenes.
|
||||
- ``parent_scene_id`` — the you-scene a meanwhile child hangs off of.
|
||||
|
||||
Pending meanwhile digests live in their own table
|
||||
(``meanwhile_digest_pending``) and are consumed when their summary is
|
||||
surfaced in the next you-scene's prompt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("meanwhile_scene_started")
|
||||
def _apply_meanwhile_scene_started(conn: Connection, e: Event) -> None:
|
||||
"""Insert a new scenes row for the meanwhile child.
|
||||
|
||||
The caller supplies an explicit ``scene_id`` so subsequent events
|
||||
(close, digest) can reference it without round-tripping through
|
||||
``lastrowid``.
|
||||
"""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT INTO scenes ("
|
||||
"id, chat_id, started_at, ended_at, significance, "
|
||||
"participants_json, present_set_kind, parent_scene_id"
|
||||
") VALUES (?, ?, ?, NULL, 0, ?, 'host_guest', ?)",
|
||||
(
|
||||
p["scene_id"],
|
||||
p["chat_id"],
|
||||
p.get("started_at"),
|
||||
# participants_json mirrors the present_set: host + guest bot.
|
||||
# json.dumps ensures bot ids with quotes/backslashes can't corrupt the JSON literal.
|
||||
json.dumps([p["host_bot_id"], p["guest_bot_id"]]),
|
||||
p["parent_scene_id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("meanwhile_scene_closed")
|
||||
def _apply_meanwhile_scene_closed(conn: Connection, e: Event) -> None:
|
||||
"""Mark the meanwhile scene closed by stamping ``ended_at``."""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE scenes SET ended_at = ? "
|
||||
"WHERE id = ? AND present_set_kind = 'host_guest' "
|
||||
"AND ended_at IS NULL",
|
||||
(p.get("closed_at"), p["scene_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("meanwhile_digest_created")
|
||||
def _apply_meanwhile_digest_created(conn: Connection, e: Event) -> None:
|
||||
"""Queue a digest for surfacing to the next you-scene's prompt."""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT INTO meanwhile_digest_pending (scene_id, chat_id, summary) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(p["scene_id"], p["chat_id"], p["summary"]),
|
||||
)
|
||||
|
||||
|
||||
@on("meanwhile_digest_consumed")
|
||||
def _apply_meanwhile_digest_consumed(conn: Connection, e: Event) -> None:
|
||||
"""Mark a pending digest as consumed (idempotent on re-projection)."""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE meanwhile_digest_pending SET consumed_at = ? "
|
||||
"WHERE id = ? AND consumed_at IS NULL",
|
||||
(p.get("consumed_at"), p["digest_id"]),
|
||||
)
|
||||
|
||||
|
||||
def _scene_row_to_dict(row: tuple) -> dict:
|
||||
"""Shape a meanwhile-scene row.
|
||||
|
||||
``status`` is derived from ``ended_at`` for callers that prefer the
|
||||
higher-level vocabulary; ``closed_at`` aliases ``ended_at`` for the
|
||||
same reason. The underlying column remains ``ended_at``.
|
||||
"""
|
||||
ended_at = row[5]
|
||||
return {
|
||||
"id": row[0],
|
||||
"chat_id": row[1],
|
||||
"started_at": row[2],
|
||||
"present_set_kind": row[3],
|
||||
"parent_scene_id": row[4],
|
||||
"closed_at": ended_at,
|
||||
"status": "closed" if ended_at is not None else "active",
|
||||
}
|
||||
|
||||
|
||||
def list_meanwhile_scenes(
|
||||
conn: Connection, chat_id: str, status: str = "active"
|
||||
) -> list[dict]:
|
||||
"""Return meanwhile scenes for ``chat_id`` filtered by derived status."""
|
||||
if status == "active":
|
||||
ended_clause = "s.ended_at IS NULL"
|
||||
elif status == "closed":
|
||||
ended_clause = "s.ended_at IS NOT NULL"
|
||||
else:
|
||||
raise ValueError(f"unknown status: {status!r}")
|
||||
rows = conn.execute(
|
||||
"SELECT s.id, s.chat_id, s.started_at, s.present_set_kind, "
|
||||
"s.parent_scene_id, s.ended_at "
|
||||
"FROM scenes s "
|
||||
"WHERE s.chat_id = ? AND s.present_set_kind = 'host_guest' "
|
||||
f"AND {ended_clause} "
|
||||
"ORDER BY s.id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [_scene_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_parent_scene(conn: Connection, scene_id: int) -> dict | None:
|
||||
"""Given a meanwhile scene id, return its parent (you-scene) row."""
|
||||
row = conn.execute(
|
||||
"SELECT s.id, s.chat_id, s.started_at, s.present_set_kind, "
|
||||
"s.parent_scene_id, s.ended_at "
|
||||
"FROM scenes s JOIN scenes m ON m.parent_scene_id = s.id "
|
||||
"WHERE m.id = ?",
|
||||
(scene_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _scene_row_to_dict(row)
|
||||
|
||||
|
||||
def list_pending_meanwhile_digests(
|
||||
conn: Connection, chat_id: str
|
||||
) -> list[dict]:
|
||||
"""Return digests for ``chat_id`` that haven't been consumed yet."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, scene_id, chat_id, summary, created_at "
|
||||
"FROM meanwhile_digest_pending "
|
||||
"WHERE chat_id = ? AND consumed_at IS NULL "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"scene_id": r[1],
|
||||
"chat_id": r[2],
|
||||
"summary": r[3],
|
||||
"created_at": r[4],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
from sqlite3 import Connection
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
_VALID_WITNESS_ROLES = {"you", "host", "guest"}
|
||||
|
||||
|
||||
def _row_to_dict(conn: Connection, row: tuple) -> dict:
|
||||
cols = [c[1] for c in conn.execute("PRAGMA table_info(memories)").fetchall()]
|
||||
return dict(zip(cols, row))
|
||||
|
||||
|
||||
@on("memory_written")
|
||||
def _apply_memory_written(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT INTO memories ("
|
||||
"owner_id, chat_id, scene_id, pov_summary, "
|
||||
"witness_you, witness_host, witness_guest, "
|
||||
"chat_clock_at, source, reliability, significance, pinned, auto_pinned"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
p["owner_id"],
|
||||
p["chat_id"],
|
||||
p.get("scene_id"),
|
||||
p["pov_summary"],
|
||||
int(p["witness_you"]),
|
||||
int(p["witness_host"]),
|
||||
int(p["witness_guest"]),
|
||||
p.get("chat_clock_at"),
|
||||
p.get("source", "direct"),
|
||||
float(p.get("reliability", 1.0)),
|
||||
int(p.get("significance", 1)),
|
||||
int(p.get("pinned", 0)),
|
||||
int(p.get("auto_pinned", 0)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("memory_significance_set")
|
||||
def _apply_memory_significance_set(conn: Connection, e: Event) -> None:
|
||||
"""Update an existing memory's significance score (T22).
|
||||
|
||||
Emitted by the async significance worker after it scores the turn.
|
||||
"""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE memories SET significance = ? WHERE id = ?",
|
||||
(int(p["significance"]), int(p["memory_id"])),
|
||||
)
|
||||
|
||||
|
||||
@on("memory_pin_changed")
|
||||
def _apply_memory_pin_changed(conn: Connection, e: Event) -> None:
|
||||
"""Toggle a memory's pin state (T22, §8.5).
|
||||
|
||||
Used both for auto-pinning a pivotal turn and for evicting the oldest
|
||||
auto-pin when the per-owner soft cap is exceeded. Manual pins use the
|
||||
same handler; the ``auto_pinned`` flag distinguishes them so the
|
||||
eviction query can leave manual pins alone.
|
||||
"""
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE memories SET pinned = ?, auto_pinned = ? WHERE id = ?",
|
||||
(int(p["pinned"]), int(p["auto_pinned"]), int(p["memory_id"])),
|
||||
)
|
||||
|
||||
|
||||
def get_memory(conn: Connection, memory_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM memories WHERE id = ?", (memory_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(conn, row)
|
||||
|
||||
|
||||
def get_pinned(conn: Connection, owner_id: str) -> list[dict]:
|
||||
cur = conn.execute(
|
||||
"SELECT * FROM memories WHERE owner_id = ? AND pinned = 1 "
|
||||
"ORDER BY created_at DESC, id DESC",
|
||||
(owner_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cols = [c[1] for c in conn.execute("PRAGMA table_info(memories)").fetchall()]
|
||||
return [dict(zip(cols, row)) for row in rows]
|
||||
|
||||
|
||||
# Composite-score weights used by ``search_memories`` (T23, §8 retrieval).
|
||||
# FTS5 BM25 ``rank`` is *more negative* for better matches, so subtracting a
|
||||
# positive boost from it drives stronger candidates further down (i.e. earlier
|
||||
# in an ascending sort). Hardcoded for v1 — tunable in a later pass.
|
||||
_SIGNIFICANCE_WEIGHT = 0.3
|
||||
_RECENCY_WEIGHT = 0.5
|
||||
|
||||
# T57 (Phase 3, §11.1): significance multiplier applied to the SQL ORDER BY in
|
||||
# ``search_memories`` so that the FTS over-fetch already prefers
|
||||
# higher-significance rows for tied / near-tied BM25 ranks. Module-level so it
|
||||
# can be tuned without a code change. BM25 ``rank`` is lower-is-better, so the
|
||||
# bias is *subtracted* from rank in the ASC ordering — equivalent to multiplying
|
||||
# a higher-is-better score by a positive constant per the spec wording.
|
||||
SIGNIFICANCE_RANK_BIAS = 0.5
|
||||
|
||||
# T96 (Phase 4): reciprocal-rank-fusion constant used when ``search_memories``
|
||||
# is given a ``query_vector`` and must merge FTS + vector candidate lists. The
|
||||
# value 60 is the canonical RRF constant from Cormack et al. ("Reciprocal Rank
|
||||
# Fusion outperforms Condorcet and Individual Rank Learning Methods", SIGIR
|
||||
# 2009): large enough to dampen the head of either ranking so that a strong
|
||||
# top-1 in ranking A doesn't crowd out a moderate top-3 in ranking B, but
|
||||
# small enough that the position-1/position-2 gap still matters.
|
||||
RRF_CONST = 60
|
||||
|
||||
|
||||
def search_memories(
|
||||
conn: Connection,
|
||||
owner_id: str,
|
||||
witness_role: str,
|
||||
query: str,
|
||||
k: int = 4,
|
||||
*,
|
||||
query_vector: list[float] | None = None,
|
||||
) -> list[dict]:
|
||||
"""FTS5 search over pov_summary, scoped by owner and witness role.
|
||||
|
||||
witness_role must be one of {"you", "host", "guest"} per the witness flags
|
||||
on each memory row. Returns up to ``k`` rows ranked by a composite score
|
||||
that combines the FTS5 BM25 rank with two boosts (§8 retrieval rules):
|
||||
|
||||
* **significance boost** — ``0.3 * significance`` (0..3 per §11.1).
|
||||
* **recency boost** — ``0.5 * (id / max_id)``, using the row id as a
|
||||
monotonic recency proxy. Newer memories therefore tilt above older ones
|
||||
when the BM25 rank and significance are otherwise tied.
|
||||
|
||||
BM25 returns negative scores (lower = better). Both boosts are subtracted
|
||||
so that stronger candidates yield smaller composite scores; the result is
|
||||
sorted ascending and truncated to ``k``. The unmodified ``fts_rank`` and a
|
||||
debug-friendly ``composite_score`` are kept on each returned dict.
|
||||
|
||||
The result ordering applies TWO independent significance boosts:
|
||||
|
||||
* **SQL-side** — ``ORDER BY (rank - significance * SIGNIFICANCE_RANK_BIAS)``
|
||||
pushes higher-significance memories ahead in the FTS5 candidate set so
|
||||
the over-fetch already prefers them for tied / near-tied BM25 ranks
|
||||
(T57, §11.1).
|
||||
* **Python-side** — a composite re-rank with ``_SIGNIFICANCE_WEIGHT``
|
||||
reinforces the ordering after candidate retrieval, alongside the
|
||||
recency boost above.
|
||||
|
||||
PHASE 4 EXTENSION (T96): when ``query_vector`` is provided, fuses FTS and
|
||||
vector hits via reciprocal-rank fusion (RRF):
|
||||
|
||||
fusion_score = 1/(RRF_CONST + fts_rank) + 1/(RRF_CONST + vec_rank)
|
||||
|
||||
where ``fts_rank`` and ``vec_rank`` are the 0-indexed positions of the
|
||||
memory in each candidate list. Each candidate gets the sum of its
|
||||
reciprocal ranks across both rankings; memories appearing in only one
|
||||
ranking still get a partial score (the other term is dropped). Both
|
||||
candidate lists are over-fetched at ``k * 2`` so a memory dominant in
|
||||
only one channel has a fair chance to surface. The Python-side
|
||||
significance + recency re-rank is then applied as a final pass to
|
||||
break ties in favour of more important / more recent memories.
|
||||
|
||||
When ``query_vector`` is None: FTS-only behaviour unchanged — all
|
||||
Phase 1-3.5 callers see the same row shape and ordering as before.
|
||||
"""
|
||||
if witness_role not in _VALID_WITNESS_ROLES:
|
||||
raise ValueError(
|
||||
f"witness_role must be one of {sorted(_VALID_WITNESS_ROLES)}, "
|
||||
f"got {witness_role!r}"
|
||||
)
|
||||
if not query.strip():
|
||||
return []
|
||||
witness_col = f"witness_{witness_role}"
|
||||
cols = [c[1] for c in conn.execute("PRAGMA table_info(memories)").fetchall()]
|
||||
select_list = ", ".join(f"m.{c}" for c in cols)
|
||||
# Over-fetch from FTS so the Python-side re-rank has room to reorder
|
||||
# results that BM25 alone would have demoted past the top-k boundary.
|
||||
# When fusing with a vector ranking, we still over-fetch (k*2 from each
|
||||
# channel) so memories that are weak in FTS but strong in vector — and
|
||||
# vice versa — make it into the merge pool.
|
||||
over_fetch = max(k * 2, 20) if query_vector is not None else max(k * 4, 20)
|
||||
sql = (
|
||||
f"SELECT {select_list}, memories_fts.rank AS fts_rank "
|
||||
"FROM memories_fts "
|
||||
"JOIN memories m ON m.id = memories_fts.rowid "
|
||||
f"WHERE m.owner_id = ? AND m.{witness_col} = 1 "
|
||||
"AND memories_fts MATCH ? "
|
||||
# T57: significance multiplier biases the FTS over-fetch order. BM25
|
||||
# ``rank`` is lower-is-better, so subtracting ``significance * BIAS``
|
||||
# surfaces higher-significance rows above lower-significance rows with
|
||||
# equal/near-equal match strength. Equivalent to ``score × constant``
|
||||
# per §11.1 once the rank is inverted to a higher-is-better score.
|
||||
"ORDER BY (memories_fts.rank - m.significance * ?) ASC "
|
||||
"LIMIT ?"
|
||||
)
|
||||
cur = conn.execute(sql, (owner_id, query, SIGNIFICANCE_RANK_BIAS, over_fetch))
|
||||
rows = cur.fetchall()
|
||||
|
||||
# FTS-only path: preserve pre-T96 behaviour exactly.
|
||||
if query_vector is None:
|
||||
if not rows:
|
||||
return []
|
||||
return _composite_rerank(conn, cols, rows, owner_id, k)
|
||||
|
||||
# Fused path: combine FTS candidates with vector candidates via RRF.
|
||||
return _rrf_fuse_and_rerank(
|
||||
conn,
|
||||
cols=cols,
|
||||
fts_rows=rows,
|
||||
owner_id=owner_id,
|
||||
witness_role=witness_role,
|
||||
query_vector=query_vector,
|
||||
k=k,
|
||||
)
|
||||
|
||||
|
||||
def _composite_rerank(
|
||||
conn: Connection,
|
||||
cols: list[str],
|
||||
rows: list[tuple],
|
||||
owner_id: str,
|
||||
k: int,
|
||||
) -> list[dict]:
|
||||
"""Apply the significance + recency composite re-rank to FTS rows.
|
||||
|
||||
Extracted from ``search_memories`` so the no-vector path stays a single
|
||||
call and the fused path can re-use the same boost formulae after RRF.
|
||||
"""
|
||||
max_id_row = conn.execute(
|
||||
"SELECT MAX(id) FROM memories WHERE owner_id = ?", (owner_id,)
|
||||
).fetchone()
|
||||
max_id = max_id_row[0] if max_id_row and max_id_row[0] else 1
|
||||
|
||||
result_cols = cols + ["fts_rank"]
|
||||
enriched: list[dict] = []
|
||||
for row in rows:
|
||||
d = dict(zip(result_cols, row))
|
||||
fts_rank = d.get("fts_rank") or 0.0
|
||||
sig_boost = _SIGNIFICANCE_WEIGHT * (d.get("significance") or 0)
|
||||
recency_boost = _RECENCY_WEIGHT * ((d.get("id") or 0) / max_id)
|
||||
d["composite_score"] = fts_rank - sig_boost - recency_boost
|
||||
enriched.append(d)
|
||||
|
||||
enriched.sort(key=lambda x: x["composite_score"])
|
||||
return enriched[:k]
|
||||
|
||||
|
||||
def _rrf_fuse_and_rerank(
|
||||
conn: Connection,
|
||||
*,
|
||||
cols: list[str],
|
||||
fts_rows: list[tuple],
|
||||
owner_id: str,
|
||||
witness_role: str,
|
||||
query_vector: list[float],
|
||||
k: int,
|
||||
) -> list[dict]:
|
||||
"""Merge FTS + vector candidates via reciprocal-rank fusion, then apply
|
||||
the existing significance + recency boost as a final tie-breaker.
|
||||
|
||||
RRF formula (Cormack et al. 2009)::
|
||||
|
||||
fusion_score = sum over rankings r of 1 / (RRF_CONST + rank_r)
|
||||
|
||||
where ``rank_r`` is the 0-indexed position of the memory in ranking r.
|
||||
"Missing from a ranking" is handled by SKIPPING the term for that
|
||||
ranking — i.e. that channel contributes 0 to the sum, which preserves
|
||||
the fairness property: a memory that only appears in one ranking is
|
||||
not penalised relative to itself, just relative to memories that
|
||||
appeared in both. This matches the canonical RRF presentation.
|
||||
|
||||
The final composite score subtracted from the *negated* fusion score
|
||||
is::
|
||||
|
||||
composite = -fusion - sig_boost - recency_boost
|
||||
|
||||
Sorted ascending, smaller-is-better — the same ordering convention as
|
||||
the FTS-only path so the Python-side significance + recency boosts
|
||||
apply as a clean tie-breaker without inverting any sign.
|
||||
"""
|
||||
# Lazy import to avoid a hard module-level cycle: vector_search reads
|
||||
# from chat.state.embeddings, which is itself a sibling of this module.
|
||||
from chat.services.vector_search import vector_search
|
||||
|
||||
fts_rank_by_id: dict[int, int] = {}
|
||||
fts_row_by_id: dict[int, tuple] = {}
|
||||
id_idx = cols.index("id")
|
||||
for rank, row in enumerate(fts_rows):
|
||||
memory_id = row[id_idx]
|
||||
fts_rank_by_id[memory_id] = rank
|
||||
fts_row_by_id[memory_id] = row
|
||||
|
||||
# Over-fetch the vector channel symmetrically so each channel gets a
|
||||
# fair shot at surfacing its strongest candidates.
|
||||
vec_over_fetch = max(k * 2, 20)
|
||||
vec_hits = vector_search(
|
||||
conn,
|
||||
owner_id=owner_id,
|
||||
witness_role=witness_role,
|
||||
query_vector=query_vector,
|
||||
k=vec_over_fetch,
|
||||
)
|
||||
vec_rank_by_id: dict[int, int] = {
|
||||
hit["memory_id"]: rank for rank, hit in enumerate(vec_hits)
|
||||
}
|
||||
|
||||
# If the vector channel returned nothing (no embeddings indexed), the
|
||||
# fused path collapses cleanly to the FTS-only path. No error, no
|
||||
# surprise zero-hit return.
|
||||
if not vec_rank_by_id and not fts_row_by_id:
|
||||
return []
|
||||
if not vec_rank_by_id:
|
||||
return _composite_rerank(conn, cols, fts_rows, owner_id, k)
|
||||
|
||||
# For any vector-only hits we don't have a full memory row for yet,
|
||||
# fetch them in a single round-trip. The FTS row carries an ``fts_rank``
|
||||
# column at the end; vector-only rows get ``None`` there.
|
||||
missing_ids = [mid for mid in vec_rank_by_id if mid not in fts_row_by_id]
|
||||
select_list = ", ".join(cols)
|
||||
if missing_ids:
|
||||
placeholders = ",".join("?" * len(missing_ids))
|
||||
cur = conn.execute(
|
||||
f"SELECT {select_list} FROM memories WHERE id IN ({placeholders})",
|
||||
missing_ids,
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
# Pad with a None for the trailing ``fts_rank`` slot so the row
|
||||
# shape matches FTS rows downstream.
|
||||
fts_row_by_id[row[id_idx]] = tuple(row) + (None,)
|
||||
|
||||
# Compute fusion score per candidate. Missing-from-ranking terms are
|
||||
# simply omitted from the sum.
|
||||
all_ids = set(fts_rank_by_id) | set(vec_rank_by_id)
|
||||
fusion_by_id: dict[int, float] = {}
|
||||
for mid in all_ids:
|
||||
score = 0.0
|
||||
if mid in fts_rank_by_id:
|
||||
score += 1.0 / (RRF_CONST + fts_rank_by_id[mid])
|
||||
if mid in vec_rank_by_id:
|
||||
score += 1.0 / (RRF_CONST + vec_rank_by_id[mid])
|
||||
fusion_by_id[mid] = score
|
||||
|
||||
# Final composite re-rank: significance + recency boosts on top of the
|
||||
# negated fusion score so the sort direction matches the FTS-only path.
|
||||
max_id_row = conn.execute(
|
||||
"SELECT MAX(id) FROM memories WHERE owner_id = ?", (owner_id,)
|
||||
).fetchone()
|
||||
max_id = max_id_row[0] if max_id_row and max_id_row[0] else 1
|
||||
|
||||
result_cols = cols + ["fts_rank"]
|
||||
enriched: list[dict] = []
|
||||
for mid in all_ids:
|
||||
row = fts_row_by_id.get(mid)
|
||||
if row is None:
|
||||
# Defensive: a vector hit with no memory row would be a logic
|
||||
# bug (vector_search joins memories), so just skip it rather
|
||||
# than crash the whole search.
|
||||
continue
|
||||
d = dict(zip(result_cols, row))
|
||||
sig_boost = _SIGNIFICANCE_WEIGHT * (d.get("significance") or 0)
|
||||
recency_boost = _RECENCY_WEIGHT * ((d.get("id") or 0) / max_id)
|
||||
fusion = fusion_by_id[mid]
|
||||
# Sort ascending, smaller-is-better → negate fusion so a larger
|
||||
# fusion score yields a smaller composite. Significance and recency
|
||||
# boosts then act as tie-breakers exactly like the FTS-only path.
|
||||
d["fusion_score"] = fusion
|
||||
d["composite_score"] = -fusion - sig_boost - recency_boost
|
||||
enriched.append(d)
|
||||
|
||||
enriched.sort(key=lambda x: x["composite_score"])
|
||||
return enriched[:k]
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("thread_opened")
|
||||
def _apply_thread_opened(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO threads "
|
||||
"(thread_id, chat_id, title, summary, status) "
|
||||
"VALUES (?, ?, ?, ?, 'open')",
|
||||
(
|
||||
p["thread_id"],
|
||||
p["chat_id"],
|
||||
p["title"],
|
||||
p.get("summary", ""),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("thread_updated")
|
||||
def _apply_thread_updated(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
# Idempotent: closed threads ignore subsequent updates.
|
||||
conn.execute(
|
||||
"UPDATE threads SET summary = ?, last_referenced_scene_id = ?, "
|
||||
"updated_at = datetime('now') "
|
||||
"WHERE thread_id = ? AND status = 'open'",
|
||||
(
|
||||
p.get("summary", ""),
|
||||
p.get("last_referenced_scene_id"),
|
||||
p["thread_id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("thread_closed")
|
||||
def _apply_thread_closed(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE threads SET status = 'closed', closed_at = ?, "
|
||||
"updated_at = datetime('now') "
|
||||
"WHERE thread_id = ? AND status = 'open'",
|
||||
(p.get("closed_at"), p["thread_id"]),
|
||||
)
|
||||
|
||||
|
||||
def get_thread(conn: Connection, thread_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE thread_id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"thread_id": row[0],
|
||||
"chat_id": row[1],
|
||||
"title": row[2],
|
||||
"summary": row[3],
|
||||
"status": row[4],
|
||||
"opened_at": row[5],
|
||||
"closed_at": row[6],
|
||||
"last_referenced_scene_id": row[7],
|
||||
"created_at": row[8],
|
||||
"updated_at": row[9],
|
||||
}
|
||||
|
||||
|
||||
def list_open_threads(conn: Connection, chat_id: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE chat_id = ? AND status = 'open' "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"thread_id": r[0], "chat_id": r[1], "title": r[2],
|
||||
"summary": r[3], "status": r[4],
|
||||
"opened_at": r[5], "closed_at": r[6],
|
||||
"last_referenced_scene_id": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def list_threads(conn: Connection, chat_id: str, status: str | None = None) -> list[dict]:
|
||||
if status is None:
|
||||
rows = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE chat_id = ? ORDER BY id ASC",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT thread_id, chat_id, title, summary, status, "
|
||||
"opened_at, closed_at, last_referenced_scene_id, "
|
||||
"created_at, updated_at "
|
||||
"FROM threads WHERE chat_id = ? AND status = ? "
|
||||
"ORDER BY id ASC",
|
||||
(chat_id, status),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"thread_id": r[0], "chat_id": r[1], "title": r[2],
|
||||
"summary": r[3], "status": r[4],
|
||||
"opened_at": r[5], "closed_at": r[6],
|
||||
"last_referenced_scene_id": r[7],
|
||||
"created_at": r[8], "updated_at": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
def _row_to_dict(conn: Connection, table: str, row: tuple) -> dict:
|
||||
cols = [c[1] for c in conn.execute(f"PRAGMA table_info({table})").fetchall()]
|
||||
return dict(zip(cols, row))
|
||||
|
||||
|
||||
@on("chat_created")
|
||||
def _apply_chat_created(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT INTO chats (id, host_bot_id, guest_bot_id) VALUES (?, ?, ?)",
|
||||
(p["id"], p["host_bot_id"], p.get("guest_bot_id")),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_state (chat_id, time, weather, active_scene_id, narrative_anchor) "
|
||||
"VALUES (?, ?, ?, NULL, ?)",
|
||||
(
|
||||
p["id"],
|
||||
p["initial_time"],
|
||||
p.get("weather", ""),
|
||||
p.get("narrative_anchor", ""),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("time_skip_elision")
|
||||
def _apply_time_skip_elision(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET time = ? WHERE chat_id = ?",
|
||||
(p["new_time"], p["chat_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("time_skip_jump")
|
||||
def _apply_time_skip_jump(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
chat_id = p["chat_id"]
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET time = ? WHERE chat_id = ?",
|
||||
(p["new_time"], chat_id),
|
||||
)
|
||||
if p.get("reset_activity", False):
|
||||
# Activity rows are keyed by entity_id with a container_id FK.
|
||||
# Each chat owns its containers, so deleting activity rows whose
|
||||
# container_id belongs to this chat clears every present entity.
|
||||
conn.execute(
|
||||
"DELETE FROM activity "
|
||||
"WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)",
|
||||
(chat_id,),
|
||||
)
|
||||
|
||||
|
||||
@on("guest_added")
|
||||
def _apply_guest_added(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE chats SET guest_bot_id = ? WHERE id = ?",
|
||||
(p["guest_bot_id"], p["chat_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("guest_removed")
|
||||
def _apply_guest_removed(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE chats SET guest_bot_id = NULL WHERE id = ?",
|
||||
(p["chat_id"],),
|
||||
)
|
||||
|
||||
|
||||
@on("container_created")
|
||||
def _apply_container_created(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT INTO containers (chat_id, name, type, properties_json, parent_id) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
p["chat_id"],
|
||||
p["name"],
|
||||
p["type"],
|
||||
json.dumps(p.get("properties", {})),
|
||||
p.get("parent_id"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("activity_change")
|
||||
def _apply_activity_change(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO activity ("
|
||||
"entity_id, container_id, slot, posture, action_json, "
|
||||
"attention, holding_json, status_json, updated_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
|
||||
(
|
||||
p["entity_id"],
|
||||
p.get("container_id"),
|
||||
p.get("slot"),
|
||||
p.get("posture", ""),
|
||||
json.dumps(p.get("action", {})),
|
||||
p.get("attention", ""),
|
||||
json.dumps(p.get("holding", [])),
|
||||
json.dumps(p.get("status", {})),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("scene_opened")
|
||||
def _apply_scene_opened(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
cur = conn.execute(
|
||||
"INSERT INTO scenes (chat_id, container_id, started_at, ended_at, "
|
||||
"significance, participants_json) VALUES (?, ?, ?, NULL, 0, ?)",
|
||||
(
|
||||
p["chat_id"],
|
||||
p.get("container_id"),
|
||||
p["started_at"],
|
||||
json.dumps(p.get("participants", [])),
|
||||
),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET active_scene_id = ? WHERE chat_id = ?",
|
||||
(new_id, p["chat_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("scene_closed")
|
||||
def _apply_scene_closed(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
scene_id = p["scene_id"]
|
||||
significance = int(p.get("significance", 0))
|
||||
conn.execute(
|
||||
"UPDATE scenes SET ended_at = ?, significance = ? WHERE id = ?",
|
||||
(p["ended_at"], significance, scene_id),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT chat_id FROM scenes WHERE id = ?", (scene_id,)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
chat_id = row[0]
|
||||
conn.execute(
|
||||
"UPDATE chat_state SET active_scene_id = NULL WHERE chat_id = ?",
|
||||
(chat_id,),
|
||||
)
|
||||
|
||||
|
||||
def _chat_select_columns() -> str:
|
||||
return (
|
||||
"c.id, c.host_bot_id, c.guest_bot_id, c.created_at, "
|
||||
"s.time, s.weather, s.active_scene_id, s.narrative_anchor"
|
||||
)
|
||||
|
||||
|
||||
def _chat_row_to_dict(row: tuple) -> dict:
|
||||
return {
|
||||
"id": row[0],
|
||||
"host_bot_id": row[1],
|
||||
"guest_bot_id": row[2],
|
||||
"created_at": row[3],
|
||||
"time": row[4],
|
||||
"weather": row[5],
|
||||
"active_scene_id": row[6],
|
||||
"narrative_anchor": row[7],
|
||||
}
|
||||
|
||||
|
||||
def get_chat(conn: Connection, chat_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
f"SELECT {_chat_select_columns()} FROM chats c "
|
||||
"JOIN chat_state s ON s.chat_id = c.id WHERE c.id = ?",
|
||||
(chat_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return _chat_row_to_dict(row)
|
||||
|
||||
|
||||
def list_chats(conn: Connection) -> list[dict]:
|
||||
cur = conn.execute(
|
||||
f"SELECT {_chat_select_columns()} FROM chats c "
|
||||
"JOIN chat_state s ON s.chat_id = c.id ORDER BY c.id"
|
||||
)
|
||||
return [_chat_row_to_dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def get_container(conn: Connection, container_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM containers WHERE id = ?", (container_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = _row_to_dict(conn, "containers", row)
|
||||
d["properties"] = json.loads(d.pop("properties_json"))
|
||||
return d
|
||||
|
||||
|
||||
def find_container(conn: Connection, chat_id: str, name: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM containers WHERE chat_id = ? AND name = ?",
|
||||
(chat_id, name),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = _row_to_dict(conn, "containers", row)
|
||||
d["properties"] = json.loads(d.pop("properties_json"))
|
||||
return d
|
||||
|
||||
|
||||
def get_activity(conn: Connection, entity_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM activity WHERE entity_id = ?", (entity_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = _row_to_dict(conn, "activity", row)
|
||||
d["action"] = json.loads(d.pop("action_json"))
|
||||
d["holding"] = json.loads(d.pop("holding_json"))
|
||||
d["status"] = json.loads(d.pop("status_json"))
|
||||
return d
|
||||
|
||||
|
||||
def get_scene(conn: Connection, scene_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM scenes WHERE id = ?", (scene_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = _row_to_dict(conn, "scenes", row)
|
||||
d["participants"] = json.loads(d.pop("participants_json"))
|
||||
return d
|
||||
|
||||
|
||||
def active_scene(conn: Connection, chat_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT active_scene_id FROM chat_state WHERE chat_id = ?",
|
||||
(chat_id,),
|
||||
).fetchone()
|
||||
if not row or row[0] is None:
|
||||
return None
|
||||
return get_scene(conn, row[0])
|
||||
@@ -0,0 +1,125 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
margin: 0;
|
||||
color: #1c1c1c;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.rail {
|
||||
width: 200px;
|
||||
background: #1c1c1c;
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail a { color: #fff; text-decoration: none; }
|
||||
.rail-brand {
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.rail ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rail li { margin: 4px 0; }
|
||||
.rail li a { display: block; padding: 6px 8px; border-radius: 3px; }
|
||||
.rail li a.active { background: #333; }
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
background: #fafafa;
|
||||
overflow: auto;
|
||||
}
|
||||
.brand { font-weight: 600; text-decoration: none; color: inherit; }
|
||||
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
|
||||
h1 { margin-top: 0; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.btn, button {
|
||||
display: inline-block; padding: 8px 14px;
|
||||
border: 1px solid #444; background: #1c1c1c; color: #fff;
|
||||
border-radius: 4px; text-decoration: none; cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.bot-form label { display: block; margin-bottom: 14px; }
|
||||
.bot-form label span { display: block; font-weight: 600; margin-bottom: 4px; }
|
||||
.bot-form input[type=text], .bot-form textarea {
|
||||
width: 100%; padding: 6px 8px; font: inherit;
|
||||
border: 1px solid #ccc; border-radius: 3px; background: #fff;
|
||||
}
|
||||
.bot-form small { display: block; color: #666; margin-top: 2px; }
|
||||
.bot-list { list-style: none; padding: 0; }
|
||||
.bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; }
|
||||
.chat-list { list-style: none; padding: 0; margin: 0; }
|
||||
.chat-row { border-bottom: 1px solid #eee; }
|
||||
.chat-row a { display: block; padding: 12px 0; text-decoration: none; color: inherit; }
|
||||
.chat-row a:hover { background: #f0f0f0; }
|
||||
.chat-row-name { font-weight: 600; }
|
||||
.chat-row-snippet { font-size: 14px; }
|
||||
.chat-row-meta { font-size: 12px; }
|
||||
.muted { color: #666; }
|
||||
.error {
|
||||
padding: 8px 12px; border: 1px solid #c33; background: #fdecea;
|
||||
color: #a00; border-radius: 3px;
|
||||
}
|
||||
.success {
|
||||
padding: 8px 12px; border: 1px solid #2d7a3a; background: #eafaf0;
|
||||
color: #1f5c2a; border-radius: 3px;
|
||||
}
|
||||
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||
.chat-shell { display: flex; flex-direction: column; height: 100%; max-width: 760px; margin: 0 auto; }
|
||||
.chat-header { display: flex; align-items: center; gap: 16px; border-bottom: 1px solid #e5e5e5; padding-bottom: 8px; margin-bottom: 16px; }
|
||||
.chat-header h1 { margin: 0; flex: 1; }
|
||||
.chat-meta { font-size: 13px; }
|
||||
.drawer-toggle { padding: 4px 10px; border: 1px solid #ccc; background: #fff; color: #1c1c1c; border-radius: 3px; cursor: pointer; }
|
||||
.timeline { flex: 1; overflow-y: auto; min-height: 200px; padding: 8px 0; }
|
||||
.turn { margin: 12px 0; }
|
||||
.turn strong { display: block; margin-bottom: 4px; }
|
||||
.turn p { margin: 0 0 8px; }
|
||||
.turn p:last-child { margin-bottom: 0; }
|
||||
.turn-you strong { color: #1a73e8; }
|
||||
.turn-bot strong { color: #1c1c1c; }
|
||||
/* ``*action*`` — italic narration. */
|
||||
.action { font-style: italic; color: #555; }
|
||||
/* ``((ooc))`` — author-to-system aside. Dim, italic, smaller, set off
|
||||
from surrounding prose so it doesn't read as in-fiction speech. */
|
||||
.ooc {
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: inline-block;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.turn blockquote {
|
||||
border-left: 3px solid #ccc;
|
||||
padding-left: 12px;
|
||||
margin: 8px 0;
|
||||
color: #555;
|
||||
}
|
||||
.turn-input { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid #e5e5e5; }
|
||||
.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; }
|
||||
/* Streaming UX (T34): typing indicator, Stop button, disconnect banner. */
|
||||
.streaming { opacity: 0.85; }
|
||||
.streaming-text:after {
|
||||
content: "\025AE";
|
||||
margin-left: 2px;
|
||||
animation: blink 1s steps(2, start) infinite;
|
||||
}
|
||||
@keyframes blink { to { visibility: hidden; } }
|
||||
.stop-streaming { background: #c33; border-color: #a00; margin-bottom: 8px; align-self: flex-start; }
|
||||
.connection-lost { margin-bottom: 8px; }
|
||||
@@ -0,0 +1,634 @@
|
||||
<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','')">×</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>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/chat/narrative-anchor"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Narrative anchor:
|
||||
<input type="text" name="new_value" maxlength="500"
|
||||
value="{{ chat.narrative_anchor or '' }}">
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/chat/weather"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Weather:
|
||||
<input type="text" name="new_value" maxlength="500"
|
||||
value="{{ chat.weather or '' }}">
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% 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 %}
|
||||
|
||||
<details class="skip-controls">
|
||||
<summary>Elision skip</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/skip/elision"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Landing state hint:
|
||||
<input type="text" name="landing_state_hint"
|
||||
placeholder="e.g. arriving at the office">
|
||||
</label>
|
||||
<label>
|
||||
New time (ISO 8601):
|
||||
<input type="text" name="new_time" required
|
||||
placeholder="2026-04-26T20:30:00+00:00">
|
||||
</label>
|
||||
<button type="submit">Skip ahead</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="skip-controls">
|
||||
<summary>Jump skip</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/skip/jump"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
New time (ISO 8601):
|
||||
<input type="text" name="new_time" required
|
||||
placeholder="2026-04-27T08:00:00+00:00">
|
||||
</label>
|
||||
<label>
|
||||
Anything notable happen? (optional)
|
||||
<textarea name="notable_prose" rows="3"
|
||||
placeholder="leave blank to jump without synthesizing memories"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="reset_activity" value="1">
|
||||
Reset activity at landing
|
||||
</label>
|
||||
<button type="submit">Jump ahead</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Events</h3>
|
||||
{% if active_events %}
|
||||
<ul class="event-list">
|
||||
{% for ev in active_events %}
|
||||
<li class="event-row">
|
||||
<strong>{{ ev.kind }}</strong>
|
||||
<span class="muted"> ({{ ev.status }})</span>
|
||||
{% if ev.planned_for %}
|
||||
<p class="muted">planned for: {{ ev.planned_for }}</p>
|
||||
{% endif %}
|
||||
{% if ev.props %}
|
||||
<p class="muted">{{ ev.props|tojson }}</p>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/event/cancel/{{ ev.event_id }}"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Cancel</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No active events.</p>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary>Plan event</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/event/plan"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Kind:
|
||||
<input type="text" name="kind" required
|
||||
placeholder="e.g. dinner_reservation">
|
||||
</label>
|
||||
<label>
|
||||
Planned for (ISO 8601):
|
||||
<input type="text" name="planned_for" required
|
||||
placeholder="2026-04-26T19:00:00+00:00">
|
||||
</label>
|
||||
<label>
|
||||
Props (JSON):
|
||||
<textarea name="props_json" rows="3"
|
||||
placeholder='{"location": "Bistro X"}'>{}</textarea>
|
||||
</label>
|
||||
<button type="submit">Plan event</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Threads</h3>
|
||||
{% if open_threads %}
|
||||
<ul class="thread-list">
|
||||
{% for th in open_threads %}
|
||||
<li class="thread-row">
|
||||
<strong>{{ th.title }}</strong>
|
||||
{% if th.summary %}
|
||||
<p>{{ th.summary }}</p>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/thread/close/{{ th.thread_id }}"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Close</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No open threads.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if guest_bot %}
|
||||
<section class="drawer-section">
|
||||
<h3>Guest</h3>
|
||||
<p><strong>{{ guest_bot.name }}</strong></p>
|
||||
{% if guest_activity %}
|
||||
<p>{{ guest_activity.posture or "—" }} / {{ (guest_activity.action or {}).verb or "—" }}</p>
|
||||
{% if guest_activity.attention %}<p class="muted">attention: {{ guest_activity.attention }}</p>{% endif %}
|
||||
{% if guest_activity.holding %}<p class="muted">holding: {{ guest_activity.holding|join(", ") }}</p>{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">No activity recorded.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if edge_h2g %}
|
||||
<div class="edge-row">
|
||||
<strong>{{ host_bot.name }} → {{ guest_bot.name }}</strong>
|
||||
<p>Affinity: {{ edge_h2g.affinity }}/100 · Trust: {{ edge_h2g.trust }}/100</p>
|
||||
{% if edge_h2g.knowledge %}
|
||||
<details><summary>Knowledge ({{ edge_h2g.knowledge|length }})</summary>
|
||||
<ul>{% for fact in edge_h2g.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if edge_g2h %}
|
||||
<div class="edge-row">
|
||||
<strong>{{ guest_bot.name }} → {{ host_bot.name }}</strong>
|
||||
<p>Affinity: {{ edge_g2h.affinity }}/100 · Trust: {{ edge_g2h.trust }}/100</p>
|
||||
{% if edge_g2h.knowledge %}
|
||||
<details><summary>Knowledge ({{ edge_g2h.knowledge|length }})</summary>
|
||||
<ul>{% for fact in edge_g2h.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if edge_y2g %}
|
||||
<div class="edge-row">
|
||||
<strong>you → {{ guest_bot.name }}</strong>
|
||||
<p>Affinity: {{ edge_y2g.affinity }}/100 · Trust: {{ edge_y2g.trust }}/100</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if edge_g2y %}
|
||||
<div class="edge-row">
|
||||
<strong>{{ guest_bot.name }} → you</strong>
|
||||
<p>Affinity: {{ edge_g2y.affinity }}/100 · Trust: {{ edge_g2y.trust }}/100</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/guest/remove"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Remove guest</button>
|
||||
</form>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="drawer-section">
|
||||
<h3>Add guest</h3>
|
||||
{% if available_guests %}
|
||||
{% 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 class="add-guest-select">
|
||||
{% for b in available_guests %}
|
||||
<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 %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if group_node %}
|
||||
<section class="drawer-section">
|
||||
<h3>Group</h3>
|
||||
{% if group_node.summary %}
|
||||
<p>{{ group_node.summary }}</p>
|
||||
{% else %}
|
||||
<p class="muted">No group summary yet.</p>
|
||||
{% endif %}
|
||||
{% if group_node.dynamic %}
|
||||
<p class="muted">Dynamic: {{ group_node.dynamic }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Edges</h3>
|
||||
{% if edge_b2y %}
|
||||
<div class="edge-row">
|
||||
<strong>{{ host_bot.name }} → you</strong>
|
||||
<p>Affinity: {{ edge_b2y.affinity }}/100 · 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>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/trust"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||
<input type="hidden" name="target_id" value="you">
|
||||
<label>
|
||||
Trust:
|
||||
<input type="range" name="new_value" min="0" max="100"
|
||||
value="{{ edge_b2y.trust }}"
|
||||
oninput="this.nextElementSibling.value = this.value">
|
||||
<output>{{ edge_b2y.trust }}</output>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/summary"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||
<input type="hidden" name="target_id" value="you">
|
||||
<label>
|
||||
Summary:
|
||||
<textarea name="new_summary" rows="3" maxlength="2000">{{ edge_b2y.summary or "" }}</textarea>
|
||||
</label>
|
||||
<button type="submit">Save summary</button>
|
||||
</form>
|
||||
<details>
|
||||
<summary>Knowledge ({{ (edge_b2y.knowledge or [])|length }})</summary>
|
||||
{% if edge_b2y.knowledge %}
|
||||
<ul>
|
||||
{% for fact in edge_b2y.knowledge %}
|
||||
<li>
|
||||
{{ fact }}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/knowledge-facts"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||
<input type="hidden" name="target_id" value="you">
|
||||
<input type="hidden" name="action" value="remove">
|
||||
<input type="hidden" name="fact" value="{{ fact }}">
|
||||
<button type="submit">Remove</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/edge/knowledge-facts"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||
<input type="hidden" name="target_id" value="you">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<label>
|
||||
Add fact:
|
||||
<input type="text" name="fact" maxlength="500" required>
|
||||
</label>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if edge_y2b %}
|
||||
<div class="edge-row">
|
||||
<strong>you → {{ host_bot.name }}</strong>
|
||||
<p>Affinity: {{ edge_y2b.affinity }}/100 · Trust: {{ edge_y2b.trust }}/100</p>
|
||||
<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 %}
|
||||
<p class="muted">No edges yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Branches</h3>
|
||||
{% if branches %}
|
||||
<ul class="branch-list">
|
||||
{% for b in branches %}
|
||||
<li class="branch-row{% if b.is_active %} branch-active{% endif %}">
|
||||
<strong>{{ b.name }}</strong>
|
||||
{% if b.is_active %}<span class="muted"> (active)</span>{% endif %}
|
||||
<span class="muted"> · {{ b.event_count }} events</span>
|
||||
{% if not b.is_active %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/branch/switch"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="name" value="{{ b.name }}">
|
||||
<button type="submit">Switch</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No branches yet.</p>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary>Create branch</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/branch/create"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Name:
|
||||
<input type="text" name="name" required
|
||||
placeholder="e.g. experiment_a">
|
||||
</label>
|
||||
<label>
|
||||
Origin event id:
|
||||
<input type="number" name="origin_event_id" required min="0">
|
||||
</label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Recent turns</h3>
|
||||
{% if recent_turns %}
|
||||
<ul class="recent-turns-list">
|
||||
{% for t in recent_turns %}
|
||||
<li class="turn-row{% if t.hidden %} turn-hidden{% endif %}">
|
||||
<span class="muted">#{{ t.event_id }} {{ t.kind }}</span>
|
||||
<strong>{{ t.speaker }}:</strong>
|
||||
{{ t.excerpt }}{% if t.excerpt|length >= 120 %}…{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/turn/hide/{{ t.event_id }}"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="hidden" value="{{ 0 if t.hidden else 1 }}">
|
||||
<label>
|
||||
<input type="checkbox" {% if t.hidden %}checked{% endif %}
|
||||
onchange="this.form.requestSubmit()">
|
||||
hide from view
|
||||
</label>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No turns yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Significance review</h3>
|
||||
{% set total_mem = significance_distribution.values()|sum %}
|
||||
{% if total_mem %}
|
||||
<ul class="significance-distribution">
|
||||
{% for level in [0, 1, 2, 3] %}
|
||||
{% set count = significance_distribution[level] %}
|
||||
{% set marker = ['·','•','★','★★'][level] %}
|
||||
{% set pct = (100 * count / total_mem)|round(0, 'floor')|int if total_mem else 0 %}
|
||||
<li class="sig-bar sig-{{ level }}">
|
||||
<span class="sig-label">{{ marker }} ({{ level }})</span>
|
||||
<span class="sig-bar-fill" style="width: {{ pct }}%"></span>
|
||||
<span class="sig-count">{{ count }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No memories yet.</p>
|
||||
{% endif %}
|
||||
{% if recent_memories %}
|
||||
<details>
|
||||
<summary>Edit significance (recent memories)</summary>
|
||||
<ul class="significance-edit-list">
|
||||
{% for m in recent_memories %}
|
||||
<li>
|
||||
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
|
||||
{{ m.pov_summary[:80] }}{% if m.pov_summary|length > 80 %}…{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/significance"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Significance:
|
||||
<input type="range" name="significance" min="0" max="3"
|
||||
value="{{ m.significance|default(0) }}"
|
||||
oninput="this.nextElementSibling.value = this.value">
|
||||
<output>{{ m.significance|default(0) }}</output>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% 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>
|
||||
<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>
|
||||
{% else %}
|
||||
<p class="muted">No memories yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}chat{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}New bot - chat{% endblock %}
|
||||
{% block content %}
|
||||
<h1>New bot</h1>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/bots/new" class="bot-form">
|
||||
<label>
|
||||
<span>id</span>
|
||||
<input type="text" name="id" required value="{{ values.id|default('', true) }}">
|
||||
<small>slug-like identifier (e.g. <code>bot_a</code>, <code>alice_office</code>)</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>name</span>
|
||||
<input type="text" name="name" required value="{{ values.name|default('', true) }}">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>persona</span>
|
||||
<textarea name="persona" rows="4" required>{{ values.persona|default('', true) }}</textarea>
|
||||
<small>a short description, ~3-5 lines</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>voice samples</span>
|
||||
<textarea name="voice_samples" rows="6">{{ values.voice_samples|default('', true) }}</textarea>
|
||||
<small>1-3 samples, separated by a line containing only <code>---</code></small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>traits</span>
|
||||
<textarea name="traits" rows="3">{{ values.traits|default('', true) }}</textarea>
|
||||
<small>comma- or newline-separated; 3-15 typical</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>backstory</span>
|
||||
<textarea name="backstory" rows="6">{{ values.backstory|default('', true) }}</textarea>
|
||||
<small>100-500 words target</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>initial relationship to you</span>
|
||||
<textarea name="initial_relationship_to_you" rows="3" required>{{ values.initial_relationship_to_you|default('', true) }}</textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>kickoff prose</span>
|
||||
<textarea name="kickoff_prose" rows="4" required>{{ values.kickoff_prose|default('', true) }}</textarea>
|
||||
<small>a short opening scene; parsed in the next step</small>
|
||||
</label>
|
||||
|
||||
<button type="submit">Save bot</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Bots - chat{% endblock %}
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<h1>Bots</h1>
|
||||
<a class="btn" href="/bots/new">+ New bot</a>
|
||||
</header>
|
||||
{% if bots %}
|
||||
<ul class="bot-list">
|
||||
{% for bot in bots %}
|
||||
<li>
|
||||
<a href="/bots/{{ bot.id }}">{{ bot.name }}</a>
|
||||
<details class="bot-row-reset">
|
||||
<summary>Reset</summary>
|
||||
<form method="post" action="/bots/{{ bot.id }}/reset" class="inline-edit">
|
||||
<label>Type "{{ bot.name }}" to confirm:
|
||||
<input type="text" name="confirm_name" required>
|
||||
</label>
|
||||
<button type="submit">Reset bot</button>
|
||||
</form>
|
||||
</details>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No bots yet. <a href="/bots/new">Create your first bot.</a></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,192 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}{{ host_bot.name }} - chat{% endblock %}
|
||||
{% block content %}
|
||||
<div class="chat-shell" data-chat-id="{{ chat.id }}"
|
||||
hx-ext="sse"
|
||||
sse-connect="/chats/{{ chat.id }}/events">
|
||||
<header class="chat-header">
|
||||
<h1>{{ host_bot.name }}</h1>
|
||||
<div class="chat-meta muted">{{ chat.time }}</div>
|
||||
<button class="drawer-toggle" type="button" aria-controls="drawer" aria-expanded="false">Drawer</button>
|
||||
</header>
|
||||
|
||||
<section class="timeline" id="timeline"
|
||||
sse-swap="turn_html"
|
||||
hx-swap="beforeend">
|
||||
{% if not turns %}
|
||||
<p class="muted">No turns yet. Start typing below.</p>
|
||||
{% else %}
|
||||
{% for turn in turns %}
|
||||
<div{% if turn.event_id is not none %} id="turn-{{ turn.event_id }}"{% endif %} class="turn turn-{{ turn.role }}">
|
||||
<strong>{{ turn.speaker }}</strong>
|
||||
{{ turn.text|render_prose|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<form class="turn-input" method="post" action="/chats/{{ chat.id }}/turns">
|
||||
<textarea name="prose" rows="3" placeholder="What do you say or do?" required></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
|
||||
<aside class="drawer" id="drawer" hidden
|
||||
hx-get="/chats/{{ chat.id }}/drawer"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="innerHTML">
|
||||
<p class="muted">Loading drawer…</p>
|
||||
</aside>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
|
||||
const drawer = document.getElementById('drawer');
|
||||
const isHidden = drawer.hasAttribute('hidden');
|
||||
if (isHidden) drawer.removeAttribute('hidden');
|
||||
else drawer.setAttribute('hidden', '');
|
||||
e.target.setAttribute('aria-expanded', String(isHidden));
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Streaming UX (T34): typing indicator, Stop button, send-lock,
|
||||
// disconnect banner. Listens to the existing HTMX SSE channel for
|
||||
// `token` (per-chunk) and `turn_html` (final swap) events. The
|
||||
// mid-stream disconnect path is server-side: ``request.is_disconnected()``
|
||||
// in T19 commits truncated; this script just shows the banner when
|
||||
// the SSE EventSource fires `error` after the connection drops.
|
||||
(function () {
|
||||
const shell = document.querySelector('.chat-shell');
|
||||
if (!shell) return;
|
||||
const chatId = shell.dataset.chatId;
|
||||
const form = shell.querySelector('.turn-input');
|
||||
if (!form) return;
|
||||
const textarea = form.querySelector('textarea[name="prose"]');
|
||||
const sendBtn = form.querySelector('button[type="submit"]');
|
||||
const timeline = document.getElementById('timeline');
|
||||
|
||||
let isStreaming = false;
|
||||
let typingEl = null;
|
||||
|
||||
function ensureTypingEl() {
|
||||
if (typingEl) return typingEl;
|
||||
typingEl = document.createElement('div');
|
||||
typingEl.className = 'turn turn-bot streaming';
|
||||
typingEl.innerHTML = '<strong>...</strong><p class="streaming-text"></p>';
|
||||
timeline.appendChild(typingEl);
|
||||
return typingEl;
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
isStreaming = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
if (textarea) {
|
||||
textarea.readOnly = false;
|
||||
textarea.value = '';
|
||||
textarea.focus();
|
||||
}
|
||||
const stop = shell.querySelector('.stop-streaming');
|
||||
if (stop) stop.remove();
|
||||
}
|
||||
|
||||
function showBanner(msg) {
|
||||
let banner = shell.querySelector('.connection-lost');
|
||||
if (banner) return;
|
||||
banner = document.createElement('div');
|
||||
banner.className = 'connection-lost error';
|
||||
banner.textContent = msg;
|
||||
form.parentElement.insertBefore(banner, form);
|
||||
}
|
||||
|
||||
// HTMX SSE extension dispatches `htmx:sseMessage` with detail.type
|
||||
// (event name) and detail.data (payload string).
|
||||
shell.addEventListener('htmx:sseMessage', (e) => {
|
||||
const evt = e.detail.type;
|
||||
const data = e.detail.data;
|
||||
if (evt === 'token' && isStreaming) {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(data); } catch (_) { return; }
|
||||
const el = ensureTypingEl();
|
||||
el.querySelector('.streaming-text').textContent += (parsed.text || '');
|
||||
} else if (evt === 'turn_html') {
|
||||
// The server already pushes the final HTML via sse-swap on the
|
||||
// timeline element; we just remove the typing placeholder and
|
||||
// unlock the input. (Don't replace innerHTML here — HTMX has
|
||||
// already done the append by the time this fires.)
|
||||
if (typingEl) {
|
||||
typingEl.remove();
|
||||
typingEl = null;
|
||||
}
|
||||
unlock();
|
||||
}
|
||||
});
|
||||
|
||||
// T86: live-swap regenerated turns. The backend (chat/services/
|
||||
// regenerate.py) broadcasts a ``turn_html_replace`` SSE frame after
|
||||
// appending the new assistant_turn — JSON payload of shape
|
||||
// ``{data: <html>, turn_id: <new_id>, supersedes_id: <old_id>}``.
|
||||
// We replace the prior turn's DOM node in-place when we can locate
|
||||
// it by id, otherwise fall back to appending so a tab opened mid-
|
||||
// regenerate still shows the new turn. The renderer
|
||||
// (chat/web/render.py::render_turn_html) and the Jinja loop above
|
||||
// both stamp ``id="turn-<event_id>"`` on each turn DIV, so the
|
||||
// primary in-place swap path is the live one — the append fallback
|
||||
// only kicks in when a tab opened AFTER the regenerate started (no
|
||||
// prior turn DOM node to replace).
|
||||
shell.addEventListener('htmx:sseMessage', (e) => {
|
||||
if (e.detail.type !== 'turn_html_replace') return;
|
||||
let data;
|
||||
try { data = JSON.parse(e.detail.data); } catch (_) { return; }
|
||||
const html = (data && data.data) || '';
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) return;
|
||||
const oldNode = document.getElementById('turn-' + data.supersedes_id);
|
||||
if (oldNode) {
|
||||
const tmpl = document.createElement('template');
|
||||
tmpl.innerHTML = trimmed;
|
||||
const newNode = tmpl.content.firstChild;
|
||||
if (newNode) oldNode.replaceWith(newNode);
|
||||
} else {
|
||||
// Fallback: append if the prior turn isn't in the DOM (e.g. user
|
||||
// opened the tab AFTER the regenerate started, or the renderer
|
||||
// hasn't yet stamped per-turn ids — see comment above).
|
||||
timeline.insertAdjacentHTML('beforeend', trimmed);
|
||||
}
|
||||
});
|
||||
|
||||
// SSE connection lost — show a banner and unlock so the user can
|
||||
// retry. The server commits the partial as truncated when its
|
||||
// request.is_disconnected() poll trips (T19).
|
||||
shell.addEventListener('htmx:sseError', () => {
|
||||
if (isStreaming) {
|
||||
showBanner('connection lost — partial response saved');
|
||||
unlock();
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
isStreaming = true;
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
// readOnly (not disabled) — disabled fields are excluded from the
|
||||
// form submission, which would send prose="" and trigger the
|
||||
// server's empty-prose 400.
|
||||
if (textarea) textarea.readOnly = true;
|
||||
if (!shell.querySelector('.stop-streaming')) {
|
||||
const stopBtn = document.createElement('button');
|
||||
stopBtn.type = 'button';
|
||||
stopBtn.className = 'stop-streaming btn';
|
||||
stopBtn.textContent = 'Stop';
|
||||
stopBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await fetch('/chats/' + encodeURIComponent(chatId) + '/turns/cancel', {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (_) {
|
||||
// Network error on cancel is non-fatal — server will time out
|
||||
// its own stream eventually and commit truncated.
|
||||
}
|
||||
});
|
||||
form.parentElement.insertBefore(stopBtn, form);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Chats - chat{% endblock %}
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<h1>Chats</h1>
|
||||
<a class="btn" href="/bots/new">+ New bot</a>
|
||||
</header>
|
||||
{% if chats %}
|
||||
<ul class="chat-list">
|
||||
{% for chat in chats %}
|
||||
<li class="chat-row">
|
||||
<a href="/chats/{{ chat.id }}">
|
||||
<div class="chat-row-name">{{ chat.host_bot_name }}</div>
|
||||
<div class="chat-row-snippet muted">{{ chat.last_message_snippet or '—' }}</div>
|
||||
<div class="chat-row-meta muted">
|
||||
<span>{{ chat.time }}</span>
|
||||
{% if chat.last_played_at %}<span>· {{ chat.last_played_at }}</span>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No chats yet. <a href="/bots/new">Create a bot</a> to start.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Error - chat{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-page">
|
||||
<h1>{{ status_code }}</h1>
|
||||
<p>{{ detail }}</p>
|
||||
<p><a href="/chats">Back to chats</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,118 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Confirm kickoff - chat{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Confirm kickoff</h1>
|
||||
<p>Review and edit the parsed opening scene for <strong>{{ values.bot_name }}</strong>, then confirm to start the chat.</p>
|
||||
|
||||
<form method="post" action="/bots/{{ values.bot_id }}/kickoff" class="kickoff-form">
|
||||
|
||||
<fieldset>
|
||||
<legend>Container</legend>
|
||||
<label>
|
||||
<span>name</span>
|
||||
<input type="text" name="container_name" required value="{{ values.container_name|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>type</span>
|
||||
<input type="text" name="container_type" required value="{{ values.container_type|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>properties (JSON)</span>
|
||||
<textarea name="container_properties" rows="6">{{ values.container_properties|default('{}', true) }}</textarea>
|
||||
<small>JSON object; invalid JSON falls back to <code>{}</code></small>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Initial in-fiction time</legend>
|
||||
<label>
|
||||
<span>initial_time_iso</span>
|
||||
<input type="text" name="initial_time_iso" required value="{{ values.initial_time_iso|default('', true) }}">
|
||||
<small>ISO 8601, e.g. <code>2026-04-26T20:00:00+00:00</code></small>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Your activity</legend>
|
||||
<label>
|
||||
<span>posture</span>
|
||||
<input type="text" name="you_activity_posture" value="{{ values.you_activity_posture|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>action verb</span>
|
||||
<input type="text" name="you_activity_action_verb" value="{{ values.you_activity_action_verb|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>interruptible</span>
|
||||
<input type="checkbox" name="you_activity_action_interruptible"{% if values.you_activity_action_interruptible %} checked{% endif %}>
|
||||
</label>
|
||||
<label>
|
||||
<span>required attention</span>
|
||||
<input type="text" name="you_activity_action_required_attention" value="{{ values.you_activity_action_required_attention|default('low', true) }}">
|
||||
<small>low / medium / high</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>expected duration</span>
|
||||
<input type="text" name="you_activity_action_expected_duration" value="{{ values.you_activity_action_expected_duration|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>attention</span>
|
||||
<input type="text" name="you_activity_attention" value="{{ values.you_activity_attention|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>holding (comma-separated)</span>
|
||||
<input type="text" name="you_activity_holding" value="{{ values.you_activity_holding|default('', true) }}">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{{ values.bot_name }}'s activity</legend>
|
||||
<label>
|
||||
<span>posture</span>
|
||||
<input type="text" name="bot_activity_posture" value="{{ values.bot_activity_posture|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>action verb</span>
|
||||
<input type="text" name="bot_activity_action_verb" value="{{ values.bot_activity_action_verb|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>interruptible</span>
|
||||
<input type="checkbox" name="bot_activity_action_interruptible"{% if values.bot_activity_action_interruptible %} checked{% endif %}>
|
||||
</label>
|
||||
<label>
|
||||
<span>required attention</span>
|
||||
<input type="text" name="bot_activity_action_required_attention" value="{{ values.bot_activity_action_required_attention|default('low', true) }}">
|
||||
<small>low / medium / high</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>expected duration</span>
|
||||
<input type="text" name="bot_activity_action_expected_duration" value="{{ values.bot_activity_action_expected_duration|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>attention</span>
|
||||
<input type="text" name="bot_activity_attention" value="{{ values.bot_activity_attention|default('', true) }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>holding (comma-separated)</span>
|
||||
<input type="text" name="bot_activity_holding" value="{{ values.bot_activity_holding|default('', true) }}">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Edge seed</legend>
|
||||
<label>
|
||||
<span>summary</span>
|
||||
<textarea name="edge_seed_summary" rows="3">{{ values.edge_seed_summary|default('', true) }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>knowledge facts (one per line)</span>
|
||||
<textarea name="edge_seed_knowledge_facts" rows="6">{{ values.edge_seed_knowledge_facts|default('', true) }}</textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Confirm and start chat</button>
|
||||
<a href="/bots">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<nav class="rail">
|
||||
<a class="rail-brand" href="/chats">chat</a>
|
||||
<ul>
|
||||
<li><a href="/chats" class="{% if active_nav == 'chats' %}active{% endif %}">Chats</a></li>
|
||||
<li><a href="/bots" class="{% if active_nav == 'bots' %}active{% endif %}">Bots</a></li>
|
||||
<li><a href="/snapshots" class="{% if active_nav == 'snapshots' %}active{% endif %}">Snapshots</a></li>
|
||||
<li><a href="/settings" class="{% if active_nav == 'settings' %}active{% endif %}">Settings</a></li>
|
||||
</ul>
|
||||
{# T100: cross-chat search box. GET /search so the URL is shareable
|
||||
and back-button friendly; the results page itself re-renders this
|
||||
form with the query pre-filled. #}
|
||||
<form class="rail-search" action="/search" method="get" role="search">
|
||||
<input type="search" name="q" placeholder="Search" aria-label="Search memories">
|
||||
<button type="submit">Go</button>
|
||||
</form>
|
||||
</nav>
|
||||
<main class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Search - chat{% endblock %}
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<h1>Search</h1>
|
||||
</header>
|
||||
|
||||
<form class="search-page-form" action="/search" method="get">
|
||||
<input type="text" name="q" value="{{ query|default('', true) }}"
|
||||
placeholder="Search memories across all chats" autofocus>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
{% if not query %}
|
||||
{# Empty-state placeholder: the top-bar form submits to /search even
|
||||
with no input, so this page must render cleanly with no query. #}
|
||||
<p class="muted search-empty">Enter a query to search memories across all chats.</p>
|
||||
{% elif not results %}
|
||||
<p class="muted">No matches for “{{ query }}”.</p>
|
||||
{% else %}
|
||||
<ul class="search-results">
|
||||
{% for r in results %}
|
||||
<li class="search-result">
|
||||
<a class="search-result-link" href="/chats/{{ r.chat_id }}">
|
||||
<div class="search-result-meta muted">
|
||||
<strong>{{ r.owner_name }}</strong>
|
||||
<span>· {{ r.chat_id }}</span>
|
||||
{% if r.chat_name %}<span>· {{ r.chat_name }}</span>{% endif %}
|
||||
{% if r.scene_label %}<span>· scene {{ r.scene_label }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="search-result-summary">{{ r.pov_summary }}</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Settings - chat{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Settings</h1>
|
||||
{% if saved %}
|
||||
<p class="success">Settings saved.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/settings" class="bot-form">
|
||||
<label>
|
||||
<span>name</span>
|
||||
<input type="text" name="name" required value="{{ values.name|default('', true) }}">
|
||||
<small>required</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>pronouns</span>
|
||||
<input type="text" name="pronouns" value="{{ values.pronouns|default('', true) }}">
|
||||
<small>optional (e.g. they/them)</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>persona</span>
|
||||
<textarea name="persona" rows="3">{{ values.persona|default('', true) }}</textarea>
|
||||
<small>optional but recommended; a short description of you</small>
|
||||
</label>
|
||||
|
||||
<button type="submit">Save settings</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Snapshots - chat{% endblock %}
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<h1>Snapshots</h1>
|
||||
<form method="post" action="/snapshots/take" class="inline-edit">
|
||||
<button type="submit">Take snapshot now</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{% if preview %}
|
||||
<section class="snapshot-preview">
|
||||
<h2>Preview: {{ preview.snapshot_id }}</h2>
|
||||
<dl>
|
||||
<dt>kind</dt><dd>{{ preview.kind }}</dd>
|
||||
<dt>filename</dt><dd>{{ preview.filename }}</dd>
|
||||
<dt>file size (bytes)</dt><dd>{{ preview.file_size_bytes }}</dd>
|
||||
<dt>snapshot last_event_id</dt><dd>{{ preview.last_event_id }}</dd>
|
||||
<dt>current event_log max id</dt><dd>{{ preview.current_event_log_max_id }}</dd>
|
||||
<dt>events since snapshot</dt><dd>{{ preview.event_delta }}</dd>
|
||||
<dt>events stored in snapshot</dt><dd>{{ preview.event_log_rows_in_snapshot }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if snapshots %}
|
||||
<table class="snapshot-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Kind</th>
|
||||
<th>Created (UTC)</th>
|
||||
<th>Size (bytes)</th>
|
||||
<th>last_event_id</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for snap in snapshots %}
|
||||
<tr>
|
||||
<td>{{ snap.snapshot_id }}</td>
|
||||
<td>{{ snap.kind }}</td>
|
||||
<td>{{ snap.created_at }}</td>
|
||||
<td>{{ snap.file_size_bytes }}</td>
|
||||
<td>{{ snap.last_event_id if snap.last_event_id is not none else '?' }}</td>
|
||||
<td>
|
||||
<a href="/snapshots/{{ snap.snapshot_id }}/preview?kind={{ snap.kind }}">Preview</a>
|
||||
<details class="snapshot-row-restore">
|
||||
<summary>Restore</summary>
|
||||
<form method="post" action="/snapshots/restore/{{ snap.snapshot_id }}" class="inline-edit">
|
||||
<input type="hidden" name="kind" value="{{ snap.kind }}">
|
||||
<label>Type "{{ snap.snapshot_id }}" to confirm:
|
||||
<input type="text" name="confirm_id" required>
|
||||
</label>
|
||||
<button type="submit">Restore from this snapshot</button>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No snapshots yet. Use "Take snapshot now" to create one.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
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
|
||||
|
||||
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
REQUIRED_FIELDS = ("id", "name", "persona", "initial_relationship_to_you", "kickoff_prose")
|
||||
|
||||
|
||||
def get_conn(request: Request):
|
||||
settings = request.app.state.settings
|
||||
db_path: Path = settings.db_path
|
||||
with open_db(db_path, check_same_thread=False) as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def _split_voice_samples(text: str) -> list[str]:
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
# Split on a line containing only "---" (with optional surrounding whitespace).
|
||||
parts: list[str] = []
|
||||
buf: list[str] = []
|
||||
for line in text.splitlines():
|
||||
if line.strip() == "---":
|
||||
if buf:
|
||||
parts.append("\n".join(buf).strip())
|
||||
buf = []
|
||||
continue
|
||||
buf.append(line)
|
||||
if buf:
|
||||
parts.append("\n".join(buf).strip())
|
||||
return [p for p in parts if p]
|
||||
|
||||
|
||||
def _split_traits(text: str) -> list[str]:
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
items: list[str] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "," in line:
|
||||
items.extend(p.strip() for p in line.split(","))
|
||||
else:
|
||||
items.append(line)
|
||||
return [t for t in items if t]
|
||||
|
||||
|
||||
@router.get("/bots", response_class=HTMLResponse)
|
||||
async def bots_list(request: Request, conn=Depends(get_conn)):
|
||||
bots = list_bots(conn)
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request, "bot_list.html", {"bots": bots, "active_nav": "bots"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bots/new", response_class=HTMLResponse)
|
||||
async def bot_form(request: Request):
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request, "bot_form.html", {"values": {}, "error": None, "active_nav": "bots"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bots/new")
|
||||
async def bot_create(
|
||||
request: Request,
|
||||
id: str = Form(""),
|
||||
name: str = Form(""),
|
||||
persona: str = Form(""),
|
||||
voice_samples: str = Form(""),
|
||||
traits: str = Form(""),
|
||||
backstory: str = Form(""),
|
||||
initial_relationship_to_you: str = Form(""),
|
||||
kickoff_prose: str = Form(""),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
values = {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"persona": persona,
|
||||
"voice_samples": voice_samples,
|
||||
"traits": traits,
|
||||
"backstory": backstory,
|
||||
"initial_relationship_to_you": initial_relationship_to_you,
|
||||
"kickoff_prose": kickoff_prose,
|
||||
}
|
||||
missing = [f for f in REQUIRED_FIELDS if not values[f].strip()]
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"missing required: {', '.join(missing)}")
|
||||
|
||||
payload = {
|
||||
"id": id.strip(),
|
||||
"name": name.strip(),
|
||||
"persona": persona.strip(),
|
||||
"voice_samples": _split_voice_samples(voice_samples),
|
||||
"traits": _split_traits(traits),
|
||||
"backstory": backstory.strip(),
|
||||
"initial_relationship_to_you": initial_relationship_to_you.strip(),
|
||||
"kickoff_prose": kickoff_prose.strip(),
|
||||
}
|
||||
append_event(conn, kind="bot_authored", payload=payload)
|
||||
project(conn)
|
||||
return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303)
|
||||
|
||||
|
||||
@router.post("/bots/{bot_id}/reset")
|
||||
async def reset_bot_route(
|
||||
bot_id: str,
|
||||
request: Request,
|
||||
confirm_name: str = Form(""),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
from chat.services.reset import reset_bot
|
||||
|
||||
try:
|
||||
reset_bot(conn, bot_id, confirm_name=confirm_name)
|
||||
except ValueError as e:
|
||||
msg = str(e).lower()
|
||||
if "not found" in msg:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return RedirectResponse(url="/bots", status_code=303)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Chat detail (shell) page.
|
||||
|
||||
Renders ``/chats/<id>``: the title (host bot's name), a timeline placeholder,
|
||||
the user-input form, and the drawer toggle. Turn handling lives in T19; this
|
||||
module only sets up the structural shell.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from chat.state.entities import get_bot
|
||||
from chat.state.world import get_chat
|
||||
from chat.web.bots import get_conn
|
||||
from chat.web.render import render_prose
|
||||
from chat.web.turns import _read_recent_dialogue
|
||||
|
||||
TEMPLATES = Jinja2Templates(
|
||||
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
||||
)
|
||||
# Register the prose renderer as a Jinja filter so the chat-detail
|
||||
# template can use ``{{ turn.text|render_prose|safe }}`` (Task 33).
|
||||
# The renderer escapes user content internally; ``|safe`` is required
|
||||
# because the output contains intentional ``<p>``/``<em>``/etc. tags.
|
||||
TEMPLATES.env.filters["render_prose"] = render_prose
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/chats/{chat_id}", response_class=HTMLResponse)
|
||||
async def chat_detail(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:
|
||||
# Defensive: chat row references a bot that doesn't exist. Treat as 404
|
||||
# rather than crashing the template render.
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"host bot not found: {chat['host_bot_id']}"
|
||||
)
|
||||
|
||||
# T19: render the timeline from event_log. We pull both user_turn and
|
||||
# assistant_turn events for this chat, in chronological order. Each row
|
||||
# is shaped ``{"speaker": ..., "text": ...}`` and the template
|
||||
# discriminates roles via the speaker id (the literal "you" vs. a bot id).
|
||||
raw_turns = _read_recent_dialogue(conn, chat_id, limit=200)
|
||||
turns: list[dict] = []
|
||||
for t in raw_turns:
|
||||
# event_id is forwarded so the Jinja loop can stamp
|
||||
# ``id="turn-<event_id>"`` on each rendered turn — the
|
||||
# ``turn_html_replace`` SSE handler in chat.html relies on this
|
||||
# id to swap a regenerated turn in-place (T86 follow-up).
|
||||
if t["speaker"] == "you":
|
||||
turns.append(
|
||||
{
|
||||
"role": "you",
|
||||
"speaker": "you",
|
||||
"text": t["text"],
|
||||
"event_id": t.get("event_id"),
|
||||
}
|
||||
)
|
||||
else:
|
||||
bot = get_bot(conn, t["speaker"])
|
||||
label = bot["name"] if bot else t["speaker"]
|
||||
turns.append(
|
||||
{
|
||||
"role": "bot",
|
||||
"speaker": label,
|
||||
"text": t["text"],
|
||||
"event_id": t.get("event_id"),
|
||||
}
|
||||
)
|
||||
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"chat.html",
|
||||
{
|
||||
"chat": chat,
|
||||
"host_bot": host_bot,
|
||||
"turns": turns,
|
||||
"active_nav": "chats",
|
||||
},
|
||||
)
|
||||
+1478
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
"""Kickoff parse-and-confirm flow.
|
||||
|
||||
After a bot is authored, the user lands on ``/bots/<id>/kickoff``. We call the
|
||||
LLM-backed ``parse_kickoff`` to extract a structured opening scene from the
|
||||
authored prose and render it as an editable form. On submit, the (possibly
|
||||
edited) values are turned into a sequence of events that initialize the chat,
|
||||
its container, the participants' activities, an open scene, and a seed edge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from chat.eventlog.log import append_event
|
||||
from chat.eventlog.projector import project
|
||||
from chat.llm.client import LLMClient
|
||||
from chat.services.kickoff import parse_kickoff
|
||||
from chat.state.entities import get_bot, get_you
|
||||
from chat.web.bots import get_conn
|
||||
|
||||
TEMPLATES = Jinja2Templates(
|
||||
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_llm_client(request: Request) -> LLMClient:
|
||||
"""Production LLM client. Tests override this via ``app.dependency_overrides``."""
|
||||
settings = request.app.state.settings
|
||||
from chat.llm.featherless import FeatherlessClient
|
||||
|
||||
return FeatherlessClient(
|
||||
api_key=settings.featherless_api_key,
|
||||
base_url=settings.featherless_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _parse_holding(text: str) -> list[str]:
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
return [p.strip() for p in text.split(",") if p.strip()]
|
||||
|
||||
|
||||
def _parse_facts(text: str) -> list[str]:
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
return [line.strip() for line in text.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _parse_properties(text: str) -> dict:
|
||||
"""Parse the container_properties textarea as JSON.
|
||||
|
||||
Returns ``{}`` on invalid JSON rather than raising — the form is editable
|
||||
and a bad value should not block the user from confirming the rest.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return {}
|
||||
try:
|
||||
loaded = json.loads(text)
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}/kickoff", response_class=HTMLResponse)
|
||||
async def kickoff_get(
|
||||
bot_id: str,
|
||||
request: Request,
|
||||
conn=Depends(get_conn),
|
||||
llm=Depends(get_llm_client),
|
||||
):
|
||||
bot = get_bot(conn, bot_id)
|
||||
if bot is None:
|
||||
raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}")
|
||||
|
||||
you = get_you(conn)
|
||||
you_name = you["name"] if you else "You"
|
||||
|
||||
settings = request.app.state.settings
|
||||
parsed = await parse_kickoff(
|
||||
llm,
|
||||
model=settings.classifier_model,
|
||||
bot_name=bot["name"],
|
||||
bot_persona=bot["persona"],
|
||||
initial_relationship_to_you=bot.get("initial_relationship_to_you", ""),
|
||||
kickoff_prose=bot.get("kickoff_prose", ""),
|
||||
you_name=you_name,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
|
||||
# Render values onto the form. ``container_properties`` is shown as JSON;
|
||||
# ``holding`` lists are rendered as comma-separated text; the seed
|
||||
# knowledge facts are rendered one-per-line.
|
||||
values = {
|
||||
"bot_id": bot_id,
|
||||
"bot_name": bot["name"],
|
||||
"container_name": parsed.container_name,
|
||||
"container_type": parsed.container_type,
|
||||
"container_properties": json.dumps(parsed.container_properties, indent=2),
|
||||
"initial_time_iso": parsed.initial_time_iso,
|
||||
"you_activity_posture": parsed.you_activity.posture,
|
||||
"you_activity_action_verb": parsed.you_activity.action_verb,
|
||||
"you_activity_action_interruptible": parsed.you_activity.action_interruptible,
|
||||
"you_activity_action_required_attention": parsed.you_activity.action_required_attention,
|
||||
"you_activity_action_expected_duration": parsed.you_activity.action_expected_duration,
|
||||
"you_activity_attention": parsed.you_activity.attention,
|
||||
"you_activity_holding": ", ".join(parsed.you_activity.holding),
|
||||
"bot_activity_posture": parsed.bot_activity.posture,
|
||||
"bot_activity_action_verb": parsed.bot_activity.action_verb,
|
||||
"bot_activity_action_interruptible": parsed.bot_activity.action_interruptible,
|
||||
"bot_activity_action_required_attention": parsed.bot_activity.action_required_attention,
|
||||
"bot_activity_action_expected_duration": parsed.bot_activity.action_expected_duration,
|
||||
"bot_activity_attention": parsed.bot_activity.attention,
|
||||
"bot_activity_holding": ", ".join(parsed.bot_activity.holding),
|
||||
"edge_seed_summary": parsed.edge_seed_summary,
|
||||
"edge_seed_knowledge_facts": "\n".join(parsed.edge_seed_knowledge_facts),
|
||||
}
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request, "kickoff_confirm.html", {"values": values, "active_nav": "bots"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bots/{bot_id}/kickoff")
|
||||
async def kickoff_post(
|
||||
bot_id: str,
|
||||
request: Request,
|
||||
container_name: str = Form(""),
|
||||
container_type: str = Form(""),
|
||||
container_properties: str = Form(""),
|
||||
initial_time_iso: str = Form(""),
|
||||
you_activity_posture: str = Form(""),
|
||||
you_activity_action_verb: str = Form(""),
|
||||
you_activity_action_interruptible: str = Form(""),
|
||||
you_activity_action_required_attention: str = Form("low"),
|
||||
you_activity_action_expected_duration: str = Form(""),
|
||||
you_activity_attention: str = Form(""),
|
||||
you_activity_holding: str = Form(""),
|
||||
bot_activity_posture: str = Form(""),
|
||||
bot_activity_action_verb: str = Form(""),
|
||||
bot_activity_action_interruptible: str = Form(""),
|
||||
bot_activity_action_required_attention: str = Form("low"),
|
||||
bot_activity_action_expected_duration: str = Form(""),
|
||||
bot_activity_attention: str = Form(""),
|
||||
bot_activity_holding: str = Form(""),
|
||||
edge_seed_summary: str = Form(""),
|
||||
edge_seed_knowledge_facts: str = Form(""),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
bot = get_bot(conn, bot_id)
|
||||
if bot is None:
|
||||
raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}")
|
||||
|
||||
# Loose ISO 8601 validation. ``datetime.fromisoformat`` accepts the offset
|
||||
# form ``2026-04-26T20:00:00+00:00`` we use; reject anything it can't parse.
|
||||
if initial_time_iso.strip():
|
||||
try:
|
||||
datetime.fromisoformat(initial_time_iso.strip())
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"invalid initial_time_iso: {initial_time_iso!r}",
|
||||
)
|
||||
|
||||
chat_id = f"chat_{bot_id}"
|
||||
|
||||
# Predict the next container id so we can reference it from later events
|
||||
# without needing a mid-flow projection. Containers use AUTOINCREMENT-style
|
||||
# rowid, so MAX(id)+1 is safe within this single-writer transaction.
|
||||
next_container_row = conn.execute(
|
||||
"SELECT COALESCE(MAX(id), 0) + 1 FROM containers"
|
||||
).fetchone()
|
||||
container_id = next_container_row[0]
|
||||
|
||||
# 1. chat_created
|
||||
append_event(
|
||||
conn,
|
||||
kind="chat_created",
|
||||
payload={
|
||||
"id": chat_id,
|
||||
"host_bot_id": bot_id,
|
||||
"initial_time": initial_time_iso,
|
||||
"narrative_anchor": "Day 1",
|
||||
"weather": "",
|
||||
},
|
||||
)
|
||||
|
||||
# 2. container_created
|
||||
append_event(
|
||||
conn,
|
||||
kind="container_created",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"name": container_name,
|
||||
"type": container_type,
|
||||
"properties": _parse_properties(container_properties),
|
||||
"parent_id": None,
|
||||
},
|
||||
)
|
||||
|
||||
you_interruptible = bool(you_activity_action_interruptible)
|
||||
bot_interruptible = bool(bot_activity_action_interruptible)
|
||||
|
||||
# 3. activity_change for "you"
|
||||
append_event(
|
||||
conn,
|
||||
kind="activity_change",
|
||||
payload={
|
||||
"entity_id": "you",
|
||||
"container_id": container_id,
|
||||
"posture": you_activity_posture,
|
||||
"action": {
|
||||
"verb": you_activity_action_verb,
|
||||
"interruptible": you_interruptible,
|
||||
"required_attention": you_activity_action_required_attention,
|
||||
"expected_duration": you_activity_action_expected_duration,
|
||||
"started_at": initial_time_iso,
|
||||
},
|
||||
"attention": you_activity_attention,
|
||||
"holding": _parse_holding(you_activity_holding),
|
||||
"status": {},
|
||||
},
|
||||
)
|
||||
|
||||
# 4. activity_change for bot
|
||||
append_event(
|
||||
conn,
|
||||
kind="activity_change",
|
||||
payload={
|
||||
"entity_id": bot_id,
|
||||
"container_id": container_id,
|
||||
"posture": bot_activity_posture,
|
||||
"action": {
|
||||
"verb": bot_activity_action_verb,
|
||||
"interruptible": bot_interruptible,
|
||||
"required_attention": bot_activity_action_required_attention,
|
||||
"expected_duration": bot_activity_action_expected_duration,
|
||||
"started_at": initial_time_iso,
|
||||
},
|
||||
"attention": bot_activity_attention,
|
||||
"holding": _parse_holding(bot_activity_holding),
|
||||
"status": {},
|
||||
},
|
||||
)
|
||||
|
||||
# 5. scene_opened
|
||||
append_event(
|
||||
conn,
|
||||
kind="scene_opened",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"container_id": container_id,
|
||||
"started_at": initial_time_iso,
|
||||
"participants": ["you", bot_id],
|
||||
},
|
||||
)
|
||||
|
||||
# 6. edge_update (seed). The seed summary is preserved as the first
|
||||
# knowledge fact prefixed with ``[summary] `` — proper summary writes happen
|
||||
# at scene-close (T27).
|
||||
facts = _parse_facts(edge_seed_knowledge_facts)
|
||||
if edge_seed_summary.strip():
|
||||
facts.insert(0, f"[summary] {edge_seed_summary.strip()}")
|
||||
append_event(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": bot_id,
|
||||
"target_id": "you",
|
||||
"chat_id": chat_id,
|
||||
"knowledge_facts": facts,
|
||||
},
|
||||
)
|
||||
|
||||
# Project all events at once. ``bot_authored`` (already in log from prior
|
||||
# POST) is idempotent (INSERT OR REPLACE); the new events project cleanly
|
||||
# because they're being applied for the first time.
|
||||
project(conn)
|
||||
|
||||
return RedirectResponse(url=f"/chats/{chat_id}", status_code=303)
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Meanwhile-mode turn controller (T64).
|
||||
|
||||
A meanwhile scene is a private 2-bot scene running alongside an active
|
||||
you-scene (its parent). The user manually advances it by POSTing to the
|
||||
existing ``/chats/<id>/turns`` endpoint; the route detects an active
|
||||
meanwhile scene at the start of ``post_turn`` and dispatches here.
|
||||
|
||||
Unlike the normal turn flow, "you" is NOT a witness to the scene. The
|
||||
controller mirrors ``post_turn`` shape but with:
|
||||
|
||||
- Speaker alternation derived from the latest meanwhile ``assistant_turn``
|
||||
scoped to this scene_id (host first, then alternating).
|
||||
- Prompt assembly with ``present_set_kind="host_guest"`` so the prompt
|
||||
builder drops the "you" activity bullet and any speaker -> "you" edge.
|
||||
- Memory writes via ``record_meanwhile_memory`` — both bots get rows
|
||||
with witness flags ``[you=0, host=1, guest=1]``.
|
||||
- State updates over exactly 2 directed pairs (host <-> guest); no
|
||||
you-related pairs fire.
|
||||
- The ``assistant_turn`` payload carries ``meanwhile_scene_id`` and
|
||||
``present_set_kind="host_guest"`` so downstream filters (alternation
|
||||
lookup, drawer rendering, scene-close detection) can scope to the
|
||||
meanwhile slice without conflating it with the parent you-scene's
|
||||
history.
|
||||
|
||||
Scene-close detection for meanwhile scenes is not auto-fired here —
|
||||
T65 covers the close + digest pipeline. The controller's job ends
|
||||
after the post-turn classifier passes land.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.llm.client import LLMClient
|
||||
from chat.services.memory_write import record_meanwhile_memory
|
||||
from chat.services.multi_state_update import compute_state_updates_for_present
|
||||
from chat.services.prompt import assemble_narrative_prompt
|
||||
from chat.services.turn_parse import parse_turn
|
||||
from chat.state.edges import get_edge
|
||||
from chat.state.entities import get_bot
|
||||
from chat.state.meanwhile import list_meanwhile_scenes
|
||||
from chat.state.world import get_chat
|
||||
from chat.web.pubsub import publish
|
||||
from chat.web.render import render_turn_html as _render_turn_html
|
||||
|
||||
|
||||
def _strip_ooc_for_prompt(parsed) -> str:
|
||||
"""Mirror of the helper in turns.py — concatenate non-OOC segments."""
|
||||
keep = [s.text for s in parsed.segments if s.kind != "ooc"]
|
||||
return " ".join(keep).strip()
|
||||
|
||||
|
||||
def _read_recent_meanwhile_dialogue(
|
||||
conn, chat_id: str, scene_id: int, limit: int = 50
|
||||
) -> list[dict]:
|
||||
"""Return the meanwhile scene's prior turns shaped as
|
||||
``{"speaker": <id>, "text": <prose>}``.
|
||||
|
||||
Pulls ``user_turn`` rows for the chat (the user-side prose driving
|
||||
this meanwhile scene rides through the same chat) plus only those
|
||||
``assistant_turn`` rows whose ``meanwhile_scene_id`` matches the
|
||||
given scene id. Other meanwhile scenes on the same chat — and the
|
||||
parent you-scene's assistant_turns — are excluded so the prompt
|
||||
context stays scoped to the private beat.
|
||||
|
||||
Filters chat_id (and meanwhile_scene_id for assistant_turn) via
|
||||
``json_extract`` in SQL so SQLite stops at the first ``limit`` rows
|
||||
that already match — avoids an unbounded scan as ``event_log``
|
||||
grows. The user-side rows match on chat_id only since they aren't
|
||||
tagged with a scene id (they ride the chat-wide log).
|
||||
"""
|
||||
cur = conn.execute(
|
||||
"SELECT id, kind, payload_json FROM event_log "
|
||||
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
|
||||
" AND superseded_by IS NULL AND hidden = 0 "
|
||||
" AND json_extract(payload_json, '$.chat_id') = ? "
|
||||
" AND ("
|
||||
" kind IN ('user_turn', 'user_turn_edit') "
|
||||
" OR json_extract(payload_json, '$.meanwhile_scene_id') = ?"
|
||||
" ) "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(chat_id, scene_id, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
rows.reverse()
|
||||
out: list[dict] = []
|
||||
for _row_id, kind, payload_json in rows:
|
||||
p = json.loads(payload_json)
|
||||
if kind in ("user_turn", "user_turn_edit"):
|
||||
out.append({"speaker": "you", "text": p.get("prose", "")})
|
||||
else:
|
||||
out.append(
|
||||
{
|
||||
"speaker": p.get("speaker_id", "bot"),
|
||||
"text": p.get("text", ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _last_meanwhile_speaker(conn, chat_id: str, scene_id: int) -> str | None:
|
||||
"""Return the speaker_id of the latest assistant_turn linked to
|
||||
``scene_id`` for ``chat_id``, or ``None`` if no prior turn exists.
|
||||
|
||||
Used to alternate the speaker across consecutive meanwhile turns —
|
||||
the OTHER bot speaks next. Pushes both filters into SQL via
|
||||
``json_extract`` and bounds with ``LIMIT 1`` so SQLite stops at the
|
||||
first match instead of scanning the whole assistant_turn slice.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT json_extract(payload_json, '$.speaker_id') AS speaker "
|
||||
"FROM event_log "
|
||||
"WHERE kind = 'assistant_turn' "
|
||||
" AND superseded_by IS NULL AND hidden = 0 "
|
||||
" AND json_extract(payload_json, '$.chat_id') = ? "
|
||||
" AND json_extract(payload_json, '$.meanwhile_scene_id') = ? "
|
||||
"ORDER BY id DESC "
|
||||
"LIMIT 1",
|
||||
(chat_id, scene_id),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def process_meanwhile_turn(
|
||||
conn,
|
||||
client: LLMClient,
|
||||
settings: Settings,
|
||||
*,
|
||||
chat_id: str,
|
||||
prose: str,
|
||||
app=None,
|
||||
) -> dict:
|
||||
"""Run one meanwhile turn end-to-end.
|
||||
|
||||
Returns a small dict shape ``{"assistant_text": ..., "speaker_id":
|
||||
..., "scene_id": ..., "user_turn_id": ..., "assistant_event_id":
|
||||
...}`` so callers can introspect the produced beat (HTTP route maps
|
||||
to a ``204``; future SSE rebroadcast may use the dict directly).
|
||||
|
||||
Raises ``ValueError`` when there is no active meanwhile scene on
|
||||
``chat_id`` — the caller (turns.py) only dispatches here after a
|
||||
positive ``list_meanwhile_scenes`` lookup, but the defensive raise
|
||||
keeps the controller usable in isolation.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise ValueError(f"chat not found: {chat_id}")
|
||||
|
||||
scenes = list_meanwhile_scenes(conn, chat_id, status="active")
|
||||
if not scenes:
|
||||
raise ValueError(f"no active meanwhile scene on chat: {chat_id}")
|
||||
scene = scenes[0]
|
||||
scene_id = scene["id"]
|
||||
|
||||
host_bot_id = chat["host_bot_id"]
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
if guest_bot_id is None:
|
||||
# A meanwhile scene without a guest is a schema violation —
|
||||
# the projector requires both ids on meanwhile_scene_started.
|
||||
raise ValueError(
|
||||
f"meanwhile scene {scene_id} on chat {chat_id} lacks a guest"
|
||||
)
|
||||
|
||||
host_bot = get_bot(conn, host_bot_id)
|
||||
guest_bot = get_bot(conn, guest_bot_id)
|
||||
if host_bot is None or guest_bot is None:
|
||||
raise ValueError(
|
||||
f"meanwhile bots missing: host={host_bot_id} guest={guest_bot_id}"
|
||||
)
|
||||
|
||||
# 1. Parse the user prose with the classifier — same shape as the
|
||||
# normal turn flow so OOC-stripping, segment-typing, etc. all work.
|
||||
parsed = await parse_turn(
|
||||
client, model=settings.classifier_model, prose=prose
|
||||
)
|
||||
prompt_prose = _strip_ooc_for_prompt(parsed)
|
||||
|
||||
# 2. Append user_turn — kept on the chat-wide log so the user can
|
||||
# see their own prose in the timeline. Tagged with the meanwhile
|
||||
# scene_id so future renderers can group it with the right scene.
|
||||
user_turn_event_id = append_event(
|
||||
conn,
|
||||
kind="user_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"prose": prose,
|
||||
"segments": [s.model_dump() for s in parsed.segments],
|
||||
"meanwhile_scene_id": scene_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 3. Alternate the speaker. First turn -> host speaks; each
|
||||
# subsequent turn -> the OTHER bot from the previous beat. Lookup
|
||||
# is scoped by ``meanwhile_scene_id`` so unrelated assistant_turns
|
||||
# on the same chat don't perturb the alternation.
|
||||
last_speaker = _last_meanwhile_speaker(conn, chat_id, scene_id)
|
||||
if last_speaker is None or last_speaker == guest_bot_id:
|
||||
speaker_bot = host_bot
|
||||
addressee_bot = guest_bot
|
||||
else:
|
||||
speaker_bot = guest_bot
|
||||
addressee_bot = host_bot
|
||||
|
||||
# 4. Placeholder marker so SSE observers see "in flight". No
|
||||
# projector handler is registered for this kind — it's transcript-
|
||||
# only, same as the normal turn flow.
|
||||
append_event(
|
||||
conn,
|
||||
kind="assistant_turn_started",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": speaker_bot["id"],
|
||||
"user_turn_id": user_turn_event_id,
|
||||
"meanwhile_scene_id": scene_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 5. Build the narrative prompt. ``present_set_kind="host_guest"``
|
||||
# tells the assembler to drop the "you" activity bullet and any
|
||||
# speaker -> "you" edge — both irrelevant inside a private beat.
|
||||
# Addressee is the OTHER bot, not "you".
|
||||
recent_dialogue = _read_recent_meanwhile_dialogue(conn, chat_id, scene_id)
|
||||
if recent_dialogue and recent_dialogue[-1].get("speaker") == "you":
|
||||
recent_dialogue = recent_dialogue[:-1]
|
||||
messages = assemble_narrative_prompt(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
speaker_bot_id=speaker_bot["id"],
|
||||
addressee=addressee_bot["id"],
|
||||
user_turn_prose=prompt_prose if prompt_prose else None,
|
||||
recent_dialogue=recent_dialogue,
|
||||
budget_soft=settings.narrative_budget_soft,
|
||||
budget_hard=settings.narrative_budget_hard,
|
||||
guest_id=guest_bot_id,
|
||||
present_set_kind="host_guest",
|
||||
)
|
||||
|
||||
# 6. Stream + accumulate. Same SSE pattern as the normal flow —
|
||||
# tokens publish under the speaker's id so the UI can label the
|
||||
# right bubble. Register the streaming task in the chat-keyed
|
||||
# in-flight registry so POST /chats/<id>/turns/cancel can call
|
||||
# ``.cancel()`` on it; without this, the Stop button is a no-op for
|
||||
# meanwhile beats. We import the underscore name from turns.py
|
||||
# deliberately — it's the same single-process registry the cancel
|
||||
# route reads, and exposing it via a public alias would require
|
||||
# touching every existing call site for no behavioural gain.
|
||||
from chat.web.turns import _in_flight_tasks # noqa: PLC0415
|
||||
|
||||
accumulated: list[str] = []
|
||||
truncated = False
|
||||
cancelled = False
|
||||
|
||||
async def _stream() -> None:
|
||||
async for chunk in client.stream(
|
||||
messages,
|
||||
model=settings.narrative_model,
|
||||
max_tokens=settings.narrative_max_tokens,
|
||||
temperature=settings.narrative_temperature,
|
||||
):
|
||||
accumulated.append(chunk)
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "token",
|
||||
"text": chunk,
|
||||
"speaker_id": speaker_bot["id"],
|
||||
},
|
||||
)
|
||||
|
||||
stream_task = asyncio.create_task(_stream())
|
||||
_in_flight_tasks[chat_id] = stream_task
|
||||
try:
|
||||
await stream_task
|
||||
except asyncio.CancelledError:
|
||||
truncated = True
|
||||
cancelled = True
|
||||
except Exception:
|
||||
truncated = True
|
||||
finally:
|
||||
# Always unregister so a subsequent turn can register a fresh
|
||||
# task. Mirrors the cleanup in turns.py::post_turn.
|
||||
_in_flight_tasks.pop(chat_id, None)
|
||||
|
||||
text = "".join(accumulated)
|
||||
|
||||
# 7. Append assistant_turn — tagged with meanwhile_scene_id so the
|
||||
# next turn's alternation lookup can find it, and present_set_kind
|
||||
# so downstream renderers / digesters can filter scope.
|
||||
assistant_event_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": speaker_bot["id"],
|
||||
"text": text,
|
||||
"truncated": truncated,
|
||||
"user_turn_id": user_turn_event_id,
|
||||
"meanwhile_scene_id": scene_id,
|
||||
"present_set_kind": "host_guest",
|
||||
},
|
||||
)
|
||||
|
||||
# 8. Per-turn memory writes — both bots get a row with witness flags
|
||||
# [you=0, host=1, guest=1]. Skipped on cancellation so we don't
|
||||
# record memory for a partial beat the user never read.
|
||||
if not cancelled and text.strip():
|
||||
record_meanwhile_memory(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=host_bot_id,
|
||||
guest_bot_id=guest_bot_id,
|
||||
narrative_text=text,
|
||||
scene_id=scene_id,
|
||||
chat_clock_at=chat.get("time"),
|
||||
app=app,
|
||||
)
|
||||
|
||||
# 9. Post-turn state-update — exactly 2 directed pairs over the
|
||||
# bot pair. No you-related pairs fire (you isn't present).
|
||||
present_ids = [host_bot_id, guest_bot_id]
|
||||
present_names = {
|
||||
host_bot_id: host_bot["name"],
|
||||
guest_bot_id: guest_bot["name"],
|
||||
}
|
||||
personas = {
|
||||
host_bot_id: host_bot.get("persona") or "",
|
||||
guest_bot_id: guest_bot.get("persona") or "",
|
||||
}
|
||||
prior_edges: dict[tuple[str, str], dict] = {}
|
||||
for src in present_ids:
|
||||
for tgt in present_ids:
|
||||
if src == tgt:
|
||||
continue
|
||||
edge = get_edge(conn, src, tgt) or {
|
||||
"affinity": 50,
|
||||
"trust": 50,
|
||||
"summary": "",
|
||||
}
|
||||
prior_edges[(src, tgt)] = edge
|
||||
|
||||
state_updates = await compute_state_updates_for_present(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
present_ids=present_ids,
|
||||
present_names=present_names,
|
||||
personas=personas,
|
||||
prior_edges=prior_edges,
|
||||
recent_dialogue=recent_dialogue,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
last_at = chat.get("time")
|
||||
for src_id, tgt_id, update in state_updates:
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="edge_update",
|
||||
payload={
|
||||
"source_id": src_id,
|
||||
"target_id": tgt_id,
|
||||
"chat_id": chat_id,
|
||||
"affinity_delta": update.affinity_delta,
|
||||
"trust_delta": update.trust_delta,
|
||||
"knowledge_facts": update.knowledge_facts,
|
||||
"last_interaction_at": last_at,
|
||||
"last_interaction_chat_id": chat_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 10. SSE broadcast for the timeline UI — completion event + an HTML
|
||||
# fragment for the HTMX SSE swap. Same pattern as the normal turn
|
||||
# flow so the rendered transcript shows the meanwhile beat inline.
|
||||
await publish(
|
||||
chat_id,
|
||||
{
|
||||
"event": "assistant_turn_complete",
|
||||
"speaker_id": speaker_bot["id"],
|
||||
"text": text,
|
||||
"truncated": truncated,
|
||||
},
|
||||
)
|
||||
turn_html = _render_turn_html(
|
||||
speaker_bot["name"],
|
||||
text,
|
||||
role="bot",
|
||||
event_id=assistant_event_id,
|
||||
)
|
||||
await publish(chat_id, {"event": "turn_html", "data": turn_html})
|
||||
|
||||
if cancelled:
|
||||
# Re-raise after the partial-turn has been recorded so callers
|
||||
# see the cancel propagate (mirrors normal turn flow).
|
||||
raise asyncio.CancelledError
|
||||
|
||||
return {
|
||||
"assistant_text": text,
|
||||
"speaker_id": speaker_bot["id"],
|
||||
"scene_id": scene_id,
|
||||
"user_turn_id": user_turn_event_id,
|
||||
"assistant_event_id": assistant_event_id,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["process_meanwhile_turn"]
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.state.entities import get_you, list_bots
|
||||
|
||||
|
||||
class FirstRunRedirectMiddleware(BaseHTTPMiddleware):
|
||||
"""Redirect users through the first-run flow (per requirements §16.2).
|
||||
|
||||
Behavior on GET requests to landing routes (``/`` and ``/chats``):
|
||||
|
||||
- No ``you_entity`` → ``/settings``
|
||||
- ``you_entity`` exists but no bots → ``/bots/new``
|
||||
- Otherwise pass through to the underlying handler.
|
||||
|
||||
The middleware is a no-op for:
|
||||
|
||||
- Non-GET requests (POST/PUT writes proceed and surface their own errors).
|
||||
- Static assets, health checks, and any path under ``/settings``,
|
||||
``/bots``, ``/api``, ``/health``, ``/favicon`` — so the user can
|
||||
actually complete setup once redirected.
|
||||
- Sub-paths of ``/chats`` (e.g. ``/chats/<id>``, ``/chats/<id>/drawer``);
|
||||
only the bare landing pages get the redirect treatment. Sub-resources
|
||||
either 404 cleanly or are HTMX partials that should not page-redirect.
|
||||
"""
|
||||
|
||||
SKIP_PREFIXES = (
|
||||
"/static",
|
||||
"/settings",
|
||||
"/bots",
|
||||
"/health",
|
||||
"/favicon",
|
||||
"/api",
|
||||
)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method != "GET":
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
if any(path.startswith(p) for p in self.SKIP_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# Only fire on the landing routes themselves.
|
||||
if path != "/" and path != "/chats":
|
||||
return await call_next(request)
|
||||
|
||||
settings = request.app.state.settings
|
||||
with open_db(settings.db_path) as conn:
|
||||
you = get_you(conn)
|
||||
bots = list_bots(conn)
|
||||
|
||||
if you is None:
|
||||
return RedirectResponse(url="/settings", status_code=303)
|
||||
if not bots:
|
||||
return RedirectResponse(url="/bots/new", status_code=303)
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from chat.web.bots import get_conn
|
||||
from chat.state.world import list_chats
|
||||
from chat.state.entities import get_bot
|
||||
|
||||
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def home():
|
||||
return RedirectResponse(url="/chats", status_code=303)
|
||||
|
||||
|
||||
@router.get("/chats", response_class=HTMLResponse)
|
||||
async def chats_list(request: Request, conn=Depends(get_conn)):
|
||||
chats = list_chats(conn)
|
||||
# Annotate each chat with the host bot's name for display.
|
||||
for ch in chats:
|
||||
bot = get_bot(conn, ch["host_bot_id"])
|
||||
ch["host_bot_name"] = bot["name"] if bot else ch["host_bot_id"]
|
||||
# Last-message snippet and last-played-at are blank in v1; T19 fills them.
|
||||
ch["last_message_snippet"] = ""
|
||||
ch["last_played_at"] = None
|
||||
return TEMPLATES.TemplateResponse(request, "chat_list.html", {
|
||||
"chats": chats,
|
||||
"active_nav": "chats",
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
"""In-process per-chat broadcast channel.
|
||||
|
||||
Each ``chat_id`` has a list of subscriber ``asyncio.Queue`` instances. T16
|
||||
provides only the registry and fan-out mechanism; T19+ will publish events
|
||||
(turn appends, streamed tokens, drawer updates, scene close, edge updates)
|
||||
through this channel so all browser tabs viewing a chat stay in sync.
|
||||
|
||||
The registry is process-local: appropriate for a single-user local server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
# {chat_id: [queue, queue, ...]}
|
||||
_subscribers: dict[str, list[asyncio.Queue]] = defaultdict(list)
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def subscribe(chat_id: str) -> asyncio.Queue:
|
||||
"""Subscribe to a chat's broadcast channel.
|
||||
|
||||
Returns a fresh ``asyncio.Queue`` that will receive every event published
|
||||
to ``chat_id`` while the subscription is active. Callers must invoke
|
||||
:func:`unsubscribe` when finished (typically on client disconnect) to
|
||||
avoid leaking queues into the registry.
|
||||
"""
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
async with _lock:
|
||||
_subscribers[chat_id].append(queue)
|
||||
return queue
|
||||
|
||||
|
||||
async def unsubscribe(chat_id: str, queue: asyncio.Queue) -> None:
|
||||
"""Remove ``queue`` from the registry; remove the chat key if empty."""
|
||||
async with _lock:
|
||||
if chat_id in _subscribers:
|
||||
if queue in _subscribers[chat_id]:
|
||||
_subscribers[chat_id].remove(queue)
|
||||
if not _subscribers[chat_id]:
|
||||
del _subscribers[chat_id]
|
||||
|
||||
|
||||
async def publish(chat_id: str, event: dict[str, Any]) -> None:
|
||||
"""Fan-out ``event`` to every subscriber of ``chat_id``.
|
||||
|
||||
The same dict reference is enqueued to all subscribers. Callers should
|
||||
treat published events as immutable. Queues are unbounded for v1.
|
||||
"""
|
||||
async with _lock:
|
||||
queues = list(_subscribers.get(chat_id, []))
|
||||
for q in queues:
|
||||
await q.put(event)
|
||||
|
||||
|
||||
def subscriber_count(chat_id: str) -> int:
|
||||
"""Test helper. Returns the number of active subscribers for a chat."""
|
||||
return len(_subscribers.get(chat_id, []))
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Transcript display formatting (Task 33, Requirements §16.3).
|
||||
|
||||
Bot and user prose is rendered with **lightweight markdown**:
|
||||
|
||||
* ``*action*`` → ``<em class="action">…</em>`` — italic narration.
|
||||
* ``**bold**`` → ``<strong>…</strong>`` — emphasis.
|
||||
* ``((ooc))`` → ``<span class="ooc">((ooc))</span>`` — author-to-system
|
||||
asides; visible to the reader, dimmed/italic in CSS, and stripped from
|
||||
the prompt sent to the bot (see :func:`chat.web.turns._strip_ooc_for_prompt`).
|
||||
* ``> line`` → ``<blockquote>line</blockquote>``.
|
||||
* Double newline → paragraph break.
|
||||
* Everything else is HTML-escaped and wrapped in ``<p>…</p>``.
|
||||
|
||||
No headings, code blocks, links, images, or tables — out of scope per
|
||||
Requirements §16.3. The renderer is the single source of truth used by
|
||||
both the chat-detail GET (initial timeline render, via Jinja filter) and
|
||||
the per-turn SSE fragments emitted from :mod:`chat.web.turns`.
|
||||
|
||||
Order of operations matters:
|
||||
|
||||
1. ``html.escape`` the whole input first — every replacement below assumes
|
||||
user-supplied ``<``/``>``/``&`` are already neutralised, so the wrapper
|
||||
tags we add can never collide with an attacker-controlled tag.
|
||||
2. OOC wrap before action/bold so its inner ``*`` are not interpreted.
|
||||
3. Bold (``**``) before action (``*``) — the bold pattern is stricter and
|
||||
would otherwise be partially consumed by the action regex.
|
||||
4. Blockquote pass over already-escaped lines (so we match ``>``).
|
||||
5. Paragraph split on double newline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
# ``((…))`` — non-greedy, allows newlines so a multi-line OOC aside still
|
||||
# wraps cleanly. The inner ``[^)]*?`` keeps it from spanning across a
|
||||
# closing-paren boundary.
|
||||
_OOC_PATTERN = re.compile(r"\(\([^)]*?\)\)", re.DOTALL)
|
||||
|
||||
# ``**bold**`` — strict: no embedded asterisks or newlines. Must run
|
||||
# *before* the single-asterisk action pattern, otherwise ``**x**`` would
|
||||
# be partly consumed by ``*…*``.
|
||||
_BOLD_PATTERN = re.compile(r"\*\*([^*\n]+)\*\*")
|
||||
|
||||
# ``*action*`` — single-asterisk italics; same restriction as bold.
|
||||
_ACTION_PATTERN = re.compile(r"\*([^*\n]+)\*")
|
||||
|
||||
# ``> line`` at start of a line — note we match the *escaped* form
|
||||
# ``>`` because this pass runs after ``html.escape``.
|
||||
_BLOCKQUOTE_PATTERN = re.compile(r"^>\s?(.+)$", re.MULTILINE)
|
||||
|
||||
|
||||
def render_prose(text: str) -> str:
|
||||
"""Render prose to safe HTML.
|
||||
|
||||
Returns an empty string for empty/whitespace-only input so the caller
|
||||
can append the result without producing stray ``<p></p>`` tags.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
|
||||
# Normalise CRLF so paragraph splitting on ``\n\n`` works for input
|
||||
# pasted from Windows clients.
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
escaped = html.escape(text)
|
||||
|
||||
# OOC first — the wrapped span survives subsequent passes.
|
||||
escaped = _OOC_PATTERN.sub(
|
||||
lambda m: f'<span class="ooc">{m.group(0)}</span>', escaped
|
||||
)
|
||||
|
||||
# Bold strictly before action (regex precedence — see module docstring).
|
||||
escaped = _BOLD_PATTERN.sub(r"<strong>\1</strong>", escaped)
|
||||
escaped = _ACTION_PATTERN.sub(r'<em class="action">\1</em>', escaped)
|
||||
|
||||
# Blockquote on already-escaped ``>`` markers.
|
||||
escaped = _BLOCKQUOTE_PATTERN.sub(r"<blockquote>\1</blockquote>", escaped)
|
||||
|
||||
# Paragraph splitting — drop empty fragments so a trailing ``\n\n``
|
||||
# doesn't yield an empty ``<p></p>`` block.
|
||||
paragraphs = [p.strip() for p in escaped.split("\n\n") if p.strip()]
|
||||
return "".join(f"<p>{p}</p>" for p in paragraphs)
|
||||
|
||||
|
||||
def render_turn_html(
|
||||
speaker: str,
|
||||
text: str,
|
||||
role: str = "bot",
|
||||
*,
|
||||
event_id: int | None = None,
|
||||
) -> str:
|
||||
"""Render a full transcript turn as ``<div class="turn …">…</div>``.
|
||||
|
||||
Used by both the SSE fragment publisher in :mod:`chat.web.turns`
|
||||
(per-turn live updates) and indirectly by the chat-detail Jinja
|
||||
template (initial render, via the ``render_prose`` filter).
|
||||
|
||||
``role`` selects the CSS class (``turn-you`` vs ``turn-bot``); the
|
||||
speaker label and role name are HTML-escaped defensively even though
|
||||
they currently come from trusted server-side state.
|
||||
|
||||
``event_id`` (T86 follow-up) stamps ``id="turn-<event_id>"`` on the
|
||||
wrapper div so the chat-page ``turn_html_replace`` SSE handler can
|
||||
locate the prior turn node by id and swap it in-place. When omitted
|
||||
the id attribute is dropped so SSE-only fragments without a stable
|
||||
event id (legacy callers) still render cleanly.
|
||||
"""
|
||||
speaker_html = html.escape(speaker)
|
||||
role_html = html.escape(role)
|
||||
body_html = render_prose(text)
|
||||
id_attr = f' id="turn-{int(event_id)}"' if event_id is not None else ""
|
||||
return (
|
||||
f'<div{id_attr} class="turn turn-{role_html}">'
|
||||
f"<strong>{speaker_html}</strong>"
|
||||
f"{body_html}"
|
||||
f"</div>"
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""T100 (Phase 4): cross-chat search UX route.
|
||||
|
||||
Wraps T93's :func:`chat.services.cross_chat_search.search_all_memories`
|
||||
in a small read-only HTML surface so the top-bar search input has
|
||||
somewhere to land. The route does no filtering of its own beyond the
|
||||
empty-query fast-path that T93 already implements; ranking, owner
|
||||
scope, and witness scope all live in the service layer.
|
||||
|
||||
For each match we hydrate just enough metadata to render a row:
|
||||
* the owner bot's display name (so users see "BOTA" not "bot_a"),
|
||||
* the originating ``chat_id`` (the link target — there's no per-turn
|
||||
anchor today because memories don't carry an ``event_id`` column,
|
||||
so we deep-link to the chat as a whole),
|
||||
* the originating scene title when one exists,
|
||||
* and the ``pov_summary`` itself.
|
||||
|
||||
We deliberately keep this module synchronous and template-only — no
|
||||
HTMX swaps, no JSON API — because the search box is a "leave the
|
||||
current chat to look something up" surface, not an inline drawer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from chat.services.cross_chat_search import search_all_memories
|
||||
from chat.state.entities import get_bot
|
||||
from chat.state.world import get_chat, get_scene
|
||||
from chat.web.bots import get_conn
|
||||
|
||||
TEMPLATES = Jinja2Templates(
|
||||
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/search", response_class=HTMLResponse)
|
||||
async def search(request: Request, q: str = "", conn=Depends(get_conn)):
|
||||
"""Render ``search.html`` with up to 50 cross-chat FTS matches.
|
||||
|
||||
``q`` is intentionally allowed to be empty — that path renders the
|
||||
page's "enter a query" placeholder rather than a 400, because the
|
||||
top-bar form submits to this URL even with an empty input. T93's
|
||||
service short-circuits whitespace-only queries to ``[]`` so there
|
||||
is no FTS5 ``MATCH ''`` syntax error to guard against here.
|
||||
"""
|
||||
raw_results = search_all_memories(conn, query=q, k=50) if q else []
|
||||
|
||||
# Hydrate display fields per row. We do this in the route (not the
|
||||
# service) so the service stays a pure FTS shim that other UIs
|
||||
# can reuse.
|
||||
results = []
|
||||
for row in raw_results:
|
||||
bot = get_bot(conn, row["owner_id"])
|
||||
chat = get_chat(conn, row["chat_id"])
|
||||
scene = get_scene(conn, row["scene_id"]) if row["scene_id"] else None
|
||||
results.append(
|
||||
{
|
||||
"memory_id": row["memory_id"],
|
||||
"owner_id": row["owner_id"],
|
||||
"owner_name": bot["name"] if bot else row["owner_id"],
|
||||
"chat_id": row["chat_id"],
|
||||
"chat_name": (
|
||||
chat.get("narrative_anchor") if chat else None
|
||||
),
|
||||
"scene_id": row["scene_id"],
|
||||
# Scenes have no ``title`` column today; surface the
|
||||
# ``started_at`` timestamp as a human-friendly label
|
||||
# when a scene is set, otherwise leave it blank.
|
||||
"scene_label": (
|
||||
scene.get("started_at") if scene else None
|
||||
),
|
||||
"pov_summary": row["pov_summary"],
|
||||
"significance": row["significance"],
|
||||
"ts": row["ts"],
|
||||
}
|
||||
)
|
||||
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"search.html",
|
||||
{
|
||||
"query": q,
|
||||
"results": results,
|
||||
"active_nav": "search",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
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_event
|
||||
from chat.eventlog.projector import project
|
||||
from chat.state.entities import get_you
|
||||
from chat.web.bots import get_conn
|
||||
|
||||
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_get(request: Request, conn=Depends(get_conn)):
|
||||
you = get_you(conn) or {"name": "", "pronouns": "", "persona": ""}
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"settings.html",
|
||||
{"values": you, "saved": False, "active_nav": "settings"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/settings", response_class=HTMLResponse)
|
||||
async def settings_post(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
pronouns: str = Form(""),
|
||||
persona: str = Form(""),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
if not name.strip():
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
|
||||
payload = {
|
||||
"name": name.strip(),
|
||||
"pronouns": pronouns.strip(),
|
||||
"persona": persona.strip(),
|
||||
}
|
||||
append_event(conn, kind="you_authored", payload=payload)
|
||||
project(conn)
|
||||
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"settings.html",
|
||||
{"values": payload, "saved": True, "active_nav": "settings"},
|
||||
)
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Shared skip-flow controllers (T62).
|
||||
|
||||
Both the drawer skip routes (T59) and the natural-language skip parse
|
||||
(T62) call into these controllers. Keep the controllers free of HTTP
|
||||
concerns — they take ``conn`` + ``client`` + ``settings`` and structured
|
||||
args, append events, and return a small result dict the caller can map
|
||||
to whatever response shape it owes (drawer partial, 204, 422, etc.).
|
||||
|
||||
``ValueError`` is the controller-level signal for caller-mappable
|
||||
validation failure (bad ISO timestamp, backwards skip). The drawer
|
||||
routes translate it to ``HTTP 400``; the natural-language path either
|
||||
swallows it (the parser handed us a degenerate hint) or surfaces it the
|
||||
same way. Anything else (LLM failure, unexpected exception) propagates
|
||||
uncaught — :func:`narrate_skip` already has its own deterministic
|
||||
fallback for the routine LLM-down case, so a real exception here means
|
||||
something we want to see.
|
||||
|
||||
The two controllers mirror the drawer T59 logic closely so the v1
|
||||
guarantees (``time_skip_*`` lands first → memory writes ride the
|
||||
post-skip clock → narration ``assistant_turn`` is appended last) hold
|
||||
identically across the two entry points.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.config import Settings
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.llm.client import LLMClient
|
||||
from chat.services.memory_write import record_turn_memory_for_present
|
||||
from chat.services.skip_narration import narrate_skip
|
||||
from chat.services.synthesized_memories import synthesize_memories
|
||||
from chat.state.entities import get_bot, get_you
|
||||
from chat.state.world import get_activity, get_chat
|
||||
|
||||
|
||||
class ChatNotFoundError(Exception):
|
||||
"""Raised when a ``chat_id`` doesn't resolve to a chat row.
|
||||
|
||||
Distinguishes the missing-chat case from generic input-validation
|
||||
failures (which still raise :class:`ValueError`). HTTP callers map
|
||||
this to ``404`` and ``ValueError`` to ``400`` — replacing the
|
||||
earlier ``str(exc).startswith("chat not found")`` prefix sniff
|
||||
(T81) with a typed dispatch.
|
||||
"""
|
||||
|
||||
|
||||
def _parse_iso_time(value: str) -> datetime | None:
|
||||
"""Permissive ISO 8601 parser shared with the drawer routes (T59).
|
||||
|
||||
``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until
|
||||
Python 3.11; we normalize it to ``+00:00`` so older interpreters
|
||||
parse the same set of strings the drawer accepts.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
v = value.strip()
|
||||
if v.endswith("Z"):
|
||||
v = v[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _validate_new_time(chat: dict, new_time: str) -> None:
|
||||
"""Raise ``ValueError`` if ``new_time`` is unparseable or backwards.
|
||||
|
||||
The drawer route maps the raised error to ``HTTP 400``; the
|
||||
natural-language path may also surface it as a ``400``. Centralizing
|
||||
the rule here means both entry points enforce the same invariant
|
||||
(no causality-corrupting backwards jumps).
|
||||
"""
|
||||
new_dt = _parse_iso_time(new_time)
|
||||
if new_dt is None:
|
||||
raise ValueError(f"new_time must be ISO 8601, got {new_time!r}")
|
||||
cur_dt = _parse_iso_time(chat.get("time") or "")
|
||||
if cur_dt is not None and new_dt < cur_dt:
|
||||
raise ValueError(
|
||||
"new_time must not be earlier than the current chat clock"
|
||||
)
|
||||
|
||||
|
||||
async def process_elision_skip(
|
||||
conn: Connection,
|
||||
client: LLMClient,
|
||||
settings: Settings,
|
||||
*,
|
||||
chat_id: str,
|
||||
new_time: str,
|
||||
landing_state_hint: str = "",
|
||||
app=None,
|
||||
) -> dict:
|
||||
"""Run an elision skip end-to-end.
|
||||
|
||||
Validates ``new_time`` against the current chat clock, appends a
|
||||
``time_skip_elision`` event (chat clock advances), generates a
|
||||
transition narration via :func:`narrate_skip`, and appends an
|
||||
``assistant_turn`` carrying the narration. ``narrate_skip`` has its
|
||||
own deterministic fallback so this never blocks on the model.
|
||||
|
||||
Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id":
|
||||
..., "assistant_event_id": ...}`` so callers can introspect the
|
||||
generated turn (e.g. for SSE rebroadcast or test assertions).
|
||||
|
||||
Raises :class:`ChatNotFoundError` when the chat row is missing
|
||||
(HTTP ``404``) and ``ValueError`` on input-validation failure
|
||||
(HTTP ``400``). Splitting the two lets the drawer route dispatch
|
||||
on type instead of sniffing the error string (T81).
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise ChatNotFoundError(f"chat not found: {chat_id}")
|
||||
|
||||
_validate_new_time(chat, new_time)
|
||||
|
||||
host_bot = get_bot(conn, chat["host_bot_id"]) or {
|
||||
"id": chat["host_bot_id"],
|
||||
"name": "host",
|
||||
"persona": "",
|
||||
}
|
||||
you_entity = get_you(conn) or {"name": "you"}
|
||||
|
||||
# The drawer route reaches into the host bot's current activity to
|
||||
# surface the verb to the narration helper — we do the same so both
|
||||
# entry points produce the same prose for the same chat state.
|
||||
bot_activity = get_activity(conn, chat["host_bot_id"]) or {}
|
||||
current_activity = (bot_activity.get("action") or {}).get("verb") or ""
|
||||
|
||||
narration = await narrate_skip(
|
||||
client,
|
||||
narrative_model=settings.narrative_model,
|
||||
skip_kind="elision",
|
||||
speaker_bot=host_bot,
|
||||
you_name=you_entity.get("name") or "you",
|
||||
current_time=chat.get("time") or "",
|
||||
new_time=new_time,
|
||||
current_activity=current_activity,
|
||||
landing_state_hint=landing_state_hint,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
|
||||
skip_event_id = append_and_apply(
|
||||
conn,
|
||||
kind="time_skip_elision",
|
||||
payload={"chat_id": chat_id, "new_time": new_time},
|
||||
)
|
||||
speaker_id = host_bot.get("id") or chat["host_bot_id"]
|
||||
assistant_event_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": speaker_id,
|
||||
"text": narration,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"assistant_text": narration,
|
||||
"speaker_id": speaker_id,
|
||||
"skip_event_id": skip_event_id,
|
||||
"assistant_event_id": assistant_event_id,
|
||||
}
|
||||
|
||||
|
||||
async def process_jump_skip(
|
||||
conn: Connection,
|
||||
client: LLMClient,
|
||||
settings: Settings,
|
||||
*,
|
||||
chat_id: str,
|
||||
new_time: str,
|
||||
notable_prose: str = "",
|
||||
reset_activity: bool = False,
|
||||
app=None,
|
||||
) -> dict:
|
||||
"""Run a jump skip end-to-end.
|
||||
|
||||
Same validations as :func:`process_elision_skip`. Emits
|
||||
``time_skip_jump`` *before* synthesizing memories so per-bot writes
|
||||
record the post-jump chat clock (mirroring how a regular turn's
|
||||
memory carries the chat clock). When ``notable_prose`` is non-empty,
|
||||
runs :func:`synthesize_memories` once per present bot witness, then
|
||||
fans the resulting memories out via
|
||||
:func:`record_turn_memory_for_present` with ``source="synthesized"``.
|
||||
Finally appends the narration ``assistant_turn``.
|
||||
|
||||
Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id":
|
||||
..., "assistant_event_id": ...}``.
|
||||
|
||||
Raises :class:`ChatNotFoundError` on missing chat (caller maps to
|
||||
``404``) and ``ValueError`` on input-validation failure (caller maps
|
||||
to ``400``).
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise ChatNotFoundError(f"chat not found: {chat_id}")
|
||||
|
||||
_validate_new_time(chat, new_time)
|
||||
|
||||
host_bot = get_bot(conn, chat["host_bot_id"]) or {
|
||||
"id": chat["host_bot_id"],
|
||||
"name": "host",
|
||||
"persona": "",
|
||||
}
|
||||
you_entity = get_you(conn) or {"name": "you"}
|
||||
you_name = you_entity.get("name") or "you"
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
guest_bot = get_bot(conn, guest_bot_id) if guest_bot_id else None
|
||||
|
||||
# Emit time_skip_jump up front so subsequent memory writes ride the
|
||||
# post-jump chat clock (matches the drawer T59 behavior pinned by
|
||||
# test_post_skip_jump_with_notable_prose_writes_synthesized_memories).
|
||||
skip_event_id = append_and_apply(
|
||||
conn,
|
||||
kind="time_skip_jump",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"new_time": new_time,
|
||||
"reset_activity": reset_activity,
|
||||
},
|
||||
)
|
||||
|
||||
# Synthesize per-bot memories when prose is non-empty. The helper
|
||||
# short-circuits on whitespace prose, but gating the loop here keeps
|
||||
# the canned-LLM-queue accounting predictable for tests.
|
||||
if notable_prose.strip():
|
||||
present_bots: list[dict] = [host_bot]
|
||||
if guest_bot is not None:
|
||||
present_bots.append(guest_bot)
|
||||
for bot in present_bots:
|
||||
digest = await synthesize_memories(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
prose=notable_prose,
|
||||
bot_name=bot.get("name") or "",
|
||||
bot_persona=bot.get("persona") or "",
|
||||
you_name=you_name,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for mem in digest.memories:
|
||||
# ``record_turn_memory_for_present`` writes one row per
|
||||
# present bot per call — we already iterate by bot here,
|
||||
# so guest_bot_id=None avoids double-writing the guest's
|
||||
# row when bot==guest.
|
||||
record_turn_memory_for_present(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=bot["id"],
|
||||
guest_bot_id=None,
|
||||
narrative_text=mem.text,
|
||||
chat_clock_at=new_time,
|
||||
source="synthesized",
|
||||
significance=mem.significance,
|
||||
app=app,
|
||||
)
|
||||
|
||||
narration = await narrate_skip(
|
||||
client,
|
||||
narrative_model=settings.narrative_model,
|
||||
skip_kind="jump",
|
||||
speaker_bot=host_bot,
|
||||
you_name=you_name,
|
||||
current_time=chat.get("time") or "",
|
||||
new_time=new_time,
|
||||
current_activity="",
|
||||
landing_state_hint=notable_prose,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
speaker_id = host_bot.get("id") or chat["host_bot_id"]
|
||||
assistant_event_id = append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": speaker_id,
|
||||
"text": narration,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"assistant_text": narration,
|
||||
"speaker_id": speaker_id,
|
||||
"skip_event_id": skip_event_id,
|
||||
"assistant_event_id": assistant_event_id,
|
||||
}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""UTC ISO timestamp used by callers as a chat-clock fallback."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChatNotFoundError",
|
||||
"process_elision_skip",
|
||||
"process_jump_skip",
|
||||
"_now_iso",
|
||||
"_parse_iso_time",
|
||||
]
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Snapshot UX routes (T99).
|
||||
|
||||
Surfaces the existing snapshot service (``chat/services/snapshot.py``)
|
||||
through HTML so the user can see, take, restore, and preview snapshots
|
||||
without dropping to a shell.
|
||||
|
||||
Routes:
|
||||
|
||||
* ``GET /snapshots`` list all snapshots (both kinds)
|
||||
* ``POST /snapshots/take`` take a periodic snapshot now
|
||||
* ``POST /snapshots/restore/{id}`` restore (requires matching ``confirm_id``)
|
||||
* ``GET /snapshots/{id}/preview`` show metadata + delta vs current
|
||||
|
||||
The ``snapshot_id`` is the filename stem (the UTC timestamp written by
|
||||
:func:`chat.services.snapshot.take_snapshot`) — there's no separate UUID,
|
||||
and the timestamp filename is already unique per snapshot kind. Both
|
||||
periodic and rewind snapshots share the same id space lookup-wise, so
|
||||
the restore + preview routes accept ``kind`` as a form/query param to
|
||||
disambiguate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from chat.services.snapshot import (
|
||||
restore_from_snapshot,
|
||||
take_snapshot,
|
||||
)
|
||||
from chat.web.bots import get_conn
|
||||
|
||||
TEMPLATES = Jinja2Templates(
|
||||
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SNAPSHOT_KINDS = ("periodic", "rewind")
|
||||
|
||||
|
||||
def _list_all_snapshots(data_dir: Path) -> list[dict]:
|
||||
"""Walk ``data/snapshots/{kind}/`` for both kinds and collect metadata.
|
||||
|
||||
Each entry exposes the fields the template needs: ``snapshot_id``
|
||||
(filename stem), ``kind``, ``created_at`` (file mtime as ISO), the
|
||||
on-disk ``file_size_bytes``, and the snapshot's stored
|
||||
``last_event_id`` (parsed from the JSON body — small enough that
|
||||
listing isn't a performance concern for the handful of files we keep).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
rows: list[dict] = []
|
||||
for kind in SNAPSHOT_KINDS:
|
||||
snap_dir = data_dir / "snapshots" / kind
|
||||
if not snap_dir.exists():
|
||||
continue
|
||||
for path in sorted(snap_dir.glob("*.json")):
|
||||
try:
|
||||
dump = json.loads(path.read_text())
|
||||
last_event_id = dump.get("last_event_id", 0)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# Corrupt or unreadable files still get listed so the
|
||||
# user can see and delete them; just don't crash here.
|
||||
last_event_id = None
|
||||
stat = path.stat()
|
||||
rows.append(
|
||||
{
|
||||
"snapshot_id": path.stem,
|
||||
"kind": kind,
|
||||
"created_at": datetime.fromtimestamp(
|
||||
stat.st_mtime, tz=timezone.utc
|
||||
).isoformat(),
|
||||
"file_size_bytes": stat.st_size,
|
||||
"last_event_id": last_event_id,
|
||||
"filename": path.name,
|
||||
}
|
||||
)
|
||||
# Newest first for display.
|
||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def _resolve_snapshot_path(
|
||||
data_dir: Path, snapshot_id: str, kind: str
|
||||
) -> Path:
|
||||
"""Map an ``(id, kind)`` pair to the on-disk file, or 404."""
|
||||
if kind not in SNAPSHOT_KINDS:
|
||||
raise HTTPException(status_code=400, detail=f"unknown kind: {kind}")
|
||||
path = data_dir / "snapshots" / kind / f"{snapshot_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="snapshot not found")
|
||||
return path
|
||||
|
||||
|
||||
@router.get("/snapshots", response_class=HTMLResponse)
|
||||
async def snapshots_list(request: Request):
|
||||
settings = request.app.state.settings
|
||||
rows = _list_all_snapshots(settings.data_dir)
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"snapshots.html",
|
||||
{"snapshots": rows, "active_nav": "snapshots"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/snapshots/take")
|
||||
async def snapshots_take(request: Request, conn=Depends(get_conn)):
|
||||
"""Take a periodic snapshot now.
|
||||
|
||||
We use ``kind="periodic"`` for manual snapshots since they're
|
||||
user-initiated checkpoints, not pre-rewind safety dumps. They count
|
||||
against the 5-snapshot retention but that's fine — manual ones are
|
||||
the most recent so they're the last to be pruned.
|
||||
"""
|
||||
settings = request.app.state.settings
|
||||
take_snapshot(conn, data_dir=settings.data_dir, kind="periodic")
|
||||
return RedirectResponse(url="/snapshots", status_code=303)
|
||||
|
||||
|
||||
@router.post("/snapshots/restore/{snapshot_id}")
|
||||
async def snapshots_restore(
|
||||
snapshot_id: str,
|
||||
request: Request,
|
||||
confirm_id: str = Form(""),
|
||||
kind: str = Form("periodic"),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Hard-confirm restore: ``confirm_id`` must equal the path id.
|
||||
|
||||
Mismatched confirm → 400 (without touching the DB). On match, the
|
||||
existing :func:`restore_from_snapshot` clears projected tables and
|
||||
re-loads them from the dump.
|
||||
"""
|
||||
if confirm_id != snapshot_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="confirm_id does not match snapshot id",
|
||||
)
|
||||
settings = request.app.state.settings
|
||||
path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind)
|
||||
restore_from_snapshot(conn, path)
|
||||
return RedirectResponse(url="/snapshots", status_code=303)
|
||||
|
||||
|
||||
@router.get("/snapshots/{snapshot_id}/preview", response_class=HTMLResponse)
|
||||
async def snapshots_preview(
|
||||
snapshot_id: str,
|
||||
request: Request,
|
||||
kind: str = "periodic",
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Show snapshot metadata + a basic delta against the current event log.
|
||||
|
||||
Phase 4 keeps this simple: the snapshot's ``last_event_id`` plus the
|
||||
current ``MAX(event_log.id)`` is enough to tell the user how far the
|
||||
log has moved on. A richer per-table diff is a Phase 4.5+ concern.
|
||||
"""
|
||||
settings = request.app.state.settings
|
||||
path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind)
|
||||
dump = json.loads(path.read_text())
|
||||
last_event_id = dump.get("last_event_id", 0)
|
||||
|
||||
cur = conn.execute("SELECT MAX(id) FROM event_log")
|
||||
row = cur.fetchone()
|
||||
current_max_id = row[0] if row[0] is not None else 0
|
||||
|
||||
stat = path.stat()
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"snapshots.html",
|
||||
{
|
||||
"snapshots": _list_all_snapshots(settings.data_dir),
|
||||
"active_nav": "snapshots",
|
||||
"preview": {
|
||||
"snapshot_id": snapshot_id,
|
||||
"kind": kind,
|
||||
"filename": path.name,
|
||||
"file_size_bytes": stat.st_size,
|
||||
"last_event_id": last_event_id,
|
||||
"current_event_log_max_id": current_max_id,
|
||||
"event_delta": current_max_id - last_event_id,
|
||||
"event_log_rows_in_snapshot": len(dump.get("event_log", [])),
|
||||
},
|
||||
},
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user