diff --git a/src/GenWave.Tts/LlmCopyWriter.cs b/src/GenWave.Tts/LlmCopyWriter.cs index 2de8abac..5153ab8f 100644 --- a/src/GenWave.Tts/LlmCopyWriter.cs +++ b/src/GenWave.Tts/LlmCopyWriter.cs @@ -391,7 +391,16 @@ async Task RequestCompletionAsync( // Built before the request goes out (moved ahead of EndpointUri.Combine, T41 review // finding) so systemPrompt/userPrompt are available to the ring for every failure this // method can raise, not just the ones after prompt assembly. - systemPrompt = LlmPromptBuilder.BuildSystemPrompt(LlmPromptBuilder.BuildPersonaSection(persona, card)); + // + // gh-#150: real DJs occasionally say their own name. The roll is taken HERE, outside + // the pure builder (the same posture BuildStationClockLine takes with timeProvider — + // nondeterminism stays injectable at the seam, the builder stays a pure function), from + // Random.Shared — the source the builder's own SampleQuirks and Orchestration's + // SystemRandomSource already standardize on. The builder enforces the persona gate + // itself: with no persona section there is no line, however the roll lands. + var mentionOwnName = Random.Shared.NextDouble() < LlmPromptBuilder.SelfNameMentionProbability; + systemPrompt = LlmPromptBuilder.BuildSystemPrompt( + LlmPromptBuilder.BuildPersonaSection(persona, card, mentionOwnName)); // Read HERE — after WaitAsync above, i.e. already inside the single-flight critical // section — not by the caller before this method was ever invoked (SPEC F83.1, T65 diff --git a/src/GenWave.Tts/LlmPromptBuilder.cs b/src/GenWave.Tts/LlmPromptBuilder.cs index 5d1afb5d..f5862b53 100644 --- a/src/GenWave.Tts/LlmPromptBuilder.cs +++ b/src/GenWave.Tts/LlmPromptBuilder.cs @@ -35,37 +35,68 @@ static class LlmPromptBuilder "each break, and never reuse an example's literal values on air."; /// - /// Baked house scaffold for the system prompt (SPEC F34.3): personality-neutral radio DJ, 1-2 - /// spoken sentences, no stage directions. (SPEC F35.2, F35.3, - /// F71.3) appends an active persona's soul + sampled quirks beneath the scaffold; null/empty - /// (no active persona, or one with nothing to show) leaves the neutral scaffold untouched — - /// blurbs work persona-less exactly as before T6. + /// Baked house scaffold for the system prompt (SPEC F34.3): 1-2 spoken sentences, no stage + /// directions. (SPEC F35.2, F35.3, F71.3) appends an active + /// persona's soul + sampled quirks beneath the scaffold AND swaps the opening line to a + /// write-in-this-voice directive (gh-#152); null/empty (no active persona, or one with nothing + /// to show) keeps the personality-neutral opening — blurbs work persona-less exactly as + /// before T6. /// public static string BuildSystemPrompt(string? personaSection) { + // gh-#152: "personality-neutral" and a persona section's "Style: bubbly, energetic, + // expressive" cancelled each other inside the SAME prompt. The neutral framing now applies + // ONLY when there is no persona section; with one, the opening line points the model at + // the persona's voice instead. The shared body below is identical either way. + const string NeutralOpening = + "You are a personality-neutral radio DJ writing live station patter."; + const string PersonaOpening = + "You are a radio DJ writing live station patter - write every word in the voice of the " + + "persona described below."; + // gh-#188: the old closing line ("You may embellish with genuine knowledge of the track, // artist, or era.") was a license a small local model cannot safely hold — observed live // renaming an artist on air (LaBarcaDeSua spoken as "Barcarola") and inventing origins // ("rural Cuba"). Era/genre color stays welcome; specific unprovided facts do not. - const string Scaffold = - "You are a personality-neutral radio DJ writing live station patter. Write exactly one " + - "or two sentences of spoken copy to be read aloud on air. Plain spoken words only - no " + + // + // gh-#151: an artist's gender is one more unprovided fact — observed live inferring it + // from a French first name ("it's off HIS self-titled EP"). they/them/their unless the + // metadata itself says otherwise; a name is never evidence. + const string ScaffoldBody = + "Write exactly one or two sentences of spoken copy to be read aloud on air. " + + "Plain spoken words only - no " + "stage directions, no emoji, no markdown formatting, no sound-effect cues. You may add " + "color about the era or genre, but never state specific facts about the artist or track " + "that you were not given, and never alter the artist's name or the track's title - when " + - "unsure, stay with what the prompt provides."; + "unsure, stay with what the prompt provides. Refer to artists and bands as " + + "they/them/their unless the provided metadata explicitly states pronouns - never infer " + + "gender from a name."; - return string.IsNullOrEmpty(personaSection) ? Scaffold : $"{Scaffold}\n\n{personaSection}"; + return string.IsNullOrEmpty(personaSection) + ? $"{NeutralOpening} {ScaffoldBody}" + : $"{PersonaOpening} {ScaffoldBody}\n\n{personaSection}"; } + /// + /// gh-#150 — how often a persona-voiced break is asked to work the DJ's own name in. Real + /// radio DJs occasionally say their own name; roughly one break in seven keeps it a habit, + /// not a tic. The roll itself is taken at the call site () and + /// arrives here as mentionOwnName — every member of this builder stays a pure function + /// of its arguments, so specs drive both outcomes deterministically. + /// + public const double SelfNameMentionProbability = 0.15; + /// /// Composes the persona section (SPEC F35.2, F35.3, F71.3): a soul line/block (see /// ) plus, when the card carries any, a line of 2-3 SAMPLED quirks (see /// ) — never the full set (F71.3). A persona that yields neither /// (no soul text, no quirks) returns null (falls back to the neutral scaffold — the "neutral /// otherwise" half of F35.2, not just the no-persona case). + /// (gh-#150) appends the say-your-own-name line (see + /// ) — a rolled-true break's request, honored only when + /// there is an actual persona section for it to ride on. /// - public static string? BuildPersonaSection(Persona? persona, PersonaCard? card) + public static string? BuildPersonaSection(Persona? persona, PersonaCard? card, bool mentionOwnName = false) { var lines = new List(); @@ -88,9 +119,45 @@ public static string BuildSystemPrompt(string? personaSection) } } + // gh-#150: the name line is a rider on an actual persona section, never a section by + // itself — a persona with no soul and no quirks stays on the neutral scaffold (the + // "neutral otherwise" half of F35.2 above) even on a rolled-true break. + if (mentionOwnName && lines.Count > 0 && ResolveName(persona, card) is { } name) + lines.Add(BuildSelfNameMentionLine(name)); + return lines.Count == 0 ? null : string.Join('\n', lines); } + /// + /// The persona's on-air name for , card-first with the + /// legacy row as fallback — the same read-path precedence established + /// (for an admin-managed persona the two are kept in lockstep anyway; see BuildSoul's remarks). + /// Null when neither carries one — no name, no line. + /// + static string? ResolveName(Persona? persona, PersonaCard? card) + { + if (card is { Name.Length: > 0 }) + return card.Name; + + return persona is { Name.Length: > 0 } ? persona.Name : null; + } + + /// + /// gh-#150 — the say-your-own-name line: real DJs occasionally drop their own name, and the + /// persona section doesn't otherwise state it, so the model can't be asked to "work your name + /// in" without being told what it is. Phrased as this break's ask ("once is plenty") — the + /// occasionally lives in 's roll, never in the model's + /// own discretion. is operator-entered and flows straight into the + /// prompt, so it gets the house cap exactly like 's + /// counterpart name (T123 review finding). + /// + static string BuildSelfNameMentionLine(string name) + { + var djName = Truncate(name, MaxSoulChars); + return $"Name note: your on-air name is {djName} - briefly work your own name into this " + + $"break where it lands naturally (e.g. \"you're with {djName}\"); once is plenty."; + } + /// /// Soul read-path decision (T36 review carry-forward #2, STORY-193): prefer the ACTIVE /// persona's when it has any content, falling back to the legacy diff --git a/tests/GenWave.Tts.Tests/Specs/Issue150_DjSelfNameMention.cs b/tests/GenWave.Tts.Tests/Specs/Issue150_DjSelfNameMention.cs new file mode 100644 index 00000000..b22a9edb --- /dev/null +++ b/tests/GenWave.Tts.Tests/Specs/Issue150_DjSelfNameMention.cs @@ -0,0 +1,82 @@ +// gh-#150 — personas: DJs should occasionally mention their own name. +// +// BDD specification — xUnit. Real radio DJs occasionally say their own name on air. On a +// SelfNameMentionProbability fraction of persona-voiced breaks, the persona section carries one +// extra instruction line asking the DJ to work their own name in naturally. The roll itself is +// taken at the call site (LlmCopyWriter) — the builder stays pure — so these specs drive the +// mentionOwnName parameter directly and pin the persona gate: no persona, no line, whatever the +// roll said. + +using GenWave.Core.Domain; + +namespace GenWave.Tts.Tests.Specs; + +public static class FeatureDjSelfNameMention +{ + static Persona BuildPersona() => new(1, "DJ Nova", "", "", "", DateTime.UtcNow, DateTime.UtcNow); + + static PersonaCard BuildCard(string soul = "A washed-up 90s radio jock chasing one more big break.") => + new( + SchemaVersion: 1, + Name: "DJ Nova", + Tagline: "", + Soul: soul, + Quirks: [], + Voice: new VoiceSpec(Engine: "", VoiceId: "", Pace: 1.0, Language: "en"), + EnergyDisposition: 0, + Lore: [], + Corrections: []); + + public static class ScenarioRolledTrueOnAPersonaVoicedBreak + { + [Fact] + public static void The_section_asks_the_dj_to_work_their_own_name_in() + { + // Given a persona-voiced break whose roll came up true + var section = LlmPromptBuilder.BuildPersonaSection(BuildPersona(), BuildCard(), mentionOwnName: true); + + // Then the name line rides beneath the persona section, naming the DJ + Assert.NotNull(section); + Assert.Contains("Name note: your on-air name is DJ Nova", section); + Assert.Contains("work your own name", section); + } + } + + public static class ScenarioRolledFalseOnAPersonaVoicedBreak + { + [Fact] + public static void The_section_carries_no_name_line() + { + // Given the same persona on a break whose roll came up false + var section = LlmPromptBuilder.BuildPersonaSection(BuildPersona(), BuildCard(), mentionOwnName: false); + + // Then the section is untouched — no name line + Assert.NotNull(section); + Assert.DoesNotContain("Name note:", section); + } + } + + public static class ScenarioNoPersonaNeverCarriesTheLine + { + [Fact] + public static void No_persona_yields_no_line_regardless_of_the_roll() + { + // Given no persona at all, on a rolled-true break + var section = LlmPromptBuilder.BuildPersonaSection(persona: null, card: null, mentionOwnName: true); + + // Then there is no section for the line to ride on + Assert.Null(section); + } + + [Fact] + public static void A_persona_with_nothing_to_show_stays_neutral_even_when_rolled_true() + { + // Given a named persona whose soul and quirks are both empty — the "neutral otherwise" + // half of F35.2: such a persona falls back to the neutral scaffold, and the name line + // is a rider on an actual persona section, never a section by itself + var section = LlmPromptBuilder.BuildPersonaSection(BuildPersona(), BuildCard(soul: ""), mentionOwnName: true); + + Assert.Null(section); + } + } +} diff --git a/tests/GenWave.Tts.Tests/Specs/Issue151_ArtistPronouns.cs b/tests/GenWave.Tts.Tests/Specs/Issue151_ArtistPronouns.cs new file mode 100644 index 00000000..86d7facc --- /dev/null +++ b/tests/GenWave.Tts.Tests/Specs/Issue151_ArtistPronouns.cs @@ -0,0 +1,43 @@ +// gh-#151 — llm: patter must use they/their for artists — never infer gender from a name. +// +// BDD specification — xUnit. Observed live on the demo box: the DJ said "it's off HIS +// self-titled EP" about an artist whose gender no metadata ever stated — inferred from a French +// first name. The system scaffold now pins the rule: they/them/their unless the provided +// metadata explicitly states pronouns; a name is never evidence of gender. + +namespace GenWave.Tts.Tests.Specs; + +public static class FeatureArtistPronouns +{ + public static class ScenarioPronounRuleRidesEverySystemPrompt + { + [Fact] + public static void The_persona_less_prompt_pins_they_them_their_for_artists() + { + // Given/When the persona-less system prompt is built + var prompt = LlmPromptBuilder.BuildSystemPrompt(personaSection: null); + + // Then the pronoun rule is present + Assert.Contains("they/them/their", prompt); + } + + [Fact] + public static void The_persona_less_prompt_forbids_inferring_gender_from_a_name() + { + var prompt = LlmPromptBuilder.BuildSystemPrompt(personaSection: null); + + Assert.Contains("never infer gender from a name", prompt); + } + + [Fact] + public static void A_persona_voiced_prompt_carries_the_same_rule() + { + // Given a system prompt with an active persona section appended + var prompt = LlmPromptBuilder.BuildSystemPrompt("Style: bubbly, energetic, expressive"); + + // Then the pronoun rule rides along unchanged — persona or not + Assert.Contains("they/them/their", prompt); + Assert.Contains("never infer gender from a name", prompt); + } + } +} diff --git a/tests/GenWave.Tts.Tests/Specs/Issue152_PersonaVoiceOpening.cs b/tests/GenWave.Tts.Tests/Specs/Issue152_PersonaVoiceOpening.cs new file mode 100644 index 00000000..dc3dd9ba --- /dev/null +++ b/tests/GenWave.Tts.Tests/Specs/Issue152_PersonaVoiceOpening.cs @@ -0,0 +1,58 @@ +// gh-#152 — llm: "personality-neutral" boilerplate contradicts persona Style in the same prompt. +// +// BDD specification — xUnit. The old system prompt opened "You are a personality-neutral radio +// DJ..." and then appended a persona section ending in "Style: bubbly, energetic, expressive" — +// the two cancelled each other. The neutral framing now applies ONLY when there is no persona +// section; with one, the opening line directs the model to write in the persona's voice instead. +// (The issue's Admin-UI half — exposing the prompt — is out of scope here and stays open.) + +namespace GenWave.Tts.Tests.Specs; + +public static class FeaturePersonaVoiceOpening +{ + const string PersonaSection = "Style: bubbly, energetic, expressive"; + + public static class ScenarioNoPersonaKeepsTheNeutralOpening + { + [Fact] + public static void The_persona_less_prompt_opens_personality_neutral() + { + // Given/When the persona-less system prompt is built + var prompt = LlmPromptBuilder.BuildSystemPrompt(personaSection: null); + + // Then the neutral framing is present + Assert.Contains("personality-neutral", prompt); + } + + [Fact] + public static void The_persona_less_prompt_never_points_at_a_persona_below() + { + var prompt = LlmPromptBuilder.BuildSystemPrompt(personaSection: null); + + // No persona section exists, so nothing may direct the model at one + Assert.DoesNotContain("voice of the persona described below", prompt); + } + } + + public static class ScenarioPersonaSwapsTheOpeningForItsOwnVoice + { + [Fact] + public static void A_persona_voiced_prompt_drops_the_neutral_boilerplate() + { + // Given a system prompt with an active persona section appended + var prompt = LlmPromptBuilder.BuildSystemPrompt(PersonaSection); + + // Then the contradiction is gone — neutral framing never rides with a Style line + Assert.DoesNotContain("personality-neutral", prompt); + } + + [Fact] + public static void A_persona_voiced_prompt_directs_the_model_at_the_personas_voice() + { + var prompt = LlmPromptBuilder.BuildSystemPrompt(PersonaSection); + + Assert.Contains("write every word in the voice of the persona described below", prompt); + Assert.Contains(PersonaSection, prompt); + } + } +}