-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathgenerate-podcast.js
More file actions
executable file
·2071 lines (1737 loc) · 66 KB
/
generate-podcast.js
File metadata and controls
executable file
·2071 lines (1737 loc) · 66 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
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
/**
* Unified Podcast Generation Script
*
* Generates podcast scripts and audio from MDX course content:
* 1. Script Generation: Converts MDX to dialog using Claude Code CLI (Opus 4.5)
* 2. Audio Generation: Synthesizes multi-speaker audio using Gemini TTS
*
* Modes:
* - Default: Interactive file selection → generate script + audio
* - --script-only: Generate script only
* - --audio-only: Generate audio from existing script (skip script generation)
* - --all: Batch process all files
* - --file <path>: Process specific file
* - --module <name>: Process all files in module directory
* - --debug: Save prompt for validation
*
* Usage:
* node scripts/generate-podcast.js # Interactive: script + audio
* node scripts/generate-podcast.js --script-only # Interactive: script only
* node scripts/generate-podcast.js --audio-only # Interactive: audio only
* node scripts/generate-podcast.js --all # Batch: all files
* node scripts/generate-podcast.js --file intro.md # Specific file
*/
import { GoogleGenerativeAI } from "@google/generative-ai";
import {
readFileSync,
writeFileSync,
mkdirSync,
readdirSync,
statSync,
existsSync,
unlinkSync,
} from "fs";
import { join, relative, dirname, basename, extname } from "path";
import { fileURLToPath } from "url";
import { spawn, spawnSync } from "child_process";
import * as readline from "readline";
// ES module __dirname equivalent
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Configuration
const DOCS_DIR = join(__dirname, "../website/docs");
const SCRIPT_OUTPUT_DIR = join(__dirname, "output/podcasts");
const AUDIO_OUTPUT_DIR = join(__dirname, "../website/static/audio");
const SCRIPT_MANIFEST_PATH = join(SCRIPT_OUTPUT_DIR, "manifest.json");
const AUDIO_MANIFEST_PATH = join(AUDIO_OUTPUT_DIR, "manifest.json");
// Model configuration
const TTS_MODEL = "gemini-2.5-pro-preview-tts";
// API key for Gemini TTS
const API_KEY =
process.env.GOOGLE_API_KEY ||
process.env.GEMINI_API_KEY ||
process.env.GCP_API_KEY;
/**
* Check if ffmpeg is available with libmp3lame support
*/
function checkFfmpegAvailable() {
const result = spawnSync("ffmpeg", ["-version"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
if (result.error || result.status !== 0) {
console.error(`
Error: ffmpeg is required for MP3 conversion but was not found.
Install ffmpeg with:
brew install ffmpeg
Then run this script again.
`);
process.exit(1);
}
// Check for libmp3lame support
const output = result.stdout || "";
if (!output.includes("--enable-libmp3lame")) {
console.error(`
Error: ffmpeg is installed but lacks libmp3lame support for MP3 encoding.
Reinstall ffmpeg with MP3 support:
brew reinstall ffmpeg
Then run this script again.
`);
process.exit(1);
}
return true;
}
// Parse command-line arguments
function parseArgs() {
const args = process.argv.slice(2);
const config = {
mode: "interactive",
pipeline: "both", // 'both', 'script-only', 'audio-only'
file: null,
module: null,
debug: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--all") {
config.mode = "batch";
} else if (arg === "--script-only") {
config.pipeline = "script-only";
} else if (arg === "--audio-only") {
config.pipeline = "audio-only";
} else if (arg === "--file") {
config.mode = "batch";
config.file = args[++i];
} else if (arg === "--module") {
config.mode = "batch";
config.module = args[++i];
} else if (arg === "--debug") {
config.debug = true;
}
}
return config;
}
// ============================================================================
// SCRIPT GENERATION - From generate-podcast-script.js
// ============================================================================
/**
* Calculate semantic overlap between two text segments using word-based Jaccard similarity
* This is a lightweight approach that doesn't require external NLP libraries
*
* @param {string} text1 - First text segment
* @param {string} text2 - Second text segment
* @param {number} threshold - Similarity threshold (0-1), default 0.75
* @returns {boolean} - True if texts are semantically similar above threshold
*/
function detectSemanticOverlap(text1, text2, threshold = 0.75) {
// Normalize: lowercase, remove punctuation, split into words
const normalize = (text) => {
return text
.toLowerCase()
.replace(/[^\w\s]/g, " ")
.split(/\s+/)
.filter((word) => word.length > 3); // Ignore short words (a, the, is, etc.)
};
const words1 = new Set(normalize(text1));
const words2 = new Set(normalize(text2));
if (words1.size === 0 || words2.size === 0) {
return false;
}
// Jaccard similarity: intersection / union
const intersection = new Set([...words1].filter((word) => words2.has(word)));
const union = new Set([...words1, ...words2]);
const similarity = intersection.size / union.size;
return similarity >= threshold;
}
/**
* Extract unique sentences from text2 that are not semantically covered in text1
* Used to preserve novel information from pedagogical notes
*
* @param {string} text1 - Main text (reference)
* @param {string} text2 - Secondary text (to filter)
* @returns {string} - Sentences from text2 not covered in text1
*/
function extractUniqueSentences(text1, text2) {
const sentences2 = text2
.split(/[.!?]+/)
.map((s) => s.trim())
.filter((s) => s.length > 20);
const uniqueSentences = [];
for (const sentence of sentences2) {
// Check if this sentence is already covered in text1
if (!detectSemanticOverlap(sentence, text1, 0.6)) {
uniqueSentences.push(sentence);
}
}
return uniqueSentences.join(". ");
}
/**
* Analyze code block context and generate audio-appropriate description
* Transforms code into natural language that preserves pedagogical value
*/
function describeCodeBlock(codeBlock, precedingContext, followingContext) {
// Extract language and code content
const langMatch = codeBlock.match(/```(\w+)?\n([\s\S]*?)```/);
const language = langMatch?.[1] || "";
const code = langMatch?.[2]?.trim() || "";
if (!code) {
return "[Code example]";
}
// Focus on immediate context (last 100 chars before, first 100 chars after)
const immediatePreContext = precedingContext.substring(
Math.max(0, precedingContext.length - 100),
);
const immediatePostContext = followingContext.substring(0, 100);
// Detect code block type based on context and content
const fullContext = (precedingContext + " " + followingContext).toLowerCase();
const immediateContext = (
immediatePreContext +
" " +
immediatePostContext
).toLowerCase();
// Type 1: Comparison examples (ineffective vs effective)
// Prioritize immediate context for these labels
if (
immediateContext.includes("**ineffective:**") ||
immediateContext.includes("**risky:**") ||
immediateContext.includes("**bad:**") ||
immediateContext.includes("**wrong:**") ||
immediateContext.includes("**don't rely on llms")
) {
return `[INEFFECTIVE CODE EXAMPLE: ${extractCodeSummary(code, language)}]`;
}
if (
immediateContext.includes("**effective:**") ||
immediateContext.includes("**better:**") ||
immediateContext.includes("**good:**") ||
immediateContext.includes("**correct:**") ||
immediateContext.includes("**instead")
) {
return `[EFFECTIVE CODE EXAMPLE: ${extractCodeSummary(code, language)}]`;
}
// Fallback to emojis and broader context if no immediate label found
if (fullContext.includes("❌") && !immediateContext.includes("✅")) {
return `[INEFFECTIVE CODE EXAMPLE: ${extractCodeSummary(code, language)}]`;
}
if (fullContext.includes("✅") && !immediateContext.includes("❌")) {
return `[EFFECTIVE CODE EXAMPLE: ${extractCodeSummary(code, language)}]`;
}
// Type 2: Pattern demonstrations (showing structure)
if (
fullContext.includes("pattern") ||
fullContext.includes("structure") ||
immediateContext.includes("example") ||
fullContext.includes("template")
) {
return `[CODE PATTERN: ${extractCodeSummary(code, language)}]`;
}
// Type 3: Specifications with requirements (numbered lists, constraints)
if (
code.includes("\n-") ||
code.includes("\n•") ||
/\d+\./.test(code) ||
fullContext.includes("requirement") ||
fullContext.includes("constraint") ||
fullContext.includes("specification")
) {
const requirements = extractRequirements(code);
return `[CODE SPECIFICATION: ${extractCodeSummary(code, language)}. ${requirements}]`;
}
// Type 4: Default - describe the code structure
return `[CODE EXAMPLE: ${extractCodeSummary(code, language)}]`;
}
/**
* Extract a concise summary of what the code does/shows
*/
function extractCodeSummary(code, language) {
const lines = code.split("\n").filter((l) => l.trim());
// Detect function definitions - more precise matching
const functionMatch = code.match(
/(?:^|\n)\s*(?:export\s+)?(?:async\s+)?(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?function)/m,
);
if (functionMatch) {
const funcName = functionMatch[1] || functionMatch[2] || functionMatch[3];
// Validate it's a real function name (at least 3 chars, starts with letter)
if (funcName && funcName.length >= 3 && /^[a-zA-Z]/.test(funcName)) {
const params = code.match(/\(([^)]*)\)/)?.[1] || "";
const paramCount = params.trim() ? params.split(",").length : 0;
const hasReturn = code.includes("return");
return `Function '${funcName}'${paramCount > 0 ? ` with ${paramCount} parameter${paramCount > 1 ? "s" : ""}` : ""}${hasReturn ? " that returns a value" : ""}`;
}
}
// Detect type/interface definitions
if (code.includes("interface") || code.includes("type")) {
const typeMatch = code.match(/(?:interface|type)\s+(\w+)/);
if (typeMatch) {
return `Type definition '${typeMatch[1]}'`;
}
}
// Detect class definitions
if (code.includes("class")) {
const classMatch = code.match(/class\s+(\w+)/);
if (classMatch) {
return `Class '${classMatch[1]}'`;
}
}
// Detect imports
if (code.includes("import") || code.includes("require")) {
return "Import statements for dependencies";
}
// Detect configuration/object
if (
code.trim().startsWith("{") ||
code.includes("config") ||
code.includes("options")
) {
return "Configuration object with properties";
}
// Detect command-line/shell
if (
language === "bash" ||
language === "sh" ||
code.includes("$") ||
code.includes("npm") ||
code.includes("git")
) {
const commands = lines.filter((l) => !l.startsWith("#")).length;
return `Shell command${commands > 1 ? "s" : ""} (${commands} line${commands > 1 ? "s" : ""})`;
}
// Detect specifications/requirements
if (code.includes("-") || code.includes("•") || /^\d+\./.test(code)) {
const items = lines.filter((l) => l.match(/^[\s-•\d]/)).length;
return `Specification with ${items} requirement${items > 1 ? "s" : ""}`;
}
// Default: describe by line count and language
const lineCount = lines.length;
return `${language || "Code"} snippet (${lineCount} line${lineCount > 1 ? "s" : ""})`;
}
/**
* Extract and summarize requirements from code
*/
function extractRequirements(code) {
const lines = code.split("\n");
const requirements = [];
// Extract lines that look like requirements (bullets, numbers, dashes)
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.match(/^[-•\d]+[.)]?\s+(.+)/) && trimmed.length > 5) {
const content = trimmed.replace(/^[-•\d]+[.)]?\s+/, "").trim();
if (content.length > 0) {
requirements.push(content);
}
}
}
if (requirements.length === 0) {
return "";
}
if (requirements.length <= 3) {
return `Requirements: ${requirements.join("; ")}`;
}
return `${requirements.length} specific requirements including: ${requirements.slice(0, 2).join("; ")}`;
}
/**
* Parse MDX/MD file and extract clean text content
* Transforms code blocks into audio-appropriate descriptions
*/
function parseMarkdownContent(filePath) {
const content = readFileSync(filePath, "utf-8");
// Remove frontmatter
let cleaned = content.replace(/^---[\s\S]*?---\n/, "");
// DEDUPLICATION PHASE 1: Handle Deep Dive sections (<details> tags)
// Do this BEFORE removing HTML tags so we can detect and process them
// These often duplicate main explanations - extract and deduplicate first
const detailsRegex =
/<details>\s*<summary>([\s\S]*?)<\/summary>\s*([\s\S]*?)<\/details>/gi;
let detailsMatch;
const detailsToProcess = [];
console.log(` 🔍 Scanning for Deep Dive sections...`);
while ((detailsMatch = detailsRegex.exec(cleaned)) !== null) {
detailsToProcess.push({
fullMatch: detailsMatch[0],
title: detailsMatch[1].trim(),
content: detailsMatch[2].trim(),
index: detailsMatch.index,
});
}
// Process details sections in reverse order to maintain correct indices
for (let i = detailsToProcess.length - 1; i >= 0; i--) {
const detail = detailsToProcess[i];
// Extract broader context (1000 chars before - deep dives cover broader topics)
const contextStart = Math.max(0, detail.index - 1000);
const precedingContext = cleaned.substring(contextStart, detail.index);
// Check overlap with preceding content
const overlapHigh = detectSemanticOverlap(
detail.content,
precedingContext,
0.7,
);
const overlapMedium = detectSemanticOverlap(
detail.content,
precedingContext,
0.45,
);
let replacement;
if (overlapHigh) {
// >70% overlap: Deep dive is redundant, remove entirely
console.log(
` 🔧 Deduplication: Removed redundant Deep Dive "${detail.title}" (>70% overlap with main content)`,
);
replacement = "";
} else if (overlapMedium) {
// 45-70% overlap: Keep only unique sentences
const uniqueContent = extractUniqueSentences(
precedingContext,
detail.content,
);
if (uniqueContent.length > 30) {
console.log(
` 🔧 Deduplication: Condensed Deep Dive "${detail.title}" (45-70% overlap, kept unique parts)`,
);
replacement = `\n[DEEP DIVE: ${detail.title}]\n${uniqueContent}\n[END DEEP DIVE]\n`;
} else {
console.log(
` 🔧 Deduplication: Removed Deep Dive "${detail.title}" (no unique content after filtering)`,
);
replacement = "";
}
} else {
// <45% overlap: Keep entire deep dive (genuinely new information)
replacement = `\n[DEEP DIVE: ${detail.title}]\n${detail.content}\n[END DEEP DIVE]\n`;
}
// Replace in cleaned content
cleaned =
cleaned.substring(0, detail.index) +
replacement +
cleaned.substring(detail.index + detail.fullMatch.length);
}
console.log(` 🔍 Found ${detailsToProcess.length} Deep Dive section(s)`);
// DEDUPLICATION PHASE 2: Handle pedagogical note boxes (:::tip, :::warning, etc.)
// Extract and deduplicate against surrounding context to prevent repetition
console.log(` 🔍 Scanning for pedagogical note boxes...`);
// Match both formats: ":::tip Title" and ":::tip[Title]"
const pedagogicalNoteRegex =
/:::(tip|warning|info|note|caution)\s*(?:\[([^\]]*)\]|([^\n]*))\s*\n([\s\S]*?)\n:::/gi;
let noteMatch;
const notesToProcess = [];
while ((noteMatch = pedagogicalNoteRegex.exec(cleaned)) !== null) {
notesToProcess.push({
fullMatch: noteMatch[0],
type: noteMatch[1],
title: (noteMatch[2] || noteMatch[3] || "Note").trim(),
content: noteMatch[4].trim(),
index: noteMatch.index,
});
}
// Process pedagogical notes in reverse order to maintain correct indices
for (let i = notesToProcess.length - 1; i >= 0; i--) {
const note = notesToProcess[i];
// Extract surrounding context (500 chars before and after)
const contextStart = Math.max(0, note.index - 500);
const contextEnd = Math.min(
cleaned.length,
note.index + note.fullMatch.length + 500,
);
const surroundingContext =
cleaned.substring(contextStart, note.index) +
cleaned.substring(note.index + note.fullMatch.length, contextEnd);
// Check overlap with surrounding context
const overlapHigh = detectSemanticOverlap(
note.content,
surroundingContext,
0.75,
);
const overlapMedium = detectSemanticOverlap(
note.content,
surroundingContext,
0.5,
);
let replacement;
if (overlapHigh) {
// >75% overlap: Completely redundant, remove entirely
console.log(
` 🔧 Deduplication: Removed redundant ${note.type} note (>75% overlap)`,
);
replacement = "";
} else if (overlapMedium) {
// 50-75% overlap: Keep only unique sentences
const uniqueContent = extractUniqueSentences(
surroundingContext,
note.content,
);
if (uniqueContent.length > 20) {
console.log(
` 🔧 Deduplication: Condensed ${note.type} note (50-75% overlap, kept unique parts)`,
);
replacement = `\n[PEDAGOGICAL ${note.type.toUpperCase()}: ${note.title}] ${uniqueContent}\n`;
} else {
console.log(
` 🔧 Deduplication: Removed ${note.type} note (no unique content after filtering)`,
);
replacement = "";
}
} else {
// <50% overlap: Keep entire note (genuinely new information)
replacement = `\n[PEDAGOGICAL ${note.type.toUpperCase()}: ${note.title}]\n${note.content}\n[END NOTE]\n`;
}
// Replace in cleaned content
cleaned =
cleaned.substring(0, note.index) +
replacement +
cleaned.substring(note.index + note.fullMatch.length);
}
console.log(` 🔍 Found ${notesToProcess.length} pedagogical note(s)`);
// NOW safe to remove remaining JSX/HTML components after deduplication
cleaned = cleaned.replace(/<[^>]+>/g, "");
// Process code blocks: Find all code blocks and their contexts
const codeBlocks = [];
const codeRegex = /```[\s\S]*?```/g;
let codeMatch;
while ((codeMatch = codeRegex.exec(cleaned)) !== null) {
const precedingStart = Math.max(0, codeMatch.index - 200);
const precedingContext = cleaned.substring(precedingStart, codeMatch.index);
const followingEnd = Math.min(
cleaned.length,
codeMatch.index + codeMatch[0].length + 200,
);
const followingContext = cleaned.substring(
codeMatch.index + codeMatch[0].length,
followingEnd,
);
codeBlocks.push({
original: codeMatch[0],
index: codeMatch.index,
precedingContext,
followingContext,
});
}
// Replace code blocks with descriptions
let offset = 0;
for (const block of codeBlocks) {
const description = describeCodeBlock(
block.original,
block.precedingContext,
block.followingContext,
);
const adjustedIndex = block.index + offset;
cleaned =
cleaned.substring(0, adjustedIndex) +
description +
cleaned.substring(adjustedIndex + block.original.length);
offset += description.length - block.original.length;
}
// Remove inline code
cleaned = cleaned.replace(/`[^`]+`/g, (match) => match.replace(/`/g, ""));
// Remove images
cleaned = cleaned.replace(/!\[.*?\]\(.*?\)/g, "[Image]");
// Clean up markdown links but keep text
cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
// Remove HTML comments
cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, "");
// Clean up excessive whitespace
cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
return cleaned;
}
/**
* Calculate target token count based on source material complexity
*/
function calculateTargetTokens(sourceContent) {
const MIN_TOKENS = 3000;
const MAX_TOKENS = 15000;
// Estimate source token count (rough: ~4 chars per token)
const sourceTokenCount = Math.ceil(sourceContent.length / 4);
// Base scaling: 0.6x source tokens (allows expansion for dialogue format)
let target = Math.floor(sourceTokenCount * 0.6);
// Complexity multipliers - count structural elements
const hasCodeBlocks = (sourceContent.match(/```/g) || []).length / 2;
const hasTables = (sourceContent.match(/^\|/gm) || []).length;
const hasDeepDives = (sourceContent.match(/<details>/g) || []).length;
const hasPedagogicalNotes = (
sourceContent.match(/:::(tip|warning|info|note)/gi) || []
).length;
// Add tokens for complex content that needs narration
target += hasCodeBlocks * 200; // Each code block needs explanation
target += hasTables * 150; // Tables need verbal description
target += hasDeepDives * 500; // Deep dives = high information density
target += hasPedagogicalNotes * 100; // Pedagogical notes add context
// Clamp to reasonable bounds
const finalTarget = Math.max(MIN_TOKENS, Math.min(MAX_TOKENS, target));
console.log(` 📊 Source complexity analysis:`);
console.log(` - Estimated source tokens: ${sourceTokenCount}`);
console.log(` - Code blocks: ${hasCodeBlocks}`);
console.log(` - Tables: ${hasTables}`);
console.log(` - Deep dives: ${hasDeepDives}`);
console.log(` - Pedagogical notes: ${hasPedagogicalNotes}`);
console.log(` - Target podcast tokens: ${finalTarget}`);
return finalTarget;
}
/**
* Select appropriate model based on content complexity
*/
function selectModel(targetTokenCount, sourceTokenCount) {
// Use Sonnet for complex lessons requiring depth
// if (targetTokenCount > 8000 || sourceTokenCount > 6000) {
// console.log(` 🤖 Selected model: Sonnet (high complexity)`);
// return 'sonnet';
// }
// Opus for shorter, simpler content
console.log(` 🤖 Selected model: Opus (standard complexity)`);
return "opus";
}
/**
* Generate podcast dialog prompt optimized for Claude Opus 4.5
*/
function buildDialogPrompt(
content,
fileName,
outputPath,
targetTokens,
sourceTokens,
) {
// Special handling for intro.md - add brief meta-acknowledgement
const isIntro = fileName === "intro";
const metaCommentary = isIntro
? `
SPECIAL CONTEXT FOR THIS EPISODE - BRIEF META-ACKNOWLEDGEMENT:
This is the course introduction. When discussing how the course was developed using AI, include a SHORT moment of self-awareness:
REQUIREMENTS:
✓ Brief acknowledgement that this script was generated using AI tools
✓ Quick moment of self-awareness about the recursive nature (AI teaching about AI)
✓ Keep it light and concise - acknowledge the irony, then move on
✓ Integrate naturally into the conversation flow
✓ Maintain senior engineer sensibility
EXAMPLE APPROACH:
- Mention that the course (and this script) were developed using AI tools
- Brief observation about the recursive nature
- Quick return to the actual course content
LENGTH: Keep this to 3-5 exchanges maximum, then return to introducing the course content.`
: "";
return `You are a podcast script writer specializing in educational content for senior software engineers.
TASK: Convert the technical course material below into an engaging two-person podcast dialog.
SPEAKERS:
- Alex: Instructor with 15+ years experience. Teaching style: clear, measured, pedagogical. Guides the conversation and explains concepts thoroughly.
- Sam: Senior engineer with 8 years experience. Thoughtful, asks clarifying questions that peers would ask, connects concepts to real-world production scenarios.
TARGET AUDIENCE:
Senior software engineers (3+ years experience) who understand fundamentals. They want practical, production-focused insights. No hand-holding or basic explanations.
DIALOG STYLE REQUIREMENTS:
✓ DO: Create argument-driven content - make clear points, explore areas of nuance
✓ DO: Use storytelling and analogies to illustrate complex concepts
✓ DO: Reference real-world production scenarios and trade-offs
✓ DO: Keep the conversation natural and flowing
✓ DO: Have Sam ask relevant questions that advance understanding
✓ DO: Go deep on fewer points rather than surface-level on many
✓ DO: Maintain professional composure - engaging but measured
✓ DO: Include brief moments of insight or "aha" understanding
✗ AVOID: Excessive enthusiasm or exclamations
✗ AVOID: "Laundry lists" of points without depth
✗ AVOID: Dumbing down technical concepts
✗ AVOID: Marketing language or hype
CRITICAL: HANDLING CODE BLOCKS IN AUDIO FORMAT
The source material includes code blocks that have been transformed into audio-appropriate descriptions with tags like:
- [INEFFECTIVE CODE EXAMPLE: ...] - Shows what NOT to do
- [EFFECTIVE CODE EXAMPLE: ...] - Shows the correct approach
- [CODE PATTERN: ...] - Demonstrates a structure or template
- [CODE SPECIFICATION: ...] - Lists requirements or constraints
- [CODE EXAMPLE: ...] - General code reference
HOW TO NARRATE CODE IN DIALOG:
✓ DO: Narrate the code's structure and intent in natural language
Example: "The effective version is a TypeScript function called validateEmail that takes an email string and returns an object with a valid boolean and optional reason. It has three specific constraints: reject multiple @ symbols, reject missing domains, and accept plus addressing."
✓ DO: Explain what the code demonstrates pedagogically
Example: "This code pattern shows how starting with a function signature and type annotations helps the AI understand exactly what you want."
✓ DO: Connect code to the conversational point being made
Example: "See the difference? The ineffective prompt just says 'validate emails', but the effective version specifies the RFC standard, lists edge cases, and defines the return structure."
✓ DO: Use code as concrete examples to back abstract discussions
Example: "When I say 'be specific', here's what I mean: instead of 'handle errors', write 'throw ValidationError with a descriptive message for each of these five cases'."
✓ DO: Compare code blocks when showing ineffective vs effective patterns
Example: "The first version gives no context, just 'Write a function that validates emails.' The second version specifies TypeScript, references RFC 5322, lists three edge cases, and defines the exact return type."
✗ DO NOT: Skip over code blocks - they're pedagogically important
✗ DO NOT: Say "there's a code example here" without describing what it shows
✗ DO NOT: Lose the specificity that the code demonstrates
✗ DO NOT: Read code character-by-character or line-by-line
✗ DO NOT: Use phrases like "the code shows" without explaining WHAT it shows
COMPARING EFFECTIVE VS INEFFECTIVE CODE:
When you see both [INEFFECTIVE CODE EXAMPLE: ...] and [EFFECTIVE CODE EXAMPLE: ...]:
1. First, describe what's wrong with the ineffective version (vague, missing context, unclear requirements)
2. Then, contrast with the effective version (specific, constrained, clear expectations)
3. Explain WHY the effective version works better (helps AI understand intent, reduces ambiguity, produces better results)
4. Make the contrast explicit and compelling
EXAMPLE DIALOG FOR CODE BLOCKS:
Alex: "Let me show you the difference between an ineffective and effective prompt. The ineffective one just says 'Write a function that validates emails.' That's it. No language, no specification, no edge cases."
Sam: "So the AI has to guess everything - which TypeScript or Python, which validation standard, how to handle plus addressing..."
Alex: "Exactly. Now look at the effective version: 'Write a TypeScript function that validates email addresses per RFC 5322. Handle these edge cases: reject multiple @ symbols, reject missing domains, accept plus addressing. Return an object with a valid boolean and optional reason field.' See how much clearer that is?"
Sam: "That's night and day. The second version gives the AI everything it needs - language, standard, edge cases, return type."
CRITICAL: PRESERVE TECHNICAL SPECIFICITY
The source material contains actionable technical details that MUST be preserved in the podcast:
✓ PRESERVE: Exact numbers (token counts, LOC thresholds, dimensions, percentages, ratios)
Example: "60-120K tokens reliable attention" NOT "somewhat less than advertised"
Example: "<10K LOC use agentic search, 10-100K use semantic search" NOT "different tools for different sizes"
✓ PRESERVE: Tool and product names with brief context
Example: "ChunkHound for structured multi-hop traversal" NOT just "a tool"
Example: "ChromaDB, pgvector, or Qdrant vector databases" NOT just "vector databases"
✓ PRESERVE: Decision matrices and selection criteria
Example: "Under 10K lines use X, 10-100K lines use Y, above 100K use Z with reason" NOT just "pick the right tool"
✓ PRESERVE: Technical architecture details
Example: "768-1536 dimensional vectors with cosine similarity" NOT just "high-dimensional vectors"
✓ PRESERVE: Concrete examples with specific numbers
Example: "10 chunks at 15K tokens plus 25K for files equals 40K" NOT just "uses a lot of tokens"
✗ DO NOT: Replace specific numbers with vague descriptors ("a lot", "many", "significant")
✗ DO NOT: Skip tool names - always mention them with 1-sentence context of what they do
✗ DO NOT: Simplify decision criteria into generic advice
✗ DO NOT: Omit architectural details that explain how things work
EXAMPLE - BAD (too vague):
"You need different approaches for different codebase sizes to get good results."
EXAMPLE - GOOD (preserves specifics):
"For codebases under 10,000 lines, agentic search with Grep and Read works well. Between 10,000 and 100,000 lines,
switch to semantic search - tools like ChunkHound or Claude Context via MCP servers. Above 100,000 lines, you need
ChunkHound's structured multi-hop traversal because autonomous agents start missing connections."
CRITICAL: CONTENT HAS BEEN PRE-DEDUPLICATED
The source content has been programmatically deduplicated during preprocessing:
- Redundant pedagogical note boxes (:::tip, :::warning) have been removed or condensed
- Duplicate deep dive sections have been filtered out
- Only unique information remains in [PEDAGOGICAL NOTE] and [DEEP DIVE] tags
Your job is to transform this already-clean content into engaging dialog:
✓ TRUST THE PREPROCESSING: Content is already deduplicated - focus on dialog quality
✓ NATURAL FLOW: Create conversational progression without forced repetition checks
✓ AVOID CIRCULAR PHRASES: Don't use "going back to", "as I mentioned", "to circle back"
✓ PROGRESSIVE DISCUSSION: Each exchange should advance understanding, not restate
The heavy lifting of deduplication is done. Focus on creating engaging, technically accurate dialog.
OUTPUT FORMAT:
Use clear speaker labels followed by natural dialog. Structure your output within XML tags:
<podcast_dialog>
Alex: [natural dialog here]
Sam: [natural dialog here]
Alex: [natural dialog here]
[continue the conversation...]
</podcast_dialog>${metaCommentary}
LENGTH CONSTRAINT:
Target ${targetTokens}-${targetTokens + 1500} tokens for the complete dialog (dynamically calculated based on source complexity).
This lesson has ${sourceTokens} estimated source tokens with specific complexity factors considered.
IMPORTANT: Depth is prioritized over compression for this content.
- Preserve ALL technical specifics, numbers, tool names, and decision criteria
- The token budget is adaptive - complex lessons get more space to preserve detail
- Deduplication is for removing redundancy, NOT for cutting essential technical information
- Focus on making content clear and complete, not artificially short
TECHNICAL CONTENT TITLE: ${fileName}
TECHNICAL CONTENT:
${content}
IMPORTANT: Write the complete podcast dialog directly to the file: ${outputPath}
The file should contain ONLY the podcast dialog wrapped in XML tags - no preamble, no summary, no explanation.
Just write the raw dialog to the file now.`;
}
/**
* Call Claude Code CLI in headless mode to generate dialog
*/
async function generateDialogWithClaude(prompt, outputPath, model = "opus") {
return new Promise((resolve, reject) => {
console.log(` 🤖 Calling Claude Code CLI (${model})...`);
// Ensure output directory exists before Claude tries to write
mkdirSync(dirname(outputPath), { recursive: true });
// Spawn claude process with headless mode
const claude = spawn("claude", [
"-p", // Headless mode (non-interactive)
"--model",
model, // Use specified model (opus)
"--allowedTools",
"Edit",
"Write", // Allow file editing and writing only
]);
let stdout = "";
let stderr = "";
// Collect stdout
claude.stdout.on("data", (data) => {
stdout += data.toString();
});
// Collect stderr
claude.stderr.on("data", (data) => {
stderr += data.toString();
});
// Handle process completion
claude.on("close", (code) => {
if (code !== 0) {
reject(new Error(`Claude CLI exited with code ${code}: ${stderr}`));
return;
}
// Debug: Log Claude's response
if (process.env.DEBUG) {
console.log(` 🔍 DEBUG - Claude output:\n${stdout.slice(0, 300)}`);
}
// Check if Claude created the file
if (!existsSync(outputPath)) {
reject(
new Error(
`Claude did not create the output file: ${outputPath}\n` +
`Claude response: ${stdout.slice(0, 200)}`,
),
);
return;
}
console.log(` ✅ File created: ${outputPath}`);
// Read the file content that Claude wrote
let fileContent;
try {
fileContent = readFileSync(outputPath, "utf-8");
} catch (readError) {
reject(new Error(`Failed to read created file: ${readError.message}`));
return;
}
// Extract dialog from XML tags in the file
const match = fileContent.match(
/<podcast_dialog>([\s\S]*?)<\/podcast_dialog>/,
);
if (!match) {
reject(
new Error(
`File exists but missing XML tags.\n` +
`File preview: ${fileContent.slice(0, 200)}...`,
),
);
return;
}
const dialog = match[1].trim();
console.log(` ✅ Extracted dialog (${dialog.split("\n").length} lines)`);
resolve(dialog);
});
// Handle errors
claude.on("error", (err) => {
reject(
new Error(
`Failed to spawn Claude CLI: ${err.message}. Is 'claude' installed and in PATH?`,
),
);
});
// Send prompt to stdin
claude.stdin.write(prompt);
claude.stdin.end();
});
}
/**
* Validate technical depth and information preservation
*/
function validateTechnicalDepth(dialog, sourceContent) {
const warnings = [];
// Extract numbers from source and dialog (including LOC like "10K", percentages, dimensions)
const sourceNumbers =
sourceContent.match(/\b\d+[KM]?(?:%|K|M|,\d{3})*\b/g) || [];
const dialogNumbers = dialog.match(/\b\d+[KM]?(?:%|K|M|,\d{3})*\b/g) || [];
// Should preserve at least 40% of specific numbers
if (dialogNumbers.length < sourceNumbers.length * 0.4) {
warnings.push(
`⚠️ Low number preservation: ${dialogNumbers.length}/${sourceNumbers.length} numbers mentioned ` +
`(${((dialogNumbers.length / sourceNumbers.length) * 100).toFixed(0)}%)`,
);
}
// Extract tool/product names (capitalized technical terms)
const toolPattern =
/\b(?:[A-Z][a-z]+(?:[A-Z][a-z]+)*(?:DB|RAG|Search|Agent|Hound|Seek|Context|MCP|Serena|Perplexity|ChunkHound|ArguSeek)|ChunkHound|ArguSeek|ChromaDB|pgvector|Qdrant)\b/g;
const sourceTools = new Set(sourceContent.match(toolPattern) || []);
const dialogTools = new Set(dialog.match(toolPattern) || []);
const missingTools = [...sourceTools].filter((t) => !dialogTools.has(t));
if (missingTools.length > sourceTools.size * 0.3) {
warnings.push(
`⚠️ Missing important tools: ${missingTools.slice(0, 5).join(", ")}` +
`${missingTools.length > 5 ? ` (+ ${missingTools.length - 5} more)` : ""}`,
);
}