-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPodcastScript.cs
More file actions
376 lines (343 loc) · 19.9 KB
/
Copy pathPodcastScript.cs
File metadata and controls
376 lines (343 loc) · 19.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
using System.Text;
using System.Text.RegularExpressions;
namespace AIOrchestrator.API;
/// <summary>Internal: writes the podcast body act by act from the plan's outline (length
/// predictable, arc coherent) with a bounded per-act enrichment fallback, then the separate
/// welcome intro that announces the themes the body ACTUALLY covers. The text follows the
/// narrative style spec — continuous prose, no bullet lists, only the [markers] that drive the
/// TTS and the mixing; emoji are removed deterministically.</summary>
internal static class PodcastScript
{
/// <summary>The full narrative style spec given to the model on every act.</summary>
private const string StyleSpec = """
Write the script for a podcast episode of about thirty minutes, meant to be read by a text-to-speech voice.
The style must be narrative and engaging, structured as a dramatic arc — never as a list of facts.
- Start with a strong hook: a question, a mystery, or an image that grabs attention immediately.
- Develop the story in layers, alternating tension and lighter pauses, revealing information progressively.
- Use short, direct sentences: one idea per sentence.
- Use punctuation intentionally: periods build anticipation, commas give breath, question and exclamation marks guide the TTS intonation.
- Make irony and self-irony explicit with phrases like "as absurd as it sounds" or "picture the scene".
- Write rhetorical questions as real questions, ending with a question mark.
- Alternate dense factual sections with reflective, descriptive ones to create rhythm.
- Insert musical or silence cues like [pausa musicale] or [breve silenzio] every 150-200
characters of speech (about every 10-12 seconds) to break the artificial voice flow —
the cues are a REQUIREMENT, place them densely and regularly.
- Repeat key concepts with slightly different words a few minutes apart to emphasize them.
- Speak as if to a single person: conversational and intimate, without imitating human improvisation.
- Every sentence must be clear and self-contained: a synthetic voice cannot fix ambiguity with intonation.
- NO bullet lists, NO numbered lists, NO markdown headings, NO bold, NO emoji, NO links: pure continuous prose with the [markers] only.
""";
/// <summary>Writes the episode body act by act. The per-act lengths are DETERMINISTIC
/// (base <paramref name="actChars"/> from PodcastLengths): after each act the cumulative
/// difference vs the cumulative prediction is applied to the NEXT act's target, so the
/// total converges to actChars × act count. The LLM never decides the lengths.</summary>
internal static string? Generate(string topic, string lang, string brief, PodcastPlan plan, int actChars, bool minimizeAcronyms = true)
{
var llm = new LLMUtility(Setup.ProviderConfig.ProviderName);
var langName = LangName(lang);
var style = StyleSpec;
if (minimizeAcronyms)
style += "\n- NO acronyms or initialisms: expand every abbreviation and acronym to its full form (write \"Bitcoin\", not \"BTC\").";
var sb = new StringBuilder();
var markers = ActMarkers(plan.Acts.Length);
var cumulativeActual = 0;
for (int i = 0; i < plan.Acts.Length; i++)
{
var act = plan.Acts[i];
// Deterministic correction: expected cumulative at this point = base × i; the
// difference (real − expected) is applied to the next act's target (bounded, so a
// single big overrun cannot force an unusable act). A model with a small tolerance
// gets small corrections; a loose model gets bigger ones — self-adjusting.
var diff = cumulativeActual - actChars * i;
var target = Math.Clamp(actChars - diff, actChars / 2, actChars * 3 / 2);
var seam = sb.Length > 0 ? sb.ToString()[^Math.Min(300, sb.Length)..] : "";
var beats = act.Beats.Length > 0 ? string.Join("; ", act.Beats) : "(follow the act's narrative arc)";
var (text, error) = llm.SendQuery(
$"""
Today's date is {DateTime.UtcNow:yyyy-MM-dd} (UTC) — use it as the temporal
reference for any event mentioned (an event "on 28 August" is today, a past or
upcoming date is anchored to this day).
{style}
Episode topic: {topic}.
You are writing ACT {act.Act} of the episode, in {langName}.
Act title: {act.Title}.
Story beats for this act: {beats}.
Target length for this act: about {target} characters — aim for exactly that
number. The next act compensates for any over- or underrun, so keep it close.
{seamInstruction(seam)}
Write only this act. Do not add act markers, do not write the intro or the outro.
Research brief for the whole episode (use its facts woven into the narrative):
{brief}
""",
useHistory: false, role: LLMUtility.SystemRole.None,
// Generous safety cap only: episodes are NEVER cut for length, so the cap must
// not be tight enough to sever a full act mid-narrative (the provider's strict
// enforcement proved unreliable anyway).
maxToken: 9000,
temperature: 0.8);
if (error != null || string.IsNullOrWhiteSpace(text))
{
Log.LogStep($"PodcastScript: act {act.Act} failed — {(error is int h ? $"HTTP {h}" : "empty response")}");
return null;
}
text = TrimToSentenceEnd(text.Trim());
if (i > 0) sb.Append('\n').Append(markers[i]).Append('\n');
else sb.Append(markers[i]).Append('\n');
sb.Append(text);
cumulativeActual += text.Length;
Log.LogStep($"PodcastScript: act {act.Act} '{act.Title}' → {text.Length:N0} chars (deterministic target {target:N0}, base {actChars:N0})");
}
// Episodes are NEVER cut (the operating rule): the length follows the writer — the
// explicit per-act targets + the cumulative correction keep the total on schedule
// WITHOUT trimming. No deterministic trim, no outro repair: the narrative stays whole.
var body = FilterEmoji(sb.ToString()).Trim();
if (body.Length == 0) return null;
// Bounded enrichment: acts far below their target are expanded inline (narrative
// considerations woven in, same style), at most two rounds over the body.
for (int round = 0; round < 2; round++)
{
var expanded = false;
for (int i = 0; i < plan.Acts.Length; i++)
{
var act = plan.Acts[i];
if (act.TargetChars <= 0 || MeasureAct(body, i) >= act.TargetChars * 0.8) continue;
var (enriched, err) = llm.SendQuery(
$"""
{style}
The {Ordinal(act.Act)} act of the podcast episode about "{topic}" (in {langName}) is
shorter than its target ({MeasureAct(body, i)} vs ~{act.TargetChars} characters).
Expand it INLINE: add narrative considerations, anecdotes, context and reflection that
deepen the existing story beats — keep the same style, do not repeat sentences, do not
add act markers, do not write the intro or outro. The act must end up AT MOST about
{act.TargetChars} characters — do not overshoot.
Write the full expanded act.
Current act text:
{ExtractAct(body, i)}
""",
useHistory: false, role: LLMUtility.SystemRole.None, maxToken: 9000, temperature: 0.8);
if (err == null && !string.IsNullOrWhiteSpace(enriched))
{
var before = MeasureAct(body, i);
body = ReplaceAct(body, i, StripActMarkers(enriched.Trim()));
Log.LogStep($"PodcastScript: act {act.Act} enriched {before:N0} → {MeasureAct(body, i):N0} chars");
expanded = true;
}
}
if (!expanded) break;
}
body = FilterEmoji(body).Trim();
Log.LogStep($"PodcastScript: body → {body.Length:N0} chars (deterministic total {actChars * plan.Acts.Length:N0}, base {actChars:N0}/act); enrichment rounds logged per act");
return body.Length > 0 ? body : null;
}
/// <summary>Act markers in Italian ordinal order, up to the plan's act count ("[PRIMO
/// ATTO]" ... "[SESTO ATTO]"). The mixer treats any "[...]" marker as a short pause
/// (unrecognized names fall back to a small silence), so extra acts need no mixer change.</summary>
private static string[] ActMarkers(int count)
{
var names = new[] { "primo", "secondo", "terzo", "quarto", "quinto", "sesto" };
return names.Take(Math.Clamp(count, 1, names.Length))
.Select(n => $"[{n.ToUpperInvariant()} ATTO]")
.ToArray();
}
/// <summary>Writes the short welcome intro announcing the themes the body actually covers
/// (given the body's opening) — generated AFTER the body, in the episode language.</summary>
internal static string GenerateIntro(string topic, string lang, string? podcastName, string body)
{
var llm = new LLMUtility(Setup.ProviderConfig.ProviderName);
var welcome = string.IsNullOrWhiteSpace(podcastName)
? "Welcome the listeners briefly."
: $"Welcome the listeners of the podcast named \"{podcastName}\" by name.";
var opening = body.Length > 600 ? body[..600] : body;
var (text, error) = llm.SendQuery(
$"""
Today's date is {DateTime.UtcNow:yyyy-MM-dd} (UTC) — use it as the temporal reference.
Write a SHORT welcome intro (3 to 5 sentences, at most 700 characters) for a podcast
episode in {LangName(lang)}. {welcome} Announce the themes that the episode covers.
The episode opens like this: "{opening}"
Plain flowing prose, no emoji, no markers, no bullets.
""",
useHistory: false, role: LLMUtility.SystemRole.None, maxToken: 800, temperature: 0.7);
if (error == null && !string.IsNullOrWhiteSpace(text))
{
var intro = FilterEmoji(text).Trim();
if (intro.Length > 0)
{
Log.LogStep($"PodcastScript: intro → {intro.Length:N0} chars");
return intro;
}
}
// Language-aware fallback (the model call failed — never leave an English podcast with
// an Italian welcome).
return lang == "it"
? $"Benvenuti. Oggi parliamo di {topic}."
: $"Welcome. Today we talk about {topic}.";
}
/// <summary>Writes the short host farewell that closes the episode ("Avete ascoltato
/// [podcast name]... grazie e alla prossima") — generated AFTER the body, in the episode
/// language, with a language-aware fallback. The mixing appends a very short musical
/// stinger right after it.</summary>
internal static string GenerateOutro(string topic, string lang, string? podcastName)
{
var llm = new LLMUtility(Setup.ProviderConfig.ProviderName);
var farewell = string.IsNullOrWhiteSpace(podcastName)
? "Tell the listeners they have been listening to this episode."
: $"Tell the listeners they have been listening to \"{podcastName}\".";
var (text, error) = llm.SendQuery(
$"""
Today's date is {DateTime.UtcNow:yyyy-MM-dd} (UTC) — use it as the temporal reference.
Write a SHORT farewell outro (2 to 4 sentences, at most 400 characters) closing a
podcast episode about "{topic}" in {LangName(lang)}. {farewell} Thank them for
listening and leave a warm closing thought. Do NOT announce what a future episode
will cover — the next episode is not planned, inventing one would be false.
Plain flowing prose, no emoji, no markers, no bullets.
""",
useHistory: false, role: LLMUtility.SystemRole.None, maxToken: 500, temperature: 0.7);
if (error == null && !string.IsNullOrWhiteSpace(text))
{
var outro = FilterEmoji(text).Trim();
if (outro.Length > 0)
{
Log.LogStep($"PodcastScript: outro → {outro.Length:N0} chars");
return outro;
}
}
// Language-aware fallback (the model call failed — never leave an English podcast with
// an Italian farewell).
return string.IsNullOrWhiteSpace(podcastName)
? lang == "it"
? "E con questo episodio il nostro viaggio si conclude. Grazie per l'ascolto, e alla prossima."
: "And with this episode our journey comes to an end. Thank you for listening, see you next time."
: lang == "it"
? $"Avete ascoltato \"{podcastName}\". Grazie per l'ascolto, e alla prossima."
: $"You have been listening to \"{podcastName}\". Thank you for listening, see you next time.";
}
/// <summary>Removes emoji and symbols deterministically (no LLM) while keeping the ASCII
/// [markers] and the punctuation the TTS needs. Same filter as the voice pipeline.</summary>
internal static string FilterEmoji(string text)
{
var result = new StringBuilder(text.Length);
for (int i = 0; i < text.Length; i++)
{
var cat = char.GetUnicodeCategory(text, i);
if (cat is System.Globalization.UnicodeCategory.UppercaseLetter
or System.Globalization.UnicodeCategory.LowercaseLetter
or System.Globalization.UnicodeCategory.TitlecaseLetter
or System.Globalization.UnicodeCategory.ModifierLetter
or System.Globalization.UnicodeCategory.OtherLetter
or System.Globalization.UnicodeCategory.DecimalDigitNumber
or System.Globalization.UnicodeCategory.LetterNumber
or System.Globalization.UnicodeCategory.OtherNumber
or System.Globalization.UnicodeCategory.SpaceSeparator
or System.Globalization.UnicodeCategory.LineSeparator
or System.Globalization.UnicodeCategory.ParagraphSeparator
or System.Globalization.UnicodeCategory.DashPunctuation
or System.Globalization.UnicodeCategory.OpenPunctuation
or System.Globalization.UnicodeCategory.ClosePunctuation
or System.Globalization.UnicodeCategory.InitialQuotePunctuation
or System.Globalization.UnicodeCategory.FinalQuotePunctuation
or System.Globalization.UnicodeCategory.OtherPunctuation
or System.Globalization.UnicodeCategory.CurrencySymbol
or System.Globalization.UnicodeCategory.MathSymbol)
{
result.Append(text[i]);
}
else if (cat == System.Globalization.UnicodeCategory.Surrogate)
{
i++; // skip the emoji surrogate pair whole
}
}
return result.ToString();
}
/// <summary>The two-letter languages the tool can write and speak.</summary>
internal static readonly string[] KnownLanguages = { "en", "it", "fr", "es", "de", "pt", "ja", "zh", "ko" };
/// <summary>Full language names → two-letter code (defensive: some agents send "Spanish"
/// instead of "es", and a naive 2-char truncation would produce the wrong "sp").</summary>
private static readonly Dictionary<string, string> LanguageNameToCode = new(StringComparer.OrdinalIgnoreCase)
{
["italian"] = "it", ["english"] = "en", ["french"] = "fr", ["spanish"] = "es",
["german"] = "de", ["portuguese"] = "pt", ["japanese"] = "ja", ["chinese"] = "zh",
["korean"] = "ko",
};
/// <summary>Normalizes a language argument to a two-letter code (case-insensitive,
/// full names accepted); null when not recognized.</summary>
internal static string? NormalizeLanguage(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var v = value.Trim();
if (LanguageNameToCode.TryGetValue(v, out var full)) return full;
var code = v.ToLowerInvariant();
if (code.Length >= 2 && char.IsLetter(code[0]) && char.IsLetter(code[1]))
{
var two = code[..2];
if (KnownLanguages.Contains(two)) return two;
}
return null;
}
internal static string LangName(string lang) => lang switch
{
"it" => "Italian",
"en" => "English",
"fr" => "French",
"es" => "Spanish",
"de" => "German",
"pt" => "Portuguese",
"ja" => "Japanese",
"zh" => "Chinese",
"ko" => "Korean",
_ => "English",
};
// ── act measurement / extraction (the act markers are the deterministic boundaries) ──
// The markers are discovered from the body itself, so any number of acts works (the
// writer emits "[PRIMO ATTO]" ... "[SESTO ATTO]" deterministically from the plan).
private static readonly Regex ActMarkerRegex = new(@"\[[A-Z]+ ATTO\]", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static string[] ActMarkersIn(string body) =>
ActMarkerRegex.Matches(body).Select(m => m.Value).Distinct().ToArray();
private static string ExtractAct(string body, int actIndex)
{
var markers = ActMarkersIn(body);
if (actIndex >= markers.Length) return body;
var start = body.IndexOf(markers[actIndex], StringComparison.Ordinal);
if (start < 0) return body;
start += markers[actIndex].Length;
var end = actIndex + 1 < markers.Length
? body.IndexOf(markers[actIndex + 1], StringComparison.Ordinal)
: body.Length;
return end < 0 ? body[start..] : body[start..end];
}
private static int MeasureAct(string body, int actIndex)
{
var act = ExtractAct(body, actIndex);
// Exclude the markers themselves from the measurement.
foreach (var m in ActMarkersIn(body)) act = act.Replace(m, "");
return act.Length;
}
private static string ReplaceAct(string body, int actIndex, string newAct)
{
var markers = ActMarkersIn(body);
if (actIndex >= markers.Length) return body;
var start = body.IndexOf(markers[actIndex], StringComparison.Ordinal);
if (start < 0) return body;
var end = actIndex + 1 < markers.Length
? body.IndexOf(markers[actIndex + 1], StringComparison.Ordinal)
: body.Length;
if (end < 0) end = body.Length;
return body[..start] + markers[actIndex] + "\n" + newAct + body[end..];
}
private static string StripActMarkers(string text) =>
ActMarkerRegex.Replace(text, "").Trim();
/// <summary>When the output hits the token cap mid-flow, cuts at the last sentence
/// boundary so the act ends cleanly (the next act continues from the seam).</summary>
private static string TrimToSentenceEnd(string text)
{
if (text.Length == 0) return text;
var last = text[^1];
if (last is '.' or '!' or '?' or '…' or '"' or '”' or '»') return text;
var cut = text.LastIndexOfAny(['.', '!', '?']);
return cut >= text.Length / 2 ? text[..(cut + 1)] : text;
}
private static string Ordinal(int n) => n switch { 1 => "first", 2 => "second", 3 => "third", _ => n + "th" };
private static string seamInstruction(string seam) =>
seam.Length == 0
? "This is the opening act: start with the strong hook."
: $"Continue seamlessly from where the previous act ended (its last words: \"{seam}\") — the transition must feel like one continuous story.";
}