From cbd8ec61e8a4e4bde17593f9d3e0d3ca914648d6 Mon Sep 17 00:00:00 2001 From: GenWave Radio Date: Fri, 14 Aug 2026 11:00:56 -0600 Subject: [PATCH 1/3] =?UTF-8?q?chore(plan):=20pending=20specs=20for=20the?= =?UTF-8?q?=20crosstalk=20stretch=20(STORY-326=E2=80=93329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four spec scaffolds for the voice epic's expressiveness stretch (SPEC F127, PLAN VQ-i, T281–T289): the two-voice script writer, per-line rendering + single-asset assembly, the single-use ahead-of-air stock, and the on-air vend with its fail-closed config. All 49 facts pending (Skip = 'Pending TXXX') per the Story253 idiom — /build-loop turns them green task by task, gated behind T280 (the start gate) and T283 (Dean's paper-audition checkpoint). STORY-330 is manual close-out, no specs. Solution builds clean; both suites green with the new skips registered. Refs #385 --- .../Specs/Story328_StockedAheadAiredOnce.cs | 112 ++++++++++++++ .../Specs/Story329_BanterOnTheAir.cs | 127 +++++++++++++++ .../Specs/Story326_BoothWritesForTwo.cs | 146 ++++++++++++++++++ .../Specs/Story327_TwoVoicesOneClip.cs | 102 ++++++++++++ 4 files changed, 487 insertions(+) create mode 100644 tests/GenWave.Orchestration.Tests/Specs/Story328_StockedAheadAiredOnce.cs create mode 100644 tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs create mode 100644 tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs create mode 100644 tests/GenWave.Tts.Tests/Specs/Story327_TwoVoicesOneClip.cs diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story328_StockedAheadAiredOnce.cs b/tests/GenWave.Orchestration.Tests/Specs/Story328_StockedAheadAiredOnce.cs new file mode 100644 index 0000000..6b5803c --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story328_StockedAheadAiredOnce.cs @@ -0,0 +1,112 @@ +// STORY-328 — Stocked ahead, aired once (gh-#385 · SPEC F127.2/.7 · PLAN VQ-i, T285–T286) +// +// BDD specification — xUnit, pending until /build-loop turns them green. The single-use +// queue ruling: exchanges generate and render OFF the on-air clock (LLM latency stops +// mattering), air once, retire at air — re-airing is the "said that before" artifact the +// 07-31 evergreen rejection named. Casting comes free from the grid (drop-in neighbor: +// no authoring surface, no show→persona reference). No schema: a restart regenerates +// (the F125.4 durability posture). One assertion per Fact; happy first; sad segregated. +// The T288 wire acceptance is a production check, not represented here. +// ⛔ Gated behind T283's paper-audition go. + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureStockedAheadAiredOnce +{ + // ── HAPPY PATH ────────────────────────────────────────────────────────── + + public static class ScenarioCastingComesFreeFromTheGrid + { + [Fact(Skip = "Pending T285 — see docs/PLAN.md")] + public static void The_second_voice_is_the_next_blocks_persona_when_one_exists() + { + // Given an enabled show whose grid neighbor is a distinct persona + // Then host = the show's DJ, second = the NEXT block's persona + // (tease-forward is radio-natural) + Assert.Fail("pending T285"); + } + + [Fact(Skip = "Pending T285 — see docs/PLAN.md")] + public static void The_previous_blocks_persona_casts_when_no_next_exists() + { + Assert.Fail("pending T285"); + } + } + + public static class ScenarioTheStockFillsOffTheClock + { + [Fact(Skip = "Pending T286 — see docs/PLAN.md")] + public static void A_show_below_its_stock_target_triggers_generation() + { + // Target: ≤2 ready exchanges per enabled show. + Assert.Fail("pending T286"); + } + + [Fact(Skip = "Pending T286 — see docs/PLAN.md")] + public static void The_worker_never_generates_or_renders_inside_a_break_window() + { + // Off the on-air clock, always — the render fence serves air first. + Assert.Fail("pending T286"); + } + } + + public static class ScenarioAiredOnceRetiredAtAir + { + [Fact(Skip = "Pending T285 — see docs/PLAN.md")] + public static void Retirement_deletes_the_aired_exchanges_asset() + { + Assert.Fail("pending T285"); + } + + [Fact(Skip = "Pending T285 — see docs/PLAN.md")] + public static void A_retired_exchange_can_never_vend_again() + { + Assert.Fail("pending T285"); + } + } + + public static class ScenarioAScheduleEditInvalidatesTheCast + { + [Fact(Skip = "Pending T285 — see docs/PLAN.md")] + public static void A_stale_cast_pair_is_discarded_at_vend_with_one_reason_line() + { + // Given a stocked exchange whose cast no longer matches grid adjacency + Assert.Fail("pending T285"); + } + + [Fact(Skip = "Pending T285 — see docs/PLAN.md")] + public static void A_discarded_stale_exchange_is_restocked() + { + Assert.Fail("pending T285"); + } + } + + // ── SAD PATH ──────────────────────────────────────────────────────────── + + public static class ScenarioNoDistinctNeighborNoExchange + { + [Fact(Skip = "Pending T285 — see docs/PLAN.md")] + public static void Adjacent_blocks_sharing_the_host_persona_skip_the_airing() + { + // The host never banters with themself. + Assert.Fail("pending T285"); + } + + [Fact(Skip = "Pending T285 — see docs/PLAN.md")] + public static void No_adjacent_persona_at_all_skips_the_airing() + { + Assert.Fail("pending T285"); + } + } + + public static class ScenarioARestartForgetsAndThatIsFine + { + [Fact(Skip = "Pending T285 — see docs/PLAN.md")] + public static void No_stock_state_survives_a_restart() + { + // No persisted queue exists — the stock regenerates from nothing, and + // retirement-by-deletion means nothing ever airs twice. + Assert.Fail("pending T285"); + } + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs b/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs new file mode 100644 index 0000000..1738410 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs @@ -0,0 +1,127 @@ +// STORY-329 — Banter on the air (gh-#385 · SPEC F127.1/.8/.9 · PLAN VQ-i, T281 + T287) +// +// BDD specification — xUnit, pending until /build-loop turns them green. Banter owns its +// moment: a new SegmentKind vending at mid-block seams only (the F92/F124 boundary ladder +// structurally untouched), superseding the F107.5/F116 gated lanes in any break it airs — +// one voice-moment per break, the epic's recorded #1 risk honored. `Crosstalk:Shows` +// empty = OFF, fail-closed: no station's sound changes on upgrade. One assertion per +// Fact; happy first; sad segregated. The T288 wire acceptance (byte-identical air with +// the list emptied, on the production binary) is a production check, not represented +// here. ⛔ T287 carries the Orchestrator drain-region serialization flag. + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureBanterOnTheAir +{ + // ── HAPPY PATH ────────────────────────────────────────────────────────── + + public static class ScenarioANewKindAtAMidBlockSeam + { + [Fact(Skip = "Pending T281 — see docs/PLAN.md")] + public static void SegmentKind_Crosstalk_exists_as_an_additive_member() + { + // The published Abstractions contract grows by one enum member — + // minor version, no binary break. + Assert.Fail("pending T281"); + } + + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void A_due_exchange_vends_at_a_mid_block_break_seam() + { + Assert.Fail("pending T287"); + } + + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void No_exchange_ever_vends_inside_the_boundary_ceremony_window() + { + // The F92/F124 ladder is untouched by construction — banter and + // ceremony never share a moment. + Assert.Fail("pending T287"); + } + } + + public static class ScenarioBanterSupersedesTheGatedLanes + { + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void No_show_flavor_line_airs_in_a_crosstalk_break() + { + Assert.Fail("pending T287"); + } + + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void No_context_patter_fact_airs_in_a_crosstalk_break() + { + Assert.Fail("pending T287"); + } + } + + public static class ScenarioTheCadenceKnob + { + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void One_exchange_airs_per_Nth_eligible_airing_of_an_enabled_show() + { + // Crosstalk:EveryNthAiring — Dean's "1 every X shows" knob. + Assert.Fail("pending T287"); + } + + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void The_cadence_setting_is_live_editable_with_a_default_of_one() + { + Assert.Fail("pending T287"); + } + } + + public static class ScenarioTheAiredScriptIsOnTheRecord + { + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void The_booth_row_carries_the_full_script_in_its_stamp() + { + // The `pick jsonb` precedent — "what did they say" is answerable from + // the booth log, not just the ear. + Assert.Fail("pending T287"); + } + + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void The_demo_hour_instrument_counts_a_Crosstalk_row_like_any_kind() + { + Assert.Fail("pending T287"); + } + } + + // ── SAD PATH ──────────────────────────────────────────────────────────── + + public static class ScenarioAnEmptyListMeansOffByteIdentical + { + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void With_Crosstalk_Shows_empty_no_exchange_ever_vends() + { + // The shipped default — fail-closed, the sounds-identical-on-upgrade + // discipline. + Assert.Fail("pending T287"); + } + + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void With_Crosstalk_Shows_empty_the_gated_lane_arbitration_is_unchanged() + { + // The F107.5/F116 golden holds byte-for-byte when the feature is off. + Assert.Fail("pending T287"); + } + } + + public static class ScenarioAnEmptyStockSkipsSilently + { + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void A_due_airing_with_no_ready_exchange_skips_the_slot() + { + Assert.Fail("pending T287"); + } + + [Fact(Skip = "Pending T287 — see docs/PLAN.md")] + public static void The_skipped_break_proceeds_with_its_ordinary_lanes() + { + // Skip means the break falls back to flavor/fact arbitration as if + // crosstalk never existed — banter's absence costs nothing. + Assert.Fail("pending T287"); + } + } +} diff --git a/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs b/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs new file mode 100644 index 0000000..06d5d26 --- /dev/null +++ b/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs @@ -0,0 +1,146 @@ +// STORY-326 — The booth writes for two (gh-#385 · SPEC F127.3/.4 · PLAN VQ-i, T282) +// +// BDD specification — xUnit, pending until /build-loop turns them green. The design's +// named risk (2026-08-14): a 3B model writing two coherent voices — these facts pin the +// contract that makes bad output unairable, not the output good. One completion produces +// the WHOLE exchange (reactions must react to what was actually said); validation is +// fail-closed and the failure mode is silent skip — no template rung, no salvage. One +// assertion per Fact; happy first; sad segregated. The T288 wire acceptance (an exchange +// airs once on a running stack) is a production check, not represented here. ⛔ T283, the +// paper-audition checkpoint, gates everything after T282 — these facts green first. + +namespace GenWave.Tts.Tests.Specs; + +public static class FeatureBoothWritesForTwo +{ + // ── HAPPY PATH ────────────────────────────────────────────────────────── + + public static class ScenarioOneCallWholeExchange + { + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void Exactly_one_completion_is_issued_per_exchange() + { + // Given the host and neighbor persona cards plus show/daypart/time hooks + // When the writer requests an exchange + // Then ONE completion request leaves — never per-turn calls + Assert.Fail("pending T282"); + } + + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void The_request_carries_both_persona_cards() + { + Assert.Fail("pending T282"); + } + + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void The_request_carries_the_F123_derived_generation_cap() + { + // The one-knob discipline extends: the cap derives from Llm:MaxCopyChars, + // no second operator setting for banter. + Assert.Fail("pending T282"); + } + } + + public static class ScenarioTheScriptParsesStrictly + { + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void A_well_formed_response_yields_three_to_eight_speaker_tagged_lines() + { + Assert.Fail("pending T282"); + } + + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void Both_speakers_are_present_in_an_accepted_script() + { + Assert.Fail("pending T282"); + } + + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void Alternation_holds_outside_interjection_marked_lines() + { + // Strict A/B alternation; an interjection-marked line is the one + // sanctioned exception (it overlaps rather than follows). + Assert.Fail("pending T282"); + } + } + + public static class ScenarioPerLineHygieneWithoutTrimming + { + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void Every_accepted_line_has_cleared_the_standing_copy_cleanup() + { + Assert.Fail("pending T282"); + } + + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void No_line_is_ever_trimmed() + { + // A cut dialogue line breaks the reaction to it — over-budget rejects + // the WHOLE exchange (sad path), it never salvages a line (F123.2's + // trim deliberately does NOT extend here). + Assert.Fail("pending T282"); + } + } + + public static class ScenarioTheExchangeFitsItsMoment + { + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void A_script_under_the_duration_target_is_accepted() + { + Assert.Fail("pending T282"); + } + + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void The_duration_target_is_live_editable_with_a_25s_default() + { + Assert.Fail("pending T282"); + } + } + + public static class ScenarioGenerationIsVisible + { + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void The_call_appears_in_the_llm_ring_under_its_own_kind() + { + // Accepted or rejected-with-reason — /api/llm-calls answers "why was + // there no banter" without a log stack (the F123.4 posture). + Assert.Fail("pending T282"); + } + } + + // ── SAD PATH ──────────────────────────────────────────────────────────── + + public static class ScenarioAnyValidationFailureDiscardsSilently + { + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void A_malformed_response_produces_no_exchange_and_one_reason_line() + { + // No template rung, no salvage — banter is optional color; one voice + // doing "banter" is itself the wince. + Assert.Fail("pending T282"); + } + + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void An_over_budget_line_rejects_the_whole_exchange() + { + Assert.Fail("pending T282"); + } + + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void An_over_duration_script_rejects_whole() + { + Assert.Fail("pending T282"); + } + } + + public static class ScenarioTheCurrentTrackIsStructurallyUnknowable + { + [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + public static void The_prompt_contains_no_current_track_reference() + { + // Exchanges are generated ahead of air and cannot know it — the prompt + // shape carries show/daypart/time hooks only. + Assert.Fail("pending T282"); + } + } +} diff --git a/tests/GenWave.Tts.Tests/Specs/Story327_TwoVoicesOneClip.cs b/tests/GenWave.Tts.Tests/Specs/Story327_TwoVoicesOneClip.cs new file mode 100644 index 0000000..ed8df8a --- /dev/null +++ b/tests/GenWave.Tts.Tests/Specs/Story327_TwoVoicesOneClip.cs @@ -0,0 +1,102 @@ +// STORY-327 — Two voices, one clip (gh-#385 · SPEC F127.5/.6 · PLAN VQ-i, T284) +// +// BDD specification — xUnit, pending until /build-loop turns them green. The craft target +// (Dean's word): reaction lines and interruption timing — the walkie-talkie turn-taking +// tell is what assembly exists to kill. Each line renders through the ONE funnel with ITS +// speaker's TtsRenderContext (F97.6 carriage, per line); ffmpeg assembles ONE asset the +// playout pipeline treats like any segment — no engine change, no multi-source mixing at +// air time. F99 extends per line: both voices or nobody. One assertion per Fact; happy +// first; sad segregated. The T288 wire acceptance is a production check, not here. +// ⛔ Gated behind T283's paper-audition go. + +namespace GenWave.Tts.Tests.Specs; + +public static class FeatureTwoVoicesOneClip +{ + // ── HAPPY PATH ────────────────────────────────────────────────────────── + + public static class ScenarioEveryLineRidesItsOwnSpeakersContext + { + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void A_hosts_line_renders_with_the_hosts_rules_and_pace() + { + // Given a validated script + // When the lines render + // Then each render's TtsRenderContext carries THAT speaker's resolved + // pronunciation rules and pace — never the other's + Assert.Fail("pending T284"); + } + + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void A_neighbors_line_renders_with_the_neighbors_rules_and_pace() + { + Assert.Fail("pending T284"); + } + } + + public static class ScenarioAssemblyBreathes + { + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void Assembly_produces_exactly_one_audio_asset() + { + Assert.Fail("pending T284"); + } + + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void Inter_line_gaps_are_jittered_within_the_bounded_range() + { + // ~0.2–0.8s, seeded per exchange — uniform gaps are the second-biggest + // TTS-dialogue tell. + Assert.Fail("pending T284"); + } + + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void An_interjection_overlaps_the_prior_lines_tail_by_a_bounded_offset() + { + Assert.Fail("pending T284"); + } + } + + public static class ScenarioTheClipIsAFirstClassSegment + { + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void The_assembled_clip_is_loudness_measured_like_any_segment() + { + Assert.Fail("pending T284"); + } + } + + // ── SAD PATH ──────────────────────────────────────────────────────────── + + public static class ScenarioBothVoicesOrNobody + { + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void One_line_failing_the_right_voice_bar_discards_the_whole_exchange() + { + // F99 per line — no other voice ever speaks a persona's line, and no + // single-voice salvage of a two-voice exchange exists. + Assert.Fail("pending T284"); + } + + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void A_discarded_exchange_leaves_no_asset_behind() + { + Assert.Fail("pending T284"); + } + } + + public static class ScenarioTheEstimateLied + { + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void A_clip_past_one_point_five_times_the_target_is_discarded() + { + Assert.Fail("pending T284"); + } + + [Fact(Skip = "Pending T284 — see docs/PLAN.md")] + public static void The_discard_logs_both_the_estimated_and_actual_durations() + { + Assert.Fail("pending T284"); + } + } +} From 47c4d3858a30a2a91a2241230d159b95e90ee59e Mon Sep 17 00:00:00 2001 From: GenWave Radio Date: Fri, 14 Aug 2026 15:52:21 -0600 Subject: [PATCH 2/3] =?UTF-8?q?feat(abstractions):=20T281=20=E2=80=94=20Se?= =?UTF-8?q?gmentKind.Crosstalk,=20additive=20(STORY-329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eighth kind (F127.1), appended last — value sequence [0..7] pinned by the flipped Story329 fact. One load-bearing arm: PatterTemplateRenderer gains a Crosstalk placeholder ('Two voices, one moment.') because the preview endpoint reflection-parses kinds and would have 500'd; the arm carries the T287 airable-filler hazard note (the TtsSegmentSource drop guard does not list Crosstalk — the wiring task must decide). Ripples: piper-only EngineByKind seed gains the kind (this branch's baseline; superseded when the VQ-d branch's seed removal merges), Story005 count 7→8. Review rounds: mechanical sweep PASSED round 1; rounds 1-2 caught and fixed falsified closed-set documentation — the published Abstractions XML docs (IPersonaPreviewWriter/PersonaPreviewResult now defer to the IsLlmAuthored predicate instead of enumerating) and the routing SSOT comments (LlmCopyWriter class summary + IsLlmAuthored doc + echoes, LlmPromptBuilder, LlmCopyStatusHolder) all now name or defer correctly; Crosstalk is template-class on this seam BY DESIGN — its real copy arrives via the T282 script writer. Final one-clause fix applied by the orchestrator verbatim from the reviewer's prescription (recorded deviation: no builder round for a prescribed comment clause). Air-inert: no producer emits the kind until T287. Suites: 0 failed. Release note: additive enum member = minor Abstractions bump at the next tagged release (no in-repo version file to edit — tag-driven). --- compose.piper-only.yaml | 10 +++---- .../Abstractions/IPersonaPreviewWriter.cs | 12 ++++---- .../Domain/PersonaPreviewResult.cs | 7 +++-- .../Domain/SegmentKind.cs | 8 +++++ src/GenWave.Tts/LlmCopyStatusHolder.cs | 4 +-- src/GenWave.Tts/LlmCopyWriter.cs | 29 ++++++++++++------- src/GenWave.Tts/LlmPromptBuilder.cs | 3 +- src/GenWave.Tts/PatterTemplateRenderer.cs | 13 ++++++++- .../Specs/Story329_BanterOnTheAir.cs | 14 ++++++--- .../Specs/Story005_TtsSegmentSource.cs | 6 ++-- 10 files changed, 71 insertions(+), 35 deletions(-) diff --git a/compose.piper-only.yaml b/compose.piper-only.yaml index 1105706..efda3c6 100644 --- a/compose.piper-only.yaml +++ b/compose.piper-only.yaml @@ -11,7 +11,7 @@ # resident in the stack, and a 4GB box (Raspberry Pi 4, small VPS) can't afford it # * drops api's hard `depends_on: kokoro: service_healthy` (keeps db/engine), so the # api boots without any kokoro container existing at all -# * seeds `Tts:EngineByKind` mapping ALL SEVEN SegmentKinds -> "piper", so every on-air +# * seeds `Tts:EngineByKind` mapping ALL EIGHT SegmentKinds -> "piper", so every on-air # render routes straight to the piper sidecar and never touches the absent primary # # The default experience is untouched: this file is opt-in via -f (never auto-loaded), @@ -83,15 +83,15 @@ services: engine: condition: service_healthy # convenience; the feeder retries regardless environment: - # All seven SegmentKinds pinned to the piper engine (SPEC F70.3, STORY-191; bumped - # from six to seven for ContextSegment, SPEC F107.3, STORY-297, PLAN T223) — the + # All eight SegmentKinds pinned to the piper engine (SPEC F70.3, STORY-191; bumped + # from seven to eight for Crosstalk, SPEC F127.1, STORY-329, PLAN T281) — the # allowlist's expected shape: a JSON object mapping SegmentKind names to # "kokoro"/"piper". With this map, every on-air render (TtsSegmentSource carries - # the kind) goes straight to Piper; kokoro is never contacted. If an eighth kind + # the kind) goes straight to Piper; kokoro is never contacted. If a ninth kind # is ever added to SegmentKind, add it here too — the gh-#242 spec pins this map # against the enum, so the build fails loudly rather than silently routing the # new kind at a kokoro that doesn't exist. - Tts__EngineByKind: '{"StationId":"piper","LeadIn":"piper","BackAnnounce":"piper","TimeDate":"piper","SignOff":"piper","SignOn":"piper","ContextSegment":"piper"}' + Tts__EngineByKind: '{"StationId":"piper","LeadIn":"piper","BackAnnounce":"piper","TimeDate":"piper","SignOff":"piper","SignOn":"piper","ContextSegment":"piper","Crosstalk":"piper"}' # gh-#334 — enrichment halved, by the same reasoning as the kokoro and LLM removals above. # This overlay exists because a 4GB/4-core box cannot afford the default topology, and the # ffmpeg analyzers are the heaviest sustained load GenWave produces: base compose's 4 pins diff --git a/src/GenWave.Abstractions/Abstractions/IPersonaPreviewWriter.cs b/src/GenWave.Abstractions/Abstractions/IPersonaPreviewWriter.cs index b44305e..162e474 100644 --- a/src/GenWave.Abstractions/Abstractions/IPersonaPreviewWriter.cs +++ b/src/GenWave.Abstractions/Abstractions/IPersonaPreviewWriter.cs @@ -22,11 +22,13 @@ public interface IPersonaPreviewWriter /// persona, a draft persona built from unsaved fields, or for the /// neutral house scaffold — in place of whatever persona is actually active on-air. /// - /// / requests route - /// straight to the template rung (mirrors production's own kind-based routing — those kinds - /// never call the LLM on-air either, so this is not a fallback). LeadIn/BackAnnounce/SignOff/ - /// SignOn requests call the LLM and never degrade: any failure (disabled endpoint, timeout, - /// non-2xx, empty/over-length copy) yields instead of + /// Routing follows the same closed set production uses + /// (GenWave.Tts.LlmCopyWriter.IsLlmAuthored — the single source of truth for which + /// values ever reach the LLM): a kind that predicate reports false for + /// routes straight to the template rung (mirrors production's own kind-based routing — this is + /// not a fallback, those kinds never call the LLM on-air either), and every kind it reports true + /// for calls the LLM and never degrades — any failure (disabled endpoint, timeout, non-2xx, + /// empty/over-length copy) yields instead of /// substituting template text. /// Task WritePreviewAsync( diff --git a/src/GenWave.Abstractions/Domain/PersonaPreviewResult.cs b/src/GenWave.Abstractions/Domain/PersonaPreviewResult.cs index 3c550da..720709c 100644 --- a/src/GenWave.Abstractions/Domain/PersonaPreviewResult.cs +++ b/src/GenWave.Abstractions/Domain/PersonaPreviewResult.cs @@ -12,9 +12,10 @@ public abstract record PersonaPreviewResult private PersonaPreviewResult() { } /// - /// The rendered copy, ready to display. Carries either genuine LLM output or — for - /// /, which never touch the - /// LLM even on-air — the exact template text production would air for that kind. + /// The rendered copy, ready to display. Carries either genuine LLM output or — for whichever + /// values GenWave.Tts.LlmCopyWriter.IsLlmAuthored reports false + /// for (never touch the LLM even on-air) — the exact template text production would air for that + /// kind. /// public sealed record Success(string Text) : PersonaPreviewResult; diff --git a/src/GenWave.Abstractions/Domain/SegmentKind.cs b/src/GenWave.Abstractions/Domain/SegmentKind.cs index f80cb39..0af2e4d 100644 --- a/src/GenWave.Abstractions/Domain/SegmentKind.cs +++ b/src/GenWave.Abstractions/Domain/SegmentKind.cs @@ -30,4 +30,12 @@ public enum SegmentKind /// that actually produces a SegmentRequest of this kind is T224's, not this enum's own. /// ContextSegment, + + /// + /// A banter exchange in which two personas share one clip (SPEC F127.1, STORY-329) — a + /// mid-block-seam voice moment, superseding the gated flavor/fact lanes in any break it + /// airs. Additive member; the wiring that vends a SegmentRequest of this kind is + /// T287's, not this enum's own. + /// + Crosstalk, } diff --git a/src/GenWave.Tts/LlmCopyStatusHolder.cs b/src/GenWave.Tts/LlmCopyStatusHolder.cs index 6e8a5d0..e8133a3 100644 --- a/src/GenWave.Tts/LlmCopyStatusHolder.cs +++ b/src/GenWave.Tts/LlmCopyStatusHolder.cs @@ -4,8 +4,8 @@ namespace GenWave.Tts; /// In-memory record of the most recent completion attempt (SPEC F34.8). /// Singleton, process-lifetime only — no persistence and no active health-polling; GET /// /api/status (STORY-125) reads whatever the last render produced. A disabled writer and the -/// templated kinds (StationId/TimeDate) never call — this holder reflects LLM -/// attempts only. +/// templated kinds (StationId/TimeDate/Crosstalk) never call — this holder +/// reflects LLM attempts only. /// /// (SPEC F69.2, STORY-188) is what /// reads for the auto-drop side of the mode state machine — the diff --git a/src/GenWave.Tts/LlmCopyWriter.cs b/src/GenWave.Tts/LlmCopyWriter.cs index 0c01e92..a8834b4 100644 --- a/src/GenWave.Tts/LlmCopyWriter.cs +++ b/src/GenWave.Tts/LlmCopyWriter.cs @@ -14,9 +14,11 @@ namespace GenWave.Tts; /// copy for exactly the kinds reports true for — , /// , , /// , and, as of T224, — -/// from an OpenAI-compatible chat-completions endpoint. and -/// always delegate straight to with -/// zero HTTP — brand/time copy stays fixed and forever-cached. +/// from an OpenAI-compatible chat-completions endpoint. , +/// , and always delegate +/// straight to with zero HTTP — brand/time copy stays fixed and +/// forever-cached, and Crosstalk's real copy arrives via its own ahead-of-air script writer +/// (T282, SPEC F127.3) rather than this seam. /// Enabled-ness and every other option are read from fresh on each /// call (F36.2) — an empty Llm:Endpoint means disabled. Any failure (disabled, timeout, /// non-2xx, connect, empty/over-length copy) degrades to 's template copy @@ -241,9 +243,9 @@ public sealed class LlmCopyWriter( /// non-fresh-copy guard, extended alongside SignOff/SignOn for exactly that reason). Gates both /// and so the two can never drift apart, /// and is the fact 's own exhaustiveness switch - /// relies on staying in sync with (see that method's remarks): - /// and are the only two kinds this reports false for, and they - /// never reach a prompt at all. + /// relies on staying in sync with (see that method's remarks): , + /// , and (as of PLAN T281) + /// are the three kinds this reports false for today, and none of them ever reach a prompt. /// static bool IsLlmAuthored(SegmentKind kind) => kind is SegmentKind.LeadIn or SegmentKind.BackAnnounce or SegmentKind.SignOff or SegmentKind.SignOn @@ -252,8 +254,11 @@ kind is SegmentKind.LeadIn or SegmentKind.BackAnnounce or SegmentKind.SignOff or public async Task WriteAsync(SegmentRequest request, CancellationToken ct) { // StationId/TimeDate stay templated — brand/time copy must be crisp, consistent, and - // forever-cacheable; the two track-anchored kinds (F34.2) and the two handoff kinds (F92.2, - // F92.5) are the ones worth an LLM's while (see IsLlmAuthored, the single source of truth). + // forever-cacheable. Crosstalk (PLAN T281) also stays templated here, for a different + // reason: its real copy arrives via its own ahead-of-air script writer (T282, SPEC F127.3), + // not this seam, so this writer is template-class for it by design, not degrading. The two + // track-anchored kinds (F34.2) and the two handoff kinds (F92.2, F92.5) are the ones worth an + // LLM's while (see IsLlmAuthored, the single source of truth). if (!IsLlmAuthored(request.Kind)) return await fallback.WriteAsync(request, ct); @@ -358,9 +363,11 @@ public async Task WriteAsync(SegmentRequest request, CancellationTo public async Task WritePreviewAsync( SegmentRequest request, Persona? personaOverride, CancellationToken ct) { - // StationId/TimeDate route straight to the template rung — mirrors WriteAsync's own - // kind-based routing (F34.2, IsLlmAuthored). This is not a fallback: those two kinds never - // call the LLM on-air either, so template text IS the correct preview for them. + // StationId/TimeDate/Crosstalk route straight to the template rung — mirrors WriteAsync's + // own kind-based routing (F34.2, IsLlmAuthored; see that method's own comment for why + // Crosstalk joins the two — its real copy arrives via its own ahead-of-air script writer, + // T282, SPEC F127.3, not this seam). This is not a fallback: none of the three call the LLM + // on-air either, so template text IS the correct preview for them. if (!IsLlmAuthored(request.Kind)) { var templated = await fallback.WriteAsync(request, ct); diff --git a/src/GenWave.Tts/LlmPromptBuilder.cs b/src/GenWave.Tts/LlmPromptBuilder.cs index 1267d04..0171680 100644 --- a/src/GenWave.Tts/LlmPromptBuilder.cs +++ b/src/GenWave.Tts/LlmPromptBuilder.cs @@ -611,7 +611,8 @@ public static bool IsPatterFactKind(SegmentKind kind) => /// this break is so the model never has to guess its own role. Only ever called with a kind /// reports true for — the single source of truth for /// "which kinds"; the remaining kinds (, - /// ) never reach the LLM and so never reach this method either. + /// , ) never reach the LLM + /// and so never reach this method either. /// Exhaustive switch below: a new LLM-eligible needs a matching arm /// added HERE as well as in for it to actually take /// effect end to end — the compiler's own exhaustiveness check on this switch is the guard diff --git a/src/GenWave.Tts/PatterTemplateRenderer.cs b/src/GenWave.Tts/PatterTemplateRenderer.cs index f044c5d..2af7bac 100644 --- a/src/GenWave.Tts/PatterTemplateRenderer.cs +++ b/src/GenWave.Tts/PatterTemplateRenderer.cs @@ -102,8 +102,19 @@ public sealed class PatterTemplateRenderer // (T224 review finding): ContextSegment is now one of LlmCopyWriter.IsLlmAuthored's kinds, so // WritePreviewAsync never falls through to this template rung on an LLM miss the way it would // have under T223 — it returns PersonaPreviewResult.Failed instead (F35.6: a preview never - // silently substitutes template copy). + // silently substitutes template copy). The sibling Crosstalk arm just below carries no such + // guarantee: TtsSegmentSource's own non-LLM-authored drop guard (`SignOff or SignOn or + // ContextSegment`, TtsSegmentSource.cs:93) does not list Crosstalk, so if a future producer + // (T287) ever routes a Crosstalk render through this template rung, "Two voices, one moment." + // would become airable filler unless that guard is extended first — the wiring task's call. SegmentKind.ContextSegment => "Here's something worth knowing.", + // No producer builds a Crosstalk SegmentRequest yet (SPEC F127.1, PLAN T281 — the vend + // itself is T287's), so this arm never reaches air today. It exists purely so the switch + // below stays total: TemplateCopyWriter's own "never fails for any SegmentRequest" contract, + // and POST /api/personas/preview (kind is validated against any real SegmentKind name, see + // PersonaController.TryParseKind), both need a correct, non-throwing landing spot for this + // kind now that it exists — the same discipline ContextSegment's arm above was added under. + SegmentKind.Crosstalk => "Two voices, one moment.", _ => throw new ArgumentOutOfRangeException( nameof(request.Kind), request.Kind, message: null), }; diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs b/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs index 1738410..4e6432e 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story329_BanterOnTheAir.cs @@ -9,6 +9,8 @@ // the list emptied, on the production binary) is a production check, not represented // here. ⛔ T287 carries the Orchestrator drain-region serialization flag. +using GenWave.Core.Domain; + namespace GenWave.Orchestration.Tests.Specs; public static class FeatureBanterOnTheAir @@ -17,12 +19,16 @@ public static class FeatureBanterOnTheAir public static class ScenarioANewKindAtAMidBlockSeam { - [Fact(Skip = "Pending T281 — see docs/PLAN.md")] + [Fact] public static void SegmentKind_Crosstalk_exists_as_an_additive_member() { - // The published Abstractions contract grows by one enum member — - // minor version, no binary break. - Assert.Fail("pending T281"); + // The published Abstractions contract grows by one enum member — minor version, no + // binary break. One assertion: the full ordered underlying-value sequence, so + // "Crosstalk is additive" (appended last) and "every pre-existing member keeps its + // original value" are pinned together, not as separate facts. + Assert.Equal( + [0, 1, 2, 3, 4, 5, 6, 7], + Enum.GetValues().Select(kind => (int)kind)); } [Fact(Skip = "Pending T287 — see docs/PLAN.md")] diff --git a/tests/GenWave.Tts.Tests/Specs/Story005_TtsSegmentSource.cs b/tests/GenWave.Tts.Tests/Specs/Story005_TtsSegmentSource.cs index e6f4fae..482ca07 100644 --- a/tests/GenWave.Tts.Tests/Specs/Story005_TtsSegmentSource.cs +++ b/tests/GenWave.Tts.Tests/Specs/Story005_TtsSegmentSource.cs @@ -112,11 +112,11 @@ public void SegmentRequestRecordExistsInAbstractions() } [Fact] - public void SegmentKindEnumHasSevenCases() + public void SegmentKindEnumHasEightCases() { - // Bumped from six (STORY-297, PLAN T223): ContextSegment joined the enum (SPEC F107.3). + // Bumped from seven (STORY-329, PLAN T281): Crosstalk joined the enum (SPEC F127.1). var values = Enum.GetValues(); - Assert.Equal(7, values.Length); + Assert.Equal(8, values.Length); } } From 8a6729136e3bd19db2f72e0af673a4101a999455 Mon Sep 17 00:00:00 2001 From: GenWave Radio Date: Fri, 14 Aug 2026 17:17:53 -0600 Subject: [PATCH 3/3] =?UTF-8?q?feat(tts):=20T282=20=E2=80=94=20the=20cross?= =?UTF-8?q?talk=20script=20writer,=20fail-closed=20(STORY-326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One completion per exchange (F127.3 — reactions must react to what was actually said): CrosstalkScriptWriter + a two-card prompt built BESIDE LlmPromptBuilder (F116 goldens byte-identical, verified), speaker-tagged HOST:/NEIGHBOR: wire format with an (interjects) overlap marker — plain prefixes over JSON because the production 3B emits them reliably. The F123 machinery is shared, not copied (ApplyCopyHygiene extracted; DeriveMaxTokens/ClassifyForRing widened internal — extraction proven byte-identical by the pre-existing F123/gh-#186 facts redding under helper mutations). Validation is fail-closed, skip-only (F127.4): 3–8 lines, both speakers (incl. the all-interjection one-voice hole), strict alternation outside interjections, per-line hygiene + budget with NO trimming, a live Crosstalk:DurationTargetSeconds estimate (default 25s, allowlist 5–120), and — review round 1's catch — finish_reason=='length' discards a token-capped reply whose chopped final line would otherwise validate clean (the gh-#424 class, ruled into F127.4's scope). Operator hooks (ShowName/Daypart) truncate at the MaxSoulChars precedent; discard reasons echo at most 120 chars of model text. Every attempt is visible: LlmCallKind (Copy|Crosstalk) rides the ring through /api/llm-calls to a new Kind chip, and Rejected renders brass with its own label — a content-quality decision, never the danger tone of an outage (the gh-#277 red-tile lesson, honored this time). 17 facts live (15 planned + 2 the review forced against vacuous pins); every fact mutation-verified — six deletion/inversion mutations re-run by the reviewer, each redding exactly its fact. Review: FAIL(6)→FAIL(1 word)→PASS across 3 gates; final one-word comment fix applied by the orchestrator from the reviewer's prescription (recorded deviation). Suites: dotnet 3,733 passed / 0 failed; admin-ui 1,054 passed; goldens 17/17. Recorded for T286: start-time gating is insufficient — shared completion gate or cancel-in-flight required (the #277 fence). --- admin-ui/__specs__/llm-calls-page.spec.tsx | 38 ++ .../app/(authed)/booth-log/LlmCallsFeed.tsx | 40 +- .../app/(authed)/settings/SettingsForm.tsx | 5 + .../(authed)/settings/settings-help-keys.ts | 1 + admin-ui/lib/llm-calls-api.ts | 6 + src/GenWave.Host/Api/LlmCallDto.cs | 7 +- src/GenWave.Host/Api/LlmCallsController.cs | 3 +- .../Configuration/SettingValidator.cs | 17 + .../Configuration/StationSettingsAllowlist.cs | 9 + src/GenWave.Host/appsettings.json | 3 + src/GenWave.Tts/ChatCompletionChoice.cs | 16 +- src/GenWave.Tts/ChatCompletionResponse.cs | 9 +- src/GenWave.Tts/CrosstalkExchangeRequest.cs | 30 + src/GenWave.Tts/CrosstalkLine.cs | 14 + src/GenWave.Tts/CrosstalkOptions.cs | 26 + src/GenWave.Tts/CrosstalkPromptBuilder.cs | 106 ++++ src/GenWave.Tts/CrosstalkScript.cs | 13 + src/GenWave.Tts/CrosstalkScriptParser.cs | 181 ++++++ src/GenWave.Tts/CrosstalkScriptWriter.cs | 189 +++++++ src/GenWave.Tts/CrosstalkSpeaker.cs | 18 + src/GenWave.Tts/CrosstalkWriteResult.cs | 29 + src/GenWave.Tts/LlmCallKind.cs | 19 + src/GenWave.Tts/LlmCallOutcome.cs | 13 + src/GenWave.Tts/LlmCallRecord.cs | 15 +- src/GenWave.Tts/LlmCallRing.cs | 9 +- src/GenWave.Tts/LlmCopyWriter.cs | 48 +- .../TtsServiceCollectionExtensions.cs | 15 + .../Support/HttpClientSeams.cs | 5 +- .../MockCompletionsServer.cs | 18 +- .../Specs/Story326_BoothWritesForTwo.cs | 520 +++++++++++++++--- 30 files changed, 1310 insertions(+), 112 deletions(-) create mode 100644 src/GenWave.Tts/CrosstalkExchangeRequest.cs create mode 100644 src/GenWave.Tts/CrosstalkLine.cs create mode 100644 src/GenWave.Tts/CrosstalkOptions.cs create mode 100644 src/GenWave.Tts/CrosstalkPromptBuilder.cs create mode 100644 src/GenWave.Tts/CrosstalkScript.cs create mode 100644 src/GenWave.Tts/CrosstalkScriptParser.cs create mode 100644 src/GenWave.Tts/CrosstalkScriptWriter.cs create mode 100644 src/GenWave.Tts/CrosstalkSpeaker.cs create mode 100644 src/GenWave.Tts/CrosstalkWriteResult.cs create mode 100644 src/GenWave.Tts/LlmCallKind.cs diff --git a/admin-ui/__specs__/llm-calls-page.spec.tsx b/admin-ui/__specs__/llm-calls-page.spec.tsx index e5d083b..c465dd2 100644 --- a/admin-ui/__specs__/llm-calls-page.spec.tsx +++ b/admin-ui/__specs__/llm-calls-page.spec.tsx @@ -34,6 +34,7 @@ interface EntryOverrides { response?: string | null; promptChars?: number; responseChars?: number; + kind?: string; } function makeEntry(overrides: EntryOverrides = {}) { @@ -50,6 +51,7 @@ function makeEntry(overrides: EntryOverrides = {}) { response: "Coming up, a deep cut to ease into the evening.", promptChars: 100, responseChars: 48, + kind: "copy", ...overrides, }; } @@ -169,6 +171,42 @@ describe("Feature: LLM call inspector", () => { expect(chip.className).toContain("text-accent-2"); expect(chip.className).not.toContain("text-danger"); }); + + // SPEC F127.4, F127.11 (gh-#385) — the gh-#277 lesson re-created and re-fixed one outcome + // over: a crosstalk validation reject is a content-quality decision, not an outage, so its + // chip must never paint the inspector red the same way a real failed/timeout call does. + it("shows a Rejected status chip in the non-danger brass tone, not the danger tone of a real miss", async () => { + installFetchMock(ok([makeEntry({ status: "rejected", statusDetail: "no HOST line appeared" })])); + + render(); + await flush(); + + const chip = screen.getByText("Rejected"); + expect(chip.className).toContain("text-accent-2"); + expect(chip.className).not.toContain("text-danger"); + }); + }); + + // gh-#385, SPEC F127.11 — the Kind column names which generation surface produced the call so + // an operator can tell "why was there no banter" apart from an ordinary blurb miss. + describe("Scenario: rows show which surface produced the call", () => { + it("renders a Crosstalk kind chip for a CrosstalkScriptWriter call", async () => { + installFetchMock(ok([makeEntry({ kind: "crosstalk" })])); + + render(); + await flush(); + + expect(screen.getByText("Crosstalk")).toBeInTheDocument(); + }); + + it("renders a Copy kind chip for an ordinary segment-copy call", async () => { + installFetchMock(ok([makeEntry({ kind: "copy" })])); + + render(); + await flush(); + + expect(screen.getByText("Copy")).toBeInTheDocument(); + }); }); // gh-#429: personas now author the copy this table exists to triage, so each row names who diff --git a/admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx b/admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx index ee69b41..e1c4f3c 100644 --- a/admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx +++ b/admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx @@ -15,9 +15,22 @@ interface LlmCallsFeedProps { timeZone?: string; } -const STATUS_LABELS: Record = { ok: "Ok", failed: "Failed", timeout: "Timeout", trimmed: "Trimmed" }; +const STATUS_LABELS: Record = { + ok: "Ok", + failed: "Failed", + timeout: "Timeout", + trimmed: "Trimmed", + rejected: "Rejected", +}; const MODE_LABELS: Record = { normal: "Normal", soft: "Soft", hard: "Hard" }; +/** SPEC F127.11, PLAN T282 — which generation surface produced the call + * (GenWave.Tts.LlmCallKind): "copy" for every ordinary segment-copy call, "crosstalk" for a + * CrosstalkScriptWriter call. Rendered so the new wire value isn't shipped unrendered — an + * unstyled kind this UI doesn't specifically label still shows its raw text (the same + * "never drop an unknown kind" discipline STATUS_LABELS/MODE_LABELS already follow). */ +const KIND_LABELS: Record = { copy: "Copy", crosstalk: "Crosstalk" }; + /** gh-#142: the MODE column is the F69/F70 degradation ladder, which nothing on the page said — * a native-title flyover per chip explains the rung without spending any screen real estate. * Wording tracks DegradationMode's own docs. gh-#210: these same three lines also feed the Mode @@ -59,12 +72,16 @@ function Chip({ tone, children }: { tone: ChipTone; children: ReactNode }): Reac ); } -/** ok -> success, trimmed -> brass (the same "system note, not a fault" tone modeTone uses for - * soft — a trim airs shorter copy, it did not fail), failed/timeout -> danger — both real misses - * read the same "something's wrong" tone; the label text (STATUS_LABELS) is what tells them apart. */ +/** ok -> success, trimmed/rejected -> brass (the same "system note, not a fault" tone modeTone + * uses for soft — a trim airs shorter copy and a reject skips a banter exchange, neither one + * failed), failed/timeout -> danger — both real misses read the same "something's wrong" tone; + * the label text (STATUS_LABELS) is what tells them apart. rejected specifically is the gh-#277 + * lesson re-applied one outcome over (SPEC F127.4, F127.11): a validation reject is a + * content-quality decision this project made on a genuinely successful HTTP call, never an + * outage, so it must never paint the inspector red. */ function statusTone(status: string): ChipTone { if (status === "ok") return "success"; - if (status === "trimmed") return "brass"; + if (status === "trimmed" || status === "rejected") return "brass"; return "danger"; } @@ -80,6 +97,13 @@ function StatusChip({ status }: { status: string }): ReactNode { return {STATUS_LABELS[status] ?? status}; } +/** Neutral tone (mirrors BoothLogFeed's own track-started kind badge) — Kind is a plain + * "which surface authored this" label, not a state judgment, so it never borrows a + * success/danger/brass tone the way StatusChip/ModeChip do. */ +function KindChip({ kind }: { kind: string }): ReactNode { + return {KIND_LABELS[kind] ?? kind}; +} + function ModeChip({ mode }: { mode: string }): ReactNode { return ( @@ -154,6 +178,7 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R Time Persona + Kind Status {/* gh-#210: the header carries the house `?` flyover (gh-#145 pattern) — the @@ -194,6 +219,9 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R {entry.personaName ?? "—"} + + + @@ -217,7 +245,7 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R {isExpanded && ( - +
diff --git a/admin-ui/app/(authed)/settings/SettingsForm.tsx b/admin-ui/app/(authed)/settings/SettingsForm.tsx index 344c93f..047612a 100644 --- a/admin-ui/app/(authed)/settings/SettingsForm.tsx +++ b/admin-ui/app/(authed)/settings/SettingsForm.tsx @@ -390,6 +390,11 @@ const FIELD_HELP_TEXT: Record = { "How often a show's flavor may color an ordinary lead-in/back-announce, in minutes — shares " + "the same one-line slot as the context patter lines above; a due context fact always wins. " + "0 disables the show-flavor line. Accepted range: 0–1440.", + + // ── Crosstalk two-voice banter (SPEC F127.4) ──────────────────────────────────────────────── + "Crosstalk:DurationTargetSeconds": + "The longest a generated two-voice banter exchange may run, in seconds, before it is " + + "discarded and skipped rather than aired. Defaults to 25. Accepted range: 5–120.", }; /** diff --git a/admin-ui/app/(authed)/settings/settings-help-keys.ts b/admin-ui/app/(authed)/settings/settings-help-keys.ts index 7753321..b14f1fc 100644 --- a/admin-ui/app/(authed)/settings/settings-help-keys.ts +++ b/admin-ui/app/(authed)/settings/settings-help-keys.ts @@ -83,6 +83,7 @@ export const SETTINGS_HELP_KEYS = [ "Station:Imaging:TimeAnnouncements", "Station:Imaging:TimeAnnouncementStaleMinutes", "Station:Shows:PatterCadenceMinutes", + "Crosstalk:DurationTargetSeconds", ] as const; export type SettingsHelpKey = (typeof SETTINGS_HELP_KEYS)[number]; diff --git a/admin-ui/lib/llm-calls-api.ts b/admin-ui/lib/llm-calls-api.ts index b5eec7f..a2959cc 100644 --- a/admin-ui/lib/llm-calls-api.ts +++ b/admin-ui/lib/llm-calls-api.ts @@ -22,6 +22,12 @@ export interface LlmCallEntry { response: string | null; promptChars: number; responseChars: number; + /** gh-#385, SPEC F127.11 — which generation surface produced this call + * (GenWave.Tts.LlmCallKind): `"copy"` for every ordinary segment-copy call, `"crosstalk"` for a + * CrosstalkScriptWriter call — so an operator can tell "why was there no banter" apart from an + * ordinary blurb miss. Plain string, not a closed union, for the same reason `status`/`mode` + * are above. */ + kind: string; } /** diff --git a/src/GenWave.Host/Api/LlmCallDto.cs b/src/GenWave.Host/Api/LlmCallDto.cs index 7612b8d..c73101a 100644 --- a/src/GenWave.Host/Api/LlmCallDto.cs +++ b/src/GenWave.Host/Api/LlmCallDto.cs @@ -8,7 +8,9 @@ namespace GenWave.Host.Api; /// own remarks). / are a cheap at-a-glance size /// for the table view; the full text is what the expandable row shows. /// (gh-#429) is who authored the call — for a persona-less render, never an -/// empty string. +/// empty string. (SPEC F127.11, PLAN T282) is "copy" for every ordinary +/// segment-copy call or "crosstalk" for a call +/// — so an operator can tell "why was there no banter" apart from an ordinary blurb miss. /// public sealed record LlmCallDto( long Seq, @@ -22,4 +24,5 @@ public sealed record LlmCallDto( string? PromptUser, string? Response, int PromptChars, - int ResponseChars); + int ResponseChars, + string Kind); diff --git a/src/GenWave.Host/Api/LlmCallsController.cs b/src/GenWave.Host/Api/LlmCallsController.cs index 7836288..bf246ab 100644 --- a/src/GenWave.Host/Api/LlmCallsController.cs +++ b/src/GenWave.Host/Api/LlmCallsController.cs @@ -45,5 +45,6 @@ public IActionResult List() record.PromptUser, record.Response, (record.PromptSystem?.Length ?? 0) + (record.PromptUser?.Length ?? 0), - record.Response?.Length ?? 0); + record.Response?.Length ?? 0, + record.Kind.ToString().ToLowerInvariant()); } diff --git a/src/GenWave.Host/Configuration/SettingValidator.cs b/src/GenWave.Host/Configuration/SettingValidator.cs index cf558a6..d250cf2 100644 --- a/src/GenWave.Host/Configuration/SettingValidator.cs +++ b/src/GenWave.Host/Configuration/SettingValidator.cs @@ -187,6 +187,14 @@ public SettingValidator(IConfiguration configuration, ThemeCatalog? themeCatalog // a negative value would still resolve safely if it slipped through some other path. internal const int ContextPersonaIdMin = 0; + // Crosstalk:DurationTargetSeconds (SPEC F127.4, STORY-326, PLAN T282) — CrosstalkOptions' own + // [Range(1, int.MaxValue)] (boot-enforced via ValidateDataAnnotations, the + // Llm:MaxCopyChars precedent); this validator adds the F53.1 settings-API-only ceiling. Floor of + // 5s guards a degenerate near-zero target from rejecting every exchange outright; 120s (2 + // minutes) is comfortably past the spec'd 25s default while still bounding a fat-finger entry. + internal const int CrosstalkDurationTargetSecondsMin = 5; + internal const int CrosstalkDurationTargetSecondsMax = 120; + // Maps each allowlisted key to a per-key (range + type) validator. An instance method (not a // static field) purely because the Station:Theme entry below closes over the constructor's own // themeCatalog — every other entry is a plain static delegate exactly as before. @@ -406,6 +414,13 @@ static Dictionary> BuildValidators(ThemeCatalog theme // Show-flavor patter line cadence (SPEC F116.3, STORY-308, PLAN T249) — same // "0 = off, 1440 ceiling" shape as Context:{Key}:PatterCadenceMinutes above. ["Station:Shows:PatterCadenceMinutes"] = v => IsIntInRange(v, ShowsPatterCadenceMinutesMin, ShowsPatterCadenceMinutesMax), + + // Crosstalk duration-fit target (SPEC F127.4, STORY-326, PLAN T282) — floor of 5s + // guards a degenerate near-zero target from rejecting every exchange outright (see + // CrosstalkDurationTargetSecondsMin's own remarks); F53.1 adds the settings-API ceiling + // on top of CrosstalkOptions' own boot-enforced [Range(1, int.MaxValue)]. + ["Crosstalk:DurationTargetSeconds"] = + v => IsIntInRange(v, CrosstalkDurationTargetSecondsMin, CrosstalkDurationTargetSecondsMax), }; // ── Per-key validation ───────────────────────────────────────────────────────────────────── @@ -959,6 +974,8 @@ var k when k.Equals("Station:Imaging:ClockAnchoredIdents", StringComparison.Ordi => $"Value '{value}' is not valid for '{key}'. Must be a boolean (true/false).", var k when k.Equals("Station:Shows:PatterCadenceMinutes", StringComparison.OrdinalIgnoreCase) => $"Value '{value}' is not valid for '{key}'. Must be an integer between {ShowsPatterCadenceMinutesMin} and {ShowsPatterCadenceMinutesMax} (minutes); 0 disables the show-flavor line.", + var k when k.Equals("Crosstalk:DurationTargetSeconds", StringComparison.OrdinalIgnoreCase) + => $"Value '{value}' is not valid for '{key}'. Must be an integer between {CrosstalkDurationTargetSecondsMin} and {CrosstalkDurationTargetSecondsMax} (seconds).", _ => $"Value '{value}' is not valid for '{key}'.", }; } diff --git a/src/GenWave.Host/Configuration/StationSettingsAllowlist.cs b/src/GenWave.Host/Configuration/StationSettingsAllowlist.cs index 473ab3f..c94153f 100644 --- a/src/GenWave.Host/Configuration/StationSettingsAllowlist.cs +++ b/src/GenWave.Host/Configuration/StationSettingsAllowlist.cs @@ -392,6 +392,15 @@ public static IReadOnlyList ThemeChoices(ThemeCatalog themeCatalo // it entirely — an opt-in feature, not a default-on one (mirrors Context:{Key}:PatterCadenceMinutes's // own "0 = off" floor immediately above). new("Station:Shows:PatterCadenceMinutes", SettingApplyMode.Live, SettingKind.Number, "minutes"), + + // Crosstalk two-voice banter, the duration-fit knob only (SPEC F127.4, STORY-326, PLAN T282) + // — CrosstalkScriptWriter (GenWave.Tts) reads this fresh via IOptionsMonitor + // on every generation attempt, so a PUT here reaches the very next attempt with no api + // restart. Defaults to the spec'd 25s; an estimate over target discards the WHOLE exchange + // rather than trimming a line (F127.4 — a cut dialogue line breaks the reaction to it). + // Crosstalk:Shows/EveryNthAiring (F127.8's scope/cadence pair) join this allowlist in a LATER + // task (T284's CrosstalkPlanner) — nothing reads them yet. + new("Crosstalk:DurationTargetSeconds", SettingApplyMode.Live, SettingKind.Number, "seconds"), }; /// All operator-editable settings, keyed by configuration key. diff --git a/src/GenWave.Host/appsettings.json b/src/GenWave.Host/appsettings.json index d229292..51bb889 100644 --- a/src/GenWave.Host/appsettings.json +++ b/src/GenWave.Host/appsettings.json @@ -50,6 +50,9 @@ "Tts": { "BlurbRetentionHours": 24 }, + "Crosstalk": { + "DurationTargetSeconds": 25 + }, "DependencyHealth": { "ProbeIntervalSeconds": 30, "ProbeTimeoutSeconds": 10, diff --git a/src/GenWave.Tts/ChatCompletionChoice.cs b/src/GenWave.Tts/ChatCompletionChoice.cs index 72d502b..9fd3ca8 100644 --- a/src/GenWave.Tts/ChatCompletionChoice.cs +++ b/src/GenWave.Tts/ChatCompletionChoice.cs @@ -2,5 +2,17 @@ namespace GenWave.Tts; using System.Text.Json.Serialization; -/// One completion choice in a (SPEC F34.3 wire shape). -sealed record ChatCompletionChoice([property: JsonPropertyName("message")] ChatCompletionMessage? Message); +/// +/// One completion choice in a (SPEC F34.3 wire shape). +/// (SPEC F127.4, F127.11, PLAN T282 — the OpenAI/ollama-compatible +/// finish_reason field, e.g. "stop"/"length") is read only by +/// , which discards the whole exchange when it is +/// "length" — a completion cut short by max_tokens leaves a truncated last line that +/// can still PARSE cleanly and would otherwise air mid-word (the gh-#424 class, one seam over). +/// when the wire omits it (an endpoint that predates this field). Purely +/// additive: never reads this property, so its own byte-identical +/// deserialization/behavior is unaffected by this field's addition. +/// +sealed record ChatCompletionChoice( + [property: JsonPropertyName("message")] ChatCompletionMessage? Message, + [property: JsonPropertyName("finish_reason")] string? FinishReason = null); diff --git a/src/GenWave.Tts/ChatCompletionResponse.cs b/src/GenWave.Tts/ChatCompletionResponse.cs index f46e7c1..eab5b28 100644 --- a/src/GenWave.Tts/ChatCompletionResponse.cs +++ b/src/GenWave.Tts/ChatCompletionResponse.cs @@ -3,9 +3,12 @@ namespace GenWave.Tts; using System.Text.Json.Serialization; /// -/// Wire shape of an OpenAI-compatible POST /v1/chat/completions response (SPEC F34.3) — -/// only the fields needs. Internal — callers only ever see the -/// extracted, cleaned copy via . +/// Wire shape of an OpenAI-compatible POST /v1/chat/completions response (SPEC F34.3), +/// shared by and (as of PLAN T282, SPEC F127.3) +/// — only the fields either one needs, see 's own +/// remarks for exactly which class reads which field. Internal — callers only ever see the +/// extracted, cleaned copy via (or, for +/// crosstalk, a validated ). /// sealed record ChatCompletionResponse( [property: JsonPropertyName("choices")] List? Choices); diff --git a/src/GenWave.Tts/CrosstalkExchangeRequest.cs b/src/GenWave.Tts/CrosstalkExchangeRequest.cs new file mode 100644 index 0000000..3b226ac --- /dev/null +++ b/src/GenWave.Tts/CrosstalkExchangeRequest.cs @@ -0,0 +1,30 @@ +namespace GenWave.Tts; + +using GenWave.Core.Domain; + +/// +/// Everything needs to request one exchange (SPEC F127.3, +/// STORY-326 AC1, AC7) — / already CAST by the +/// caller (a LATER task's CrosstalkPlanner, SPEC F127.2 — this writer never resolves who is +/// on either side of the booth), plus the show/daypart/time-of-day hooks the prompt is allowed to +/// carry. / are both optional — an unnamed block or a +/// showless station omits the corresponding prompt line entirely, mirroring +/// 's own "invent nothing beyond what's given" discipline +/// one seam over. +/// +/// +/// Structurally carries no current track (SPEC F127.3, STORY-326 AC7). There is no +/// -typed member here, by construction, the same proof +/// Story228_RequestShoutOut's own reflection fact pins for SegmentRequest one project +/// over: exchanges are generated ahead of air and cannot know what is actually playing when they +/// eventually vend, so the type this writer's prompt is built from has nothing for a future edit to +/// accidentally interpolate a track into. +/// +/// +public sealed record CrosstalkExchangeRequest( + PersonaCard HostCard, + PersonaCard NeighborCard, + string StationName, + string? ShowName, + string? Daypart, + DateTimeOffset StationLocalNow); diff --git a/src/GenWave.Tts/CrosstalkLine.cs b/src/GenWave.Tts/CrosstalkLine.cs new file mode 100644 index 0000000..df916b4 --- /dev/null +++ b/src/GenWave.Tts/CrosstalkLine.cs @@ -0,0 +1,14 @@ +namespace GenWave.Tts; + +/// +/// One accepted line of a validated (SPEC F127.3, F127.4, F127.6, +/// STORY-326) — has already cleared +/// and the per-line char budget, and is never trimmed (F127.4: "no line is ever trimmed — a cut +/// dialogue line breaks the reaction to it"). (SPEC F127.3, F127.6) +/// marks a line that overlaps the TAIL of the line immediately before it rather than following it in +/// turn order — 's one sanctioned exception to strict +/// speaker alternation, and (a LATER task, T285's per-line render / T287's assembler) the signal to +/// mix this line's render starting before the previous line's render has finished, rather than after +/// it. +/// +public sealed record CrosstalkLine(CrosstalkSpeaker Speaker, string Text, bool IsInterjection); diff --git a/src/GenWave.Tts/CrosstalkOptions.cs b/src/GenWave.Tts/CrosstalkOptions.cs new file mode 100644 index 0000000..d357f8a --- /dev/null +++ b/src/GenWave.Tts/CrosstalkOptions.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace GenWave.Tts; + +/// +/// Configuration for two-voice banter generation (SPEC F127.4, F127.8, STORY-326). Only the +/// duration-fit knob lands here at PLAN T282 — Crosstalk:Shows/Crosstalk:EveryNthAiring +/// (SPEC F127.8's scope/cadence pair) are a LATER task's own concern (T284's CrosstalkPlanner), +/// not read by anything this project builds. +/// +public sealed class CrosstalkOptions +{ + public const string Section = "Crosstalk"; + + /// + /// The spoken-duration target a validated must fit under (SPEC + /// F127.4) — an estimate over this rejects the WHOLE exchange (never a trim; see + /// 's own remarks). Defaults to the spec'd 25 seconds. Live via + /// , read fresh by + /// on every generation attempt (mirrors every other + /// live-adjustable leaf this project's options classes carry), so an operator PUT reaches the + /// very next attempt with no api restart. + /// + [Range(1, int.MaxValue)] + public int DurationTargetSeconds { get; set; } = 25; +} diff --git a/src/GenWave.Tts/CrosstalkPromptBuilder.cs b/src/GenWave.Tts/CrosstalkPromptBuilder.cs new file mode 100644 index 0000000..4d1a4bc --- /dev/null +++ b/src/GenWave.Tts/CrosstalkPromptBuilder.cs @@ -0,0 +1,106 @@ +namespace GenWave.Tts; + +using GenWave.Core.Domain; + +/// +/// Pure, stateless prompt composition for (SPEC F127.3, STORY-326) +/// — built BESIDE , never inside it (F116's own byte-identical +/// discipline for the ordinary break prompt: that prompt must never change at all, and there are +/// golden byte-pins proving it). The two builders share only what is already a small, general-purpose +/// helper ( for each card's soul/quirks, +/// for the station-local clock line) — nothing +/// here reaches INTO an ordinary-break prompt method, and nothing there reaches into this one. +/// +static class CrosstalkPromptBuilder +{ + /// + /// Chars-per-word divisor for the stated word-budget instruction (mirrors + /// LlmPromptBuilder's own private divisor of the identical value, for the identical + /// reason — see 's neighboring remarks on why a + /// WORD estimate divides by the true average rather than padding for headroom the way a TOKEN + /// cap does). STATED only, exactly like the ordinary-break prompt's own word figure — the + /// completion request's max_tokens () is what + /// actually bounds generation. + /// + const int CharsPerWordDivisor = 6; + + /// + /// Cap for / + /// before either reaches the prompt (T282 review finding, SPEC F127.3/F127.11): both are + /// operator-editable hooks with no length constraint of their own (the db show name column is + /// unbounded text) flowing straight into a prompt, exactly the class of field every + /// LlmPromptBuilder counterpart truncates first (see that class's own + /// BuildShowLine/BuildHandoffLine remarks). Mirrors LlmPromptBuilder's own + /// private MaxSoulChars value for the identical reason — duplicated, not referenced, + /// exactly like above (see that constant's own remarks for why + /// this file states its own copy rather than reaching into a private member one class over). + /// + const int MaxSoulChars = 4000; + + /// + /// The banter scaffold plus both speakers' persona sections (SPEC F127.3). Deliberately never + /// mentions a track, a song, or "what's playing" — this writer's caller (a LATER task's + /// CrosstalkPlanner) never hands one in (see 's own + /// remarks), so there is nothing here for the model to be asked about. + /// + public static string BuildSystemPrompt(PersonaCard hostCard, PersonaCard neighborCard, int maxCopyChars) + { + var wordBudget = Math.Max(1, maxCopyChars / CharsPerWordDivisor); + + var scaffold = + $"You write a short, natural-sounding radio banter exchange between two DJs sharing " + + $"the booth: {CrosstalkScriptParser.HostTag} (the on-air host) and " + + $"{CrosstalkScriptParser.NeighborTag} (a DJ dropping in). " + + $"Write between {CrosstalkScriptParser.MinLines} and {CrosstalkScriptParser.MaxLines} " + + "short lines total, alternating speakers, in this EXACT format, one line per turn, " + + $"nothing else before or after: \"{CrosstalkScriptParser.HostTag}: \" or " + + $"\"{CrosstalkScriptParser.NeighborTag}: \". " + + $"Across the WHOLE exchange use no more than approximately {wordBudget} words total. " + + "Both DJs must speak at least once. Keep each line short and conversational, no commas, " + + "no stage directions, no emoji, no markdown formatting. " + + $"To have one speaker briefly cut in over the other's line, tag that one line " + + $"\"{CrosstalkScriptParser.HostTag} {CrosstalkScriptParser.InterjectionMarker}: \" " + + $"or \"{CrosstalkScriptParser.NeighborTag} {CrosstalkScriptParser.InterjectionMarker}: " + + "\" instead of alternating — use this rarely, only for a genuine interruption. " + + "Never mention a specific song, artist, or track - neither DJ knows what is playing " + + "right now."; + + var hostSection = LlmPromptBuilder.BuildPersonaSection(persona: null, hostCard); + var neighborSection = LlmPromptBuilder.BuildPersonaSection(persona: null, neighborCard); + + var lines = new List { scaffold }; + lines.Add($"{CrosstalkScriptParser.HostTag} persona:\n{hostSection ?? "(no defined persona)"}"); + lines.Add($"{CrosstalkScriptParser.NeighborTag} persona:\n{neighborSection ?? "(no defined persona)"}"); + + return string.Join("\n\n", lines); + } + + /// + /// Station/show/daypart/time-of-day hooks (SPEC F127.3) — every line here is OPTIONAL and omitted + /// entirely when the caller has nothing to say, mirroring LlmPromptBuilder.BuildShowLine's + /// own "invent nothing beyond what's given" discipline one seam over. No track line exists because + /// carries no track to build one from. + /// + public static string BuildUserContent(CrosstalkExchangeRequest request, string stationClockLine) + { + var lines = new List + { + $"Station: {request.StationName}", + stationClockLine, + }; + + if (request.ShowName is { Length: > 0 } showName) + lines.Add($"Show: {Truncate(showName, MaxSoulChars)}"); + if (request.Daypart is { Length: > 0 } daypart) + lines.Add($"Time of day: {Truncate(daypart, MaxSoulChars)}"); + + lines.Add("Write the exchange now."); + + return string.Join('\n', lines); + } + + /// Mirrors LlmPromptBuilder's own private Truncate helper for the + /// identical reason 's own remarks give. + static string Truncate(string text, int maxChars) => + text.Length <= maxChars ? text : text[..maxChars]; +} diff --git a/src/GenWave.Tts/CrosstalkScript.cs b/src/GenWave.Tts/CrosstalkScript.cs new file mode 100644 index 0000000..1ab15f0 --- /dev/null +++ b/src/GenWave.Tts/CrosstalkScript.cs @@ -0,0 +1,13 @@ +namespace GenWave.Tts; + +/// +/// A fully validated two-voice banter script (SPEC F127.3, F127.4, STORY-326) — the WHOLE product of +/// one completion, never a partial/salvaged one: +/// is 3-8 entries long, both values appear at +/// least once, and strict alternation holds outside any line +/// marks — is the only producer. What a LATER task (T285's +/// per-line render, T287's ffmpeg assembler) does with this — voicing each line through its own +/// speaker's TtsRenderContext and mixing the renders into one asset — is out of scope here; +/// this type is only the validated intermediate the writer hands off. +/// +public sealed record CrosstalkScript(IReadOnlyList Lines); diff --git a/src/GenWave.Tts/CrosstalkScriptParser.cs b/src/GenWave.Tts/CrosstalkScriptParser.cs new file mode 100644 index 0000000..bbdc71e --- /dev/null +++ b/src/GenWave.Tts/CrosstalkScriptParser.cs @@ -0,0 +1,181 @@ +namespace GenWave.Tts; + +/// +/// Strict parse + validation for a completion reply (SPEC F127.3, +/// F127.4, STORY-326 AC2, AC3, AC4, AC6). Fail-closed by construction: the FIRST rule a reply breaks +/// is the one returned — no partial credit, no salvage, no template rung (F127.4's "the failure mode +/// is skip"). // are the +/// single source of truth for the wire format — states the exact +/// same three tokens in the instructions it builds, so the model is never asked to emit a shape this +/// parser doesn't also accept. +/// +static class CrosstalkScriptParser +{ + /// The literal line prefix a HOST turn is tagged with. Deliberately a fixed ROLE token, + /// never the persona's own display name — a card whose Name contains a colon, a space, or + /// changes between deploys must never destabilize the parse. + public const string HostTag = "HOST"; + + /// The literal line prefix a NEIGHBOR turn is tagged with — see 's own remarks. + public const string NeighborTag = "NEIGHBOR"; + + /// + /// Appended to a speaker tag (before the colon) to mark a line as an interjection (SPEC F127.3, + /// F127.6) — e.g. "HOST (interjects): ...". Matched case-insensitively, with optional + /// surrounding whitespace, by . + /// + public const string InterjectionMarker = "(interjects)"; + + /// Fewest speaker-tagged lines a script may carry (SPEC F127.4). + public const int MinLines = 3; + + /// Most speaker-tagged lines a script may carry (SPEC F127.4). + public const int MaxLines = 8; + + /// + /// Cap for a raw model line echoed into a discard reason (T282 review finding, F127.4/F127.11): + /// that reason reaches an Information log line AND + /// (the /api/llm-calls ring), neither of which is the debug surface for a whole raw + /// reply — never logs a raw reply at Information either (see that + /// class's own LogFailure remarks: "excludes the prompt itself"). ~120 chars is enough + /// to show an operator WHICH line broke without echoing a whole unbounded model line into a log + /// line/ring entry. + /// + const int MaxEchoedLineChars = 120; + + /// + /// Spoken-rate constant for the F127.4 duration estimate — words/characters spoken per second of + /// air, the SAME 15 chars/sec cold-tier heuristic GenWave.Orchestration.RollingPatterDurationEstimator + /// already uses for ordinary patter (duplicated, not referenced: that project cannot depend on + /// this one, the identical L1/L5 layering reason that estimator's own remarks give for + /// duplicating LlmOptions.MaxCopyChars's default rather than reading it live). Using the + /// SAME figure keeps "how long will this run" answered consistently across every spoken-copy + /// estimate in the codebase rather than two independently-tuned guesses. + /// + internal const double CharsPerSecond = 15.0; + + /// + /// Parses and fully validates one completion reply into a (SPEC + /// F127.3, F127.4). is the per-line char budget (the SAME + /// Llm:MaxCopyChars ceiling an ordinary blurb carries — no second setting); a line over it + /// discards the WHOLE exchange, never a trim (F127.4). is + /// the live value. + /// + public static CrosstalkWriteResult Parse(string rawResponse, int maxLineChars, int durationTargetSeconds) + { + var rawLines = rawResponse + .Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0) + .ToList(); + + if (rawLines.Count is < MinLines or > MaxLines) + { + return Discarded( + $"expected {MinLines}-{MaxLines} speaker-tagged lines, got {rawLines.Count}"); + } + + var lines = new List(rawLines.Count); + foreach (var rawLine in rawLines) + { + if (!TryParseLine(rawLine, out var speaker, out var isInterjection, out var rawText)) + { + return Discarded( + $"line does not match the '{HostTag}:'/'{NeighborTag}:' speaker-tag format: " + + $"\"{TruncateForEcho(rawLine)}\""); + } + + var cleaned = LlmCopyWriter.ApplyCopyHygiene(rawText); + if (cleaned.Length == 0) + return Discarded($"a {DescribeSpeaker(speaker)} line was empty after cleanup"); + if (cleaned.Length > maxLineChars) + { + return Discarded( + $"a {DescribeSpeaker(speaker)} line ({cleaned.Length} chars) exceeded the " + + $"{maxLineChars}-char per-line budget — no line is ever trimmed (SPEC F127.4)"); + } + + lines.Add(new CrosstalkLine(speaker, cleaned, isInterjection)); + } + + if (lines.All(line => line.Speaker != CrosstalkSpeaker.Host)) + return Discarded($"no {HostTag} line appeared — both speakers must be present"); + if (lines.All(line => line.Speaker != CrosstalkSpeaker.Neighbor)) + return Discarded($"no {NeighborTag} line appeared — both speakers must be present"); + + for (var i = 1; i < lines.Count; i++) + { + // The sanctioned exception (SPEC F127.4): an interjection overlaps the tail of the + // previous line rather than following it in turn order, so it is exempt from the + // adjacent-pair alternation check below. + if (lines[i].IsInterjection) + continue; + + if (lines[i].Speaker == lines[i - 1].Speaker) + { + return Discarded( + $"speaker alternation broken at line {i + 1} (mark an overlapping line as " + + $"'{InterjectionMarker}' instead)"); + } + } + + var totalChars = lines.Sum(line => line.Text.Length); + var estimatedSeconds = totalChars / CharsPerSecond; + if (estimatedSeconds > durationTargetSeconds) + { + return Discarded( + $"estimated {estimatedSeconds:F1}s exceeds the {durationTargetSeconds}s " + + $"{nameof(CrosstalkOptions.DurationTargetSeconds)} target"); + } + + return new CrosstalkWriteResult.Accepted(new CrosstalkScript(lines)); + } + + /// + /// One line's speaker tag, optional interjection marker, and spoken text, split on its FIRST + /// colon — a line with no colon, or whose pre-colon tag is neither nor + /// (after stripping an optional ), + /// fails to parse. Case-insensitive against both tokens: a small model's casing is not part of + /// the contract this parser enforces, only the STRUCTURE is. + /// + static bool TryParseLine(string line, out CrosstalkSpeaker speaker, out bool isInterjection, out string text) + { + speaker = default; + isInterjection = false; + text = ""; + + var colonIndex = line.IndexOf(':'); + if (colonIndex <= 0) + return false; + + var tag = line[..colonIndex].Trim(); + text = line[(colonIndex + 1)..]; + + isInterjection = tag.EndsWith(InterjectionMarker, StringComparison.OrdinalIgnoreCase); + var roleToken = (isInterjection ? tag[..^InterjectionMarker.Length] : tag).Trim(); + + if (roleToken.Equals(HostTag, StringComparison.OrdinalIgnoreCase)) + { + speaker = CrosstalkSpeaker.Host; + return true; + } + + if (roleToken.Equals(NeighborTag, StringComparison.OrdinalIgnoreCase)) + { + speaker = CrosstalkSpeaker.Neighbor; + return true; + } + + return false; + } + + static string DescribeSpeaker(CrosstalkSpeaker speaker) => + speaker == CrosstalkSpeaker.Host ? HostTag : NeighborTag; + + /// Truncates a raw model line to before it is echoed + /// into a discard reason (F127.11 review finding — see that constant's own remarks). + static string TruncateForEcho(string text) => + text.Length <= MaxEchoedLineChars ? text : text[..MaxEchoedLineChars] + "…"; + + static CrosstalkWriteResult.Discarded Discarded(string reason) => new(reason); +} diff --git a/src/GenWave.Tts/CrosstalkScriptWriter.cs b/src/GenWave.Tts/CrosstalkScriptWriter.cs new file mode 100644 index 0000000..e1ffba3 --- /dev/null +++ b/src/GenWave.Tts/CrosstalkScriptWriter.cs @@ -0,0 +1,189 @@ +namespace GenWave.Tts; + +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +/// +/// Generates one two-voice banter exchange per call (SPEC F127.3, F127.4, STORY-326) — the +/// GenWave.Tts half of the crosstalk feature (ARCHITECTURE.md "Crosstalk (F127…)"): casting +/// (CrosstalkPlanner), per-line rendering, and assembly are ALL later tasks (T284-T287) this +/// class never touches. Lives beside and shares its F123 machinery +/// (, via +/// , ) rather than duplicating it — see +/// each reused member's own remarks for exactly why sharing that piece is safe. +/// +/// +/// One completion, whole exchange (SPEC F127.3). Unlike , this +/// writer NEVER degrades to a template — F127.4 is skip-only: any failure (disabled endpoint, +/// transport fault, a completion truncated at max_tokens, malformed reply, a line failing +/// hygiene/budget, an over-target duration estimate) returns +/// with one reason, logged at Information +/// (never WARN — banter is optional color, a miss is not an outage) and recorded into +/// under so /api/llm-calls can +/// answer "why was there no banter" (SPEC F127.11) exactly the way it already answers "why was there +/// no lead-in" for . +/// +/// +/// +/// No single-flight gate here (unlike 's own SPEC F69.6 seam). +/// Crosstalk generation happens entirely off the on-air clock, ahead of air (SPEC F127.7) — a LATER +/// task's thin stock-timer loop (T286) is what will pace how often this writer is actually called, +/// which is the natural place to coordinate backend concurrency with the on-air copy path if that +/// ever proves necessary; adding a shared gate here now, with no caller yet, would be speculative. +/// +/// +/// +/// Registered as a DI singleton with NO eager I/O in its constructor (Story125's zero-I/O invariant) +/// — every dependency here is itself a cheap seam (an options monitor, a ring, a logger, an +/// ), so constructing this class never touches the network. +/// +/// +public sealed class CrosstalkScriptWriter( + IHttpClientFactory httpClientFactory, + IOptionsMonitor llmOptions, + IOptionsMonitor crosstalkOptions, + LlmCallRing callRing, + IDegradationModeReader degradationMode, + ILogger logger, + TimeProvider timeProvider) +{ + /// + /// Requests one exchange. Never throws toward the caller for anything short of the caller's own + /// cancelling — every other fault (disabled endpoint, timeout, non-2xx, + /// connect, malformed/invalid script) resolves to + /// (SPEC F127.4's skip-only failure mode). + /// + public async Task WriteExchangeAsync(CrosstalkExchangeRequest request, CancellationToken ct) + { + var startedAt = timeProvider.GetUtcNow(); + var mode = degradationMode.CurrentMode; + var personaName = $"{request.HostCard.Name} / {request.NeighborCard.Name}"; + + var cfg = llmOptions.CurrentValue; + if (string.IsNullOrEmpty(cfg.Endpoint)) + return Discard("Llm:Endpoint is not configured", personaName, startedAt, mode, systemPrompt: null, userPrompt: null); + + var durationTargetSeconds = crosstalkOptions.CurrentValue.DurationTargetSeconds; + + var systemPrompt = CrosstalkPromptBuilder.BuildSystemPrompt(request.HostCard, request.NeighborCard, cfg.MaxCopyChars); + var userPrompt = CrosstalkPromptBuilder.BuildUserContent( + request, LlmPromptBuilder.BuildStationClockLine(request.StationLocalNow)); + + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(cfg.TimeoutSeconds)); + + var http = httpClientFactory.CreateClient(LlmCopyWriter.HttpClientName); + var requestUri = EndpointUri.Combine(cfg.Endpoint, "/v1/chat/completions"); + + var body = new + { + model = cfg.Model, + messages = new object[] + { + new { role = "system", content = systemPrompt }, + new { role = "user", content = userPrompt }, + }, + // SPEC F127.3: "the F123.1 derived generation cap applies to the whole script" — the + // SAME formula LlmCopyWriter derives an ordinary blurb's cap from, not a second, + // banter-specific one (the one-knob discipline). + max_tokens = LlmCopyWriter.DeriveMaxTokens(cfg.MaxCopyChars), + }; + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = JsonContent.Create(body), + }; + + if (!string.IsNullOrEmpty(cfg.ApiKey)) + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cfg.ApiKey); + + var response = await http.SendAsync(httpRequest, timeoutCts.Token); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(timeoutCts.Token); + var choice = payload?.Choices?.FirstOrDefault(); + var raw = choice?.Message?.Content ?? string.Empty; + + // SPEC F127.4, F127.11 (gh-#424 class, one seam over): a completion the backend cut + // short at its own max_tokens cap leaves a truncated last line that can still PARSE + // cleanly (a chopped sentence still matches the HOST:/NEIGHBOR: line shape) and would + // otherwise air mid-word — so this is checked BEFORE Parse ever runs, not left for the + // parser to maybe catch by accident. finish_reason is the OpenAI/ollama-compatible + // signal for exactly this (see ChatCompletionChoice.FinishReason's own remarks); "stop" + // (or a missing field, from an endpoint that predates it) never trips this check. + if (choice?.FinishReason == "length") + { + return Discard( + "the completion was cut short by max_tokens (finish_reason: length) — a truncated reply is never aired", + personaName, startedAt, mode, systemPrompt, userPrompt, raw); + } + + var result = CrosstalkScriptParser.Parse(raw, cfg.MaxCopyChars, durationTargetSeconds); + return result switch + { + CrosstalkWriteResult.Accepted => Accept(result, personaName, systemPrompt, userPrompt, raw, startedAt, mode), + CrosstalkWriteResult.Discarded discarded => Discard( + discarded.Reason, personaName, startedAt, mode, systemPrompt, userPrompt, raw), + _ => throw new System.Diagnostics.UnreachableException($"Unhandled {nameof(CrosstalkWriteResult)} case."), + }; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // The caller cancelled (e.g. shutdown) — not our own Llm:TimeoutSeconds budget expiring, + // and not a call outcome worth a ring entry (mirrors LlmCopyWriter's own handling). + throw; + } + catch (Exception ex) + { + var (outcome, detail) = LlmCopyWriter.ClassifyForRing(ex); + callRing.Record( + personaName, systemPrompt, userPrompt, response: null, startedAt, ElapsedMs(startedAt), + outcome, detail, mode, LlmCallKind.Crosstalk); + logger.LogInformation( + "Crosstalk exchange discarded (persona: {PersonaName}): {Detail}", + personaName.ReplaceLineEndings(" "), detail.ReplaceLineEndings(" ")); + return new CrosstalkWriteResult.Discarded(detail); + } + } + + CrosstalkWriteResult Accept( + CrosstalkWriteResult result, string personaName, string systemPrompt, string userPrompt, string raw, + DateTimeOffset startedAt, DegradationMode mode) + { + callRing.Record( + personaName, systemPrompt, userPrompt, raw, startedAt, ElapsedMs(startedAt), + LlmCallOutcome.Ok, statusDetail: null, mode, LlmCallKind.Crosstalk); + return result; + } + + /// + /// The one discard path every failure funnels through (SPEC F127.4) — records into + /// (skipped entirely when is null, i.e. + /// nothing was ever attempted — the disabled-endpoint short-circuit, mirroring + /// 's own "disabled means no ring entry" posture) and logs + /// exactly one Information line (never WARN — F127.4's own posture: a discard is discipline, not + /// an outage). + /// + CrosstalkWriteResult.Discarded Discard( + string reason, string personaName, DateTimeOffset startedAt, DegradationMode mode, + string? systemPrompt, string? userPrompt, string? raw = null) + { + if (systemPrompt is not null) + { + callRing.Record( + personaName, systemPrompt, userPrompt, raw, startedAt, ElapsedMs(startedAt), + LlmCallOutcome.Rejected, reason, mode, LlmCallKind.Crosstalk); + } + + logger.LogInformation( + "Crosstalk exchange discarded (persona: {PersonaName}): {Reason}", + personaName.ReplaceLineEndings(" "), reason.ReplaceLineEndings(" ")); + return new CrosstalkWriteResult.Discarded(reason); + } + + long ElapsedMs(DateTimeOffset startedAt) => (long)(timeProvider.GetUtcNow() - startedAt).TotalMilliseconds; +} diff --git a/src/GenWave.Tts/CrosstalkSpeaker.cs b/src/GenWave.Tts/CrosstalkSpeaker.cs new file mode 100644 index 0000000..c63da3b --- /dev/null +++ b/src/GenWave.Tts/CrosstalkSpeaker.cs @@ -0,0 +1,18 @@ +namespace GenWave.Tts; + +/// +/// The two roles a -generated exchange ever casts (SPEC F127.1, +/// F127.2, STORY-326) — never a third. is the on-air DJ; +/// is the schedule-adjacent "drop-in" persona a later task (PLAN T284's CrosstalkPlanner) +/// resolves from the grid. This writer never decides WHO fills either role — it only ever receives +/// two already-cast s on +/// and tags each generated line with which one spoke it. +/// +public enum CrosstalkSpeaker +{ + /// The on-air host persona. + Host, + + /// The schedule-adjacent drop-in persona (SPEC F127.2). + Neighbor, +} diff --git a/src/GenWave.Tts/CrosstalkWriteResult.cs b/src/GenWave.Tts/CrosstalkWriteResult.cs new file mode 100644 index 0000000..c1bd255 --- /dev/null +++ b/src/GenWave.Tts/CrosstalkWriteResult.cs @@ -0,0 +1,29 @@ +namespace GenWave.Tts; + +/// +/// Outcome of (SPEC F127.3, F127.4, STORY-326) +/// — mirrors 's closed-hierarchy shape (an +/// accepted script always carries real, fully-validated lines; a discard always carries a reason, +/// never both, never neither). There is no third "partial" case and no template/salvage rung by +/// design (F127.4): every failure — a transport fault, a malformed reply, a line that fails hygiene +/// or its budget, an over-target duration estimate — collapses to , and the +/// caller (a LATER task's CrosstalkPlanner/stock-timer loop) simply tries again on its own +/// cadence rather than distinguishing WHY this attempt produced nothing. +/// +public abstract record CrosstalkWriteResult +{ + CrosstalkWriteResult() { } + + /// A fully validated, ready-to-render two-voice script. + public sealed record Accepted(CrosstalkScript Script) : CrosstalkWriteResult; + + /// + /// No exchange was produced. is the SAME text logged at Information (SPEC + /// F127.4 — a discard is never a WARN, banter is optional color) and recorded as + /// under (a content + /// validation miss) or / (a + /// transport miss) — one string, one source of truth for "why was there no banter" across the log + /// line, the ring, and this return value. + /// + public sealed record Discarded(string Reason) : CrosstalkWriteResult; +} diff --git a/src/GenWave.Tts/LlmCallKind.cs b/src/GenWave.Tts/LlmCallKind.cs new file mode 100644 index 0000000..372fa98 --- /dev/null +++ b/src/GenWave.Tts/LlmCallKind.cs @@ -0,0 +1,19 @@ +namespace GenWave.Tts; + +/// +/// Which generation surface produced an entry (SPEC F73.1, F127.11, PLAN +/// T282) — orthogonal to (what happened) and to +/// (what the ladder was doing at the time). Every call +/// itself records — on-air, Soft-cadence, or an operator preview alike — +/// is ; 's own completion calls are +/// , so an operator reading /api/llm-calls can tell "why was there no +/// banter" apart from an ordinary blurb miss without re-deriving it from the prompt text (F127.11). +/// +public enum LlmCallKind +{ + /// An ordinary segment-copy completion () — LeadIn, BackAnnounce, SignOff, SignOn, ContextSegment, or a persona preview. + Copy, + + /// A two-voice banter script completion (, SPEC F127.3). + Crosstalk, +} diff --git a/src/GenWave.Tts/LlmCallOutcome.cs b/src/GenWave.Tts/LlmCallOutcome.cs index 53c8449..15f93d9 100644 --- a/src/GenWave.Tts/LlmCallOutcome.cs +++ b/src/GenWave.Tts/LlmCallOutcome.cs @@ -41,4 +41,17 @@ public enum LlmCallOutcome /// segment still airs the trimmed copy (see 's own remarks). /// Trimmed, + + /// + /// The completions endpoint returned 2xx, but the reply failed CONTENT validation — a + /// -only outcome (SPEC F127.4, PLAN T282): the script didn't + /// parse (wrong line count, an unrecognized speaker tag, broken alternation), a line failed + /// hygiene or its per-line budget, or the estimated spoken duration exceeded the configured + /// target. Split out from the same way split out from + /// above — a validation reject is a content-quality decision this project made + /// on a genuinely successful HTTP call, never a transport/endpoint fault. Unlike + /// , there is no salvage here: F127.4 is skip-only, no trim, no template + /// rung — carries the discard reason. + /// + Rejected, } diff --git a/src/GenWave.Tts/LlmCallRecord.cs b/src/GenWave.Tts/LlmCallRecord.cs index 674f223..3b8f202 100644 --- a/src/GenWave.Tts/LlmCallRecord.cs +++ b/src/GenWave.Tts/LlmCallRecord.cs @@ -37,9 +37,17 @@ namespace GenWave.Tts; /// /// When this call was dispatched (includes any single-flight queueing wait — mirrors 's own attemptedAt semantics). /// Wall-clock duration from to completion (success or failure). -/// ok/failed/timeout/trimmed (SPEC F73.1, F123.2-F123.4). -/// The HTTP status or exception type name for a non- outcome; for Ok and for alike — a trim is not a fault, so it carries no fault detail either. +/// ok/failed/timeout/trimmed/rejected (SPEC F73.1, F123.2-F123.4, F127.4). +/// The HTTP status or exception type name for a non- outcome; for Ok and for alike — a trim is not a fault, so it carries no fault detail either. Carries the discard reason for (SPEC F127.4, F127.11). /// The degradation mode active at call time (SPEC F73.1, F69.1) — Normal/Soft/Hard. +/// +/// Which generation surface produced this call (SPEC F127.11, PLAN T282) — +/// for every call itself records, for +/// 's own. Defaults to so +/// 's two pre-existing call sites (inside +/// ) needed no change at all when this +/// parameter was added. +/// public sealed record LlmCallRecord( long Seq, string? PersonaName, @@ -50,4 +58,5 @@ public sealed record LlmCallRecord( long ElapsedMs, LlmCallOutcome Outcome, string? StatusDetail, - DegradationMode Mode); + DegradationMode Mode, + LlmCallKind Kind = LlmCallKind.Copy); diff --git a/src/GenWave.Tts/LlmCallRing.cs b/src/GenWave.Tts/LlmCallRing.cs index 9db1719..e065c8f 100644 --- a/src/GenWave.Tts/LlmCallRing.cs +++ b/src/GenWave.Tts/LlmCallRing.cs @@ -43,17 +43,20 @@ public sealed class LlmCallRing(IOptionsMonitor options) /// Llm:CallRingCapacity trims (or grows) the ring on the very next record. /// (gh-#429) is the caller's already-resolved /// result — this ring never re-derives a name itself, - /// it only stores what it was handed. + /// it only stores what it was handed. (SPEC F127.11, PLAN T282) defaults + /// to so 's own two call sites need no + /// change — only ever passes . /// public void Record( string? personaName, string? promptSystem, string? promptUser, string? response, DateTimeOffset startedAt, - long elapsedMs, LlmCallOutcome outcome, string? statusDetail, DegradationMode mode) + long elapsedMs, LlmCallOutcome outcome, string? statusDetail, DegradationMode mode, + LlmCallKind kind = LlmCallKind.Copy) { lock (gate) { var record = new LlmCallRecord( ++nextSeq, personaName, promptSystem, promptUser, response, startedAt, elapsedMs, outcome, - statusDetail, mode); + statusDetail, mode, kind); ring.AddFirst(record); // newest first var capacity = options.CurrentValue.CallRingCapacity; diff --git a/src/GenWave.Tts/LlmCopyWriter.cs b/src/GenWave.Tts/LlmCopyWriter.cs index a8834b4..08a3a49 100644 --- a/src/GenWave.Tts/LlmCopyWriter.cs +++ b/src/GenWave.Tts/LlmCopyWriter.cs @@ -710,7 +710,7 @@ DateTimeOffset StationLocalNow() => /// out timeout, so duplicating this small a classification is simpler than threading a shared /// helper through two call sites with different needs. /// - static (LlmCallOutcome Outcome, string Detail) ClassifyForRing(Exception ex) => ex switch + internal static (LlmCallOutcome Outcome, string Detail) ClassifyForRing(Exception ex) => ex switch { OperationCanceledException => (LlmCallOutcome.Timeout, "Llm:TimeoutSeconds exceeded"), HttpRequestException { StatusCode: { } status } => (LlmCallOutcome.Failed, $"HTTP {(int)status}"), @@ -723,9 +723,13 @@ DateTimeOffset StationLocalNow() => /// , and for why the /// divisor, floor, and ceiling are what they are. Applied identically to the on-air path and /// the preview path, since both funnel through 's single - /// request-builder. + /// request-builder. Internal (PLAN T282, SPEC F127.3: "the F123.1 derived generation cap applies + /// to the whole script") — derives its own completion's + /// max_tokens through this exact formula rather than a second, independently-tuned one; + /// the one-knob discipline (a single Llm:MaxCopyChars) extends to banter with zero new + /// generation-cap machinery. /// - static int DeriveMaxTokens(int maxCopyChars) => + internal static int DeriveMaxTokens(int maxCopyChars) => Math.Clamp(maxCopyChars / CharsPerTokenDivisor, MinGenerationTokenCap, MaxGenerationTokenCap); /// @@ -745,17 +749,18 @@ static int DeriveMaxTokens(int maxCopyChars) => }; /// - /// Copy hygiene (SPEC F34.5) plus the F123.2 sentence-boundary salvage (STORY-319, PLAN T263): - /// trims, unwraps one layer of wrapping quotes, collapses newlines to spaces, and strips stage - /// directions and markdown emphasis markers. A result that still exceeds - /// after that hygiene is no longer an automatic reject — it is cut at the LAST complete sentence - /// that fits under the cap (see ), never mid-sentence, - /// and never at an abbreviation's own period (see that method's own remarks). - /// is reserved for the cases nothing salvages: - /// hygiene left an empty string, or nothing complete under the cap survives that filter — no - /// candidate at all, or every candidate under the cap was an abbreviation/lone-initial period. + /// The hygiene pass every LLM-authored line in this project runs through (SPEC F34.5): trims, + /// strips a chat preamble, unwraps one layer of wrapping quotes, collapses newlines to spaces, + /// and strips stage directions and markdown emphasis markers. Deliberately excludes the F123.2 + /// sentence-boundary SALVAGE below — that is a length-policy decision + /// layers on top for the ordinary on-air/preview path, and 's + /// own per-line validation (SPEC F127.4: cleared, never trimmed) needs the SAME text transform + /// with a completely different length policy (reject the whole exchange, never cut a line). + /// Internal (PLAN T282 extraction) — a small shared helper rather than a second hand-maintained + /// copy of these five steps, with zero change to 's own byte-for-byte + /// output (a pure extract-method refactor). /// - static LlmCopyCleanupResult CleanCopy(string raw, int maxChars) + internal static string ApplyCopyHygiene(string raw) { var text = StripChatPreamble(raw.Trim()); // gh-#186 — must run BEFORE quote unwrapping text = StripWrappingQuotes(text); @@ -763,7 +768,22 @@ static LlmCopyCleanupResult CleanCopy(string raw, int maxChars) text = BracketStageDirectionPattern.Replace(text, string.Empty); text = AsteriskStageDirectionPattern.Replace(text, string.Empty); text = MarkdownEmphasisPattern.Replace(text, string.Empty); - text = RepeatedWhitespacePattern.Replace(text, " ").Trim(); + return RepeatedWhitespacePattern.Replace(text, " ").Trim(); + } + + /// + /// Copy hygiene (, SPEC F34.5) plus the F123.2 sentence-boundary + /// salvage (STORY-319, PLAN T263): a result that still exceeds after + /// hygiene is no longer an automatic reject — it is cut at the LAST complete sentence that fits + /// under the cap (see ), never mid-sentence, and never at + /// an abbreviation's own period (see that method's own remarks). + /// is reserved for the cases nothing salvages: + /// hygiene left an empty string, or nothing complete under the cap survives that filter — no + /// candidate at all, or every candidate under the cap was an abbreviation/lone-initial period. + /// + static LlmCopyCleanupResult CleanCopy(string raw, int maxChars) + { + var text = ApplyCopyHygiene(raw); if (text.Length == 0) return new LlmCopyCleanupResult.Rejected(); diff --git a/src/GenWave.Tts/TtsServiceCollectionExtensions.cs b/src/GenWave.Tts/TtsServiceCollectionExtensions.cs index 6de567c..a522336 100644 --- a/src/GenWave.Tts/TtsServiceCollectionExtensions.cs +++ b/src/GenWave.Tts/TtsServiceCollectionExtensions.cs @@ -93,6 +93,21 @@ public static IServiceCollection AddGenWaveTts(this IServiceCollection services, .ValidateDataAnnotations() .ValidateOnStart(); + // Crosstalk two-voice banter (SPEC F127.4, F127.8, STORY-326, PLAN T282) — the ONE knob + // CrosstalkScriptWriter reads today (DurationTargetSeconds); Crosstalk:Shows/EveryNthAiring + // (F127.8) join this same section in a LATER task (T284's CrosstalkPlanner), not here. + // IOptionsMonitor (not IOptions), mirroring every other live-adjustable + // options class in this method — a live PUT reaches the very next generation attempt with no + // api restart. CrosstalkScriptWriter is a plain singleton with zero eager I/O in its + // constructor (Story125's zero-I/O invariant) — every dependency it takes is itself a cheap + // seam, so registering it here never touches the network. + services + .AddOptions() + .Bind(configuration.GetSection(CrosstalkOptions.Section)) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddSingleton(); + // Injected clock for DegradationController's cooldown math (no DateTime.Now anywhere in // this feature) — TryAdd so a host or test that already registers its own TimeProvider wins. services.TryAddSingleton(TimeProvider.System); diff --git a/tests/GenWave.Architecture.Tests/Support/HttpClientSeams.cs b/tests/GenWave.Architecture.Tests/Support/HttpClientSeams.cs index 365e24e..f479a40 100644 --- a/tests/GenWave.Architecture.Tests/Support/HttpClientSeams.cs +++ b/tests/GenWave.Architecture.Tests/Support/HttpClientSeams.cs @@ -63,7 +63,9 @@ internal static class HttpClientSeams /// /// One entry per production type this suite's detector finds depending on the /// family: TTS/LLM (typed-client injection; Kokoro/Piper/Ollama - /// synthesis, voice listing, health probes, LLM copywriting) plus its composition root + /// synthesis, voice listing, health probes, LLM copywriting, PLAN T282's crosstalk script + /// writing — it reuses LlmCopyWriter's own named client rather than minting a second one, + /// same shape as LlmWishParser below) plus its composition root /// (TtsServiceCollectionExtensions, whose AddHttpClient<T> calls are the /// construction site the DI container itself never exposes); MediaLibrary's Ollama /// mood/explicit enrichment and MusicBrainz year lookup (same shape) plus its composition root @@ -87,6 +89,7 @@ internal static class HttpClientSeams "GenWave.Tts.PiperHealthProbe", "GenWave.Tts.OllamaHealthProbe", "GenWave.Tts.LlmCopyWriter", + "GenWave.Tts.CrosstalkScriptWriter", "GenWave.Tts.TtsServiceCollectionExtensions", // MediaLibrary enrichment (Ollama mood/explicit, MusicBrainz year lookup). diff --git a/tests/GenWave.Tts.Tests/MockCompletionsServer.cs b/tests/GenWave.Tts.Tests/MockCompletionsServer.cs index da26a39..ceeebc5 100644 --- a/tests/GenWave.Tts.Tests/MockCompletionsServer.cs +++ b/tests/GenWave.Tts.Tests/MockCompletionsServer.cs @@ -40,6 +40,16 @@ sealed class MockCompletionsServer : IAsyncDisposable /// Completion text served in /. public volatile string ReplyContent = "Great tune coming up, stay tuned."; + /// + /// finish_reason served alongside (SPEC F127.4, F127.11, + /// PLAN T282) — the OpenAI/ollama-compatible per-choice field (see + /// GenWave.Tts.ChatCompletionChoice.FinishReason's own remarks). Defaults to + /// "stop" — a normal, non-truncated completion — so every pre-existing spec against + /// this stub is unaffected; a test flips this to "length" to simulate a backend that + /// cut the reply short at its own max_tokens cap. + /// + public volatile string? ReplyFinishReason = "stop"; + /// The base URI the stub is listening on (e.g. http://127.0.0.1:12345). public Uri BaseUri { get; } @@ -98,12 +108,12 @@ public static async Task StartAsync(MockCompletionsMode i case MockCompletionsMode.Delay: // Longer than any test's Llm:TimeoutSeconds (tests use 1-2s budgets). await Task.Delay(TimeSpan.FromSeconds(30), ctx.RequestAborted); - await WriteReplyAsync(ctx, server.ReplyContent); + await WriteReplyAsync(ctx, server.ReplyContent, server.ReplyFinishReason); return; case MockCompletionsMode.Serve: default: - await WriteReplyAsync(ctx, server.ReplyContent); + await WriteReplyAsync(ctx, server.ReplyContent, server.ReplyFinishReason); return; } }); @@ -118,11 +128,11 @@ public static async Task StartAsync(MockCompletionsMode i public async ValueTask DisposeAsync() => await app.DisposeAsync(); - static Task WriteReplyAsync(HttpContext ctx, string content) + static Task WriteReplyAsync(HttpContext ctx, string content, string? finishReason) { ctx.Response.StatusCode = 200; return ctx.Response.WriteAsJsonAsync( - new { choices = new[] { new { message = new { content } } } }, + new { choices = new[] { new { message = new { content }, finish_reason = finishReason } } }, ctx.RequestAborted); } } diff --git a/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs b/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs index 06d5d26..085fa13 100644 --- a/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs +++ b/tests/GenWave.Tts.Tests/Specs/Story326_BoothWritesForTwo.cs @@ -1,146 +1,520 @@ // STORY-326 — The booth writes for two (gh-#385 · SPEC F127.3/.4 · PLAN VQ-i, T282) // -// BDD specification — xUnit, pending until /build-loop turns them green. The design's -// named risk (2026-08-14): a 3B model writing two coherent voices — these facts pin the -// contract that makes bad output unairable, not the output good. One completion produces -// the WHOLE exchange (reactions must react to what was actually said); validation is -// fail-closed and the failure mode is silent skip — no template rung, no salvage. One -// assertion per Fact; happy first; sad segregated. The T288 wire acceptance (an exchange -// airs once on a running stack) is a production check, not represented here. ⛔ T283, the +// BDD specification — xUnit, LIVE as of T282. The design's named risk (2026-08-14): a 3B model +// writing two coherent voices — these facts pin the contract that makes bad output unairable, not +// the output good. One completion produces the WHOLE exchange (reactions must react to what was +// actually said); validation is fail-closed and the failure mode is silent skip — no template rung, +// no salvage. One assertion per Fact; happy first; sad path segregated. The T288 wire acceptance (an +// exchange airs once on a running stack) is a production check, not represented here. T283, the // paper-audition checkpoint, gates everything after T282 — these facts green first. namespace GenWave.Tts.Tests.Specs; +using Microsoft.Extensions.Logging; +using GenWave.Core.Domain; +using GenWave.Tts.Tests.Fakes; + public static class FeatureBoothWritesForTwo { - // ── HAPPY PATH ────────────────────────────────────────────────────────── + // ── Shared fixtures ───────────────────────────────────────────────────── + + static readonly PersonaCard HostCard = MakeCard( + "Neon Nightowl", "Neon Nightowl spins moody late-night sets deep into the small hours."); + + static readonly PersonaCard NeighborCard = MakeCard( + "Daybreak Dana", "Daybreak Dana brings bright upbeat energy straight off the morning show."); - public static class ScenarioOneCallWholeExchange + static readonly string WellFormedReply = string.Join('\n', new[] { - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void Exactly_one_completion_is_issued_per_exchange() + $"{CrosstalkScriptParser.HostTag}: Hey, welcome back to the show.", + $"{CrosstalkScriptParser.NeighborTag}: Great to drop in tonight.", + $"{CrosstalkScriptParser.HostTag}: Always good to have you around.", + }); + + static PersonaCard MakeCard(string name, string soul) => + new(PersonaCard.CurrentSchemaVersion, name, Tagline: "", soul, Quirks: [], + new VoiceSpec("kokoro", "af_heart", 1.0, "en"), EnergyDisposition: 0, Lore: [], Corrections: []); + + static CrosstalkExchangeRequest Request() => + new(HostCard, NeighborCard, "GenWave", ShowName: "Night Shift", Daypart: "late night", + StationLocalNow: DateTimeOffset.UtcNow); + + /// + /// The one constructor arg list in this file (mirrors Story319_CopyFitsItsBreak's own + /// BuildWriterWithRingAndLogger idiom) — every other builder below is expressed in terms of + /// this, not a second copy of it. + /// + static (CrosstalkScriptWriter Writer, LlmCallRing Ring, CapturingLogger Logger, + TestOptionsMonitor CrosstalkMonitor) BuildWriterWithRingAndLogger( + string endpoint, int maxCopyChars = 450, int durationTargetSeconds = 25) + { + var ring = new LlmCallRing(new TestOptionsMonitor(new LlmOptions())); + var logger = new CapturingLogger(); + var crosstalkMonitor = new TestOptionsMonitor( + new CrosstalkOptions { DurationTargetSeconds = durationTargetSeconds }); + var writer = new CrosstalkScriptWriter( + new FakeHttpClientFactory(), + new TestOptionsMonitor(new LlmOptions + { + Endpoint = endpoint, + Model = "test-model", + TimeoutSeconds = 5, + MaxCopyChars = maxCopyChars, + }), + crosstalkMonitor, + ring, + new FakeDegradationModeReader(), + logger, + TimeProvider.System); + return (writer, ring, logger, crosstalkMonitor); + } + + static CrosstalkScriptWriter BuildWriter(string endpoint, int maxCopyChars = 450, int durationTargetSeconds = 25) => + BuildWriterWithRingAndLogger(endpoint, maxCopyChars, durationTargetSeconds).Writer; + + static string ExtractSystemPrompt(string body) + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + foreach (var message in doc.RootElement.GetProperty("messages").EnumerateArray()) { - // Given the host and neighbor persona cards plus show/daypart/time hooks - // When the writer requests an exchange - // Then ONE completion request leaves — never per-turn calls - Assert.Fail("pending T282"); + if (message.GetProperty("role").GetString() == "system") + return message.GetProperty("content").GetString() ?? ""; } - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void The_request_carries_both_persona_cards() + return ""; + } + + static int ExtractMaxTokens(string body) + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + return doc.RootElement.GetProperty("max_tokens").GetInt32(); + } + + static string ExtractUserContent(string body) + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + foreach (var message in doc.RootElement.GetProperty("messages").EnumerateArray()) + { + if (message.GetProperty("role").GetString() == "user") + return message.GetProperty("content").GetString() ?? ""; + } + + return ""; + } + + // ── HAPPY PATH ────────────────────────────────────────────────────────── + + public sealed class ScenarioOneCallWholeExchange : IAsyncLifetime + { + const int MaxCopyChars = 300; + + MockCompletionsServer mock = null!; + string wireSystemPrompt = ""; + int wireMaxTokens; + + public async Task InitializeAsync() { - Assert.Fail("pending T282"); + // Given the host and neighbor persona cards plus show/daypart/time hooks... + mock = await MockCompletionsServer.StartAsync(); + mock.ReplyContent = WellFormedReply; + var writer = BuildWriter(mock.BaseUri.ToString(), MaxCopyChars); + + // When the writer requests an exchange... + await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + wireSystemPrompt = ExtractSystemPrompt(mock.Requests[0].Body); + wireMaxTokens = ExtractMaxTokens(mock.Requests[0].Body); } - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void The_request_carries_the_F123_derived_generation_cap() + public async Task DisposeAsync() => await mock.DisposeAsync(); + + [Fact] + public void Exactly_one_completion_is_issued_per_exchange() => + // Then ONE completion request leaves — never per-turn calls. + Assert.Equal(1, mock.RequestCount); + + [Fact] + public void The_request_carries_both_persona_cards() { - // The one-knob discipline extends: the cap derives from Llm:MaxCopyChars, - // no second operator setting for banter. - Assert.Fail("pending T282"); + Assert.Contains(HostCard.Soul, wireSystemPrompt, StringComparison.Ordinal); + Assert.Contains(NeighborCard.Soul, wireSystemPrompt, StringComparison.Ordinal); } + + [Fact] + public void The_request_carries_the_F123_derived_generation_cap() => + // The one-knob discipline extends: the cap derives from Llm:MaxCopyChars, no second + // operator setting for banter. + Assert.Equal(LlmCopyWriter.DeriveMaxTokens(MaxCopyChars), wireMaxTokens); } - public static class ScenarioTheScriptParsesStrictly + public sealed class ScenarioTheScriptParsesStrictly : IAsyncLifetime { - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void A_well_formed_response_yields_three_to_eight_speaker_tagged_lines() + // A NEIGHBOR interjection immediately after another NEIGHBOR line — plain alternation would + // reject this, but the interjection marker is the one sanctioned exception (SPEC F127.4). + static readonly string Reply = string.Join('\n', new[] + { + $"{CrosstalkScriptParser.HostTag}: Hey, welcome back to the show.", + $"{CrosstalkScriptParser.NeighborTag}: Great to drop in tonight.", + $"{CrosstalkScriptParser.NeighborTag} {CrosstalkScriptParser.InterjectionMarker}: Wait, I have to say —", + $"{CrosstalkScriptParser.HostTag}: Go right ahead then.", + }); + + MockCompletionsServer mock = null!; + CrosstalkWriteResult result = null!; + + public async Task InitializeAsync() { - Assert.Fail("pending T282"); + mock = await MockCompletionsServer.StartAsync(); + mock.ReplyContent = Reply; + var writer = BuildWriter(mock.BaseUri.ToString()); + + // When the script is parsed... + result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); } - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void Both_speakers_are_present_in_an_accepted_script() + public async Task DisposeAsync() => await mock.DisposeAsync(); + + [Fact] + public void A_well_formed_response_yields_three_to_eight_speaker_tagged_lines() { - Assert.Fail("pending T282"); + var accepted = Assert.IsType(result); + Assert.InRange(accepted.Script.Lines.Count, CrosstalkScriptParser.MinLines, CrosstalkScriptParser.MaxLines); } - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void Alternation_holds_outside_interjection_marked_lines() + [Fact] + public void Both_speakers_are_present_in_an_accepted_script() { - // Strict A/B alternation; an interjection-marked line is the one - // sanctioned exception (it overlaps rather than follows). - Assert.Fail("pending T282"); + var accepted = Assert.IsType(result); + Assert.Contains(accepted.Script.Lines, line => line.Speaker == CrosstalkSpeaker.Host); + Assert.Contains(accepted.Script.Lines, line => line.Speaker == CrosstalkSpeaker.Neighbor); + } + + [Fact] + public void Alternation_holds_outside_interjection_marked_lines() + { + // The reply's third line breaks strict adjacent alternation (NEIGHBOR follows NEIGHBOR) + // ONLY because it is marked as an interjection — proving the parser's one sanctioned + // exception is what let it through, not a broken alternation check. + var accepted = Assert.IsType(result); + Assert.True(accepted.Script.Lines[2].IsInterjection); + Assert.Equal(accepted.Script.Lines[1].Speaker, accepted.Script.Lines[2].Speaker); } } - public static class ScenarioPerLineHygieneWithoutTrimming + public sealed class ScenarioPerLineHygieneWithoutTrimming : IAsyncLifetime { - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void Every_accepted_line_has_cleared_the_standing_copy_cleanup() + MockCompletionsServer mock = null!; + + public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync(); + public async Task DisposeAsync() => await mock.DisposeAsync(); + + [Fact] + public async Task Every_accepted_line_has_cleared_the_standing_copy_cleanup() { - Assert.Fail("pending T282"); + // Given a parsed script whose lines carry the same hygiene hazards an ordinary blurb + // does (wrapping quotes, a bracketed stage direction)... + mock.ReplyContent = string.Join('\n', new[] + { + $"{CrosstalkScriptParser.HostTag}: \"Welcome back to the show.\"", + $"{CrosstalkScriptParser.NeighborTag}: *laughs* Great to be here tonight.", + $"{CrosstalkScriptParser.HostTag}: Always a pleasure to have you.", + }); + var writer = BuildWriter(mock.BaseUri.ToString()); + + // When validation runs... + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + // Then every accepted line has cleared the standing copy cleanup. + var accepted = Assert.IsType(result); + Assert.Equal("Welcome back to the show.", accepted.Script.Lines[0].Text); + Assert.Equal("Great to be here tonight.", accepted.Script.Lines[1].Text); } - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void No_line_is_ever_trimmed() + [Fact] + public async Task No_line_is_ever_trimmed() { - // A cut dialogue line breaks the reaction to it — over-budget rejects - // the WHOLE exchange (sad path), it never salvages a line (F123.2's - // trim deliberately does NOT extend here). - Assert.Fail("pending T282"); + // A cut dialogue line breaks the reaction to it — over-budget rejects the WHOLE exchange + // (sad path), it never salvages a line (F123.2's trim deliberately does NOT extend here). + mock.ReplyContent = string.Join('\n', new[] + { + $"{CrosstalkScriptParser.HostTag}: This line is written to run well past the tiny " + + "configured per-line character budget for this fact.", + $"{CrosstalkScriptParser.NeighborTag}: Short reply.", + $"{CrosstalkScriptParser.HostTag}: Another short one.", + }); + var writer = BuildWriter(mock.BaseUri.ToString(), maxCopyChars: 20); + + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + // Never a truncated Accepted — the whole exchange is discarded instead. + Assert.IsType(result); } } - public static class ScenarioTheExchangeFitsItsMoment + public sealed class ScenarioTheExchangeFitsItsMoment : IAsyncLifetime { - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void A_script_under_the_duration_target_is_accepted() + MockCompletionsServer mock = null!; + + public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync(); + public async Task DisposeAsync() => await mock.DisposeAsync(); + + [Fact] + public async Task A_script_under_the_duration_target_is_accepted() { - Assert.Fail("pending T282"); + // Given a validated script well under the 25s default (three short lines)... + mock.ReplyContent = WellFormedReply; + var writer = BuildWriter(mock.BaseUri.ToString()); + + // When the spoken-duration estimate is computed... + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + Assert.IsType(result); } - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void The_duration_target_is_live_editable_with_a_25s_default() + [Fact] + public async Task The_duration_target_is_live_editable_with_a_25s_default() { - Assert.Fail("pending T282"); + // Given the shipped default (SPEC F127.4) — 200 chars / 15 chars-per-sec ~= 13.3s, which + // fits comfortably under it. + Assert.Equal(25, new CrosstalkOptions().DurationTargetSeconds); + + mock.ReplyContent = string.Join('\n', new[] + { + $"{CrosstalkScriptParser.HostTag}: {new string('a', 70)}", + $"{CrosstalkScriptParser.NeighborTag}: {new string('b', 70)}", + $"{CrosstalkScriptParser.HostTag}: {new string('c', 60)}", + }); + var (writer, _, _, crosstalkMonitor) = BuildWriterWithRingAndLogger(mock.BaseUri.ToString()); + + var underDefault = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + Assert.IsType(underDefault); + + // When the live setting drops below that same script's estimate, with NO writer + // rebuild... + crosstalkMonitor.CurrentValue = new CrosstalkOptions { DurationTargetSeconds = 10 }; + var overLoweredTarget = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + // Then the very next attempt reflects the live edit. + Assert.IsType(overLoweredTarget); } } public static class ScenarioGenerationIsVisible { - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void The_call_appears_in_the_llm_ring_under_its_own_kind() + [Fact] + public static async Task The_call_appears_in_the_llm_ring_under_its_own_kind() { - // Accepted or rejected-with-reason — /api/llm-calls answers "why was - // there no banter" without a log stack (the F123.4 posture). - Assert.Fail("pending T282"); + // Given any generation attempt... + await using var mock = await MockCompletionsServer.StartAsync(); + mock.ReplyContent = WellFormedReply; + var (writer, ring, _, _) = BuildWriterWithRingAndLogger(mock.BaseUri.ToString()); + + await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + // When /api/llm-calls is read (the ring it's built from)... + var record = Assert.Single(ring.Snapshot()); + + // Then the call appears under its own kind. + Assert.Equal(LlmCallKind.Crosstalk, record.Kind); + } + + // T282 review finding (F2b): mutation-proven — deleting the discard path's own + // callRing.Record(...) call left the suite green, since nothing previously asserted a + // DISCARDED attempt is visible to the ring at all. A discard must be just as visible as an + // accept (SPEC F127.11 — "why was there no banter" has to be answerable from the ring for + // a reject, not only for a success). + [Fact] + public static async Task A_discarded_attempt_also_appears_in_the_ring() + { + // Given a response that fails validation (no recognizable speaker tags at all)... + await using var mock = await MockCompletionsServer.StartAsync(); + mock.ReplyContent = "Hey there, welcome back!\nGreat to see you too!\nLet's get into it."; + var (writer, ring, _, _) = BuildWriterWithRingAndLogger(mock.BaseUri.ToString()); + + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + // Then the ring records the discard — Rejected outcome, the Crosstalk kind, and the + // SAME reason string returned to the caller carried as StatusDetail — never silence. + var discarded = Assert.IsType(result); + var record = Assert.Single(ring.Snapshot()); + Assert.Equal(LlmCallOutcome.Rejected, record.Outcome); + Assert.Equal(LlmCallKind.Crosstalk, record.Kind); + Assert.Equal(discarded.Reason, record.StatusDetail); } } // ── SAD PATH ──────────────────────────────────────────────────────────── - public static class ScenarioAnyValidationFailureDiscardsSilently + public sealed class ScenarioAnyValidationFailureDiscardsSilently : IAsyncLifetime { - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void A_malformed_response_produces_no_exchange_and_one_reason_line() + MockCompletionsServer mock = null!; + + public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync(); + public async Task DisposeAsync() => await mock.DisposeAsync(); + + [Fact] + public async Task A_malformed_response_produces_no_exchange_and_one_reason_line() + { + // Given a response failing parse (no recognizable speaker tags at all)... + mock.ReplyContent = "Hey there, welcome back!\nGreat to see you too!\nLet's get into it."; + var (writer, _, logger, _) = BuildWriterWithRingAndLogger(mock.BaseUri.ToString()); + + // When the writer completes... + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + // Then no exchange is produced, and exactly one Information line names the reason — no + // template rung, no salvage, and never a WARN (banter is optional color, a miss is not + // an outage). + Assert.IsType(result); + Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Information); + Assert.Empty(logger.Warnings); + } + + [Fact] + public async Task An_over_budget_line_rejects_the_whole_exchange() + { + mock.ReplyContent = string.Join('\n', new[] + { + $"{CrosstalkScriptParser.HostTag}: A line intentionally longer than the tiny " + + "per-line budget configured for this fact.", + $"{CrosstalkScriptParser.NeighborTag}: Short.", + $"{CrosstalkScriptParser.HostTag}: Also short.", + }); + var writer = BuildWriter(mock.BaseUri.ToString(), maxCopyChars: 15); + + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + var discarded = Assert.IsType(result); + Assert.Contains("per-line budget", discarded.Reason, StringComparison.Ordinal); + } + + [Fact] + public async Task An_over_duration_script_rejects_whole() { - // No template rung, no salvage — banter is optional color; one voice - // doing "banter" is itself the wince. - Assert.Fail("pending T282"); + mock.ReplyContent = string.Join('\n', new[] + { + $"{CrosstalkScriptParser.HostTag}: {new string('a', 70)}", + $"{CrosstalkScriptParser.NeighborTag}: {new string('b', 70)}", + $"{CrosstalkScriptParser.HostTag}: {new string('c', 60)}", + }); + var writer = BuildWriter(mock.BaseUri.ToString(), durationTargetSeconds: 1); + + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + var discarded = Assert.IsType(result); + Assert.Contains("exceeds", discarded.Reason, StringComparison.Ordinal); } - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void An_over_budget_line_rejects_the_whole_exchange() + // T282 review finding (F2a): mutation-proven — deleting the both-speakers-present guards + // left the suite green. The reachable hole: a reply where every line AFTER the first is + // marked as an interjection (SPEC F127.4's one sanctioned exception to strict alternation) + // never trips the alternation loop below either, since IsInterjection short-circuits every + // adjacent pair — so with the guards gone, a one-voice "HOST:"/all-"HOST (interjects):" + // script would validate cleanly. Only "both speakers must be present" catches it. + [Fact] + public async Task A_single_speaker_all_interjection_reply_is_discarded() { - Assert.Fail("pending T282"); + mock.ReplyContent = string.Join('\n', new[] + { + $"{CrosstalkScriptParser.HostTag}: Hey there, just me tonight.", + $"{CrosstalkScriptParser.HostTag} {CrosstalkScriptParser.InterjectionMarker}: Still me.", + $"{CrosstalkScriptParser.HostTag} {CrosstalkScriptParser.InterjectionMarker}: Also me.", + }); + var writer = BuildWriter(mock.BaseUri.ToString()); + + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + var discarded = Assert.IsType(result); + Assert.Contains(CrosstalkScriptParser.NeighborTag, discarded.Reason, StringComparison.Ordinal); } + } + + // T282 review finding (F3, gh-#424 class one seam over): a completion the backend cuts short + // at its own max_tokens cap leaves a truncated last line that can still PARSE cleanly (a + // chopped sentence still matches the HOST:/NEIGHBOR: line shape) and would otherwise air + // mid-word — finish_reason is the OpenAI/ollama-compatible signal that catches it BEFORE Parse + // ever runs. + public sealed class ScenarioATruncatedCompletionNeverAirs : IAsyncLifetime + { + MockCompletionsServer mock = null!; - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] - public static void An_over_duration_script_rejects_whole() + public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync(); + public async Task DisposeAsync() => await mock.DisposeAsync(); + + [Fact] + public async Task A_completion_capped_by_max_tokens_is_discarded_even_though_it_would_otherwise_parse() { - Assert.Fail("pending T282"); + // Given a well-formed-LOOKING reply the backend flags as cut short by its own token cap... + mock.ReplyContent = WellFormedReply; + mock.ReplyFinishReason = "length"; + var writer = BuildWriter(mock.BaseUri.ToString()); + + // When the writer completes... + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + // Then the whole exchange is discarded — never aired truncated. + var discarded = Assert.IsType(result); + Assert.Contains("length", discarded.Reason, StringComparison.Ordinal); + } + + [Fact] + public async Task A_completion_that_finished_naturally_is_not_discarded_for_that_reason() + { + // Given the SAME well-formed reply, this time flagged as having finished naturally + // (the mock's own default) — a "stop" finish_reason must never trip this check. + mock.ReplyContent = WellFormedReply; + mock.ReplyFinishReason = "stop"; + var writer = BuildWriter(mock.BaseUri.ToString()); + + var result = await writer.WriteExchangeAsync(Request(), CancellationToken.None); + + Assert.IsType(result); } } public static class ScenarioTheCurrentTrackIsStructurallyUnknowable { - [Fact(Skip = "Pending T282 — see docs/PLAN.md")] + [Fact] public static void The_prompt_contains_no_current_track_reference() { - // Exchanges are generated ahead of air and cannot know it — the prompt - // shape carries show/daypart/time hooks only. - Assert.Fail("pending T282"); + // Given prompt assembly for an exchange — CrosstalkExchangeRequest is the ONLY input + // CrosstalkPromptBuilder ever reads from, and it carries no MediaItem/track-shaped member + // at all, by construction (mirrors Story228_RequestShoutOut's own reflection proof one + // seam over). + var properties = typeof(CrosstalkExchangeRequest).GetProperties(); + + Assert.DoesNotContain(properties, p => p.PropertyType == typeof(MediaItem)); + Assert.DoesNotContain(properties, p => p.Name.Contains("Track", StringComparison.OrdinalIgnoreCase)); + } + } + + // T282 review finding (F4): CrosstalkExchangeRequest's ShowName/Daypart are operator-editable + // hooks with no length constraint of their own (the db show name column is unbounded text) — + // every LlmPromptBuilder counterpart truncates text like this before it reaches a prompt (e.g. + // BuildShowLine's own showName truncation), and this builder must do the same. + public sealed class ScenarioOperatorHooksAreCapped : IAsyncLifetime + { + MockCompletionsServer mock = null!; + + public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync(); + public async Task DisposeAsync() => await mock.DisposeAsync(); + + [Fact] + public async Task An_oversized_show_name_reaches_the_prompt_truncated() + { + // Given a ShowName far past the house 4000-char cap... + mock.ReplyContent = WellFormedReply; + var writer = BuildWriter(mock.BaseUri.ToString()); + var oversizedShowName = new string('a', 5000); + var request = Request() with { ShowName = oversizedShowName }; + + // When the writer requests an exchange... + await writer.WriteExchangeAsync(request, CancellationToken.None); + + // Then the prompt carries the truncated form, never the full 5000 chars. + var userContent = ExtractUserContent(mock.Requests[0].Body); + Assert.DoesNotContain(oversizedShowName, userContent, StringComparison.Ordinal); + Assert.Contains(new string('a', 4000), userContent, StringComparison.Ordinal); } } }