···11import { test, expect } from "bun:test";
22-import { cleanTranscript } from "./transcript-cleaner";
22+import { cleanAndGetParagraphBoundaries } from "./transcript-cleaner";
33+44+test("cleanAndGetParagraphBoundaries cleans transcript and returns paragraph boundaries", async () => {
55+// Use a longer, more realistic transcript sample with natural paragraph breaks
66+const rawTranscript = `[SIDE CONVERSATION] Today in chapel we are talking about the fact that we believe in having gospel conversations. I'm gonna run my own PowerPoint. I'm gonna jump around. It's gonna be a little more conversational than normal. It's not gonna be like one of the normal sermons, although I know me and my tendency it'll turn into a sermon at some point just because that's the way God made me, so I can't help it.
77+88+Alright, so when it starts just have fun with it. We'll go on. Here's what it says in our doctrinal statement. It says, "Due to the commission of Christ and the urgency of the Gospel, all believers are to engage in Gospel conversations." How many of you believe that? That's pretty weak. How many of you believe that?
3944-test("cleanTranscript removes tags and fixes grammar", async () => {
55- const rawTranscript = `[SIDE CONVERSATION] Yes? So with this course packet, what quiz is and exams, and if I can study through here, what you talk about? And I give you a good review every time. Yeah, so I'd be good to just study that and then we can do it. Yeah, and all the examples and stuff that we get from class especially. And then I, like your first quiz, I give you a mock quiz exactly like the quiz. Oh, okay. so you can kind of get a feel for how I do things. [inaudible] Okay? [inaudible] Yeah. [background chatter]`;
1010+To live God-honoring lives and to work continuously for the spread of the Gospel to their neighbors and the nations. Now, let's be honest, as we start off this morning, all of us could do a better job with personal evangelism, and all of us could do a better job with a heart for missions.
61177- const result = await cleanTranscript({
88- transcriptId: "test-123",
99- rawTranscript,
1010- });
1212+So I'm not up here talking to you about something I have conquered or mastered. I'm not the expert on this. In fact, when it comes to personal evangelism in my own strength, I'm often a complete failure. But I have found that even in my weakness, God can use me in powerful ways when I make myself available to Him.`;
11131212- // Check that tags are removed
1313- expect(result.cleanedTranscript).not.toContain("[SIDE CONVERSATION]");
1414- expect(result.cleanedTranscript).not.toContain("[inaudible]");
1515- expect(result.cleanedTranscript).not.toContain("[background chatter]");
1414+// Create mock segments from raw transcript (simulating whisper output)
1515+const sentences = rawTranscript.split(/\.\s+/);
1616+const mockSegments: { index?: number; start?: number; end?: number; text: string }[] = [];
1717+let timeOffset = 0;
1818+for (let i = 0; i < sentences.length; i++) {
1919+const sentence = sentences[i]?.trim();
2020+if (!sentence) continue;
2121+const duration = sentence.split(/\s+/).length * 0.3; // ~0.3s per word
2222+mockSegments.push({
2323+index: i,
2424+start: timeOffset,
2525+end: timeOffset + duration,
2626+text: sentence,
2727+});
2828+timeOffset += duration;
2929+}
3030+3131+const result = await cleanAndGetParagraphBoundaries({
3232+transcriptId: "test-123",
3333+rawTranscript,
3434+segments: mockSegments,
3535+maxWordsMove: 3,
3636+});
3737+3838+// Check that we got a result
3939+expect(result.paragraphs).toBeDefined();
4040+expect(result.paragraphs!.length).toBeGreaterThan(1); // Should have multiple paragraphs
4141+4242+// Check that paragraphs have the expected structure
4343+for (const para of result.paragraphs!) {
4444+ expect(para).toHaveProperty('startSegmentIndex');
4545+ expect(para).toHaveProperty('endSegmentIndex');
4646+ expect(para).toHaveProperty('text');
4747+ expect(para.text.length).toBeGreaterThan(0);
4848+}
4949+5050+// The cleaned text should have tags removed
5151+const cleanedText = result.paragraphs!.map(p => p.text).join(' ');
16521717- // Check that we got some text back
1818- expect(result.cleanedTranscript.length).toBeGreaterThan(0);
1919- expect(result.cleanedTranscript.length).toBeLessThan(rawTranscript.length);
5353+expect(cleanedText).not.toContain("[SIDE CONVERSATION]");
5454+expect(cleanedText.toLowerCase()).toContain("gospel");
5555+expect(cleanedText.toLowerCase()).toContain("evangelism");
20562121- console.log("Original:", rawTranscript.substring(0, 100));
2222- console.log("Cleaned:", result.cleanedTranscript.substring(0, 100));
5757+ console.log(`Detected ${result.paragraphs!.length} paragraphs from ${mockSegments.length} segments`);
5858+ console.log("First paragraph:", result.paragraphs![0]?.text.substring(0, 100) + "...");
5959+ console.log("Last paragraph:", result.paragraphs![result.paragraphs!.length - 1]?.text.substring(0, 100) + "...");
2360}, 30000); // 30s timeout for API call
24612525-test("cleanTranscript handles empty transcript", async () => {
2626- const result = await cleanTranscript({
2727- transcriptId: "test-empty",
2828- rawTranscript: "",
2929- });
6262+test("cleanAndGetParagraphBoundaries handles empty transcript", async () => {
6363+const result = await cleanAndGetParagraphBoundaries({
6464+transcriptId: "test-empty",
6565+rawTranscript: "",
6666+segments: [],
6767+maxWordsMove: 3,
6868+});
30693131- expect(result.cleanedTranscript).toBe("");
7070+expect(result.paragraphs).toEqual([]);
3271});
33723434-test("cleanTranscript falls back to raw transcript on API error", async () => {
3535- const rawTranscript = "Test transcript";
7373+test("cleanAndGetParagraphBoundaries returns error on missing API key", async () => {
7474+const rawTranscript = "Test transcript";
36753737- // Test with missing API key (if it's actually set, this test might fail)
3838- const originalKey = process.env.GEMINI_API_KEY;
3939- delete process.env.GEMINI_API_KEY;
7676+// Test with missing API key (if it's actually set, this test might fail)
7777+const originalKey = process.env.OPENROUTER_API_KEY;
7878+delete process.env.OPENROUTER_API_KEY;
40794141- const result = await cleanTranscript({
4242- transcriptId: "test-fallback",
4343- rawTranscript,
4444- });
8080+const result = await cleanAndGetParagraphBoundaries({
8181+transcriptId: "test-fallback",
8282+rawTranscript,
8383+segments: [{ text: rawTranscript }],
8484+maxWordsMove: 3,
8585+});
45864646- expect(result.cleanedTranscript).toBe(rawTranscript);
4747- expect(result.error).toBe("GEMINI_API_KEY not set");
8787+expect(result.paragraphs).toBeUndefined();
8888+expect(result.error).toBe("OPENROUTER_API_KEY not set");
48894949- // Restore key
5050- if (originalKey) {
5151- process.env.GEMINI_API_KEY = originalKey;
5252- }
9090+// Restore key
9191+if (originalKey) {
9292+process.env.OPENROUTER_API_KEY = originalKey;
9393+}
5394});
+91-84
src/lib/transcript-cleaner.ts
···11-// Clean up transcripts using Gemini to remove tags and fix grammar
22-33-interface CleanTranscriptOptions {
44- transcriptId: string;
55- rawTranscript: string;
66-}
77-88-interface CleanTranscriptResult {
99- cleanedTranscript: string;
1010- error?: string;
11+// Paragraph boundary detection using OpenRouter. Returns a JSON array of paragraph objects.
22+export interface ParagraphBoundary {
33+ startSegmentIndex: number;
44+ endSegmentIndex: number;
55+ text: string;
66+ // Optional: list of moved words for auditing
77+ movedWords?: { word: string; fromSegmentIndex: number; toSegmentIndex: number }[];
118}
1291313-/**
1414- * Clean transcript using Gemini Flash 2.0 (cheapest model)
1515- * Removes tags like [SIDE CONVERSATION], [inaudible], etc.
1616- * Fixes grammar while preserving sentence structure
1717- */
1818-export async function cleanTranscript({
1010+// Cleans transcript and determines paragraph boundaries in one LLM request.
1111+// Returns paragraph boundaries as JSON array.
1212+export async function cleanAndGetParagraphBoundaries({
1913 transcriptId,
2014 rawTranscript,
2121-}: CleanTranscriptOptions): Promise<CleanTranscriptResult> {
2222- const apiKey = process.env.GEMINI_API_KEY;
1515+ segments,
1616+ maxWordsMove = 0,
1717+}: {
1818+ transcriptId: string;
1919+ rawTranscript: string;
2020+ segments: { index?: number; start?: number; end?: number; text: string }[];
2121+ maxWordsMove?: number;
2222+}): Promise<{ paragraphs?: ParagraphBoundary[]; error?: string }> {
2323+ // Skip processing if transcript is empty
2424+ if (!rawTranscript || rawTranscript.trim().length === 0) {
2525+ return { paragraphs: [] };
2626+ }
23272828+ const apiKey = process.env.OPENROUTER_API_KEY;
2929+ const model = process.env.OPENROUTER_MODEL || "openrouter/polaris-alpha";
2430 if (!apiKey) {
2525- return {
2626- cleanedTranscript: rawTranscript,
2727- error: "GEMINI_API_KEY not set",
2828- };
3131+ return { error: "OPENROUTER_API_KEY not set" };
2932 }
30333131- // Skip cleaning if transcript is empty
3232- if (!rawTranscript || rawTranscript.trim().length === 0) {
3333- return {
3434- cleanedTranscript: rawTranscript,
3535- };
3636- }
3434+ try {
3535+ const segmentsPayload = segments.map((s) => ({
3636+ index: s.index ?? null,
3737+ start: s.start ?? null,
3838+ end: s.end ?? null,
3939+ text: s.text ?? "",
4040+ }));
37413838- console.log(
3939- `[TranscriptCleaner] Starting cleanup for ${transcriptId} (${rawTranscript.length} chars)`,
4040- );
4242+ const prompt = `You are a transcript editor and paragrapher. Input: a list of original transcript segments with their index, start time (seconds), end time (seconds), and the RAW transcript text.
41434242- try {
4343- const prompt = `You are a transcript editor. Clean up this transcript by:
4444+Your task: First, clean the transcript by:
44451. Removing ALL tags like [SIDE CONVERSATION], [inaudible], [background chatter], etc.
45462. Fixing grammar and punctuation to make sentences readable
46473. Preserving the original sentence structure and wording as much as possible
···48495. NOT adding any new content or changing the meaning
49506. If there are obvious speaking mistakes then you can fix those (e.g. "we are going no wait sorry you should be doing")
50515151-Return ONLY the cleaned transcript text, nothing else.
5252+Then, determine paragraph boundaries by grouping the cleaned segments into logical paragraphs. A paragraph represents a complete thought, topic, or idea. Create MULTIPLE paragraphs based on:
5353+- Natural topic changes or shifts in the speaker's focus
5454+- Pauses or transitions in the speech ("Now...", "So...", "Let me tell you...", "Alright...")
5555+- Complete narrative beats or examples
5656+- Typical spoken paragraph length (30-120 seconds / 5-20 segments)
5757+5858+CRITICAL: Each paragraph MUST end with a complete sentence. DO NOT break paragraphs mid-sentence.
5959+6060+RETURN ONLY a JSON array of objects, EXACTLY in this format (no additional text):
6161+6262+[ {"startSegmentIndex": <int>, "endSegmentIndex": <int>, "text": "<paragraph text>"}, ... ]
6363+6464+Rules for paragraphing:
6565+- ALWAYS end paragraphs at sentence boundaries (after periods, question marks, or exclamation points)
6666+- NEVER break a paragraph in the middle of a sentence
6767+- Create AT LEAST one paragraph for every 30-60 seconds of speech (roughly 5-10 segments)
6868+- DO NOT put the entire transcript in a single paragraph
6969+- Paragraphs must reference original segment indexes
7070+- Do not move words across segment boundaries
7171+- Return the paragraphs in order and cover the entire cleaned transcript text without overlap or omission
7272+7373+Segments:
7474+${JSON.stringify(segmentsPayload, null, 2)}
52755353-Transcript to clean:
7676+Raw Transcript:
5477${rawTranscript}`;
55785679 const response = await fetch(
5757- "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent",
8080+ "https://openrouter.ai/api/v1/chat/completions",
5881 {
5982 method: "POST",
6083 headers: {
6184 "Content-Type": "application/json",
6262- "x-goog-api-key": apiKey,
8585+ "Authorization": `Bearer ${apiKey}`,
8686+ "HTTP-Referer": "https://thistle.app",
8787+ "X-Title": "Thistle Transcription",
6388 },
6489 body: JSON.stringify({
6565- contents: [
6666- {
6767- parts: [{ text: prompt }],
6868- },
9090+ model,
9191+ messages: [
9292+ { role: "user", content: prompt },
6993 ],
7070- generationConfig: {
7171- temperature: 0.3,
7272- topK: 40,
7373- topP: 0.95,
7474- maxOutputTokens: 8192,
7575- },
9494+ temperature: 0.0,
9595+ max_tokens: 8192,
7696 }),
7797 },
7898 );
799980100 if (!response.ok) {
81101 const errorText = await response.text();
8282- console.error(
8383- `[TranscriptCleaner] Gemini API error for ${transcriptId}:`,
8484- errorText,
8585- );
8686- return {
8787- cleanedTranscript: rawTranscript,
8888- error: `Gemini API error: ${response.status}`,
8989- };
102102+ console.error(`[Paragrapher] OpenRouter error for ${transcriptId}:`, errorText);
103103+ return { error: `OpenRouter API error: ${response.status}` };
90104 }
9110592106 const result = await response.json();
9393- const cleanedText =
9494- result.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
107107+ const raw = result.choices?.[0]?.message?.content?.trim();
108108+ if (!raw) {
109109+ return { error: "Empty paragrapher response" };
110110+ }
951119696- if (!cleanedText) {
9797- console.warn(
9898- `[TranscriptCleaner] Empty response from Gemini for ${transcriptId}`,
9999- );
100100- return {
101101- cleanedTranscript: rawTranscript,
102102- error: "Empty response from Gemini",
103103- };
112112+ let parsed: ParagraphBoundary[] | null = null;
113113+ try {
114114+ parsed = JSON.parse(raw) as ParagraphBoundary[];
115115+ } catch (e) {
116116+ // Attempt to extract JSON substring if model padded text
117117+ const firstBracket = raw.indexOf("[");
118118+ const lastBracket = raw.lastIndexOf("]");
119119+ if (firstBracket >= 0 && lastBracket > firstBracket) {
120120+ const substr = raw.substring(firstBracket, lastBracket + 1);
121121+ parsed = JSON.parse(substr) as ParagraphBoundary[];
122122+ }
104123 }
105124106106- const reduction = Math.round(
107107- ((rawTranscript.length - cleanedText.length) / rawTranscript.length) *
108108- 100,
109109- );
110110- console.log(
111111- `[TranscriptCleaner] Completed for ${transcriptId}: ${rawTranscript.length} → ${cleanedText.length} chars (${reduction}% reduction)`,
112112- );
125125+ if (!parsed || !Array.isArray(parsed)) {
126126+ return { error: "Failed to parse paragrapher JSON" };
127127+ }
113128114114- return {
115115- cleanedTranscript: cleanedText,
116116- };
117117- } catch (error) {
118118- console.error(
119119- `[TranscriptCleaner] Failed to clean ${transcriptId}:`,
120120- error,
121121- );
122122- return {
123123- cleanedTranscript: rawTranscript,
124124- error: error instanceof Error ? error.message : "Unknown error",
125125- };
129129+ return { paragraphs: parsed };
130130+ } catch (err) {
131131+ console.error("[Paragrapher] Exception:", err);
132132+ return { error: err instanceof Error ? err.message : "Unknown error" };
126133 }
127134}