Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions admin-ui/__specs__/llm-calls-page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface EntryOverrides {
response?: string | null;
promptChars?: number;
responseChars?: number;
kind?: string;
}

function makeEntry(overrides: EntryOverrides = {}) {
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -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(<LlmCallsView timeZone="UTC" />);
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(<LlmCallsView timeZone="UTC" />);
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(<LlmCallsView timeZone="UTC" />);
await flush();

expect(screen.getByText("Copy")).toBeInTheDocument();
});
});

// gh-#429: personas now author the copy this table exists to triage, so each row names who
Expand Down
40 changes: 34 additions & 6 deletions admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,22 @@ interface LlmCallsFeedProps {
timeZone?: string;
}

const STATUS_LABELS: Record<string, string> = { ok: "Ok", failed: "Failed", timeout: "Timeout", trimmed: "Trimmed" };
const STATUS_LABELS: Record<string, string> = {
ok: "Ok",
failed: "Failed",
timeout: "Timeout",
trimmed: "Trimmed",
rejected: "Rejected",
};
const MODE_LABELS: Record<string, string> = { 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<string, string> = { 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
Expand Down Expand Up @@ -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";
}

Expand All @@ -80,6 +97,13 @@ function StatusChip({ status }: { status: string }): ReactNode {
return <Chip tone={statusTone(status)}>{STATUS_LABELS[status] ?? status}</Chip>;
}

/** 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 <Chip tone="neutral">{KIND_LABELS[kind] ?? kind}</Chip>;
}

function ModeChip({ mode }: { mode: string }): ReactNode {
return (
<span title={MODE_TITLES[mode]}>
Expand Down Expand Up @@ -154,6 +178,7 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R
<tr className="border-b-2 border-line text-left">
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Time</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Persona</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Kind</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Status</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">
{/* gh-#210: the header carries the house `?` flyover (gh-#145 pattern) — the
Expand Down Expand Up @@ -194,6 +219,9 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R
<td className="max-w-[10rem] truncate py-2 pr-3 text-ink">
{entry.personaName ?? "—"}
</td>
<td className="py-2 pr-3">
<KindChip kind={entry.kind} />
</td>
<td className="py-2 pr-3">
<StatusChip status={entry.status} />
</td>
Expand All @@ -217,7 +245,7 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R
</tr>
{isExpanded && (
<tr className="border-b border-line last:border-b-0">
<td colSpan={7} className="py-3">
<td colSpan={8} className="py-3">
<div className="space-y-3 rounded-[6px] border border-line bg-surface-2 p-3">
<DetailField label="System prompt" value={entry.promptSystem} />
<DetailField label="User prompt" value={entry.promptUser} />
Expand Down
5 changes: 5 additions & 0 deletions admin-ui/app/(authed)/settings/SettingsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,11 @@ const FIELD_HELP_TEXT: Record<SettingsHelpKey, string> = {
"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.",
};

/**
Expand Down
1 change: 1 addition & 0 deletions admin-ui/app/(authed)/settings/settings-help-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
6 changes: 6 additions & 0 deletions admin-ui/lib/llm-calls-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
10 changes: 5 additions & 5 deletions compose.piper-only.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions src/GenWave.Abstractions/Abstractions/IPersonaPreviewWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ public interface IPersonaPreviewWriter
/// persona, a draft persona built from unsaved fields, or <see langword="null"/> for the
/// neutral house scaffold — in place of whatever persona is actually active on-air.
///
/// <see cref="SegmentKind.StationId"/>/<see cref="SegmentKind.TimeDate"/> 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 <see cref="PersonaPreviewResult.Failed"/> instead of
/// Routing follows the same closed set production uses
/// (<c>GenWave.Tts.LlmCopyWriter.IsLlmAuthored</c> — the single source of truth for which
/// <see cref="SegmentKind"/> 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 <see cref="PersonaPreviewResult.Failed"/> instead of
/// substituting template text.
/// </summary>
Task<PersonaPreviewResult> WritePreviewAsync(
Expand Down
7 changes: 4 additions & 3 deletions src/GenWave.Abstractions/Domain/PersonaPreviewResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ public abstract record PersonaPreviewResult
private PersonaPreviewResult() { }

/// <summary>
/// The rendered copy, ready to display. Carries either genuine LLM output or — for
/// <see cref="SegmentKind.StationId"/>/<see cref="SegmentKind.TimeDate"/>, 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
/// <see cref="SegmentKind"/> values <c>GenWave.Tts.LlmCopyWriter.IsLlmAuthored</c> reports false
/// for (never touch the LLM even on-air) — the exact template text production would air for that
/// kind.
/// </summary>
public sealed record Success(string Text) : PersonaPreviewResult;

Expand Down
8 changes: 8 additions & 0 deletions src/GenWave.Abstractions/Domain/SegmentKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,12 @@ public enum SegmentKind
/// that actually produces a <c>SegmentRequest</c> of this kind is T224's, not this enum's own.
/// </summary>
ContextSegment,

/// <summary>
/// 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 <c>SegmentRequest</c> of this kind is
/// T287's, not this enum's own.
/// </summary>
Crosstalk,
}
7 changes: 5 additions & 2 deletions src/GenWave.Host/Api/LlmCallDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ namespace GenWave.Host.Api;
/// own remarks). <see cref="PromptChars"/>/<see cref="ResponseChars"/> are a cheap at-a-glance size
/// for the table view; the full text is what the expandable row shows. <see cref="PersonaName"/>
/// (gh-#429) is who authored the call — <see langword="null"/> for a persona-less render, never an
/// empty string.
/// empty string. <see cref="Kind"/> (SPEC F127.11, PLAN T282) is <c>"copy"</c> for every ordinary
/// segment-copy call or <c>"crosstalk"</c> for a <see cref="GenWave.Tts.CrosstalkScriptWriter"/> call
/// — so an operator can tell "why was there no banter" apart from an ordinary blurb miss.
/// </summary>
public sealed record LlmCallDto(
long Seq,
Expand All @@ -22,4 +24,5 @@ public sealed record LlmCallDto(
string? PromptUser,
string? Response,
int PromptChars,
int ResponseChars);
int ResponseChars,
string Kind);
3 changes: 2 additions & 1 deletion src/GenWave.Host/Api/LlmCallsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
17 changes: 17 additions & 0 deletions src/GenWave.Host/Configuration/SettingValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -406,6 +414,13 @@ static Dictionary<string, Func<string, bool>> 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 ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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}'.",
};
}
9 changes: 9 additions & 0 deletions src/GenWave.Host/Configuration/StationSettingsAllowlist.cs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,15 @@ public static IReadOnlyList<SettingChoice> 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<CrosstalkOptions>
// 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"),
};

/// <summary>All operator-editable settings, keyed by configuration key.</summary>
Expand Down
Loading
Loading