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
11 changes: 10 additions & 1 deletion src/GenWave.Tts/LlmCopyWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,16 @@ async Task<string> 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
Expand Down
89 changes: 78 additions & 11 deletions src/GenWave.Tts/LlmPromptBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,37 +35,68 @@ static class LlmPromptBuilder
"each break, and never reuse an example's literal values on air.";

/// <summary>
/// Baked house scaffold for the system prompt (SPEC F34.3): personality-neutral radio DJ, 1-2
/// spoken sentences, no stage directions. <paramref name="personaSection"/> (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. <paramref name="personaSection"/> (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.
/// </summary>
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}";
}

/// <summary>
/// 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 (<see cref="LlmCopyWriter"/>) and
/// arrives here as <c>mentionOwnName</c> — every member of this builder stays a pure function
/// of its arguments, so specs drive both outcomes deterministically.
/// </summary>
public const double SelfNameMentionProbability = 0.15;

/// <summary>
/// Composes the persona section (SPEC F35.2, F35.3, F71.3): a soul line/block (see
/// <see cref="BuildSoul"/>) plus, when the card carries any, a line of 2-3 SAMPLED quirks (see
/// <see cref="SampleQuirks"/>) — 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).
/// <paramref name="mentionOwnName"/> (gh-#150) appends the say-your-own-name line (see
/// <see cref="BuildSelfNameMentionLine"/>) — a rolled-true break's request, honored only when
/// there is an actual persona section for it to ride on.
/// </summary>
public static string? BuildPersonaSection(Persona? persona, PersonaCard? card)
public static string? BuildPersonaSection(Persona? persona, PersonaCard? card, bool mentionOwnName = false)
{
var lines = new List<string>();

Expand All @@ -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);
}

/// <summary>
/// The persona's on-air name for <see cref="BuildSelfNameMentionLine"/>, card-first with the
/// legacy row as fallback — the same read-path precedence <see cref="BuildSoul"/> 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.
/// </summary>
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;
}

/// <summary>
/// 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 <see cref="SelfNameMentionProbability"/>'s roll, never in the model's
/// own discretion. <paramref name="name"/> is operator-entered and flows straight into the
/// prompt, so it gets the house cap exactly like <see cref="BuildHandoffLine"/>'s
/// counterpart name (T123 review finding).
/// </summary>
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.";
}

/// <summary>
/// Soul read-path decision (T36 review carry-forward #2, STORY-193): prefer the ACTIVE
/// persona's <see cref="PersonaCard.Soul"/> when it has any content, falling back to the legacy
Expand Down
82 changes: 82 additions & 0 deletions tests/GenWave.Tts.Tests/Specs/Issue150_DjSelfNameMention.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
43 changes: 43 additions & 0 deletions tests/GenWave.Tts.Tests/Specs/Issue151_ArtistPronouns.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
58 changes: 58 additions & 0 deletions tests/GenWave.Tts.Tests/Specs/Issue152_PersonaVoiceOpening.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading