Commit Graph

176 Commits

Author SHA1 Message Date
Joseph Doherty aea20a2c83 feat: frontend turn_html_replace SSE handler for regenerate live-swap (T86) 2026-04-26 22:41:35 -04:00
Joseph Doherty 82701d3c18 merge: T83 regenerate.py polish bundle (cancel + DRY + scoped query + warning + ordering) 2026-04-26 22:22:26 -04:00
Joseph Doherty 0de4d1252c refactor: regenerate event-detection ordering mirrors post_turn (T83.5)
Cosmetic-only renumbering of the event-lifecycle detection block in
``regenerate_assistant_turn`` from ``# 10.`` to ``# 9a.`` — mirrors the
``# 8a.`` shape in ``chat.web.turns.post_turn``. The block was already
in the correct structural position (immediately after the interjection
branch); only the numbering and comment reflected an earlier draft
where it read as a final step rather than the post-interjection /
pre-(absent)-scene-close slot.

No behavioural change. All 9 regenerate tests + 18 turn_flow tests
pass without modification.
2026-04-26 22:19:27 -04:00
Joseph Doherty b667a21c99 chore: document regenerate lifecycle-rollback limitation with warning log (T83.4)
When a regenerate replaces an assistant_turn that already produced
lifecycle transitions (``event_started`` / ``event_completed`` /
``event_cancelled``), those transitions are NOT rolled back before
``detect_event_transitions`` re-runs against the new text. A
regenerate-after-completion can therefore double-emit promotion
artifacts.

Phase 3.5 first cut (per the task plan): documentation + WARNING log
naming the affected event_log ids. The actual undo pass is invasive
(re-projection / inverse-handler dispatch) and is deferred to Phase 4.

Implementation:
- TODO docstring block at the top of ``regenerate_assistant_turn``.
- Module-level ``_log = logging.getLogger(__name__)``.
- Scan immediately after the original assistant_turn row is located:
  joins event_log lifecycle rows to the events table on event_id so we
  can scope by chat (lifecycle payloads carry only ``event_id``, not
  ``chat_id``). Filter ``id > original_assistant_event_id`` as the
  positional linkage to "transitions emitted as part of (or after)
  this turn's processing."

Decision (asked in the brief): the scan uses the ``id > original``
heuristic rather than scanning for explicit references. Lifecycle
event payloads do not carry a back-pointer to the assistant_turn that
triggered them — the linkage is positional in the event log. A tighter
linkage would require either adding a payload field on lifecycle
events (cross-cutting schema change) or threading the just-appended
assistant_turn id into ``detect_event_transitions``'s emit calls
(narrow but still cross-cutting). The positional heuristic is loose
but conservative: a turn that emits no lifecycle events produces no
warning, and the warning's purpose is operator-visible breadcrumbs
not an exact rollback set.

Test: test_regenerate_with_prior_lifecycle_logs_warning seeds a turn
that produced ``event_started`` + ``event_completed`` rows and asserts
the WARNING fires with the expected ids.
2026-04-26 22:18:23 -04:00
Joseph Doherty a1e2d9a24d perf: scope regenerate sibling-lookup to chat_id (T83.3)
The sibling assistant_turn lookup in ``regenerate_assistant_turn``
previously scanned every non-superseded ``assistant_turn`` row across
the whole database and filtered in Python. With many chats in the log
this is O(total_assistant_turns) per regenerate.

Push the chat_id filter into SQL via ``json_extract(payload_json,
'$.chat_id') = ?`` and add ``ORDER BY id DESC LIMIT 50`` so worst-case
work is bounded even within a single chat. Mirrors the SQL pattern in
``chat.web.meanwhile._last_meanwhile_speaker``.

Test added: test_regenerate_sibling_lookup_scoped_to_chat seeds two
chats — the second has an interjection whose ``interjection_of`` value
collides with the first chat's primary speaker. Regenerating chat A
must leave chat B's rows untouched and the regenerated chat A
interjection's ``regenerated_from`` must point at chat A's original
(not chat B's). Pre-T83.3 a global query could in principle latch
onto cross-chat rows.
2026-04-26 22:16:23 -04:00
Joseph Doherty d833bbc3e7 refactor: extract turn_common helpers from regenerate + turns (T83.2)
The recent-dialogue read and the directed-pair edge gather were
duplicated between ``chat.services.regenerate`` and ``chat.web.turns``.
Extracted into ``chat.services.turn_common`` with two helpers:

- ``read_recent_dialogue(conn, chat_id, *, limit, exclude_event_id)``:
  oldest-first ``[{speaker, text}]`` over user_turn / user_turn_edit /
  assistant_turn rows, with the standard ``superseded_by IS NULL AND
  hidden = 0`` filter. ``exclude_event_id`` covers regenerate's need to
  drop the original assistant_turn before its supersede UPDATE lands.
- ``gather_prior_edges(conn, present_ids)``: ``{(src, tgt): edge}`` over
  every directed pair across ``present_ids``, with the schema default
  50/50 baseline for missing rows.

``chat.web.turns._read_recent_dialogue`` becomes a thin delegate so the
chat-detail template and other in-module callers keep their import
shape; ``_gather_state_update_inputs`` now calls into the shared edge
gather. ``regenerate_assistant_turn`` calls both helpers in three call
sites (primary + post-interjection edges, primary + interjection
recent reads), still post-processing speaker ids to display names for
its prompts.

Decision: ``chat.services.scene_summarize._read_recent_dialogue`` is
left in place — it has a ``since_event_id`` clamp (T80.2) and excludes
``user_turn_edit`` deliberately. Folding it into the shared helper
would either silently change its read shape or require a second flag,
both more invasive than the duplication. Documented in the new module
docstring.

Tests: tests/test_turn_common.py covers chronological ordering,
supersede / other-chat / exclude_event_id filtering, and prior-edge
default-fallback. Existing 6 regenerate + 18 turn_flow tests pass
unchanged.
2026-04-26 22:14:59 -04:00
Joseph Doherty f2fd30c5a9 feat: regenerate registers stream task in _in_flight_tasks (T83.1)
Both the primary and the interjection sub-stream in
``regenerate_assistant_turn`` are now wrapped in ``asyncio.create_task``
and registered in the chat-keyed ``_in_flight_tasks`` registry that the
``/turns/cancel`` route reads. Without this, hitting Stop during a
mid-regenerate stream was a no-op.

Mirrors the meanwhile registration pattern in chat/web/meanwhile.py
(snapshot-tested by tests/test_meanwhile_turn_flow.py).

Test added: test_regenerate_registers_task_in_in_flight_tasks captures
``"chat_bot_a" in _in_flight_tasks`` at the first stream yield via a
custom MockLLMClient subclass and asserts post-flight cleanup.
2026-04-26 22:11:23 -04:00
Joseph Doherty 9e7c16de40 merge: T82 turns.py wiring (consume meanwhile digests + skip runs scene close) 2026-04-26 22:07:46 -04:00
Joseph Doherty 71245fb85a fix: natural-language skip runs scene close detection (T82.2)
The natural-language skip dispatch in chat.web.turns.post_turn
(intent="skip_elision") previously bypassed scene close detection
entirely. User prose like "fade out, skip an hour" carries both a
close signal and a skip directive — the close summary must capture
the closing scene's final beat (and promote per-POV memories) before
the time advances.

Insert detect_scene_close + apply_scene_close_summary BEFORE the skip
controller invocation in the skip_elision branch. Order: scene close
-> skip narration -> time advance. When there's no active scene or
the prose carries no close signal, detect_scene_close returns the
safe should_close=False default and the flow drops straight to the
skip controller — same behavior as today.
2026-04-26 22:06:24 -04:00
Joseph Doherty be92691f9a fix: post_turn consumes pending meanwhile digests (T82.1)
Wire chat.services.prompt.consume_pending_meanwhile_digests into
chat.web.turns.post_turn at the END of the handler, after scene-close
detection and before the response broadcast. Without this call digests
created by a meanwhile close stay pending forever — they surface in the
next you-turn's prompt (via T65) but are never marked consumed, so they
re-render on every subsequent turn.

Idempotent: re-calling the helper produces zero events when nothing's
pending. The T66 cross-feature note is updated to reflect the new
wiring; the existing direct-helper test in test_phase3_integration.py
is preserved as defensive coverage of the helper contract in isolation.
2026-04-26 22:02:25 -04:00
Joseph Doherty 6f50ce5b7a merge: T81 ChatNotFoundError typed exception for skip routes 2026-04-26 21:57:01 -04:00
Joseph Doherty f816d44438 fix: typed ChatNotFoundError replaces string-prefix sniff in skip routes (T81) 2026-04-26 21:55:53 -04:00
Joseph Doherty 6f0716201f merge: T80 scene_summarize.py polish bundle (T58 follow-ups) 2026-04-26 21:52:44 -04:00
Joseph Doherty 0d3bbf4272 test: T58 coverage gaps (truncation, update/close paths) (T80.5)
Three gaps left by T58's initial test coverage:

* test_key_quote_truncation_at_200_chars — exercises the 200-char hard
  slice in _build_key_quotes_suffix so any future change to the
  truncation strategy (ellipsis, word boundary, etc) trips the test.
* test_thread_detection_update_candidate_emits_thread_updated —
  pins the ``update`` action emission shape (thread_id, summary,
  last_referenced_scene_id).
* test_thread_detection_close_candidate_emits_thread_closed — pins
  the ``close`` action emission shape (thread_id, closed_at).

No production change; pure coverage add.
2026-04-26 21:50:55 -04:00
Joseph Doherty b91a5e9293 fix: thread_closed uses chat-clock time, not wall clock (T80.4)
T58 stamped emitted ``thread_closed`` events with
``datetime.now(timezone.utc).isoformat()``. The rest of the close
pipeline (memories.chat_clock_at, scene_closed.ended_at, edge writes)
uses the chat's in-world clock. Threads must agree so timeline
reconstruction stays consistent under time skips and replay.

Read ``chat["time"]`` (already loaded for the per-POV path) and pass
it through as ``closed_at``. Falls back to UTC now only when chat_state
has no clock yet — defensive; chat_created always seeds it.

Adds test_thread_closed_uses_chat_clock_time.
2026-04-26 21:50:04 -04:00
Joseph Doherty 9d06eaf57a fix: log swallowed exceptions in detect_threads try/except (T80.3)
The broad ``except Exception`` around detect_threads silently dropped
programmer errors (wrong kwargs, import-time failures, etc), making
diagnostics painful. Log at DEBUG with full exc_info so the failure
surfaces in local logs without breaking the close pipeline's
failure-tolerant contract.

Adds test_detect_threads_failure_is_logged using caplog.
2026-04-26 21:49:17 -04:00
Joseph Doherty dae481eb92 fix: scope thread detection transcript to closing scene (T80.2)
apply_scene_close_summary fed detect_threads the chat-wide last-50
turns. When a chat has accumulated multiple scenes' worth of dialogue,
that bleeds prior-scene turns into the second close's classifier prompt
and risks mis-attributing threads (closing one that opened earlier,
re-opening one that already closed).

Add an optional ``since_event_id`` kwarg to ``_read_recent_dialogue``
that lower-bounds by event_log id, plus a ``_scene_opened_event_id``
helper that resolves the scene-open event for a given scene_id. Wire
both into the thread-detection call site so its scene_transcript
holds only the closing scene's turns. The per-POV summarizer keeps the
chat-wide approximation it had before — that's intentional.

Adds test_thread_detection_uses_scene_scoped_transcript.
2026-04-26 21:48:44 -04:00
Joseph Doherty d123684f9a fix: guard scene close key-quote suffix against re-close bloat (T80.1)
Re-running apply_scene_close_summary on the same scene previously caused
recursive bloat: _build_key_quotes_suffix sourced quote text from
memories.pov_summary, which after the first close already carried a
"Key quotes:" suffix. The next close would then quote the quotes,
nesting deeper each time.

Strip any existing suffix from candidate text before truncating to
200 chars in the suffix builder, and from the fresh classifier output
before composing the new value in _summarize_and_apply_for_witness so
the rewrite replaces rather than stacks.

Adds test_scene_close_re_run_does_not_double_suffix.
2026-04-26 21:46:20 -04:00
Joseph Doherty 29e6f346ef merge: T79 _witness_role_for defensive None handling 2026-04-26 21:42:24 -04:00
Joseph Doherty cb570a5adc merge: T78 search_memories docstring SQL-bias note 2026-04-26 21:42:24 -04:00
Joseph Doherty ce4f56adfa merge: T77 AddresseeDecision.confidence as Literal 2026-04-26 21:42:24 -04:00
Joseph Doherty bdc93b4b67 merge: T76 narrate_skip timeout_s plumbed through 2026-04-26 21:42:24 -04:00
Joseph Doherty 9c9d71eb31 fix: _witness_role_for defensive None handling (T79) 2026-04-26 21:41:15 -04:00
Joseph Doherty 4199038b8b fix: AddresseeDecision.confidence as Literal[high|medium|low] (T77) 2026-04-26 21:40:47 -04:00
Joseph Doherty e3dfe18811 docs: search_memories docstring mentions SQL-side significance bias (T78) 2026-04-26 21:40:40 -04:00
Joseph Doherty d759b90aa1 fix: plumb narrate_skip timeout_s through to client.generate (T76) 2026-04-26 21:40:29 -04:00
Joseph Doherty fb7e97260b docs: add Phase 3.5 cleanup plan (Phase 2.6/3 + 3.5/4 backlog burn-down)
12 tasks across 7 waves consolidating the 17-item backlog tracked in
CLAUDE.md (7 from Phase 2.6/3 + 10 from Phase 3.5/4). Items are
grouped by file ownership so each wave stays file-disjoint:

- Wave 1 (parallel 4-way): trivial single-line/single-file fixes
  (timeout_s plumbing, Literal type, docstring, defensive None)
- Wave 2 (single): scene_summarize.py polish bundle (5 T58 items)
- Wave 3 (single): typed ChatNotFoundError for skip routes
- Wave 4 (single): turns.py wiring (consume_pending_meanwhile_digests
  + natural-language skip runs scene close detection)
- Wave 5 (single): regenerate.py polish (cancel hook + DRY +
  sibling query + lifecycle rollback documentation + ordering)
- Wave 6 (parallel 3-way): unified record_turn_memory API + JSON
  audit + frontend turn_html_replace SSE handler
- Wave 7 (single): docs sweep

No schema migrations. Bundled tasks split into per-item sub-commits
for clean review bisection. Uses task ids T76-T87 to avoid collision
with prior phases (Phase 3 used T49-T67, Phase 2.5 used T68-T75).
2026-04-26 21:33:16 -04:00
dohertj2 753cec327f Merge pull request 'Phase 3: events, time skips, threads, meanwhile scenes' (#4) from phase-3 into main 2026-04-26 21:26:49 -04:00
Joseph Doherty 70a5ad3ecc docs: add T66-discovered consume_pending_meanwhile_digests backlog item 2026-04-26 21:19:11 -04:00
Joseph Doherty 6709cf46a7 merge: T67 phase 3 documentation update 2026-04-26 21:18:38 -04:00
Joseph Doherty c3947bbb68 merge: T66 phase 3 cross-feature integration coverage 2026-04-26 21:18:38 -04:00
Joseph Doherty f865ac2ee2 test: phase 3 cross-feature integration coverage (T66) 2026-04-26 21:16:30 -04:00
Joseph Doherty af6c54dd05 docs: phase 3 status, behavioral defaults, deferred items (T67) 2026-04-26 21:10:49 -04:00
Joseph Doherty dc35833534 test: feed meanwhile digest canned response after Wave 6b cross-feature merge 2026-04-26 21:07:44 -04:00
Joseph Doherty 0cd41636b3 merge: T65 meanwhile summary digest surfaces to next you-scene 2026-04-26 21:06:10 -04:00
Joseph Doherty 2c7aa68af9 merge: T64 meanwhile turn flow (host+guest, no you) 2026-04-26 21:06:10 -04:00
Joseph Doherty cf43ba0993 feat: meanwhile turn flow (host+guest, no you) (T64) 2026-04-26 21:05:40 -04:00
Joseph Doherty a781732ee6 feat: meanwhile summary digest surfaces to next you-scene (T65) 2026-04-26 20:59:35 -04:00
Joseph Doherty c9d58b8229 merge: T63 meanwhile scene schema + state 2026-04-26 20:52:51 -04:00
Joseph Doherty c463dc70b2 feat: meanwhile scene schema + state (T63) 2026-04-26 20:52:45 -04:00
Joseph Doherty 819803da84 merge: T62 natural-language skip command flow + shared skip controllers 2026-04-26 20:47:07 -04:00
Joseph Doherty a7eedb8037 feat: natural-language skip detection + skip command flow (T62)
Extend ParsedTurn with intent/landing_state_hint so the classifier can
flag skip-elision and skip-jump prose. The post_turn handler short-
circuits the regular narrative path when intent != "narrative":
elision runs through the shared controller in chat/web/skip.py;
jump returns 422 directing the user to the drawer's structured form
(simpler Phase 3 path — natural-language fiction-time delta parsing
is too fragile for v1 without a structured surface).

Extract the elision/jump logic that previously lived in drawer.py
into chat/web/skip.py so both the drawer T59 routes and the new
natural-language path share one canonical implementation. The drawer
routes become thin HTTP wrappers that translate ValueError to 400
and refresh the drawer partial; the existing drawer skip tests pass
unchanged.

The new natural-language elision derives ``new_time`` by bumping the
chat clock by 1 hour (Phase 3 stub) — the drawer's structured form
remains the path for picking a specific landing time.
2026-04-26 20:45:05 -04:00
Joseph Doherty e236bcadcd merge: T61 per-turn event-lifecycle detection + completion promotion 2026-04-26 20:37:21 -04:00
Joseph Doherty 3678bcaca6 merge: T60 prompt assembly active events + open threads 2026-04-26 20:37:21 -04:00
Joseph Doherty b582567521 feat: per-turn event-lifecycle detection + completion promotion (T61) 2026-04-26 20:35:34 -04:00
Joseph Doherty 21c4ffa63c feat: prompt assembly renders active events + open threads (T60) 2026-04-26 20:34:26 -04:00
Joseph Doherty 83f94a4325 merge: T59 drawer events / threads / skip controls 2026-04-26 20:29:40 -04:00
Joseph Doherty 2d14197553 feat: drawer events / threads / skip controls (T59) 2026-04-26 20:27:47 -04:00
Joseph Doherty 8efbcdf6c3 merge: T58 scene compression + thread emission on close 2026-04-26 20:21:01 -04:00
Joseph Doherty 8aeadfd0e4 merge: T57 significance-aware retrieval ranking 2026-04-26 20:21:01 -04:00