Compare commits
279 Commits
f0594c24d2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d1900bc8f | |||
| 50ab0c8229 | |||
| 49be3cf4b9 | |||
| a11255a5e6 | |||
| 3a81e540a1 | |||
| 0f8bf94d29 | |||
| 6d57fe88b4 | |||
| f775eb7e92 | |||
| e535a0181e | |||
| f7eec707a9 | |||
| 3b83786b8b | |||
| a902d86432 | |||
| de7f6624f0 | |||
| d656ee8805 | |||
| fe9c497038 | |||
| b3d78c1603 | |||
| a03f664407 | |||
| cdfacdd0c4 | |||
| 5c4356e4e2 | |||
| 969f8963bc | |||
| f71613786b | |||
| 4afaf01de7 | |||
| 5bc9a94734 | |||
| cfc05a140c | |||
| 80ce891bd8 | |||
| 6d4ad86e33 | |||
| 7370f68bdf | |||
| f863cf0158 | |||
| 456f50d334 | |||
| 757abf24f8 | |||
| 9b7a6d459f | |||
| e0a28abbcd | |||
| ac6e74ab4c | |||
| 5f16bb575a | |||
| f05d1e0f21 | |||
| 9987da2c07 | |||
| fa87ab8c55 | |||
| fae6edef6b | |||
| 2ab8fcbdf0 | |||
| 5d5c888acf | |||
| a45a33534f | |||
| f3827706df | |||
| 2afbb9fefc | |||
| 1f8b4d2078 | |||
| 3f1a284acb | |||
| 87f93f00b5 | |||
| d1e2902655 | |||
| 54dfa8d611 | |||
| 5d36d3456f | |||
| 0e9421dcf7 | |||
| baffeb3a44 | |||
| 29b7c90b29 | |||
| 64c9ca634a | |||
| 374a76c867 | |||
| b65e1e1098 | |||
| 996a16cfb5 | |||
| a06f90a164 | |||
| df977fc985 | |||
| 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,11 @@
|
|||||||
|
|
||||||
# v1 runtime data (DB, backups, snapshots, exports, config with secrets)
|
# v1 runtime data (DB, backups, snapshots, exports, config with secrets)
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
.mlx-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.
|
- **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.
|
- **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.
|
- **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)
|
## Core concepts (vocabulary)
|
||||||
|
|
||||||
@@ -149,3 +153,217 @@ 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)
|
- Inference hosting (start with a cloud API, re-evaluate later)
|
||||||
- Character template format (during Phase 1)
|
- Character template format (during Phase 1)
|
||||||
- Multi-session / multi-character casts: **out of scope for v1**. Leave cheap schema hooks only.
|
- 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
|
||||||
|
|
||||||
|
All items shipped or deferred to Phase 5 (see "Phase 5 backlog" below). Final schema version: 14.
|
||||||
|
|
||||||
|
## Phase 4.5 status
|
||||||
|
|
||||||
|
Phase 4.5 cleanup shipped 13 of 14 planned tasks (T103–T117 with T115 deferred; T118 is this docs sweep). Two CLAUDE.md backlogs (Phase 3.6/4, Phase 4.5/5) are now empty; deferred follow-ups discovered during execution are tracked in a new "Phase 5 backlog" section below. Schema baseline advanced from version 13 to **14** (migration 0014: `memories.event_id`). Test count grew from ~413 (Phase 4) to ~457 (+~44 new tests across the wave).
|
||||||
|
|
||||||
|
- **Wave 1 — trivial polish (parallel)**:
|
||||||
|
- **T103** branches polish — global-branch (`chat_id IS NULL`) leak documented in `list_branches`; branch-switch to nonexistent name now logs a warning.
|
||||||
|
- **T104** `memory.py` DRY — `MAX(id)` helper extracted; `fts_rank=None` contract documented for vector-only rows.
|
||||||
|
- **T105** `snapshots.py` polish — `datetime`/`timezone` imports hoisted to module level; strict `kind` validation in restore/preview (rejects missing); `created_at` from file mtime documented.
|
||||||
|
- **T106** `search.py` polish — `k=50` extracted to module constant; N+1 `get_bot`/`get_chat`/`get_scene` lookups batched.
|
||||||
|
- **T107** `embeddings.py` — `timeout_s` fallback-path warning when non-default model misconfigured.
|
||||||
|
- **Wave 2 — scene-close-on-cancel (single)**:
|
||||||
|
- **T108** strengthened the T74.3 regression test + documented rationale in `turns.py`. **Surfaced a deferred bug**: existing pin only passes because `asyncio` isn't imported in the test module (NameError caught instead of CancelledError). When CancelledError fires for real, `post_turn`'s end-of-function re-raise causes `open_db`'s dependency teardown to skip `conn.commit()`, rolling back ALL post-cancel writes. Documented and deferred to Phase 5 triage.
|
||||||
|
- **Wave 3 — schema 0014 (single)**:
|
||||||
|
- **T109** `memories.event_id` column (foundation for T111 deep-link). FK CASCADE on `embeddings.memory_id` deferred (memories rows are never deleted today; defensive constraint can't fire — saved for broader migration cleanup in Phase 5).
|
||||||
|
- **Wave 4 — drawer Phase 4.5 bundle (single)**:
|
||||||
|
- **T110** `event_id <= 0` guard in `delete_turn` + `html.escape()` on delete-impact modal + Jinja partial extraction + bulk significance re-rate per chat (one `manual_edit` event per memory).
|
||||||
|
- **Wave 5 — search UX (single)**:
|
||||||
|
- **T111** FTS snippet highlighting via `snippet()` + deep-link to turn via `memories.event_id`.
|
||||||
|
- **Wave 6 — real embedding model swap (single)**:
|
||||||
|
- **T112** `LLMClient.embed()` Protocol + Mock impl with `canned_embeddings` + `FeatherlessClient.embed()` (raises `NotImplementedError` — Featherless OAI-compat doesn't expose embeddings, gap documented) + `generate_embedding` routes non-default models through `client.embed()` with fallback + `--re-embed-all` backfill flag.
|
||||||
|
- **Wave 7 — branching read-side filter (single)**:
|
||||||
|
- **T113** `active_branch_event_ids(conn)` helper + applied to `read_recent_dialogue`, `scene_summarize._read_recent_dialogue`, `search_memories`, and `meanwhile._read_recent_meanwhile_dialogue`. Cross-chat search and projector queries deliberately NOT filtered (cross-chat is by design; projectors must see full log). Bootstrap "main" branch (origin=0, head=0) detected as the no-clamp sentinel.
|
||||||
|
- **Wave 8 — regenerate lifecycle rollback (single)**:
|
||||||
|
- **T114** `triggered_by_assistant_turn_id` payload back-reference on `event_started`/`event_completed`/`event_cancelled` + new `event_status_reverted` event kind + projector handler in `chat/state/events.py` + regenerate flow emits revert events for affected lifecycle transitions.
|
||||||
|
- **Wave 9 — final polish + integration (parallel)**:
|
||||||
|
- **T115** sqlite-vec swap — **DEFERRED to Phase 5**. Pre-flight failed: host Python build doesn't expose `sqlite3.Connection.enable_load_extension` (raises `AttributeError`). Requires either Python rebuild with `--enable-loadable-sqlite-extensions` or migration to `apsw`. Phase 4 pure-Python cosine remains in production.
|
||||||
|
- **T116** structured `CannedQueue` test fixture builder + 2–3 POC test migrations (Phase 5 to migrate the rest).
|
||||||
|
- **T117** Phase 4.5 cross-feature integration tests (5 minimum: real embedding swap, branching read-side filter, lifecycle rollback, search deep-link, bulk significance re-rate).
|
||||||
|
- **T118** documentation (this section).
|
||||||
|
|
||||||
|
### Phase 5 backlog
|
||||||
|
|
||||||
|
New follow-ups discovered during Phase 4.5 reviews and execution, plus carry-over deferrals. None are blocking; pick up at any time.
|
||||||
|
|
||||||
|
- **T115 sqlite-vec swap** (environmental blocker): host Python's `sqlite3.Connection` does not expose `enable_load_extension` — `python -c "import sqlite3; sqlite3.connect(':memory:').enable_load_extension(True)"` raises `AttributeError`. Fix requires either a Python rebuild with `--enable-loadable-sqlite-extensions` or migration to `apsw`. Pure-Python cosine remains in production until then.
|
||||||
|
- **T108 follow-up: cancel-path commit bug** — `post_turn`'s re-raised `CancelledError` causes `open_db` dependency teardown to skip `conn.commit()`, rolling back all post-cancel writes. The existing T74.3 regression test passes only because `asyncio` isn't imported in the test module (NameError masks the real cancel path). Triage required — either commit before re-raise, or restructure the route to never re-raise after the close-detection branch.
|
||||||
|
- **`embeddings` FK CASCADE on `memory_id`** — deferred from T109; do as part of a broader migration consolidation in Phase 5.
|
||||||
|
- **`CannedQueue` fixture migration** — T116 shipped the builder + POC migrations; remaining tests still use positional canned arrays. Migrate in Phase 5.
|
||||||
|
- **Vector index optimization (HNSW)** — currently scales to a few thousand memories on the flat-index pure-Python cosine path; revisit when counts grow past flat-index feasibility.
|
||||||
|
- **Branch-isolated `event_log`** — each branch has its own physical `event_log` range vs the current shared id space + head filter; full branch isolation is Phase 5+.
|
||||||
|
- **Embedding model swap migration tooling** — T112 added `--re-embed-all`; a more orchestrated swap (drain old worker, re-seed all memories, swap config) is Phase 5+.
|
||||||
|
- **Real-time collaborative branching** (multi-user) — out of scope for v1.
|
||||||
|
- **Avatars / portraits** (multimodality) — deferred indefinitely per design §14.
|
||||||
|
|||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
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
|
||||||
|
from chat.llm.local_mlx import LocalMLXClient
|
||||||
|
from chat.llm.router import RoutedLLMClient
|
||||||
|
|
||||||
|
FeatherlessClient.configure_concurrency(settings.featherless_max_concurrent)
|
||||||
|
LocalMLXClient.configure_concurrency(settings.local_mlx_max_concurrent)
|
||||||
|
|
||||||
|
# Background workers (significance scoring, embedding indexer)
|
||||||
|
# construct a fresh client per job via the factory. Workers route
|
||||||
|
# through the same RoutedLLMClient as request-time traffic so the
|
||||||
|
# narrative model still goes to Featherless and the classifier +
|
||||||
|
# embeddings hit the local MLX server.
|
||||||
|
def _factory():
|
||||||
|
narrative = FeatherlessClient(
|
||||||
|
api_key=settings.featherless_api_key,
|
||||||
|
base_url=settings.featherless_base_url,
|
||||||
|
)
|
||||||
|
classifier = None
|
||||||
|
if settings.classifier_provider_order:
|
||||||
|
classifier = FeatherlessClient(
|
||||||
|
api_key=settings.featherless_api_key,
|
||||||
|
base_url=settings.featherless_base_url,
|
||||||
|
default_extra_body={
|
||||||
|
"provider": {
|
||||||
|
"order": list(settings.classifier_provider_order)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
local = LocalMLXClient(base_url=settings.local_mlx_base_url)
|
||||||
|
return RoutedLLMClient(
|
||||||
|
narrative=narrative,
|
||||||
|
classifier=classifier,
|
||||||
|
local=local,
|
||||||
|
narrative_model=settings.narrative_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.
|
||||||
|
# T112 (Phase 4.5): the embedding model is now configurable via
|
||||||
|
# ``Settings.embedding_model``. Default ``"pseudo-sha256-384"``
|
||||||
|
# keeps the local-only path; swapping to a real model routes
|
||||||
|
# through ``client.embed(...)`` and falls back to a zero vector
|
||||||
|
# plus warning if the provider doesn't support embeddings.
|
||||||
|
embedding_worker = EmbeddingWorker(
|
||||||
|
conn_factory=lambda: open_db(settings.db_path),
|
||||||
|
client=_factory(),
|
||||||
|
model=settings.embedding_model,
|
||||||
|
)
|
||||||
|
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,88 @@
|
|||||||
|
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. The asterisk-action format
|
||||||
|
# (see ``_closing_instruction`` in chat/services/prompt.py) targets
|
||||||
|
# 2-3 short interleaved action+dialogue beats. Verbose roleplay
|
||||||
|
# narrators (Cydonia, Magnum) ignore the prompt's cap and keep
|
||||||
|
# going; ``trim_to_max_beats`` in chat/services/prompt.py handles
|
||||||
|
# the actual cap by trimming at a beat boundary post-stream. This
|
||||||
|
# max_tokens setting just gives the third beat enough room to
|
||||||
|
# complete naturally before max_tokens cuts mid-action: 160 fits
|
||||||
|
# 3 substantive beats with margin. Bump to 250 for longer scenes;
|
||||||
|
# drop to 80 for terse banter.
|
||||||
|
narrative_max_tokens: int = 160
|
||||||
|
# Sampling temperature for narrative generation. 0.7 = grounded /
|
||||||
|
# instruction-compliant (current — Cydonia is verbose-by-default and
|
||||||
|
# tighter temperature helps it respect the 2-3-beat cap);
|
||||||
|
# 0.85 = creative; 1.0 = wide variety; >1.0 = often off-the-rails.
|
||||||
|
narrative_temperature: float = 0.7
|
||||||
|
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
|
||||||
|
# Local MLX server (e.g. ``mlx-omni-server``) — serves any model
|
||||||
|
# whose id starts with one of ``local_prefixes`` (default
|
||||||
|
# ``"mlx-community/"``). The :class:`RoutedLLMClient` inspects the
|
||||||
|
# ``model`` kwarg at call time: local-prefix -> local, else -> remote.
|
||||||
|
# ``embed()`` always routes local.
|
||||||
|
local_mlx_base_url: str = "http://127.0.0.1:10240/v1"
|
||||||
|
local_mlx_max_concurrent: int = 1
|
||||||
|
# Optional OpenRouter-style provider pinning for the classifier
|
||||||
|
# client. Maps to the ``provider`` field on chat.completions.create
|
||||||
|
# via ``extra_body``; the FeatherlessClient (which is just an
|
||||||
|
# AsyncOpenAI wrapper) merges it into every call. Useful for forcing
|
||||||
|
# Llama-3.1-8B classifier traffic onto Cerebras (~423 tok/s, 10x
|
||||||
|
# the default Nebius). Empty list = no pin (provider is
|
||||||
|
# OpenRouter's choice).
|
||||||
|
classifier_provider_order: list[str] = Field(default_factory=list)
|
||||||
|
# T112 (Phase 4.5): embedding model identifier. Default is the
|
||||||
|
# deterministic local pseudo so fresh installs / tests don't need
|
||||||
|
# any external infra. Override via config.toml to a real model id
|
||||||
|
# (e.g. ``"mlx-community/bge-small-en-v1.5-bf16"``) once a local
|
||||||
|
# MLX server is running.
|
||||||
|
embedding_model: str = "pseudo-sha256-384"
|
||||||
|
|
||||||
|
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,30 @@
|
|||||||
|
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)
|
||||||
|
# ``timeout`` here sets SQLite's busy_timeout, in seconds: how long
|
||||||
|
# ``conn.execute`` blocks when another connection holds the WAL
|
||||||
|
# write lock. The Python default is 5.0, which is fatal for the
|
||||||
|
# async chat app: ``conn.execute``'s busy-wait does NOT release the
|
||||||
|
# GIL, so a contending background worker (e.g. the embedding worker
|
||||||
|
# writing ``embedding_indexed`` while the request handler holds an
|
||||||
|
# open transaction) freezes the whole asyncio event loop for up to
|
||||||
|
# 5 seconds — silently turning every concurrent LLM call into a 5s
|
||||||
|
# wall-clock hit. 0.1s lets contending writers fail fast; callers
|
||||||
|
# that need durability should retry, and the embedding worker
|
||||||
|
# already logs failures so a missed embedding can be backfilled.
|
||||||
|
conn = sqlite3.connect(
|
||||||
|
path, check_same_thread=check_same_thread, timeout=0.1
|
||||||
|
)
|
||||||
|
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,25 @@
|
|||||||
|
-- 0014_phase45_schema.sql — Phase 4.5 Wave 2 schema bump (T109).
|
||||||
|
--
|
||||||
|
-- Two schema concerns are bundled into this migration:
|
||||||
|
--
|
||||||
|
-- 1. ``embeddings.memory_id`` FK should ideally carry ``ON DELETE CASCADE``
|
||||||
|
-- (T88 review nit). DEFERRED to Phase 5: ``embeddings`` rows are only ever
|
||||||
|
-- deleted when the parent ``memories`` row is deleted, and ``memories``
|
||||||
|
-- rows are never deleted today (memory hide is a soft flag; the surgical
|
||||||
|
-- ``deindex_event`` path operates on ``event_log`` and does NOT cascade
|
||||||
|
-- to projection rows). The CASCADE constraint therefore can't fire under
|
||||||
|
-- current usage — adding the SQLite table-rebuild dance (rename, recreate,
|
||||||
|
-- copy, drop, reindex) for a defensive constraint is unwarranted bloat
|
||||||
|
-- in a polish wave. Revisit during the broader Phase 5 migration cleanup
|
||||||
|
-- when other table reshapes make the rebuild worthwhile.
|
||||||
|
--
|
||||||
|
-- 2. Add ``memories.event_id`` (NULLABLE INTEGER, references ``event_log.id``)
|
||||||
|
-- so cross-chat search results can deep-link back to the originating
|
||||||
|
-- turn (foundation for T111). The column is nullable so historical
|
||||||
|
-- memory rows projected before 0014 ran continue to round-trip cleanly;
|
||||||
|
-- new rows are populated by the ``memory_written`` projector handler
|
||||||
|
-- from the projecting event's id. This is a pure additive change — no
|
||||||
|
-- backfill is performed. Older rows simply read NULL until/unless a
|
||||||
|
-- later migration backfills them; T111 surfaces are coded to accept
|
||||||
|
-- NULL gracefully (no deep-link rendered).
|
||||||
|
ALTER TABLE memories ADD COLUMN event_id INTEGER REFERENCES event_log(id);
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, ContextManager, Iterator
|
||||||
|
from sqlite3 import Connection, OperationalError
|
||||||
|
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
async def append_and_apply_with_retry(
|
||||||
|
conn_factory: Callable[[], ContextManager[Connection]],
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
branch_id: int = 1,
|
||||||
|
attempts: int = 30,
|
||||||
|
base_sleep_s: float = 0.05,
|
||||||
|
max_sleep_s: float = 0.5,
|
||||||
|
) -> int | None:
|
||||||
|
"""Append-and-apply that retries on ``database is locked``.
|
||||||
|
|
||||||
|
Background workers (embedding indexer, significance scorer) write
|
||||||
|
events to the same SQLite file as the request handler. The chat
|
||||||
|
app sets a tight ``busy_timeout=100ms`` on every connection so a
|
||||||
|
contending worker can't freeze the request's asyncio event loop.
|
||||||
|
This helper restores durability for workers: it retries up to
|
||||||
|
``attempts`` times with exponential backoff (capped at
|
||||||
|
``max_sleep_s``) until the lock clears.
|
||||||
|
|
||||||
|
Returns the appended event's id, or ``None`` if all retries failed
|
||||||
|
(logged at WARNING). Each retry opens a fresh connection via
|
||||||
|
``conn_factory`` because the failed write may have left the prior
|
||||||
|
connection in an unusable state.
|
||||||
|
"""
|
||||||
|
sleep = base_sleep_s
|
||||||
|
for attempt in range(attempts):
|
||||||
|
try:
|
||||||
|
with conn_factory() as conn:
|
||||||
|
return append_and_apply(
|
||||||
|
conn, kind=kind, payload=payload, branch_id=branch_id
|
||||||
|
)
|
||||||
|
except OperationalError as exc:
|
||||||
|
if "database is locked" not in str(exc).lower():
|
||||||
|
raise
|
||||||
|
if attempt == attempts - 1:
|
||||||
|
_log.warning(
|
||||||
|
"append_and_apply_with_retry: gave up after %d attempts "
|
||||||
|
"(kind=%s): %s",
|
||||||
|
attempts, kind, exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
await asyncio.sleep(sleep)
|
||||||
|
sleep = min(sleep * 2, max_sleep_s)
|
||||||
|
|
||||||
|
|
||||||
|
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,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import TypeVar
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
from .client import LLMClient, Message
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=BaseModel)
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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,
|
||||||
|
max_tokens: int = 512,
|
||||||
|
) -> 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),
|
||||||
|
]
|
||||||
|
# Cap output length so a misbehaving model (e.g. one that ignores
|
||||||
|
# ``response_format=json_object`` and generates prose) can't burn
|
||||||
|
# several seconds on tokens we'll never use. Classifier responses
|
||||||
|
# are small JSON objects — 512 tokens is generous; usual completions
|
||||||
|
# are 50-150.
|
||||||
|
last_text = None
|
||||||
|
last_error: BaseException | None = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
text = await asyncio.wait_for(
|
||||||
|
client.generate(
|
||||||
|
msgs,
|
||||||
|
model=model,
|
||||||
|
response_format={"type": "json_object"},
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
),
|
||||||
|
timeout=timeout_s,
|
||||||
|
)
|
||||||
|
last_text = text
|
||||||
|
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) as exc:
|
||||||
|
last_error = exc
|
||||||
|
msgs[0] = Message(
|
||||||
|
role="system",
|
||||||
|
content=system + schema_block + "\n\nRespond with valid JSON ONLY. No prose, no markdown fences.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# Log when we're falling back so flapping classifiers are
|
||||||
|
# diagnosable without taking down the request.
|
||||||
|
snippet = (last_text or "")[:200].replace("\n", " ")
|
||||||
|
_log.warning(
|
||||||
|
"classify(%s) exhausted 3 attempts; last_error=%s last_text=%r; "
|
||||||
|
"falling back to %s",
|
||||||
|
schema.__name__,
|
||||||
|
type(last_error).__name__ if last_error else "?",
|
||||||
|
snippet,
|
||||||
|
"default" if default is not None else "RuntimeError (no default)",
|
||||||
|
)
|
||||||
|
if default is None:
|
||||||
|
raise RuntimeError(f"classify failed for schema {schema.__name__} with no default")
|
||||||
|
return default
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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]: ...
|
||||||
|
# T112 (Phase 4.5): real-embedding seam. Implementations either call a
|
||||||
|
# provider's ``/v1/embeddings`` endpoint or, when the provider doesn't
|
||||||
|
# expose embeddings (e.g. Featherless today), raise ``NotImplementedError``
|
||||||
|
# so ``generate_embedding`` can catch it and degrade to the zero-vector
|
||||||
|
# fallback. The Protocol is structural, so this method only needs to
|
||||||
|
# exist on implementations; existing callers that don't use it are
|
||||||
|
# unaffected.
|
||||||
|
async def embed(self, text: str, *, model: str) -> list[float]: ...
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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",
|
||||||
|
*,
|
||||||
|
default_extra_body: dict | None = None,
|
||||||
|
):
|
||||||
|
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
# ``default_extra_body`` is merged into every chat.completions.create
|
||||||
|
# call's ``extra_body``. Useful with OpenRouter to pin specific
|
||||||
|
# upstream providers (e.g. ``{"provider": {"order": ["Cerebras"]}}``
|
||||||
|
# for 10x throughput on Llama-3.1-8B). Featherless ignores the
|
||||||
|
# field, so it's safe to leave set even when ``base_url`` points
|
||||||
|
# back at Featherless.
|
||||||
|
self._default_extra_body = default_extra_body or {}
|
||||||
|
|
||||||
|
def _merge_extra_body(self, params: dict) -> dict:
|
||||||
|
if not self._default_extra_body:
|
||||||
|
return params
|
||||||
|
eb = dict(self._default_extra_body)
|
||||||
|
eb.update(params.pop("extra_body", {}) or {})
|
||||||
|
params["extra_body"] = eb
|
||||||
|
return params
|
||||||
|
|
||||||
|
async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str:
|
||||||
|
params = self._merge_extra_body(dict(params))
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
# Diagnostic: stash provider+usage on a side-channel for the
|
||||||
|
# router timing log to pick up. OpenRouter sticks a 'provider'
|
||||||
|
# field on the response (not part of the OAI spec, but the
|
||||||
|
# SDK passes it through on its model dict).
|
||||||
|
try: # pragma: no cover — diagnostic only
|
||||||
|
import os as _os
|
||||||
|
if _os.environ.get("CHAT_LLM_TIMING") == "1":
|
||||||
|
prov = getattr(resp, "provider", None)
|
||||||
|
usage = getattr(resp, "usage", None)
|
||||||
|
ct = getattr(usage, "completion_tokens", "?") if usage else "?"
|
||||||
|
pt = getattr(usage, "prompt_tokens", "?") if usage else "?"
|
||||||
|
import logging as _logging
|
||||||
|
_logging.getLogger("chat.llm.router").info(
|
||||||
|
" ↪ provider=%s prompt_toks=%s completion_toks=%s",
|
||||||
|
prov, pt, ct,
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
return resp.choices[0].message.content or ""
|
||||||
|
|
||||||
|
async def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]:
|
||||||
|
params = self._merge_extra_body(dict(params))
|
||||||
|
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
|
||||||
|
|
||||||
|
async def embed(self, text: str, *, model: str) -> list[float]:
|
||||||
|
"""Embeddings via Featherless — unsupported in practice.
|
||||||
|
|
||||||
|
T112 (Phase 4.5) extends the LLMClient Protocol with ``embed()``
|
||||||
|
for a future real-embedding swap. Featherless's OpenAI-compatible
|
||||||
|
surface routes ``/v1/embeddings`` (no 404), but every request
|
||||||
|
returns HTTP 500 ``{"error": {"type": "completions_error", ...}}``
|
||||||
|
— including standard names like ``text-embedding-3-small`` and
|
||||||
|
``BAAI/bge-small-en-v1.5``. ``/v1/models`` confirms it: the
|
||||||
|
catalog has no embedding-class entries, only chat/completion
|
||||||
|
classes (``llama3-*``, ``gemma3-*``, ``glm5-*``, etc.).
|
||||||
|
|
||||||
|
Rather than ship a request that always 500s, this implementation
|
||||||
|
raises ``NotImplementedError``. The
|
||||||
|
:func:`chat.services.embeddings.generate_embedding` wrapper
|
||||||
|
catches it and degrades to the existing zero-vector fallback
|
||||||
|
(with the T107 warning), so misconfigured callers fail loudly in
|
||||||
|
logs but the request path keeps working.
|
||||||
|
|
||||||
|
For real embeddings, configure a different provider (OpenAI
|
||||||
|
direct, Cohere, Voyage, Together, self-hosted Ollama /
|
||||||
|
sentence-transformers). The Mock + routing seam from T112 keeps
|
||||||
|
the swap to a one-class change in ``chat/llm/``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Featherless /v1/embeddings always returns 500 "
|
||||||
|
'("completions_error") and the model catalog has no '
|
||||||
|
"embedding class; configure a different embedding provider "
|
||||||
|
"or stick with the default pseudo-sha256-384 model."
|
||||||
|
)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Local MLX OpenAI-compatible client.
|
||||||
|
|
||||||
|
Talks to a locally-running MLX server (e.g., ``mlx-omni-server``) over
|
||||||
|
the same OpenAI surface that :class:`chat.llm.featherless.FeatherlessClient`
|
||||||
|
uses, via :class:`openai.AsyncOpenAI`. The underlying server runs MLX
|
||||||
|
models on Apple Silicon (M-series) for chat completions AND embeddings.
|
||||||
|
|
||||||
|
Use cases (Phase 4.5+):
|
||||||
|
- Classifier traffic moved off Featherless to local MLX (cost + latency).
|
||||||
|
- Embeddings via ``client.embed`` actually work — Featherless's
|
||||||
|
``/v1/embeddings`` always returns 500.
|
||||||
|
|
||||||
|
Constructor takes a ``base_url`` (e.g., ``"http://127.0.0.1:10240/v1"``)
|
||||||
|
and an optional ``api_key`` (most local MLX servers don't authenticate;
|
||||||
|
the OpenAI SDK requires *some* string, so we default to a placeholder).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncIterator, Sequence
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from .client import Message
|
||||||
|
|
||||||
|
|
||||||
|
class LocalMLXClient:
|
||||||
|
"""OpenAI-compatible client for a local MLX server.
|
||||||
|
|
||||||
|
The server is single-process by default (``mlx-omni-server`` loads
|
||||||
|
one model at a time and swaps on demand). The class-level semaphore
|
||||||
|
serializes concurrent requests so we never queue more than
|
||||||
|
``max_concurrent`` at a time — defaults to 1, since MLX inference
|
||||||
|
on a single M-series device is sequential anyway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_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(1)
|
||||||
|
return cls._semaphore
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str = "http://127.0.0.1:10240/v1",
|
||||||
|
api_key: str = "not-needed",
|
||||||
|
):
|
||||||
|
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
|
||||||
|
|
||||||
|
async def embed(self, text: str, *, model: str) -> list[float]:
|
||||||
|
"""Return an embedding vector for ``text`` using the named model.
|
||||||
|
|
||||||
|
Targets ``/v1/embeddings`` on the local MLX server; the server
|
||||||
|
loads the model on first request and caches it. The embedding
|
||||||
|
model is independent of the chat model loaded for ``generate``
|
||||||
|
/ ``stream`` (the server can serve both).
|
||||||
|
"""
|
||||||
|
async with self._sem():
|
||||||
|
resp = await self._client.embeddings.create(
|
||||||
|
model=model,
|
||||||
|
input=text,
|
||||||
|
)
|
||||||
|
return list(resp.data[0].embedding)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import AsyncIterator, Sequence
|
||||||
|
from .client import Message
|
||||||
|
|
||||||
|
|
||||||
|
class MockLLMClient:
|
||||||
|
"""In-memory LLMClient for tests.
|
||||||
|
|
||||||
|
``canned`` feeds ``generate``/``stream`` (one entry per call, popped
|
||||||
|
from the front). ``canned_embeddings`` (T112, Phase 4.5) feeds
|
||||||
|
``embed`` the same way — each call pops the next vector. An empty
|
||||||
|
queue raises ``IndexError`` so misconfigured tests fail loudly
|
||||||
|
rather than returning ``None`` or hanging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
canned: list[str],
|
||||||
|
*,
|
||||||
|
canned_embeddings: list[list[float]] | None = None,
|
||||||
|
):
|
||||||
|
self._canned = list(canned)
|
||||||
|
self._canned_embeddings: list[list[float]] = list(canned_embeddings or [])
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async def embed(self, text: str, *, model: str) -> list[float]:
|
||||||
|
# Mirrors the canned-queue pattern; empty queue raises so
|
||||||
|
# misconfigured tests surface clearly instead of returning None.
|
||||||
|
return self._canned_embeddings.pop(0)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Routed LLM client — splits traffic across multiple backends by model.
|
||||||
|
|
||||||
|
Phase 4.5+ deployment: the 24B narrative model stays on Featherless,
|
||||||
|
the 8B classifier model moves to local MLX, and embeddings run on a
|
||||||
|
local BGE/MLX model. One :class:`LLMClient` interface, two underlying
|
||||||
|
backends, dispatched by the ``model`` argument at every call site.
|
||||||
|
|
||||||
|
Routing rule: requests whose ``model`` argument matches the configured
|
||||||
|
``narrative_model`` go to the narrative backend; everything else
|
||||||
|
(classifier, embeddings, future locally-hosted models) goes to the
|
||||||
|
local backend.
|
||||||
|
|
||||||
|
Set the env var ``CHAT_LLM_TIMING=1`` to log per-call timing at INFO
|
||||||
|
level. Useful for finding the slow link in a turn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import AsyncIterator, Sequence
|
||||||
|
|
||||||
|
from .client import LLMClient, Message
|
||||||
|
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
_TIMING = os.environ.get("CHAT_LLM_TIMING") == "1"
|
||||||
|
if _TIMING and not _log.handlers:
|
||||||
|
# Wire a stderr handler when timing is enabled so the per-call
|
||||||
|
# logs show up under uvicorn (which doesn't configure non-uvicorn
|
||||||
|
# loggers by default).
|
||||||
|
_h = logging.StreamHandler()
|
||||||
|
_h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||||
|
_log.addHandler(_h)
|
||||||
|
_log.setLevel(logging.INFO)
|
||||||
|
_log.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
class RoutedLLMClient:
|
||||||
|
"""Delegates to one of two underlying clients based on ``model``.
|
||||||
|
|
||||||
|
Routing rule: any model id starting with one of ``local_prefixes``
|
||||||
|
goes to the local backend (e.g. ``"mlx-community/"`` for models
|
||||||
|
served by ``mlx-omni-server``). Everything else — narrative model,
|
||||||
|
remote classifiers, anything on a hosted provider — routes to the
|
||||||
|
remote backend.
|
||||||
|
|
||||||
|
``embed`` always routes locally (the remote provider doesn't
|
||||||
|
expose a working ``/v1/embeddings``; see
|
||||||
|
:class:`chat.llm.featherless.FeatherlessClient.embed`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
narrative: LLMClient,
|
||||||
|
local: LLMClient,
|
||||||
|
narrative_model: str,
|
||||||
|
classifier: LLMClient | None = None,
|
||||||
|
local_prefixes: tuple[str, ...] = ("mlx-community/",),
|
||||||
|
) -> None:
|
||||||
|
# ``classifier`` is an optional separate backend for the
|
||||||
|
# classifier model. Useful when classifier and narrative both
|
||||||
|
# live on a remote OpenRouter-style provider but need different
|
||||||
|
# provider-pinning (e.g. Cerebras for the 8B classifier,
|
||||||
|
# default Friendli/etc. for the narrative). When ``classifier``
|
||||||
|
# is None, classifier traffic falls through to ``narrative``
|
||||||
|
# (the remote client) so old wiring keeps working.
|
||||||
|
self._narrative = narrative
|
||||||
|
self._classifier = classifier
|
||||||
|
self._local = local
|
||||||
|
self._narrative_model = narrative_model
|
||||||
|
self._local_prefixes = local_prefixes
|
||||||
|
|
||||||
|
def _pick(self, model: str) -> LLMClient:
|
||||||
|
if any(model.startswith(p) for p in self._local_prefixes):
|
||||||
|
return self._local
|
||||||
|
if model == self._narrative_model:
|
||||||
|
return self._narrative
|
||||||
|
# Anything else (most importantly, the classifier model) goes
|
||||||
|
# to the classifier client when configured, otherwise to the
|
||||||
|
# narrative remote client.
|
||||||
|
return self._classifier or self._narrative
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self, messages: Sequence[Message], *, model: str, **params
|
||||||
|
) -> str:
|
||||||
|
client = self._pick(model)
|
||||||
|
backend = (
|
||||||
|
"narrative" if client is self._narrative else
|
||||||
|
"classifier" if client is self._classifier else
|
||||||
|
"local"
|
||||||
|
)
|
||||||
|
if not _TIMING:
|
||||||
|
return await client.generate(messages, model=model, **params)
|
||||||
|
in_chars = sum(len(m.content) for m in messages)
|
||||||
|
_log.info("LLM generate START [%s] %s in_chars=%d", backend, model, in_chars)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
return await client.generate(messages, model=model, **params)
|
||||||
|
finally:
|
||||||
|
_log.info(
|
||||||
|
"LLM generate END [%s] %s in_chars=%d %.2fs",
|
||||||
|
backend, model, in_chars, time.perf_counter() - t0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream(
|
||||||
|
self, messages: Sequence[Message], *, model: str, **params
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
client = self._pick(model)
|
||||||
|
backend = (
|
||||||
|
"narrative" if client is self._narrative else
|
||||||
|
"classifier" if client is self._classifier else
|
||||||
|
"local"
|
||||||
|
)
|
||||||
|
if not _TIMING:
|
||||||
|
async for chunk in client.stream(messages, model=model, **params):
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
ttft = None
|
||||||
|
chars_out = 0
|
||||||
|
try:
|
||||||
|
async for chunk in client.stream(messages, model=model, **params):
|
||||||
|
if ttft is None:
|
||||||
|
ttft = time.perf_counter() - t0
|
||||||
|
chars_out += len(chunk)
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
dt = time.perf_counter() - t0
|
||||||
|
in_chars = sum(len(m.content) for m in messages)
|
||||||
|
_log.info(
|
||||||
|
"LLM stream [%s] %s in_chars=%d out_chars=%d ttft=%.2fs total=%.2fs",
|
||||||
|
backend, model, in_chars, chars_out, ttft or 0.0, dt,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def embed(self, text: str, *, model: str) -> list[float]:
|
||||||
|
# Embeddings always run on the local backend — the remote
|
||||||
|
# provider doesn't expose a working ``/v1/embeddings`` endpoint.
|
||||||
|
if not _TIMING:
|
||||||
|
return await self._local.embed(text, model=model)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
return await self._local.embed(text, model=model)
|
||||||
|
finally:
|
||||||
|
_log.info(
|
||||||
|
"LLM embed [local] %s in_chars=%d %.2fs",
|
||||||
|
model, len(text), time.perf_counter() - t0,
|
||||||
|
)
|
||||||
@@ -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,268 @@
|
|||||||
|
"""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, append_and_apply_with_retry
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
# Retry-on-lock: see chat/eventlog/log.py's
|
||||||
|
# ``append_and_apply_with_retry`` docstring for why workers
|
||||||
|
# need to retry while the request handler's open transaction
|
||||||
|
# holds the WAL write lock briefly.
|
||||||
|
appended_id = await append_and_apply_with_retry(
|
||||||
|
lambda: open_db(self._settings.db_path),
|
||||||
|
kind="memory_significance_set",
|
||||||
|
payload={
|
||||||
|
"memory_id": job.memory_id,
|
||||||
|
"significance": score,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Auto-pin requires a separate connection because retry-helper
|
||||||
|
# closed its own. Skip if the significance event itself failed.
|
||||||
|
if appended_id is not None and score >= 3:
|
||||||
|
with open_db(self._settings.db_path) as conn:
|
||||||
|
_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,103 @@
|
|||||||
|
"""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,
|
||||||
|
event_id, pov_summary, snippet, significance, ts, fts_rank}``,
|
||||||
|
sorted by FTS5 BM25 rank ascending (lower rank = stronger match,
|
||||||
|
surfaced first).
|
||||||
|
|
||||||
|
``event_id`` (T111.2 / T109) is the id of the ``event_log`` row that
|
||||||
|
drove the projecting ``memory_written`` event. May be ``None`` for
|
||||||
|
memory rows projected before the 0014 schema migration ran (the
|
||||||
|
column is nullable on purpose; T109 did not backfill historical
|
||||||
|
rows). The search-results UI uses it to deep-link to the originating
|
||||||
|
turn anchor (Phase 3.5 T86 stamps ``id="turn-{event_id}"`` on each
|
||||||
|
turn DOM node) and falls back to a chat-level link when ``None``.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
``snippet`` (T111.1) is the FTS5 ``snippet()`` output for the
|
||||||
|
matched ``pov_summary`` column: a windowed excerpt with each match
|
||||||
|
token wrapped in ``<mark>...</mark>`` for the search-results UI to
|
||||||
|
render verbatim. The full ``pov_summary`` is also returned so
|
||||||
|
non-highlighted callers (or fallbacks) keep the original string.
|
||||||
|
|
||||||
|
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.
|
||||||
|
#
|
||||||
|
# ``snippet(memories_fts, 0, ...)`` (T111.1) targets column 0 of the
|
||||||
|
# FTS virtual table, which is ``pov_summary`` (the only column
|
||||||
|
# indexed by ``CREATE VIRTUAL TABLE memories_fts USING fts5(
|
||||||
|
# pov_summary, ...)`` in migration 0006). SQLite passes the raw
|
||||||
|
# column text through verbatim aside from inserting the configured
|
||||||
|
# before/after match markers, so the only HTML in the output is the
|
||||||
|
# ``<mark>`` we injected — safe to render with ``|safe`` server-side.
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT m.id, m.owner_id, m.chat_id, m.scene_id, m.event_id, "
|
||||||
|
" m.pov_summary, "
|
||||||
|
" snippet(memories_fts, 0, '<mark>', '</mark>', '…', 32) "
|
||||||
|
" AS snippet, "
|
||||||
|
" 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],
|
||||||
|
"event_id": r[4],
|
||||||
|
"pov_summary": r[5],
|
||||||
|
"snippet": r[6],
|
||||||
|
"significance": r[7],
|
||||||
|
"ts": r[8],
|
||||||
|
"fts_rank": r[9],
|
||||||
|
}
|
||||||
|
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,142 @@
|
|||||||
|
"""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_with_retry
|
||||||
|
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
|
||||||
|
# Retry-on-lock: the request handler holds an open transaction
|
||||||
|
# for the duration of post_turn (a few seconds), so any worker
|
||||||
|
# write started during that window blocks. open_db's
|
||||||
|
# busy_timeout is 100ms (so the request path itself can't get
|
||||||
|
# stuck on a worker), so retry here with backoff. Each retry
|
||||||
|
# opens a fresh connection via ``conn_factory``.
|
||||||
|
await append_and_apply_with_retry(
|
||||||
|
self._conn_factory,
|
||||||
|
kind="embedding_indexed",
|
||||||
|
payload={
|
||||||
|
"memory_id": job.memory_id,
|
||||||
|
"model": result.model,
|
||||||
|
"dim": result.dim,
|
||||||
|
"vector": result.vector,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["EmbeddingJob", "EmbeddingWorker"]
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""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 logging
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from chat.llm.client import LLMClient
|
||||||
|
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# T112 (Phase 4.5): non-default model — route through the client's
|
||||||
|
# ``embed()`` method. On any failure (including ``NotImplementedError``
|
||||||
|
# from providers that don't expose embeddings, e.g. Featherless today),
|
||||||
|
# fall back to the zero vector and re-fire the T107 warning so
|
||||||
|
# misconfigured callers see the issue in logs rather than silently
|
||||||
|
# producing useless cosine results.
|
||||||
|
try:
|
||||||
|
vector = await client.embed(text, model=model)
|
||||||
|
return EmbeddingResult(vector=list(vector), model=model, dim=len(vector))
|
||||||
|
except Exception as exc: # noqa: BLE001 — any failure must degrade gracefully
|
||||||
|
_log.warning(
|
||||||
|
"generate_embedding: non-default model %r returned fallback "
|
||||||
|
"(client.embed() raised %s: %s); "
|
||||||
|
"downstream search will degrade silently. Configure a supported model.",
|
||||||
|
model,
|
||||||
|
type(exc).__name__,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
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,73 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
Pairs run concurrently via :func:`asyncio.gather`; the underlying
|
||||||
|
client should impose its own concurrency cap if the upstream provider
|
||||||
|
needs it (e.g., Featherless's 2-conn semaphore). Returning order is
|
||||||
|
preserved (natural iteration over ``present_ids x present_ids``,
|
||||||
|
src != tgt) so downstream event-append order stays deterministic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
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``, concurrently. Returns list of
|
||||||
|
``(source_id, target_id, update)`` tuples in the natural iteration
|
||||||
|
order over ``present_ids x present_ids`` — concurrent dispatch does
|
||||||
|
not change the returned order.
|
||||||
|
|
||||||
|
A single failing pair falls back to the schema-default StateUpdate
|
||||||
|
(zero deltas, empty facts) inside ``compute_state_update``; sibling
|
||||||
|
pairs continue independently because each call is wrapped in its
|
||||||
|
own try/except inside ``compute_state_update``.
|
||||||
|
"""
|
||||||
|
pair_keys: list[tuple[str, str]] = [
|
||||||
|
(src, tgt)
|
||||||
|
for src in present_ids
|
||||||
|
for tgt in present_ids
|
||||||
|
if src != tgt
|
||||||
|
]
|
||||||
|
if not pair_keys:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _one(src: str, tgt: str) -> StateUpdate:
|
||||||
|
edge = prior_edges.get((src, tgt), {})
|
||||||
|
return 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
updates = await asyncio.gather(*(_one(src, tgt) for src, tgt in pair_keys))
|
||||||
|
return [(src, tgt, upd) for (src, tgt), upd in zip(pair_keys, updates)]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["compute_state_updates_for_present"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,865 @@
|
|||||||
|
"""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__)
|
||||||
|
|
||||||
|
|
||||||
|
# T114.3: map a lifecycle-transition event kind to the events-table
|
||||||
|
# status it implicitly transitioned *from*. Regenerate uses this to pick
|
||||||
|
# the ``prior_status`` value for the ``event_status_reverted`` rollback
|
||||||
|
# event so the projector sets the row back to where it was before the
|
||||||
|
# superseded turn fired the transition.
|
||||||
|
#
|
||||||
|
# - ``event_started`` was emitted when the row was 'planned' → revert to
|
||||||
|
# 'planned'.
|
||||||
|
# - ``event_completed`` was emitted when the row was 'active' → revert
|
||||||
|
# to 'active'.
|
||||||
|
# - ``event_cancelled`` could have fired from either 'planned' or
|
||||||
|
# 'active'. Best-effort default: 'active'. The forward transitions
|
||||||
|
# below only fire detect_event_transitions for currently-active rows,
|
||||||
|
# so 'active' is the realistic prior in practice.
|
||||||
|
_PRIOR_STATUS_MAP: dict[str, str] = {
|
||||||
|
"event_started": "planned",
|
||||||
|
"event_completed": "active",
|
||||||
|
"event_cancelled": "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 (T114, Phase 4.5).**
|
||||||
|
When the superseded turn already produced lifecycle transitions
|
||||||
|
(``event_started`` / ``event_completed`` / ``event_cancelled``),
|
||||||
|
this function emits an ``event_status_reverted`` event for each
|
||||||
|
so the events row's status returns to its prior value before the
|
||||||
|
regenerated narrative is reclassified. Backward compatibility:
|
||||||
|
lifecycle events authored before T114.1 lack the
|
||||||
|
``triggered_by_assistant_turn_id`` payload field; rollback skips
|
||||||
|
those (logged at DEBUG) so historic rows are not retroactively
|
||||||
|
reverted. A WARNING about un-rolled-back transitions is still
|
||||||
|
emitted when stragglers are found — the rollback handles the
|
||||||
|
common case while older logs continue to need manual review.
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
# T114.3: roll back lifecycle transitions emitted by the superseded
|
||||||
|
# turn. The scan uses the same id-greater-than-superseded-turn
|
||||||
|
# heuristic as the legacy T83.4 warning, joined to ``events`` for
|
||||||
|
# chat scoping (lifecycle events don't carry chat_id in their
|
||||||
|
# payload — they reference an ``event_id`` FK to the ``events``
|
||||||
|
# table, which holds chat_id). For each row whose payload carries
|
||||||
|
# ``triggered_by_assistant_turn_id == original_assistant_event_id``
|
||||||
|
# (T114.1 back-reference), emit an ``event_status_reverted`` event
|
||||||
|
# so the events-row status returns to the pre-transition value.
|
||||||
|
# Lifecycle rows authored before T114.1 lack the back-reference;
|
||||||
|
# those are skipped (DEBUG log) and a WARNING tracks their count so
|
||||||
|
# operators still see legacy stragglers — preserves the T83.4
|
||||||
|
# observability contract for un-rolled-back transitions.
|
||||||
|
unrolled_lifecycle = conn.execute(
|
||||||
|
"SELECT el.id, el.kind, el.payload_json 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()
|
||||||
|
rolled_back_ids: list[int] = []
|
||||||
|
skipped_no_backref: list[int] = []
|
||||||
|
for el_id, el_kind, el_payload_json in unrolled_lifecycle:
|
||||||
|
try:
|
||||||
|
lifecycle_payload = json.loads(el_payload_json)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
skipped_no_backref.append(el_id)
|
||||||
|
continue
|
||||||
|
triggered_by = lifecycle_payload.get("triggered_by_assistant_turn_id")
|
||||||
|
if triggered_by != original_assistant_event_id:
|
||||||
|
# Either a legacy row (no field) or a transition triggered
|
||||||
|
# by a *different* turn — leave it alone. DEBUG so the
|
||||||
|
# message is available under verbose logging without
|
||||||
|
# spamming the default WARNING channel.
|
||||||
|
_log.debug(
|
||||||
|
"regenerate_assistant_turn: skipping rollback for "
|
||||||
|
"lifecycle event_log id=%d (kind=%s) — no back-reference "
|
||||||
|
"or different turn (triggered_by=%r vs superseded=%d)",
|
||||||
|
el_id,
|
||||||
|
el_kind,
|
||||||
|
triggered_by,
|
||||||
|
original_assistant_event_id,
|
||||||
|
)
|
||||||
|
if triggered_by is None:
|
||||||
|
skipped_no_backref.append(el_id)
|
||||||
|
continue
|
||||||
|
prior_status = _PRIOR_STATUS_MAP.get(el_kind)
|
||||||
|
if prior_status is None:
|
||||||
|
# Defensive: the SQL filter already restricts to the three
|
||||||
|
# known kinds, but a future schema addition shouldn't crash
|
||||||
|
# the rollback path.
|
||||||
|
continue
|
||||||
|
target_event_id = lifecycle_payload.get("event_id")
|
||||||
|
if target_event_id is None:
|
||||||
|
continue
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="event_status_reverted",
|
||||||
|
payload={
|
||||||
|
"event_id": target_event_id,
|
||||||
|
"prior_status": prior_status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rolled_back_ids.append(el_id)
|
||||||
|
if rolled_back_ids:
|
||||||
|
_log.info(
|
||||||
|
"regenerate_assistant_turn: rolled back %d lifecycle "
|
||||||
|
"transition(s) triggered by superseded turn %s "
|
||||||
|
"(event_log ids: %s)",
|
||||||
|
len(rolled_back_ids),
|
||||||
|
original_assistant_event_id,
|
||||||
|
rolled_back_ids,
|
||||||
|
)
|
||||||
|
if skipped_no_backref:
|
||||||
|
# T83.4 (legacy) compatibility: still warn about stragglers
|
||||||
|
# without the back-reference so operators can spot pre-T114
|
||||||
|
# double-emit risks. Phrased as "at-or-after turn <id>" per
|
||||||
|
# T90.2 — older transitions may legitimately belong to other
|
||||||
|
# turns.
|
||||||
|
_log.warning(
|
||||||
|
"regenerate_assistant_turn: %d lifecycle transition(s) "
|
||||||
|
"at-or-after turn %s are NOT being rolled back (no "
|
||||||
|
"triggered_by_assistant_turn_id back-reference). "
|
||||||
|
"Affected event ids: %s",
|
||||||
|
len(skipped_no_backref),
|
||||||
|
original_assistant_event_id,
|
||||||
|
skipped_no_backref,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# T114.3: original-turn transitions emitted before this regenerate
|
||||||
|
# ran were rolled back at the top of the function (see the
|
||||||
|
# ``# T114.3`` block) by appending ``event_status_reverted`` for
|
||||||
|
# each. The classify-and-emit pass below now operates against an
|
||||||
|
# ``events`` projection that has already been reverted, so it can
|
||||||
|
# safely re-fire transitions for the regenerated narrative without
|
||||||
|
# double-emitting promotion artifacts.
|
||||||
|
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"),
|
||||||
|
# T114.1: back-reference to the assistant_turn
|
||||||
|
# that triggered this transition (see turns.py
|
||||||
|
# for rationale).
|
||||||
|
"triggered_by_assistant_turn_id": (
|
||||||
|
new_assistant_event_id
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif transition.new_status == "completed":
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="event_completed",
|
||||||
|
payload={
|
||||||
|
"event_id": transition.event_id,
|
||||||
|
"completed_at": chat.get("time"),
|
||||||
|
# T114.1: back-reference (see above).
|
||||||
|
"triggered_by_assistant_turn_id": (
|
||||||
|
new_assistant_event_id
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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"),
|
||||||
|
# T114.1: back-reference (see above).
|
||||||
|
"triggered_by_assistant_turn_id": (
|
||||||
|
new_assistant_event_id
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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,727 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
T113: also clamps by the active branch's ``[origin, head]`` event-id
|
||||||
|
range so scene-summary inputs respect the user's current branch.
|
||||||
|
Bootstrap-main and "no active branch" fall through to ``(0, BIG_INT)``
|
||||||
|
so existing flows are unchanged.
|
||||||
|
"""
|
||||||
|
from chat.state.branches import active_branch_event_ids
|
||||||
|
|
||||||
|
origin, head = active_branch_event_ids(conn)
|
||||||
|
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 "
|
||||||
|
" AND id BETWEEN ? AND ? "
|
||||||
|
"ORDER BY id DESC LIMIT ?",
|
||||||
|
(origin, head, limit),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Compose ``since_event_id`` with the branch lower bound — readers
|
||||||
|
# want the tightest ``id >= max(since, origin)`` clamp without an
|
||||||
|
# extra Python pass.
|
||||||
|
lower = max(origin, since_event_id)
|
||||||
|
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 BETWEEN ? AND ? "
|
||||||
|
"ORDER BY id DESC LIMIT ?",
|
||||||
|
(lower, head, 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,140 @@
|
|||||||
|
"""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.branches import active_branch_event_ids
|
||||||
|
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.
|
||||||
|
|
||||||
|
T113: clamp by the active branch's ``[origin, head]`` event-id range so
|
||||||
|
switching branches actually changes what dialogue this read sees.
|
||||||
|
Bootstrap-main and "no active branch" both fall through to ``(0,
|
||||||
|
BIG_INT)`` — no functional change for the metadata-only Phase 4 era.
|
||||||
|
"""
|
||||||
|
origin, head = active_branch_event_ids(conn)
|
||||||
|
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 id BETWEEN ? AND ? "
|
||||||
|
" AND json_extract(payload_json, '$.chat_id') = ? "
|
||||||
|
"ORDER BY id DESC LIMIT ?",
|
||||||
|
(origin, head, 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 id BETWEEN ? AND ? "
|
||||||
|
" AND json_extract(payload_json, '$.chat_id') = ? "
|
||||||
|
"ORDER BY id DESC LIMIT ?",
|
||||||
|
(exclude_event_id, origin, head, 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,136 @@
|
|||||||
|
"""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).
|
||||||
|
|
||||||
|
Falls back to a single dialogue-shaped segment containing the
|
||||||
|
whole prose if the classifier flaps after retries — the turn flow
|
||||||
|
can keep moving (the narrative will still fire on the prose) at
|
||||||
|
the cost of finer-grained segment classification. The original
|
||||||
|
code raised ``RuntimeError`` here, which 500'd the whole request
|
||||||
|
and was particularly painful in multi-bot scenes where every
|
||||||
|
user turn paid the classifier round-trip.
|
||||||
|
"""
|
||||||
|
if not prose.strip():
|
||||||
|
return ParsedTurn(segments=[])
|
||||||
|
|
||||||
|
fallback = ParsedTurn(
|
||||||
|
segments=[TurnSegment(kind="dialogue", text=prose)],
|
||||||
|
intent="narrative",
|
||||||
|
landing_state_hint="",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_prompt = f"INPUT:\n{prose}"
|
||||||
|
return await classify(
|
||||||
|
client,
|
||||||
|
model=model,
|
||||||
|
system=_SYSTEM_PROMPT,
|
||||||
|
user=user_prompt,
|
||||||
|
schema=ParsedTurn,
|
||||||
|
default=fallback,
|
||||||
|
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,214 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from sqlite3 import Connection
|
||||||
|
|
||||||
|
from chat.eventlog.projector import on
|
||||||
|
from chat.eventlog.log import Event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
|
||||||
|
If the named branch does not exist, a warning is emitted and the
|
||||||
|
is_active flags are still cleared (preserving prior behavior — the
|
||||||
|
second UPDATE simply matches no rows). Callers should validate the
|
||||||
|
name upstream; this guard surfaces accidental mismatches in the log.
|
||||||
|
"""
|
||||||
|
p = e.payload
|
||||||
|
name = p["name"]
|
||||||
|
# Warn (don't raise) if the target branch is missing. The existing
|
||||||
|
# outcome — zero active branches — is preserved; this just makes the
|
||||||
|
# condition observable instead of silent.
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM branches WHERE name = ? LIMIT 1",
|
||||||
|
(name,),
|
||||||
|
).fetchone()
|
||||||
|
if exists is None:
|
||||||
|
logger.warning(
|
||||||
|
"branch_switched to unknown branch name %r; no branch will be active",
|
||||||
|
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]:
|
||||||
|
"""Return branch rows, optionally scoped to a chat.
|
||||||
|
|
||||||
|
When ``chat_id`` is provided the filter is ``chat_id = ? OR chat_id IS NULL``,
|
||||||
|
so global (null-chat) branches are returned in *every* per-chat scope. This
|
||||||
|
is intentional: the bootstrapped ``"main"`` branch (and any future
|
||||||
|
null-chat branches) are global by design — they belong to no single chat
|
||||||
|
and should appear alongside per-chat branches in any chat-scoped listing.
|
||||||
|
Callers that want only per-chat branches should filter the result on
|
||||||
|
``chat_id is not None``.
|
||||||
|
"""
|
||||||
|
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]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# T113: sentinel "no upper bound" used by ``active_branch_event_ids`` when the
|
||||||
|
# active branch's head is unset (the bootstrap "main" branch with origin=0 +
|
||||||
|
# head=0). Readers compose ``id BETWEEN origin AND head`` so a value larger
|
||||||
|
# than any possible row id behaves as "no clamp" without needing a separate
|
||||||
|
# code path. ``2**63 - 1`` is SQLite's max signed-int — safe forever.
|
||||||
|
_NO_HEAD_CLAMP = 2**63 - 1
|
||||||
|
|
||||||
|
|
||||||
|
def active_branch_event_ids(conn: Connection) -> tuple[int, int]:
|
||||||
|
"""Return ``(origin_event_id, head_event_id)`` for the currently active
|
||||||
|
branch, suitable as bounds for an ``event_log.id BETWEEN ? AND ?`` clamp
|
||||||
|
on user-facing reads (T113).
|
||||||
|
|
||||||
|
Defensive defaults:
|
||||||
|
|
||||||
|
* **No active branch row** (``active_branch`` returns ``None``) — return
|
||||||
|
``(0, _NO_HEAD_CLAMP)`` so readers see all events. This preserves the
|
||||||
|
Phase 4 "branches are metadata-only" contract for any code path that
|
||||||
|
somehow runs without the migration-0013 bootstrap.
|
||||||
|
* **Bootstrap "main"** — the canonical ``name="main", origin=0, head=0``
|
||||||
|
row inserted by migration 0013. Production today never emits
|
||||||
|
``branch_head_updated`` for main, so head stays at 0 even as events
|
||||||
|
accumulate. We treat this exact bootstrap state as "no clamp" and
|
||||||
|
return ``(0, _NO_HEAD_CLAMP)`` so all events remain visible. This is
|
||||||
|
what every existing test (which never configures branches) relies on.
|
||||||
|
* **Any other branch** — return the literal ``(origin, head)`` from the
|
||||||
|
branch row. A branch created at origin=N has head=N initially (per
|
||||||
|
``branch_from_event``), so ``BETWEEN N AND N`` returns just that one
|
||||||
|
seed event until the head is bumped via ``branch_head_updated``.
|
||||||
|
|
||||||
|
Note on the schema mismatch with the T113 spec: the spec describes
|
||||||
|
``head_event_id`` as nullable, but migration 0013 declared it
|
||||||
|
``NOT NULL DEFAULT 0``. We read head=0 on bootstrap main as the
|
||||||
|
"unset" sentinel; non-main branches never reach head=0 in normal
|
||||||
|
flow (creation sets head=origin, and origin=0 only for main).
|
||||||
|
"""
|
||||||
|
branch = active_branch(conn)
|
||||||
|
if branch is None:
|
||||||
|
return (0, _NO_HEAD_CLAMP)
|
||||||
|
origin = int(branch.get("origin_event_id") or 0)
|
||||||
|
head = int(branch.get("head_event_id") or 0)
|
||||||
|
# Bootstrap "main" sentinel — see docstring above. Detect by name +
|
||||||
|
# both ids being 0 to avoid mis-firing on a hypothetical future
|
||||||
|
# branch that legitimately starts at origin=0.
|
||||||
|
if branch.get("name") == "main" and origin == 0 and head == 0:
|
||||||
|
return (0, _NO_HEAD_CLAMP)
|
||||||
|
return (origin, head)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_branch",
|
||||||
|
"list_branches",
|
||||||
|
"active_branch",
|
||||||
|
"active_branch_event_ids",
|
||||||
|
]
|
||||||
@@ -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,150 @@
|
|||||||
|
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"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@on("event_status_reverted")
|
||||||
|
def _apply_event_status_reverted(conn: Connection, e: Event) -> None:
|
||||||
|
"""T114.2: Revert an event row's status to ``prior_status``.
|
||||||
|
|
||||||
|
Emitted by ``regenerate_assistant_turn`` when a superseded turn had
|
||||||
|
triggered a lifecycle transition (event_started / event_completed /
|
||||||
|
event_cancelled). The rollback step needs an inverse projection that
|
||||||
|
sets the row's status back to whatever it was *before* the now-
|
||||||
|
superseded transition fired.
|
||||||
|
|
||||||
|
Unlike the forward transitions (which guard against terminal-status
|
||||||
|
overwrites) this handler is unconditional — the entire purpose is to
|
||||||
|
reverse a transition, including reverting from a terminal status
|
||||||
|
(completed/cancelled) back to a non-terminal one.
|
||||||
|
"""
|
||||||
|
p = e.payload
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE events SET status = ?, updated_at = datetime('now') "
|
||||||
|
"WHERE event_id = ?",
|
||||||
|
(p["prior_status"], 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,432 @@
|
|||||||
|
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:
|
||||||
|
# T109 (schema 0014): persist the projecting event's id on the memory
|
||||||
|
# row so cross-chat search results can deep-link back to the
|
||||||
|
# originating turn (T111). Older memory rows projected before 0014
|
||||||
|
# ran read NULL here — the column is nullable for that reason.
|
||||||
|
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, "
|
||||||
|
"event_id"
|
||||||
|
") 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)),
|
||||||
|
e.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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 _max_event_id(conn: Connection, owner_id: str) -> int:
|
||||||
|
"""Return the largest ``memories.id`` for ``owner_id`` (1 if none exist).
|
||||||
|
|
||||||
|
Used as the recency-boost denominator by both ``_composite_rerank`` and
|
||||||
|
``_rrf_fuse_and_rerank`` (T104). The row id is a monotonic recency proxy
|
||||||
|
— newer memories have larger ids — so dividing by the per-owner max keeps
|
||||||
|
the boost in [0, 1] regardless of how many memories the owner has.
|
||||||
|
|
||||||
|
Returns 1 (not 0) when the owner has no rows so callers can divide by
|
||||||
|
the result without a guard. The "no memories" case never actually hits
|
||||||
|
this helper because the FTS query above would have returned no rows,
|
||||||
|
but the safe default keeps the helper trivially reusable.
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(id) FROM memories WHERE owner_id = ?", (owner_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row and row[0] else 1
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Row-shape contract (T104):** every returned dict carries an
|
||||||
|
``fts_rank`` key. For FTS hits this is the BM25 score (a negative float,
|
||||||
|
lower-is-better). For *vector-only* hits surfaced by the fused path —
|
||||||
|
rows that matched the query embedding but did NOT match FTS — the
|
||||||
|
``fts_rank`` value is ``None``. Downstream consumers must accept
|
||||||
|
``None`` here; do not assume ``fts_rank`` is always numeric. The
|
||||||
|
``composite_score`` is always a float on every returned row.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
# T113: branch-scope filter on ``m.event_id`` (T109's column). Memories
|
||||||
|
# whose ``event_id`` is NULL — projected before the 0014 schema migration
|
||||||
|
# ran — are *included* unconditionally so the branch filter never breaks
|
||||||
|
# legacy retrieval. Newer rows respect the active branch's bounds.
|
||||||
|
from chat.state.branches import active_branch_event_ids
|
||||||
|
|
||||||
|
origin, head = active_branch_event_ids(conn)
|
||||||
|
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 ? "
|
||||||
|
"AND (m.event_id IS NULL OR m.event_id BETWEEN ? AND ?) "
|
||||||
|
# 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, origin, head, 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 = _max_event_id(conn, owner_id)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
# T113: drop vector hits that fall outside the active branch's event-id
|
||||||
|
# range. ``vector_search`` is a generic service used elsewhere; the
|
||||||
|
# branch filter applied to the FTS leg also has to apply here so the
|
||||||
|
# fused result respects the same scope. Memories with NULL event_id
|
||||||
|
# (legacy rows projected before T109's 0014 schema migration) are
|
||||||
|
# included unconditionally — same policy as the FTS leg.
|
||||||
|
from chat.state.branches import _NO_HEAD_CLAMP, active_branch_event_ids
|
||||||
|
|
||||||
|
vec_origin, vec_head = active_branch_event_ids(conn)
|
||||||
|
if vec_hits and (vec_origin > 0 or vec_head < _NO_HEAD_CLAMP):
|
||||||
|
vec_ids = [h["memory_id"] for h in vec_hits]
|
||||||
|
placeholders_v = ",".join("?" * len(vec_ids))
|
||||||
|
in_range = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute(
|
||||||
|
f"SELECT id FROM memories "
|
||||||
|
f"WHERE id IN ({placeholders_v}) "
|
||||||
|
f" AND (event_id IS NULL OR event_id BETWEEN ? AND ?)",
|
||||||
|
(*vec_ids, vec_origin, vec_head),
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
vec_hits = [h for h in vec_hits if h["memory_id"] in in_range]
|
||||||
|
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 = _max_event_id(conn, owner_id)
|
||||||
|
|
||||||
|
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,409 @@
|
|||||||
|
* { 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;
|
||||||
|
/* Locked to viewport (was ``min-height: 100vh``) so flex children
|
||||||
|
like the chat ``.timeline`` get a bounded height and can use
|
||||||
|
``overflow-y: auto`` to scroll independently. The other pages
|
||||||
|
have ``.content`` with ``overflow: auto`` so their own
|
||||||
|
overflow still scrolls inside the right pane. */
|
||||||
|
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 — director's notebook overlay
|
||||||
|
===========================================================
|
||||||
|
Editorial popup design: a warm-paper panel floats over an inky
|
||||||
|
blurred backdrop. Single accent serif (Newsreader) at the title,
|
||||||
|
single muted-amber accent for primary interactives, generous
|
||||||
|
spacing, controlled motion.
|
||||||
|
|
||||||
|
Design tokens (scoped to the drawer so the rest of the app stays
|
||||||
|
on its existing palette).
|
||||||
|
*/
|
||||||
|
.drawer-modal {
|
||||||
|
--paper: #f6f1e8; /* warm off-white panel */
|
||||||
|
--paper-edge: #e7dfce;
|
||||||
|
--ink: #1a1d29; /* deep ink-blue */
|
||||||
|
--ink-soft: #38405a;
|
||||||
|
--ink-faint: #6c7390;
|
||||||
|
--accent: #b97e30; /* muted amber */
|
||||||
|
--accent-soft: #efd9b1;
|
||||||
|
--rule: rgba(26, 29, 41, 0.10);
|
||||||
|
--shadow-near: 0 1px 2px rgba(26, 29, 41, 0.08);
|
||||||
|
--shadow-far: 0 32px 64px -24px rgba(26, 29, 41, 0.45),
|
||||||
|
0 12px 24px -12px rgba(26, 29, 41, 0.25);
|
||||||
|
--serif: "Newsreader", "Iowan Old Style", Georgia, serif;
|
||||||
|
--duration: 180ms;
|
||||||
|
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(16px, 4vw, 48px);
|
||||||
|
/* Open/close transitions live here so the backdrop and panel
|
||||||
|
can fade together; .is-open promotes both to their visible
|
||||||
|
end-states. */
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration) var(--ease);
|
||||||
|
}
|
||||||
|
.drawer-modal[hidden] { display: none; }
|
||||||
|
.drawer-modal.is-open { opacity: 1; }
|
||||||
|
|
||||||
|
.drawer-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 25%, rgba(26, 29, 41, 0.55), rgba(26, 29, 41, 0.85) 75%);
|
||||||
|
backdrop-filter: blur(6px) saturate(1.05);
|
||||||
|
-webkit-backdrop-filter: blur(6px) saturate(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chat behind the modal stops scrolling and loses focus
|
||||||
|
entirely. body class set by the JS; resets on close. */
|
||||||
|
body.drawer-modal-open { overflow: hidden; }
|
||||||
|
|
||||||
|
.drawer-panel {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
max-height: min(82vh, 760px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--shadow-far);
|
||||||
|
/* Subtle warm-paper texture: a single soft inner highlight at the
|
||||||
|
top edge plus a faint vignette toward the bottom. Cheap, no
|
||||||
|
external image. */
|
||||||
|
background-image:
|
||||||
|
linear-gradient(180deg,
|
||||||
|
rgba(255, 255, 255, 0.50) 0%,
|
||||||
|
rgba(255, 255, 255, 0.00) 18%,
|
||||||
|
rgba(0, 0, 0, 0.00) 80%,
|
||||||
|
rgba(120, 100, 70, 0.06) 100%);
|
||||||
|
/* A 1px ink rule at the very top, set INSIDE the radius so the
|
||||||
|
corners stay clean. ::before serves as a hairline accent. */
|
||||||
|
overflow: hidden;
|
||||||
|
/* Open/close: the backdrop fades; the panel additionally lifts
|
||||||
|
slightly and scales from 98% to 100%. Controlled, no bounce. */
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
transition:
|
||||||
|
transform var(--duration) var(--ease),
|
||||||
|
opacity var(--duration) var(--ease);
|
||||||
|
opacity: 0.98;
|
||||||
|
}
|
||||||
|
.drawer-modal.is-open .drawer-panel {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.drawer-panel::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
transparent 0%, var(--accent) 14%, var(--accent) 86%, transparent 100%);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 28px 14px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.drawer-panel-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(22px, 2.4vw, 28px);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--ink);
|
||||||
|
/* Tiny editorial flourish: lowercase the title so it reads like
|
||||||
|
a column header in a printed broadside. */
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.drawer-panel-header h2::after {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-left: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
vertical-align: middle;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel-close {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color var(--duration) var(--ease),
|
||||||
|
color var(--duration) var(--ease),
|
||||||
|
transform var(--duration) var(--ease);
|
||||||
|
}
|
||||||
|
.drawer-panel-close:hover {
|
||||||
|
background: rgba(26, 29, 41, 0.06);
|
||||||
|
color: var(--ink);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.drawer-panel-close:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 28px 28px;
|
||||||
|
/* Restrict typography inside the body to the existing app font
|
||||||
|
so the existing drawer markup (forms, lists, buttons rendered
|
||||||
|
by /chats/<id>/drawer) keeps its current density and read-flow.
|
||||||
|
We only re-color a few items so they sit on the warm paper. */
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.drawer-panel-body .drawer-panel-loading {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scoped overrides for the drawer-content the server renders into
|
||||||
|
.drawer-panel-body. Keeps the existing class names working but
|
||||||
|
re-tunes them for the warm-paper context. */
|
||||||
|
/* Tabs nav — sits at the top of .drawer-content and lets the user
|
||||||
|
pivot between Scene / Cast / Story / Turns groups. Underline-style
|
||||||
|
active indicator (a single muted-amber rule) keeps the editorial
|
||||||
|
feel — no pills, no boxes, no hover-fills. */
|
||||||
|
.drawer-panel-body .drawer-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 -8px 14px; /* bleed the divider rule slightly past the body padding */
|
||||||
|
padding: 0 8px 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.drawer-panel-body .drawer-tab {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
margin-bottom: -1px; /* sit on top of the parent's border-bottom */
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--duration) var(--ease),
|
||||||
|
border-color var(--duration) var(--ease);
|
||||||
|
border-radius: 0; /* strip the global button radius */
|
||||||
|
}
|
||||||
|
.drawer-panel-body .drawer-tab:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.drawer-panel-body .drawer-tab.is-active {
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.drawer-panel-body .drawer-tab.is-active:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.drawer-panel-body .drawer-tab:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panes — only one visible at a time. Uses [hidden] so the JS can
|
||||||
|
toggle attribute-driven instead of class-driven. */
|
||||||
|
.drawer-panel-body .drawer-tab-pane[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Sections inside a pane: drop the section-level rules since the
|
||||||
|
tabs already segment the content. Keep the section h3 as a sub-
|
||||||
|
heading inside its pane — useful when a tab groups multiple
|
||||||
|
sections (e.g. Cast = Guest + Group + Edges). */
|
||||||
|
.drawer-panel-body .drawer-section {
|
||||||
|
padding: 14px 0 18px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.drawer-panel-body .drawer-tab-pane > .drawer-section:first-child { padding-top: 6px; }
|
||||||
|
.drawer-panel-body .drawer-tab-pane > .drawer-section:last-child { border-bottom: none; padding-bottom: 4px; }
|
||||||
|
/* When a pane has only one section, suppress the redundant h3 since
|
||||||
|
the tab label is the same name. */
|
||||||
|
.drawer-panel-body .drawer-tab-pane:has(> .drawer-section:only-child) > .drawer-section > h3 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.drawer-panel-body .drawer-section h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.drawer-panel-body .activity-row,
|
||||||
|
.drawer-panel-body .edge-row { margin-bottom: 12px; }
|
||||||
|
.drawer-panel-body .activity-row strong,
|
||||||
|
.drawer-panel-body .edge-row strong { display: block; color: var(--ink); }
|
||||||
|
.drawer-panel-body .muted { color: var(--ink-faint); }
|
||||||
|
.drawer-panel-body button,
|
||||||
|
.drawer-panel-body .btn {
|
||||||
|
background: var(--ink);
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.drawer-panel-body button:hover,
|
||||||
|
.drawer-panel-body .btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect reduced-motion preference: no scale, no rotate, no
|
||||||
|
blur transition — just the opacity fade. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.drawer-modal,
|
||||||
|
.drawer-panel,
|
||||||
|
.drawer-panel-close { transition-duration: 0ms; }
|
||||||
|
.drawer-panel { transform: none; }
|
||||||
|
.drawer-panel-close:hover { transform: none; }
|
||||||
|
}
|
||||||
|
.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,34 @@
|
|||||||
|
{# T110.3: delete-impact modal partial.
|
||||||
|
|
||||||
|
Rendered from :func:`chat.web.drawer.delete_preview` via a Jinja2
|
||||||
|
TemplateResponse so HTML autoescape covers user-controllable fields
|
||||||
|
(item.kind, item.description, notes) automatically — the prior
|
||||||
|
f-string assembly required explicit html.escape() calls (T110.2)
|
||||||
|
which become redundant under autoescape.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
``chat_id`` — the URL chat id (used to build the confirm form action).
|
||||||
|
``impact`` — an :class:`~chat.services.delete_impact.ImpactReport`.
|
||||||
|
#}
|
||||||
|
<div class="delete-impact-modal">
|
||||||
|
<h3>Delete event {{ impact.target_event_id }}?</h3>
|
||||||
|
<p>This will discard {{ impact.cascading|length }} events. Cascade:</p>
|
||||||
|
<ul class="delete-impact-cascade">
|
||||||
|
{% if impact.cascading %}
|
||||||
|
{% for item in impact.cascading %}
|
||||||
|
<li><strong>{{ item.kind }}</strong>: {{ item.description }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<li>none</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<ul class="delete-impact-notes">
|
||||||
|
{% for note in impact.notes %}
|
||||||
|
<li>{{ note }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<form hx-post="/chats/{{ chat_id }}/drawer/turn/delete/{{ impact.target_event_id }}"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<button type="submit">Confirm delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
<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 %}
|
||||||
|
{# T110.4: bulk significance re-rate. Move every memory in this chat
|
||||||
|
at level_from to level_to with one manual_edit event per row, so
|
||||||
|
the audit trail stays per-memory. #}
|
||||||
|
<details class="bulk-significance">
|
||||||
|
<summary>Bulk re-rate significance</summary>
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/memory/significance/bulk"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<label>
|
||||||
|
From:
|
||||||
|
<input type="number" name="level_from" min="0" max="3" value="0" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To:
|
||||||
|
<input type="number" name="level_to" min="0" max="3" value="1" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Re-rate all</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</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,24 @@
|
|||||||
|
<!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">
|
||||||
|
<!-- Newsreader: refined editorial serif for accent typography
|
||||||
|
(drawer modal title, etc.). Body stays system-ui for read-
|
||||||
|
flow legibility. Subset to the weight we use to keep the
|
||||||
|
payload tiny. -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500&display=swap">
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||||
|
<!-- htmx 1.x bundles its SSE extension at /dist/ext/sse.js. The
|
||||||
|
standalone htmx-ext-sse@2.x package is for htmx 2.x and is
|
||||||
|
not compatible with the 1.x ext API. -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/sse.js" 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,530 @@
|
|||||||
|
{% 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-modal" aria-expanded="false"
|
||||||
|
aria-haspopup="dialog">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>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer modal — director's notebook overlay.
|
||||||
|
Sits outside .chat-shell so its position:fixed backdrop covers the
|
||||||
|
whole viewport. The panel still pulls its inner HTML from
|
||||||
|
/chats/<id>/drawer via HTMX; trigger is a custom 'drawer-open'
|
||||||
|
event that the open/close script dispatches each time the modal
|
||||||
|
opens, so the content refreshes on every open. -->
|
||||||
|
<div class="drawer-modal" id="drawer-modal" hidden
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="drawer-modal-title">
|
||||||
|
<div class="drawer-modal-backdrop" data-drawer-close></div>
|
||||||
|
<article class="drawer-panel">
|
||||||
|
<header class="drawer-panel-header">
|
||||||
|
<h2 id="drawer-modal-title">Drawer</h2>
|
||||||
|
<button class="drawer-panel-close" type="button"
|
||||||
|
data-drawer-close
|
||||||
|
aria-label="Close drawer">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="drawer-panel-body" id="drawer"
|
||||||
|
hx-get="/chats/{{ chat.id }}/drawer"
|
||||||
|
hx-trigger="drawer-open from:body"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="muted drawer-panel-loading">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Drawer modal — open/close, focus management, and post-swap
|
||||||
|
// tab-grouping. The server's /chats/<id>/drawer response is left
|
||||||
|
// unchanged; this script post-processes the swapped HTML to:
|
||||||
|
// 1. Pull the bot name from the legacy <header><h2> and use it as
|
||||||
|
// the modal title.
|
||||||
|
// 2. Remove the legacy header (it has its own onclick="hidden"
|
||||||
|
// close that targets the OLD drawer semantics — broken now).
|
||||||
|
// 3. Walk .drawer-section blocks and group them into 4 tabs by
|
||||||
|
// their <h3> title:
|
||||||
|
// Scene : Scene, Activity
|
||||||
|
// Cast : Guest, Group, Edges
|
||||||
|
// Story : Events, Threads, Branches
|
||||||
|
// Turns : Recent turns, Significance review
|
||||||
|
// A tab nav is rendered above the sections; clicking switches
|
||||||
|
// which group is visible. Empty tabs (no matching sections) are
|
||||||
|
// hidden.
|
||||||
|
(function () {
|
||||||
|
const modal = document.getElementById('drawer-modal');
|
||||||
|
const toggle = document.querySelector('.drawer-toggle');
|
||||||
|
if (!modal || !toggle) return;
|
||||||
|
const titleEl = modal.querySelector('#drawer-modal-title');
|
||||||
|
const body = modal.querySelector('.drawer-panel-body');
|
||||||
|
const panel = modal.querySelector('.drawer-panel');
|
||||||
|
|
||||||
|
let lastFocus = null;
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!modal.hasAttribute('hidden')) return;
|
||||||
|
lastFocus = document.activeElement;
|
||||||
|
modal.removeAttribute('hidden');
|
||||||
|
// Force reflow so the .is-open class triggers the transition.
|
||||||
|
void modal.offsetWidth;
|
||||||
|
modal.classList.add('is-open');
|
||||||
|
toggle.setAttribute('aria-expanded', 'true');
|
||||||
|
document.body.classList.add('drawer-modal-open');
|
||||||
|
// Re-fetch drawer content via the panel's hx-trigger.
|
||||||
|
document.body.dispatchEvent(new CustomEvent('drawer-open'));
|
||||||
|
// Focus the close button so Escape / Enter both work
|
||||||
|
// immediately and screen readers announce the dialog.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const closeBtn = modal.querySelector('.drawer-panel-close');
|
||||||
|
if (closeBtn) closeBtn.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (modal.hasAttribute('hidden')) return;
|
||||||
|
modal.classList.remove('is-open');
|
||||||
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
document.body.classList.remove('drawer-modal-open');
|
||||||
|
// Wait for the fade-out before fully hiding so the transition
|
||||||
|
// can play. Match the CSS duration.
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.setAttribute('hidden', '');
|
||||||
|
if (lastFocus && typeof lastFocus.focus === 'function') {
|
||||||
|
lastFocus.focus();
|
||||||
|
}
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('click', open);
|
||||||
|
|
||||||
|
// Bind close DIRECTLY to every element flagged data-drawer-close.
|
||||||
|
// Event delegation through .stopPropagation() previously swallowed
|
||||||
|
// the close button's click (it sits inside .drawer-panel, which
|
||||||
|
// stops propagation to keep backdrop clicks from leaking through
|
||||||
|
// the panel itself). Direct binding sidesteps that and keeps the
|
||||||
|
// panel-stops-propagation rule for everything else.
|
||||||
|
function bindCloseTargets(root) {
|
||||||
|
root.querySelectorAll('[data-drawer-close]').forEach((el) => {
|
||||||
|
// Idempotent: only bind once per element.
|
||||||
|
if (el.dataset.drawerCloseBound === '1') return;
|
||||||
|
el.dataset.drawerCloseBound = '1';
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bindCloseTargets(modal);
|
||||||
|
// Clicks inside the panel that AREN'T close targets must not
|
||||||
|
// reach the backdrop click handler. (We don't have one currently
|
||||||
|
// — backdrop close is via data-drawer-close on the backdrop div —
|
||||||
|
// but stopPropagation here is defensive against future handlers.)
|
||||||
|
panel.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
// Escape closes only when the modal is open.
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && !modal.hasAttribute('hidden')) {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Tabs: group server-rendered .drawer-section blocks ----
|
||||||
|
|
||||||
|
const TAB_GROUPS = [
|
||||||
|
{ id: 'scene', label: 'Scene', sections: ['Scene', 'Activity'] },
|
||||||
|
{ id: 'cast', label: 'Cast', sections: ['Guest', 'Group', 'Edges'] },
|
||||||
|
{ id: 'story', label: 'Story', sections: ['Events', 'Threads', 'Branches'] },
|
||||||
|
{ id: 'turns', label: 'Turns', sections: ['Recent turns', 'Significance review'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function tabIdForSection(h3Text) {
|
||||||
|
const t = (h3Text || '').trim();
|
||||||
|
for (const g of TAB_GROUPS) {
|
||||||
|
if (g.sections.includes(t)) return g.id;
|
||||||
|
}
|
||||||
|
return 'scene'; // unknown sections fall into the first tab
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTabs() {
|
||||||
|
// Clean up the legacy server-rendered header inside the body
|
||||||
|
// (duplicate close + duplicate title).
|
||||||
|
const legacyHeader = body.querySelector(':scope > .drawer-content > .drawer-header');
|
||||||
|
if (legacyHeader) {
|
||||||
|
// Promote the bot name to the modal title before discarding.
|
||||||
|
const h2 = legacyHeader.querySelector('h2');
|
||||||
|
if (h2 && h2.textContent.trim()) {
|
||||||
|
titleEl.textContent = h2.textContent.trim();
|
||||||
|
}
|
||||||
|
legacyHeader.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The drawer-content wrapper holds all the sections. Group them.
|
||||||
|
const content = body.querySelector('.drawer-content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const sections = Array.from(content.querySelectorAll(':scope > .drawer-section'));
|
||||||
|
if (sections.length === 0) return;
|
||||||
|
|
||||||
|
// Bucket sections by tab id.
|
||||||
|
const buckets = new Map(TAB_GROUPS.map((g) => [g.id, []]));
|
||||||
|
for (const sec of sections) {
|
||||||
|
const h3 = sec.querySelector(':scope > h3');
|
||||||
|
const tabId = tabIdForSection(h3 ? h3.textContent : '');
|
||||||
|
buckets.get(tabId).push(sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the tab nav. Skip empty buckets so the nav reflects
|
||||||
|
// what the chat actually has (e.g. no Guest tab when 1:1).
|
||||||
|
const nav = document.createElement('nav');
|
||||||
|
nav.className = 'drawer-tabs';
|
||||||
|
nav.setAttribute('role', 'tablist');
|
||||||
|
|
||||||
|
const panes = document.createElement('div');
|
||||||
|
panes.className = 'drawer-tab-panes';
|
||||||
|
|
||||||
|
let firstActive = null;
|
||||||
|
for (const group of TAB_GROUPS) {
|
||||||
|
const items = buckets.get(group.id);
|
||||||
|
if (!items.length) continue;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'drawer-tab';
|
||||||
|
btn.setAttribute('role', 'tab');
|
||||||
|
btn.id = `drawer-tab-${group.id}`;
|
||||||
|
btn.dataset.tabTarget = group.id;
|
||||||
|
btn.textContent = group.label;
|
||||||
|
btn.setAttribute('aria-controls', `drawer-pane-${group.id}`);
|
||||||
|
nav.appendChild(btn);
|
||||||
|
|
||||||
|
const pane = document.createElement('section');
|
||||||
|
pane.className = 'drawer-tab-pane';
|
||||||
|
pane.id = `drawer-pane-${group.id}`;
|
||||||
|
pane.setAttribute('role', 'tabpanel');
|
||||||
|
pane.setAttribute('aria-labelledby', `drawer-tab-${group.id}`);
|
||||||
|
// Move the section nodes into the pane (preserves any HTMX
|
||||||
|
// event listeners and the sections' interactive forms).
|
||||||
|
for (const sec of items) pane.appendChild(sec);
|
||||||
|
panes.appendChild(pane);
|
||||||
|
|
||||||
|
if (!firstActive) firstActive = group.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the existing content with [nav][panes].
|
||||||
|
content.innerHTML = '';
|
||||||
|
content.appendChild(nav);
|
||||||
|
content.appendChild(panes);
|
||||||
|
|
||||||
|
// Tab click handler.
|
||||||
|
nav.addEventListener('click', (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
const tabId = target.dataset.tabTarget;
|
||||||
|
if (!tabId) return;
|
||||||
|
activateTab(content, tabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (firstActive) activateTab(content, firstActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateTab(content, tabId) {
|
||||||
|
content.querySelectorAll('.drawer-tab').forEach((btn) => {
|
||||||
|
const isActive = btn.dataset.tabTarget === tabId;
|
||||||
|
btn.classList.toggle('is-active', isActive);
|
||||||
|
btn.setAttribute('aria-selected', String(isActive));
|
||||||
|
btn.setAttribute('tabindex', isActive ? '0' : '-1');
|
||||||
|
});
|
||||||
|
content.querySelectorAll('.drawer-tab-pane').forEach((pane) => {
|
||||||
|
const isActive = pane.id === `drawer-pane-${tabId}`;
|
||||||
|
pane.toggleAttribute('hidden', !isActive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run after every HTMX swap into the panel body. Covers the
|
||||||
|
// initial open AND any subsequent server-driven re-render
|
||||||
|
// (e.g. an in-drawer form submit that returns refreshed HTML).
|
||||||
|
body.addEventListener('htmx:afterSwap', () => {
|
||||||
|
buildTabs();
|
||||||
|
bindCloseTargets(modal);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</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;
|
||||||
|
|
||||||
|
// Sticky-bottom autoscroll: scroll the timeline to the latest
|
||||||
|
// message when new content arrives, but ONLY if the user is
|
||||||
|
// already pinned to the bottom. Once they scroll up to read older
|
||||||
|
// turns, we leave their position alone until they manually scroll
|
||||||
|
// back down.
|
||||||
|
//
|
||||||
|
// ``isPinnedToBottom`` flips on every scroll event based on
|
||||||
|
// distance-from-bottom (with a small tolerance so a few pixels of
|
||||||
|
// overshoot from a layout shift doesn't unpin). A MutationObserver
|
||||||
|
// catches every node added to the timeline — covers the SSE-
|
||||||
|
// injected ``turn_html`` swap, the optimistic ``appendUserTurn``
|
||||||
|
// render, and the streaming typing-indicator updates.
|
||||||
|
const STICK_TOLERANCE_PX = 64;
|
||||||
|
let isPinnedToBottom = true;
|
||||||
|
|
||||||
|
function distanceFromBottom() {
|
||||||
|
return timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight;
|
||||||
|
}
|
||||||
|
function scrollToBottom() {
|
||||||
|
timeline.scrollTop = timeline.scrollHeight;
|
||||||
|
}
|
||||||
|
// Initial state: stick to the bottom on page load so the latest
|
||||||
|
// turn is visible without manual scrolling.
|
||||||
|
requestAnimationFrame(scrollToBottom);
|
||||||
|
|
||||||
|
timeline.addEventListener('scroll', () => {
|
||||||
|
isPinnedToBottom = distanceFromBottom() <= STICK_TOLERANCE_PX;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
const timelineObserver = new MutationObserver(() => {
|
||||||
|
if (isPinnedToBottom) scrollToBottom();
|
||||||
|
});
|
||||||
|
timelineObserver.observe(timeline, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true, // streaming token-by-token edits
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter-to-send (Shift+Enter for newline). Submits via the form's
|
||||||
|
// own submit event so all the optimistic-render + fetch logic
|
||||||
|
// below applies uniformly to keyboard and click submissions.
|
||||||
|
if (textarea) {
|
||||||
|
textarea.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof form.requestSubmit === 'function') {
|
||||||
|
form.requestSubmit();
|
||||||
|
} else {
|
||||||
|
form.dispatchEvent(new Event('submit', { cancelable: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the user's prose optimistically as a turn-you DOM node.
|
||||||
|
// Without this the user can't see what they just sent until the page
|
||||||
|
// reloads — the server persists ``user_turn`` events but doesn't
|
||||||
|
// publish a turn_html for them (the SSE channel is bot-output-only).
|
||||||
|
function appendUserTurn(prose) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'turn turn-you';
|
||||||
|
const strong = document.createElement('strong');
|
||||||
|
strong.textContent = 'you';
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.textContent = prose;
|
||||||
|
div.appendChild(strong);
|
||||||
|
div.appendChild(p);
|
||||||
|
// Sending a message means the user wants to see it land — force
|
||||||
|
// sticky-bottom even if they were scrolled up reading older
|
||||||
|
// turns. The MutationObserver handles the actual scroll.
|
||||||
|
isPinnedToBottom = true;
|
||||||
|
timeline.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept the form submit and POST via fetch so we can:
|
||||||
|
// 1. Render the user's prose immediately (optimistic).
|
||||||
|
// 2. Clear the textarea immediately.
|
||||||
|
// 3. Keep the page state intact while the bot streams its
|
||||||
|
// response over SSE — vanilla form POST + 204 leaves the
|
||||||
|
// browser in a half-loaded state with the textarea unflushed.
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isStreaming) return;
|
||||||
|
const prose = textarea ? (textarea.value || '').trim() : '';
|
||||||
|
if (!prose) return;
|
||||||
|
|
||||||
|
appendUserTurn(prose);
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = '';
|
||||||
|
textarea.readOnly = true;
|
||||||
|
}
|
||||||
|
if (sendBtn) sendBtn.disabled = true;
|
||||||
|
isStreaming = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire the actual POST. The bot's response arrives via SSE
|
||||||
|
// (``turn_html`` event swaps into the timeline; ``unlock()`` runs
|
||||||
|
// on receipt to clear streaming state and re-enable the form).
|
||||||
|
try {
|
||||||
|
const body = new URLSearchParams({ prose }).toString();
|
||||||
|
const resp = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok && resp.status !== 204) {
|
||||||
|
showBanner('send failed (HTTP ' + resp.status + ') — try again');
|
||||||
|
unlock();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showBanner('send failed — check your connection');
|
||||||
|
unlock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</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,52 @@
|
|||||||
|
{% 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">
|
||||||
|
{# T111.2: deep-link to the originating turn via the
|
||||||
|
``id="turn-{event_id}"`` anchor stamped by Phase 3.5 T86.
|
||||||
|
``event_id`` may be NULL for memory rows projected before the
|
||||||
|
0014 migration ran (T109 did not backfill historical rows); in
|
||||||
|
that case fall back to a chat-level link with no anchor so we
|
||||||
|
never emit ``#turn-None``. #}
|
||||||
|
<a class="search-result-link"
|
||||||
|
href="/chats/{{ r.chat_id }}{% if r.event_id %}#turn-{{ r.event_id }}{% endif %}">
|
||||||
|
<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>
|
||||||
|
{# T111.1: ``r.snippet`` is the FTS5 ``snippet()`` excerpt with
|
||||||
|
each match wrapped in ``<mark>...</mark>``. ``|safe`` is
|
||||||
|
required so the marker tags survive Jinja's auto-escape; the
|
||||||
|
snippet is built by SQLite from indexed text, so the only
|
||||||
|
HTML in the string is the ``<mark>`` we configured (any
|
||||||
|
special chars from the source content are passed through as
|
||||||
|
literal text, NOT as HTML). This is the only ``|safe`` filter
|
||||||
|
on the page — chat_id, owner_name, etc. remain auto-escaped. #}
|
||||||
|
<div class="search-result-summary">{{ r.snippet|safe }}</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,154 @@
|
|||||||
|
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_and_apply
|
||||||
|
from chat.state.entities import get_bot, list_bots
|
||||||
|
from chat.state.world import get_chat
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
# Per-event apply (NOT project()) — see docs/audits/2026-04-27-project-callers.md.
|
||||||
|
# ``project()`` replays the full log, which trips raw-INSERT handlers like
|
||||||
|
# ``_apply_chat_created`` once a second bot's events are present.
|
||||||
|
append_and_apply(conn, kind="bot_authored", payload=payload)
|
||||||
|
return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bots/{bot_id}")
|
||||||
|
async def bot_detail(bot_id: str, conn=Depends(get_conn)):
|
||||||
|
"""Click-through from the bots list. Routes to the bot's existing
|
||||||
|
chat when there is one (the v1 model is one-chat-per-host-bot,
|
||||||
|
keyed by ``chat_<bot_id>``), otherwise to the kickoff page so the
|
||||||
|
user can author the chat's opening state. 404 if the bot itself
|
||||||
|
doesn't exist.
|
||||||
|
|
||||||
|
Defined AFTER the ``/bots/new`` and ``/bots/{bot_id}/kickoff``
|
||||||
|
routes — FastAPI matches in declaration order, and a path
|
||||||
|
parameter would otherwise swallow ``/bots/new``.
|
||||||
|
"""
|
||||||
|
if get_bot(conn, bot_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="bot not found")
|
||||||
|
chat_id = f"chat_{bot_id}"
|
||||||
|
if get_chat(conn, chat_id) is not None:
|
||||||
|
return RedirectResponse(url=f"/chats/{chat_id}", status_code=303)
|
||||||
|
return RedirectResponse(url=f"/bots/{bot_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",
|
||||||
|
},
|
||||||
|
)
|
||||||
+1538
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,371 @@
|
|||||||
|
"""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_and_apply
|
||||||
|
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``.
|
||||||
|
|
||||||
|
Returns a :class:`chat.llm.router.RoutedLLMClient` that splits
|
||||||
|
traffic: the narrative model goes to Featherless, the classifier
|
||||||
|
+ embeddings go to the local MLX server (``mlx-omni-server``).
|
||||||
|
Both backends share the OpenAI-compatible surface, so the routing
|
||||||
|
is invisible to call sites — they just pass ``model=...`` and the
|
||||||
|
router picks the backend.
|
||||||
|
"""
|
||||||
|
settings = request.app.state.settings
|
||||||
|
from chat.llm.featherless import FeatherlessClient
|
||||||
|
from chat.llm.local_mlx import LocalMLXClient
|
||||||
|
from chat.llm.router import RoutedLLMClient
|
||||||
|
|
||||||
|
narrative = FeatherlessClient(
|
||||||
|
api_key=settings.featherless_api_key,
|
||||||
|
base_url=settings.featherless_base_url,
|
||||||
|
)
|
||||||
|
# Dedicated classifier client when a provider pin is configured —
|
||||||
|
# routes Llama-3.1-8B (or whatever ``classifier_model`` is) onto a
|
||||||
|
# specific upstream like Cerebras for ~10x throughput. When the
|
||||||
|
# pin is empty, ``classifier`` is None and the router falls back
|
||||||
|
# to the narrative client for classifier traffic.
|
||||||
|
classifier = None
|
||||||
|
if settings.classifier_provider_order:
|
||||||
|
classifier = FeatherlessClient(
|
||||||
|
api_key=settings.featherless_api_key,
|
||||||
|
base_url=settings.featherless_base_url,
|
||||||
|
default_extra_body={
|
||||||
|
"provider": {"order": list(settings.classifier_provider_order)}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
local = LocalMLXClient(base_url=settings.local_mlx_base_url)
|
||||||
|
return RoutedLLMClient(
|
||||||
|
narrative=narrative,
|
||||||
|
classifier=classifier,
|
||||||
|
local=local,
|
||||||
|
narrative_model=settings.narrative_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_iso_time(value: str) -> str:
|
||||||
|
"""Permissive parser that returns a canonical ISO 8601 datetime.
|
||||||
|
|
||||||
|
The kickoff classifier (chat/services/kickoff.py) returns
|
||||||
|
``initial_time_iso`` as a free-form string; in practice it emits
|
||||||
|
things like ``"Sun 2024-05-12 07:00:00"``,
|
||||||
|
``"Tuesday, May 14, 2024 7:00 AM"``, or proper ISO. The strict
|
||||||
|
``datetime.fromisoformat`` would 400 on those, so this helper
|
||||||
|
tries a sequence of common classifier-emitted formats and
|
||||||
|
returns a canonical ``YYYY-MM-DDTHH:MM:SS+00:00`` form.
|
||||||
|
|
||||||
|
Raises ``ValueError`` when nothing parses, so the caller can 400
|
||||||
|
cleanly.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
s = (value or "").strip()
|
||||||
|
if not s:
|
||||||
|
return s
|
||||||
|
# Strict ISO first (covers "2026-04-26T20:00:00+00:00" and friends).
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s)
|
||||||
|
except ValueError:
|
||||||
|
dt = None
|
||||||
|
if dt is None:
|
||||||
|
# Common classifier-emitted formats, in rough frequency order.
|
||||||
|
formats = [
|
||||||
|
"%a %Y-%m-%d %H:%M:%S", # Sun 2024-05-12 07:00:00
|
||||||
|
"%A %Y-%m-%d %H:%M:%S", # Sunday 2024-05-12 07:00:00
|
||||||
|
"%Y-%m-%d %H:%M:%S", # 2024-05-12 07:00:00
|
||||||
|
"%Y-%m-%d %H:%M", # 2024-05-12 07:00
|
||||||
|
"%Y-%m-%d", # 2024-05-12 (date only)
|
||||||
|
"%a %b %d %Y %H:%M:%S", # Sun May 12 2024 07:00:00
|
||||||
|
"%A, %B %d, %Y %I:%M %p", # Tuesday, May 14, 2024 7:00 AM
|
||||||
|
"%B %d, %Y %I:%M %p", # May 14, 2024 7:00 AM
|
||||||
|
"%a %b %d %H:%M:%S %Y", # Sun May 12 07:00:00 2024 (asctime-ish)
|
||||||
|
]
|
||||||
|
for fmt in formats:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(s, fmt)
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if dt is None:
|
||||||
|
raise ValueError(f"could not parse {value!r} as a datetime")
|
||||||
|
# Naive datetimes assumed UTC (the v1 model is single-user, single
|
||||||
|
# timezone — keeping it consistent with chat_state.time defaults).
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# Permissive datetime parsing — the classifier emits a variety of
|
||||||
|
# human-readable formats ("Sun 2024-05-12 07:00:00",
|
||||||
|
# "Tuesday, May 14, 2024 7:00 AM", proper ISO, etc.). We coerce
|
||||||
|
# to canonical ISO and only 400 if NOTHING parses.
|
||||||
|
if initial_time_iso.strip():
|
||||||
|
try:
|
||||||
|
initial_time_iso = _coerce_iso_time(initial_time_iso)
|
||||||
|
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]
|
||||||
|
|
||||||
|
# Use ``append_and_apply`` per event (live-path pattern) rather than
|
||||||
|
# appending all-then-project. ``project()`` replays the *entire*
|
||||||
|
# event log; non-idempotent handlers like ``_apply_chat_created``
|
||||||
|
# (raw INSERT into chats) then 500 with UNIQUE constraint failures
|
||||||
|
# for any chats that already exist from prior kickoffs.
|
||||||
|
|
||||||
|
# 1. chat_created
|
||||||
|
append_and_apply(
|
||||||
|
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_and_apply(
|
||||||
|
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_and_apply(
|
||||||
|
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_and_apply(
|
||||||
|
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_and_apply(
|
||||||
|
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_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": bot_id,
|
||||||
|
"target_id": "you",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"knowledge_facts": facts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/chats/{chat_id}", status_code=303)
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""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).
|
||||||
|
|
||||||
|
T113: clamp by the active branch's ``[origin, head]`` event-id range
|
||||||
|
so meanwhile prompt context respects the user's current branch.
|
||||||
|
Bootstrap-main and "no active branch" both fall through to ``(0,
|
||||||
|
BIG_INT)`` — no functional change for the metadata-only Phase 4 era.
|
||||||
|
"""
|
||||||
|
from chat.state.branches import active_branch_event_ids
|
||||||
|
|
||||||
|
origin, head = active_branch_event_ids(conn)
|
||||||
|
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 id BETWEEN ? AND ? "
|
||||||
|
" 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 ?",
|
||||||
|
(origin, head, 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>"
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user