Ionosphere.tv
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'main' into ionosphere-format-trans

+5068
+271
apps/ionosphere-appview/src/detect-boundaries-v7.ts
··· 1 + /** 2 + * v7 boundary detection pipeline — CLI entry point. 3 + * 4 + * Runs the four-stage pipeline: 5 + * Stage 0: Hallucination detector 6 + * Stage 1: Diarization segmenter 7 + * Stage 2: Transcript matcher 8 + * Stage 3: Schedule reconciler 9 + * 10 + * Usage: 11 + * npx tsx src/detect-boundaries-v7.ts <transcript.json> \ 12 + * --diarization <diarization.json> \ 13 + * --stream-slug <slug> 14 + */ 15 + 16 + import { readFileSync, writeFileSync } from 'node:fs'; 17 + import { openDb } from './db.js'; 18 + import { detectHallucinationZones } from './v7/hallucination-detector.js'; 19 + import { segmentDiarization } from './v7/diarization-segmenter.js'; 20 + import { matchAllSegments } from './v7/transcript-matcher.js'; 21 + import { reconcileSchedule } from './v7/schedule-reconciler.js'; 22 + import type { 23 + DiarizationInput, 24 + ScheduleTalk, 25 + TranscriptInput, 26 + } from './v7/types.js'; 27 + 28 + // ─── Stream metadata ────────────────────────────────────────────────────────── 29 + 30 + const STREAM_MATCH: Record<string, { rooms?: string[]; rkeyPrefix?: string }> = { 31 + 'great-hall-day-1': { rooms: ['Great Hall South'] }, 32 + 'great-hall-day-2': { rooms: ['Great Hall South'] }, 33 + 'room-2301-day-1': { rooms: ['Room 2301'] }, 34 + 'room-2301-day-2': { rooms: ['Room 2301'] }, 35 + 'performance-theatre-day-1': { rooms: ['Performance Theatre'] }, 36 + 'performance-theatre-day-2': { rooms: ['Performance Theatre'] }, 37 + atscience: { rkeyPrefix: 'ats26-' }, 38 + }; 39 + 40 + const DAY_DATES: Record<string, string> = { 41 + Friday: '2026-03-27', 42 + Saturday: '2026-03-28', 43 + Sunday: '2026-03-29', 44 + }; 45 + 46 + const SLUG_DAYS: Record<string, string> = { 47 + 'great-hall-day-1': 'Saturday', 48 + 'great-hall-day-2': 'Sunday', 49 + 'room-2301-day-1': 'Saturday', 50 + 'room-2301-day-2': 'Sunday', 51 + 'performance-theatre-day-1': 'Saturday', 52 + 'performance-theatre-day-2': 'Sunday', 53 + atscience: 'Friday', 54 + }; 55 + 56 + /** Duration (seconds) from the hardcoded STREAMS config */ 57 + const SLUG_DURATIONS: Record<string, number> = { 58 + 'great-hall-day-1': 28433, 59 + 'great-hall-day-2': 28433, 60 + 'room-2301-day-1': 27400, 61 + 'room-2301-day-2': 27000, 62 + 'performance-theatre-day-1': 24500, 63 + 'performance-theatre-day-2': 27300, 64 + atscience: 29675, 65 + }; 66 + 67 + // ─── Helpers ────────────────────────────────────────────────────────────────── 68 + 69 + function fmt(seconds: number): string { 70 + const h = Math.floor(seconds / 3600); 71 + const m = Math.floor((seconds % 3600) / 60); 72 + const s = Math.floor(seconds % 60); 73 + return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; 74 + } 75 + 76 + function loadScheduleFromDb(slug: string): ScheduleTalk[] { 77 + const db = openDb(); 78 + try { 79 + const match = STREAM_MATCH[slug]; 80 + if (!match) { 81 + console.error(`Unknown stream slug: ${slug}`); 82 + return []; 83 + } 84 + 85 + if (match.rkeyPrefix) { 86 + // ATScience: match by rkey prefix 87 + return db 88 + .prepare( 89 + `SELECT t.rkey, t.title, t.starts_at, t.ends_at, t.duration, 90 + GROUP_CONCAT(s.name) as speaker_names 91 + FROM talks t 92 + LEFT JOIN talk_speakers ts ON t.uri = ts.talk_uri 93 + LEFT JOIN speakers s ON ts.speaker_uri = s.uri 94 + WHERE t.rkey LIKE ? 95 + GROUP BY t.rkey 96 + ORDER BY t.starts_at ASC`, 97 + ) 98 + .all(`${match.rkeyPrefix}%`) as ScheduleTalk[]; 99 + } 100 + 101 + // Other streams: match by room + date 102 + const dayLabel = SLUG_DAYS[slug]; 103 + const date = dayLabel ? DAY_DATES[dayLabel] : undefined; 104 + if (!date || !match.rooms?.length) return []; 105 + 106 + const placeholders = match.rooms.map(() => '?').join(','); 107 + return db 108 + .prepare( 109 + `SELECT t.rkey, t.title, t.starts_at, t.ends_at, t.duration, 110 + GROUP_CONCAT(s.name) as speaker_names 111 + FROM talks t 112 + LEFT JOIN talk_speakers ts ON t.uri = ts.talk_uri 113 + LEFT JOIN speakers s ON ts.speaker_uri = s.uri 114 + WHERE t.room IN (${placeholders}) AND t.starts_at LIKE ? 115 + GROUP BY t.rkey 116 + ORDER BY t.starts_at ASC`, 117 + ) 118 + .all(...match.rooms, `${date}%`) as ScheduleTalk[]; 119 + } finally { 120 + db.close(); 121 + } 122 + } 123 + 124 + // ─── CLI arg parsing ────────────────────────────────────────────────────────── 125 + 126 + function parseArgs() { 127 + const args = process.argv.slice(2); 128 + 129 + const transcriptPath = args[0]; 130 + if (!transcriptPath || transcriptPath.startsWith('--')) { 131 + console.error( 132 + 'Usage: npx tsx src/detect-boundaries-v7.ts <transcript.json>\n' + 133 + ' --diarization <diarization.json>\n' + 134 + ' --stream-slug <slug>', 135 + ); 136 + process.exit(1); 137 + } 138 + 139 + const diarizationIdx = args.indexOf('--diarization'); 140 + const diarizationPath = diarizationIdx >= 0 ? args[diarizationIdx + 1] : null; 141 + if (!diarizationPath) { 142 + console.error('Error: --diarization <path> is required'); 143 + process.exit(1); 144 + } 145 + 146 + const slugIdx = args.indexOf('--stream-slug'); 147 + const streamSlug = slugIdx >= 0 ? args[slugIdx + 1] : null; 148 + if (!streamSlug) { 149 + console.error('Error: --stream-slug <slug> is required'); 150 + process.exit(1); 151 + } 152 + 153 + return { transcriptPath, diarizationPath, streamSlug }; 154 + } 155 + 156 + // ─── Summary table ──────────────────────────────────────────────────────────── 157 + 158 + function printSummary(output: Awaited<ReturnType<typeof reconcileSchedule>>) { 159 + const { stream, results, hallucinationZones, unmatchedSegments, unmatchedSchedule } = output; 160 + 161 + console.log(`\n${'='.repeat(100)}`); 162 + console.log(`Stream: ${stream}`); 163 + console.log(`Results: ${results.length} talks`); 164 + console.log(`Hallucination zones: ${hallucinationZones.length}`); 165 + console.log(`Unmatched segments: ${unmatchedSegments.length}`); 166 + console.log(`Unmatched schedule: ${unmatchedSchedule.length}`); 167 + console.log(`${'='.repeat(100)}\n`); 168 + 169 + const col = (s: string, w: number) => s.slice(0, w).padEnd(w); 170 + 171 + console.log( 172 + ` ${col('Start', 9)} ${col('End', 9)} ${col('Conf', 13)} ${col('Signals', 40)} Title`, 173 + ); 174 + console.log(` ${'-'.repeat(100)}`); 175 + 176 + for (const r of results) { 177 + const start = fmt(r.startTimestamp); 178 + const end = r.endTimestamp !== null ? fmt(r.endTimestamp) : ' -'; 179 + const conf = r.confidence.padEnd(13); 180 + const signals = r.signals.join(', ').slice(0, 40).padEnd(40); 181 + const title = r.title.slice(0, 50); 182 + console.log(` ${start.padEnd(9)} ${end.padEnd(9)} ${conf} ${signals} ${title}`); 183 + } 184 + 185 + if (unmatchedSchedule.length > 0) { 186 + console.log(`\nUnmatched schedule rkeys: ${unmatchedSchedule.join(', ')}`); 187 + } 188 + 189 + if (unmatchedSegments.length > 0) { 190 + console.log(`\nUnmatched segments:`); 191 + for (const seg of unmatchedSegments) { 192 + console.log(` ${fmt(seg.startS)} - ${fmt(seg.endS)} (${seg.type})`); 193 + } 194 + } 195 + 196 + if (hallucinationZones.length > 0) { 197 + console.log(`\nHallucination zones:`); 198 + for (const z of hallucinationZones) { 199 + console.log(` ${fmt(z.startS)} - ${fmt(z.endS)} [${z.pattern}]`); 200 + } 201 + } 202 + } 203 + 204 + // ─── Main ───────────────────────────────────────────────────────────────────── 205 + 206 + async function main() { 207 + const { transcriptPath, diarizationPath, streamSlug } = parseArgs(); 208 + 209 + console.log(`\nLoading transcript: ${transcriptPath}`); 210 + const transcript = JSON.parse(readFileSync(transcriptPath, 'utf-8')) as TranscriptInput; 211 + 212 + console.log(`Loading diarization: ${diarizationPath}`); 213 + const diarization = JSON.parse(readFileSync(diarizationPath, 'utf-8')) as DiarizationInput; 214 + 215 + console.log(`Loading schedule for stream: ${streamSlug}`); 216 + const schedule = loadScheduleFromDb(streamSlug); 217 + console.log(` Found ${schedule.length} scheduled talks`); 218 + 219 + // Determine stream duration 220 + const streamDurationS = 221 + transcript.duration_seconds || 222 + SLUG_DURATIONS[streamSlug] || 223 + 0; 224 + 225 + console.log( 226 + ` Stream: ${transcript.stream ?? streamSlug}, duration: ${fmt(streamDurationS)} (${(streamDurationS / 3600).toFixed(1)}h)`, 227 + ); 228 + console.log(` Transcript words: ${transcript.words.length}`); 229 + console.log(` Diarization segments: ${diarization.segments.length}, speakers: ${diarization.speakers.join(', ')}`); 230 + 231 + // ── Stage 0: Hallucination detection ── 232 + console.log(`\n=== Stage 0: Detecting hallucination zones ===`); 233 + const hallucinationZones = detectHallucinationZones(transcript, diarization); 234 + console.log(` Found ${hallucinationZones.length} hallucination zone(s)`); 235 + 236 + // ── Stage 1: Diarization segmentation ── 237 + console.log(`\n=== Stage 1: Segmenting diarization ===`); 238 + const segments = segmentDiarization(diarization, hallucinationZones); 239 + console.log(` Found ${segments.length} talk segment(s)`); 240 + 241 + // ── Stage 2: Transcript matching ── 242 + console.log(`\n=== Stage 2: Matching segments to schedule ===`); 243 + const matches = matchAllSegments(segments, transcript, schedule, hallucinationZones); 244 + console.log(` Produced ${matches.length} match(es)`); 245 + 246 + // ── Stage 3: Schedule reconciliation ── 247 + console.log(`\n=== Stage 3: Reconciling schedule ===`); 248 + const streamName = transcript.stream ?? streamSlug; 249 + const output = reconcileSchedule( 250 + matches, 251 + segments, 252 + schedule, 253 + hallucinationZones, 254 + streamDurationS, 255 + streamName, 256 + // No wall-clock start available from transcript, skip zone-based unverifiable check 257 + ); 258 + 259 + // Print summary table 260 + printSummary(output); 261 + 262 + // Write output 263 + const outputPath = transcriptPath.replace(/\.json$/, '') + '-boundaries-v7.json'; 264 + writeFileSync(outputPath, JSON.stringify(output, null, 2)); 265 + console.log(`\nSaved to ${outputPath}`); 266 + } 267 + 268 + main().catch((err) => { 269 + console.error(err); 270 + process.exit(1); 271 + });
+350
apps/ionosphere-appview/src/retranscribe-hallucinations.ts
··· 1 + /** 2 + * Re-transcribe hallucination zones in conference full-day streams. 3 + * 4 + * Extracts audio for each hallucination zone, re-transcribes with Whisper 5 + * using diarization-aligned chunking, and splices results back into the 6 + * existing transcript-enriched.json. 7 + * 8 + * Usage: 9 + * npx tsx src/retranscribe-hallucinations.ts \ 10 + * --stream-slug <slug> \ 11 + * --boundaries <path-to-v7-boundaries.json> \ 12 + * --diarization <path-to-diarization.json> 13 + * 14 + * Environment: OPENAI_API_KEY 15 + */ 16 + import "./env.js"; 17 + import { readFileSync, writeFileSync, existsSync, mkdirSync, createReadStream } from "node:fs"; 18 + import { execSync } from "node:child_process"; 19 + import path from "node:path"; 20 + import OpenAI from "openai"; 21 + import type { HallucinationZone, DiarizationInput, TranscriptInput } from "./v7/types.js"; 22 + 23 + // ─── Stream config ──────────────────────────────────────────────────────────── 24 + 25 + const STREAMS: Record<string, { uri: string; dirName: string }> = { 26 + "great-hall-day-1": { uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadw52j22", dirName: "Great_Hall___Day_1" }, 27 + "great-hall-day-2": { uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miighlz53o22", dirName: "Great_Hall___Day_2" }, 28 + "room-2301-day-1": { uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadx2dj22", dirName: "Room_2301___Day_1" }, 29 + "room-2301-day-2": { uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadxeqn22", dirName: "Room_2301___Day_2" }, 30 + "performance-theatre-day-1": { uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadwgvz22", dirName: "Performance_Theater___Day_1" }, 31 + "performance-theatre-day-2": { uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadwqgy22", dirName: "Performance_Theater___Day_2" }, 32 + "atscience": { uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadvruo22", dirName: "ATScience" }, 33 + }; 34 + 35 + const VOD_ENDPOINT = "https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist"; 36 + const DATA_DIR = path.resolve(import.meta.dirname, "../data/fullday"); 37 + 38 + const MAX_CHUNK_SECS = 20 * 60; // 20 minutes 39 + const SPEECH_GAP_THRESHOLD = 5; // seconds — gap between speech segments before we consider splitting 40 + 41 + // ─── Audio extraction ───────────────────────────────────────────────────────── 42 + 43 + function extractChunk(playlistUrl: string, startSec: number, durationSec: number, outputPath: string): void { 44 + console.log(` Extracting: ${(startSec / 60).toFixed(1)}m - ${((startSec + durationSec) / 60).toFixed(1)}m`); 45 + execSync( 46 + `ffmpeg -ss ${startSec} -i "${playlistUrl}" -t ${durationSec} -vn -acodec libmp3lame -ar 16000 -ac 1 -b:a 32k "${outputPath}" -y`, 47 + { stdio: "pipe", timeout: 600_000 } 48 + ); 49 + } 50 + 51 + // ─── Whisper transcription ──────────────────────────────────────────────────── 52 + 53 + async function transcribeChunk(audioPath: string): Promise<{ word: string; start: number; end: number }[]> { 54 + const client = new OpenAI(); 55 + const response = await client.audio.transcriptions.create({ 56 + model: "whisper-1", 57 + file: createReadStream(audioPath), 58 + response_format: "verbose_json", 59 + timestamp_granularities: ["word"], 60 + language: "en", 61 + prompt: "ATmosphereConf 2026 conference talk.", 62 + }); 63 + return (response.words ?? []).map(w => ({ word: w.word, start: w.start, end: w.end })); 64 + } 65 + 66 + // ─── Diarization-aligned chunking ──────────────────────────────────────────── 67 + 68 + interface TimeRange { 69 + startS: number; 70 + endS: number; 71 + } 72 + 73 + /** 74 + * Given a hallucination zone and diarization segments, compute the audio 75 + * chunks to extract and transcribe. 76 + * 77 + * - Finds diarization speech within the zone 78 + * - Groups into blocks (gap < SPEECH_GAP_THRESHOLD collapses to one block) 79 + * - Splits at gaps if total speech would exceed MAX_CHUNK_SECS 80 + */ 81 + function computeChunksForZone(zone: HallucinationZone, diarization: DiarizationInput): TimeRange[] { 82 + // Find all diarization segments that overlap with the zone 83 + const inZone = diarization.segments.filter( 84 + seg => seg.start < zone.endS && seg.end > zone.startS 85 + ); 86 + 87 + if (inZone.length === 0) return []; 88 + 89 + // Clamp to zone boundaries 90 + const clamped = inZone.map(seg => ({ 91 + startS: Math.max(seg.start, zone.startS), 92 + endS: Math.min(seg.end, zone.endS), 93 + })); 94 + 95 + // Group contiguous speech (gap < threshold) into blocks 96 + const blocks: TimeRange[] = []; 97 + let currentBlock: TimeRange = { ...clamped[0] }; 98 + for (let i = 1; i < clamped.length; i++) { 99 + const gap = clamped[i].startS - currentBlock.endS; 100 + if (gap < SPEECH_GAP_THRESHOLD) { 101 + currentBlock.endS = clamped[i].endS; 102 + } else { 103 + blocks.push(currentBlock); 104 + currentBlock = { ...clamped[i] }; 105 + } 106 + } 107 + blocks.push(currentBlock); 108 + 109 + // If total range fits in one chunk, return one range 110 + const totalDuration = blocks[blocks.length - 1].endS - blocks[0].startS; 111 + if (totalDuration <= MAX_CHUNK_SECS) { 112 + return [{ startS: blocks[0].startS, endS: blocks[blocks.length - 1].endS }]; 113 + } 114 + 115 + // Otherwise, bin blocks into chunks of up to MAX_CHUNK_SECS 116 + const chunks: TimeRange[] = []; 117 + let chunkStart = blocks[0].startS; 118 + let chunkEnd = blocks[0].endS; 119 + 120 + for (let i = 1; i < blocks.length; i++) { 121 + const block = blocks[i]; 122 + if (block.endS - chunkStart <= MAX_CHUNK_SECS) { 123 + chunkEnd = block.endS; 124 + } else { 125 + chunks.push({ startS: chunkStart, endS: chunkEnd }); 126 + chunkStart = block.startS; 127 + chunkEnd = block.endS; 128 + } 129 + } 130 + chunks.push({ startS: chunkStart, endS: chunkEnd }); 131 + return chunks; 132 + } 133 + 134 + // ─── Transcript splicing ────────────────────────────────────────────────────── 135 + 136 + type TranscriptWord = { word: string; start: number; end: number; speaker?: string; confidence?: number }; 137 + 138 + interface EnrichedTranscript { 139 + stream: string; 140 + duration_seconds: number; 141 + words: TranscriptWord[]; 142 + segments?: unknown[]; 143 + total_words: number; 144 + total_segments?: number; 145 + [key: string]: unknown; 146 + } 147 + 148 + function spliceTranscript( 149 + transcriptPath: string, 150 + zones: HallucinationZone[], 151 + newWordsByZone: Map<number, TranscriptWord[]> 152 + ): { removedCount: number; addedCount: number } { 153 + const transcript: EnrichedTranscript = JSON.parse(readFileSync(transcriptPath, "utf-8")); 154 + 155 + // Back up if not already backed up 156 + const bakPath = transcriptPath + ".bak"; 157 + if (!existsSync(bakPath)) { 158 + writeFileSync(bakPath, JSON.stringify(transcript, null, 2)); 159 + } 160 + 161 + let removedCount = 0; 162 + let addedCount = 0; 163 + 164 + // Filter out hallucinated words and insert new ones, zone by zone 165 + for (let i = 0; i < zones.length; i++) { 166 + const zone = zones[i]; 167 + const newWords = newWordsByZone.get(i) ?? []; 168 + 169 + const before = transcript.words.filter(w => w.end <= zone.startS); 170 + const after = transcript.words.filter(w => w.start >= zone.endS); 171 + const removed = transcript.words.filter(w => w.start < zone.endS && w.end > zone.startS); 172 + 173 + removedCount += removed.length; 174 + addedCount += newWords.length; 175 + 176 + transcript.words = [...before, ...newWords, ...after]; 177 + } 178 + 179 + // Re-sort by start time (in case zones overlapped or new words are out of order) 180 + transcript.words.sort((a, b) => a.start - b.start); 181 + transcript.total_words = transcript.words.length; 182 + 183 + writeFileSync(transcriptPath, JSON.stringify(transcript, null, 2)); 184 + return { removedCount, addedCount }; 185 + } 186 + 187 + // ─── CLI argument parsing ───────────────────────────────────────────────────── 188 + 189 + function parseArgs(): { streamSlug: string; boundariesPath: string; diarizationPath: string } { 190 + const args = process.argv.slice(2); 191 + let streamSlug = ""; 192 + let boundariesPath = ""; 193 + let diarizationPath = ""; 194 + 195 + for (let i = 0; i < args.length; i++) { 196 + if (args[i] === "--stream-slug" && args[i + 1]) { 197 + streamSlug = args[++i]; 198 + } else if (args[i] === "--boundaries" && args[i + 1]) { 199 + boundariesPath = args[++i]; 200 + } else if (args[i] === "--diarization" && args[i + 1]) { 201 + diarizationPath = args[++i]; 202 + } 203 + } 204 + 205 + if (!streamSlug || !boundariesPath || !diarizationPath) { 206 + console.error( 207 + "Usage: npx tsx src/retranscribe-hallucinations.ts \\\n" + 208 + " --stream-slug <slug> \\\n" + 209 + " --boundaries <path-to-v7-boundaries.json> \\\n" + 210 + " --diarization <path-to-diarization.json>" 211 + ); 212 + process.exit(1); 213 + } 214 + 215 + return { streamSlug, boundariesPath, diarizationPath }; 216 + } 217 + 218 + // ─── Main ───────────────────────────────────────────────────────────────────── 219 + 220 + async function main() { 221 + const { streamSlug, boundariesPath, diarizationPath } = parseArgs(); 222 + 223 + if (!process.env.OPENAI_API_KEY) { 224 + console.error("ERROR: OPENAI_API_KEY is not set"); 225 + process.exit(1); 226 + } 227 + 228 + // Resolve stream config 229 + const streamConfig = STREAMS[streamSlug]; 230 + if (!streamConfig) { 231 + console.error(`ERROR: Unknown stream slug "${streamSlug}". Known slugs: ${Object.keys(STREAMS).join(", ")}`); 232 + process.exit(1); 233 + } 234 + 235 + console.log(`=== Re-transcribing hallucination zones for ${streamSlug} ===`); 236 + 237 + // Load boundaries JSON 238 + const boundaries = JSON.parse(readFileSync(boundariesPath, "utf-8")); 239 + const hallucinationZones: HallucinationZone[] = boundaries.hallucinationZones ?? []; 240 + console.log(` Loaded ${hallucinationZones.length} hallucination zones`); 241 + 242 + if (hallucinationZones.length === 0) { 243 + console.log(" No hallucination zones found. Nothing to do."); 244 + return; 245 + } 246 + 247 + // Load diarization 248 + const diarization: DiarizationInput = JSON.parse(readFileSync(diarizationPath, "utf-8")); 249 + 250 + // Resolve paths 251 + const streamDir = path.join(DATA_DIR, streamConfig.dirName); 252 + const transcriptPath = path.join(streamDir, "transcript-enriched.json"); 253 + 254 + if (!existsSync(transcriptPath)) { 255 + console.error(`ERROR: transcript-enriched.json not found at ${transcriptPath}`); 256 + process.exit(1); 257 + } 258 + 259 + // Build playlist URL 260 + const playlistUrl = `${VOD_ENDPOINT}?uri=${encodeURIComponent(streamConfig.uri)}`; 261 + 262 + // Create temp chunk dir 263 + const chunkDir = path.join(streamDir, "retranscribe-chunks"); 264 + mkdirSync(chunkDir, { recursive: true }); 265 + 266 + // Process each hallucination zone 267 + const newWordsByZone = new Map<number, TranscriptWord[]>(); 268 + let totalNewWords = 0; 269 + 270 + for (let zoneIdx = 0; zoneIdx < hallucinationZones.length; zoneIdx++) { 271 + const zone = hallucinationZones[zoneIdx]; 272 + const zoneDurationMin = ((zone.endS - zone.startS) / 60).toFixed(1); 273 + console.log( 274 + ` Zone ${zoneIdx + 1}: ${(zone.startS / 60).toFixed(1)}m - ${(zone.endS / 60).toFixed(1)}m (${zoneDurationMin}m)` 275 + ); 276 + 277 + // Compute diarization-aligned chunks 278 + const chunks = computeChunksForZone(zone, diarization); 279 + 280 + if (chunks.length === 0) { 281 + console.log(` No speech in zone, skipping`); 282 + newWordsByZone.set(zoneIdx, []); 283 + continue; 284 + } 285 + 286 + // Report speech coverage 287 + const speechSegs = diarization.segments.filter( 288 + seg => seg.start < zone.endS && seg.end > zone.startS 289 + ); 290 + const totalSpeechMin = ( 291 + speechSegs.reduce((acc, seg) => acc + Math.min(seg.end, zone.endS) - Math.max(seg.start, zone.startS), 0) / 60 292 + ).toFixed(1); 293 + console.log(` Diarization speech: ${speechSegs.length} segments, ${totalSpeechMin}m total`); 294 + console.log( 295 + ` Chunks: ${chunks.length} (${chunks.map(c => ((c.endS - c.startS) / 60).toFixed(1) + "m").join(", ")})` 296 + ); 297 + 298 + // Extract and transcribe each chunk 299 + const zoneWords: TranscriptWord[] = []; 300 + 301 + for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) { 302 + const chunk = chunks[chunkIdx]; 303 + const chunkDuration = chunk.endS - chunk.startS; 304 + const chunkPath = path.join( 305 + chunkDir, 306 + `zone-${String(zoneIdx + 1).padStart(2, "0")}-chunk-${String(chunkIdx + 1).padStart(2, "0")}.mp3` 307 + ); 308 + 309 + // Extract audio 310 + console.log(` Extracting audio...`); 311 + try { 312 + extractChunk(playlistUrl, chunk.startS, chunkDuration, chunkPath); 313 + console.log(` done`); 314 + } catch (err) { 315 + console.log(` FAILED to extract audio: ${(err as Error).message?.slice(0, 80)}`); 316 + continue; 317 + } 318 + 319 + // Transcribe 320 + console.log(` Transcribing chunk ${chunkIdx + 1}/${chunks.length}...`); 321 + try { 322 + const words = await transcribeChunk(chunkPath); 323 + 324 + // Offset timestamps to absolute position in stream 325 + const absoluteWords: TranscriptWord[] = words.map(w => ({ 326 + word: w.word, 327 + start: w.start + chunk.startS, 328 + end: w.end + chunk.startS, 329 + })); 330 + 331 + zoneWords.push(...absoluteWords); 332 + console.log(` ${words.length} words`); 333 + } catch (err) { 334 + console.log(` FAILED to transcribe: ${(err as Error).message?.slice(0, 80)}`); 335 + } 336 + } 337 + 338 + newWordsByZone.set(zoneIdx, zoneWords); 339 + totalNewWords += zoneWords.length; 340 + } 341 + 342 + // Splice into transcript 343 + console.log(` Splicing ${totalNewWords.toLocaleString()} new words into transcript`); 344 + const { removedCount, addedCount } = spliceTranscript(transcriptPath, hallucinationZones, newWordsByZone); 345 + 346 + console.log(` Removed ${removedCount.toLocaleString()} hallucinated words`); 347 + console.log(` Wrote transcript-enriched.json (backup: .bak)`); 348 + } 349 + 350 + main().catch(console.error);
+281
apps/ionosphere-appview/src/v7/__tests__/diarization-segmenter.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { segmentDiarization } from '../diarization-segmenter.js'; 3 + import type { DiarizationInput, HallucinationZone } from '../types.js'; 4 + 5 + // Helpers 6 + 7 + function makeDiarization( 8 + segments: { start: number; end: number; speaker: string }[], 9 + ): DiarizationInput { 10 + const speakers = [...new Set(segments.map((s) => s.speaker))]; 11 + return { speakers, segments, total_segments: segments.length }; 12 + } 13 + 14 + function noZones(): HallucinationZone[] { 15 + return []; 16 + } 17 + 18 + function makeZone(startS: number, endS: number): HallucinationZone { 19 + return { startS, endS, pattern: 'test-pattern' }; 20 + } 21 + 22 + describe('segmentDiarization', () => { 23 + describe('empty / trivial input', () => { 24 + it('returns empty array for empty diarization', () => { 25 + const result = segmentDiarization( 26 + { speakers: [], segments: [], total_segments: 0 }, 27 + noZones(), 28 + ); 29 + expect(result).toHaveLength(0); 30 + }); 31 + 32 + it('handles single segment', () => { 33 + const diar = makeDiarization([{ start: 0, end: 30, speaker: 'SPEAKER_00' }]); 34 + const result = segmentDiarization(diar, noZones()); 35 + expect(result).toHaveLength(1); 36 + expect(result[0].startS).toBe(0); 37 + expect(result[0].endS).toBe(30); 38 + expect(result[0].type).toBe('single-speaker'); 39 + expect(result[0].dominantSpeaker).toBe('SPEAKER_00'); 40 + expect(result[0].precedingGapS).toBe(0); 41 + expect(result[0].hallucinationZone).toBe(false); 42 + }); 43 + }); 44 + 45 + describe('same-speaker gap merging (< 5s)', () => { 46 + it('merges same-speaker segments with tiny gaps into one speech block', () => { 47 + const diar = makeDiarization([ 48 + { start: 0, end: 10, speaker: 'SPEAKER_00' }, 49 + { start: 12, end: 20, speaker: 'SPEAKER_00' }, // 2s gap — should merge 50 + { start: 22, end: 30, speaker: 'SPEAKER_00' }, // 2s gap — should merge 51 + ]); 52 + const result = segmentDiarization(diar, noZones()); 53 + expect(result).toHaveLength(1); 54 + expect(result[0].startS).toBe(0); 55 + expect(result[0].endS).toBe(30); 56 + expect(result[0].type).toBe('single-speaker'); 57 + }); 58 + 59 + it('does NOT merge same-speaker segments with gap >= 5s at block level (becomes within-talk pause)', () => { 60 + // 5s+ gap between same speaker — creates two blocks but NOT a boundary since < 30s 61 + const diar = makeDiarization([ 62 + { start: 0, end: 10, speaker: 'SPEAKER_00' }, 63 + { start: 20, end: 30, speaker: 'SPEAKER_00' }, // 10s gap — separate block, but < 30s so within-talk 64 + ]); 65 + const result = segmentDiarization(diar, noZones()); 66 + // Should still be one TalkSegment since 10s gap < 30s threshold for boundary 67 + expect(result).toHaveLength(1); 68 + expect(result[0].type).toBe('single-speaker'); 69 + }); 70 + }); 71 + 72 + describe('session breaks (> 60s gaps)', () => { 73 + it('splits on a gap > 60s between any speakers', () => { 74 + const diar = makeDiarization([ 75 + { start: 0, end: 30, speaker: 'SPEAKER_00' }, 76 + { start: 130, end: 160, speaker: 'SPEAKER_01' }, // 100s gap 77 + ]); 78 + const result = segmentDiarization(diar, noZones()); 79 + expect(result).toHaveLength(2); 80 + expect(result[0].endS).toBe(30); 81 + expect(result[1].startS).toBe(130); 82 + }); 83 + 84 + it('sets precedingGapS correctly after session break', () => { 85 + const diar = makeDiarization([ 86 + { start: 0, end: 30, speaker: 'SPEAKER_00' }, 87 + { start: 130, end: 160, speaker: 'SPEAKER_01' }, // 100s gap 88 + ]); 89 + const result = segmentDiarization(diar, noZones()); 90 + expect(result[0].precedingGapS).toBe(0); 91 + expect(result[1].precedingGapS).toBe(100); 92 + }); 93 + 94 + it('splits on session break even with same speaker', () => { 95 + const diar = makeDiarization([ 96 + { start: 0, end: 30, speaker: 'SPEAKER_00' }, 97 + { start: 130, end: 160, speaker: 'SPEAKER_00' }, // 100s gap, same speaker 98 + ]); 99 + const result = segmentDiarization(diar, noZones()); 100 + expect(result).toHaveLength(2); 101 + }); 102 + }); 103 + 104 + describe('talk boundaries (30-60s gaps with speaker change)', () => { 105 + it('splits at 30-60s gap when speaker changes', () => { 106 + const diar = makeDiarization([ 107 + { start: 0, end: 30, speaker: 'SPEAKER_00' }, 108 + { start: 75, end: 105, speaker: 'SPEAKER_01' }, // 45s gap + speaker change 109 + ]); 110 + const result = segmentDiarization(diar, noZones()); 111 + expect(result).toHaveLength(2); 112 + expect(result[1].precedingGapS).toBe(45); 113 + }); 114 + 115 + it('does NOT split at 30-60s gap when same speaker continues', () => { 116 + const diar = makeDiarization([ 117 + { start: 0, end: 30, speaker: 'SPEAKER_00' }, 118 + { start: 75, end: 105, speaker: 'SPEAKER_00' }, // 45s gap, same speaker — no boundary 119 + ]); 120 + const result = segmentDiarization(diar, noZones()); 121 + expect(result).toHaveLength(1); 122 + }); 123 + 124 + it('does NOT split at gap < 30s even with speaker change', () => { 125 + const diar = makeDiarization([ 126 + { start: 0, end: 30, speaker: 'SPEAKER_00' }, 127 + { start: 50, end: 80, speaker: 'SPEAKER_01' }, // 20s gap + speaker change — within-talk pause 128 + ]); 129 + const result = segmentDiarization(diar, noZones()); 130 + expect(result).toHaveLength(1); 131 + }); 132 + }); 133 + 134 + describe('speaker classification', () => { 135 + it('classifies a segment as single-speaker when one speaker > 70%', () => { 136 + const diar = makeDiarization([ 137 + { start: 0, end: 80, speaker: 'SPEAKER_00' }, // 80s dominant 138 + { start: 80, end: 100, speaker: 'SPEAKER_01' }, // 20s — within one talk 139 + ]); 140 + const result = segmentDiarization(diar, noZones()); 141 + expect(result).toHaveLength(1); 142 + expect(result[0].type).toBe('single-speaker'); 143 + expect(result[0].dominantSpeaker).toBe('SPEAKER_00'); 144 + }); 145 + 146 + it('classifies a segment as panel when speakers are balanced', () => { 147 + const diar = makeDiarization([ 148 + { start: 0, end: 25, speaker: 'SPEAKER_00' }, 149 + { start: 25, end: 50, speaker: 'SPEAKER_01' }, 150 + { start: 50, end: 75, speaker: 'SPEAKER_02' }, 151 + { start: 75, end: 100, speaker: 'SPEAKER_03' }, 152 + ]); 153 + const result = segmentDiarization(diar, noZones()); 154 + expect(result).toHaveLength(1); 155 + expect(result[0].type).toBe('panel'); 156 + expect(result[0].dominantSpeaker).toBeUndefined(); 157 + }); 158 + 159 + it('aggregates speaker durations correctly', () => { 160 + const diar = makeDiarization([ 161 + { start: 0, end: 30, speaker: 'SPEAKER_00' }, 162 + { start: 35, end: 55, speaker: 'SPEAKER_01' }, 163 + { start: 60, end: 90, speaker: 'SPEAKER_00' }, // SPEAKER_00 total = 60s, SPEAKER_01 = 20s 164 + ]); 165 + const result = segmentDiarization(diar, noZones()); 166 + expect(result).toHaveLength(1); 167 + expect(result[0].type).toBe('single-speaker'); 168 + expect(result[0].dominantSpeaker).toBe('SPEAKER_00'); 169 + const s0 = result[0].speakers.find((s) => s.id === 'SPEAKER_00'); 170 + expect(s0?.durationS).toBe(60); 171 + }); 172 + }); 173 + 174 + describe('panel detection from real data patterns', () => { 175 + it('treats many short alternating segments with tiny gaps as one panel segment', () => { 176 + // Simulate a real panel: 4 speakers, short segments, < 5s gaps 177 + const segments: { start: number; end: number; speaker: string }[] = []; 178 + const speakers = ['SPEAKER_00', 'SPEAKER_01', 'SPEAKER_02', 'SPEAKER_03']; 179 + let t = 0; 180 + for (let i = 0; i < 20; i++) { 181 + const speaker = speakers[i % 4]; 182 + segments.push({ start: t, end: t + 15, speaker }); 183 + t += 15 + 2; // 2s gap — tiny, should all merge into one segment 184 + } 185 + const diar = makeDiarization(segments); 186 + const result = segmentDiarization(diar, noZones()); 187 + expect(result).toHaveLength(1); 188 + expect(result[0].type).toBe('panel'); 189 + }); 190 + }); 191 + 192 + describe('hallucination zone marking', () => { 193 + it('marks segment as hallucinationZone when it overlaps a zone', () => { 194 + const diar = makeDiarization([ 195 + { start: 100, end: 200, speaker: 'SPEAKER_00' }, 196 + ]); 197 + const zones = [makeZone(150, 180)]; 198 + const result = segmentDiarization(diar, zones); 199 + expect(result).toHaveLength(1); 200 + expect(result[0].hallucinationZone).toBe(true); 201 + }); 202 + 203 + it('does NOT mark segment when zone is outside its range', () => { 204 + const diar = makeDiarization([ 205 + { start: 100, end: 200, speaker: 'SPEAKER_00' }, 206 + ]); 207 + const zones = [makeZone(250, 300)]; 208 + const result = segmentDiarization(diar, zones); 209 + expect(result[0].hallucinationZone).toBe(false); 210 + }); 211 + 212 + it('marks only the overlapping segment, not others', () => { 213 + const diar = makeDiarization([ 214 + { start: 0, end: 50, speaker: 'SPEAKER_00' }, 215 + { start: 150, end: 200, speaker: 'SPEAKER_01' }, // 100s gap — session break 216 + ]); 217 + const zones = [makeZone(160, 180)]; // overlaps second segment only 218 + const result = segmentDiarization(diar, zones); 219 + expect(result).toHaveLength(2); 220 + expect(result[0].hallucinationZone).toBe(false); 221 + expect(result[1].hallucinationZone).toBe(true); 222 + }); 223 + 224 + it('marks segment when zone partially overlaps start', () => { 225 + const diar = makeDiarization([ 226 + { start: 100, end: 200, speaker: 'SPEAKER_00' }, 227 + ]); 228 + const zones = [makeZone(80, 120)]; // zone starts before segment 229 + const result = segmentDiarization(diar, zones); 230 + expect(result[0].hallucinationZone).toBe(true); 231 + }); 232 + 233 + it('marks segment when zone partially overlaps end', () => { 234 + const diar = makeDiarization([ 235 + { start: 100, end: 200, speaker: 'SPEAKER_00' }, 236 + ]); 237 + const zones = [makeZone(180, 220)]; // zone ends after segment 238 + const result = segmentDiarization(diar, zones); 239 + expect(result[0].hallucinationZone).toBe(true); 240 + }); 241 + }); 242 + 243 + describe('lightning talk pattern (multiple short sessions with 30-60s gaps)', () => { 244 + it('splits a series of lightning talks separated by 45s gaps with speaker changes', () => { 245 + const talks = [ 246 + // Talk 1: single speaker, 20 minutes 247 + { start: 0, end: 1200, speaker: 'SPEAKER_00' }, 248 + // 45s gap, new speaker 249 + { start: 1245, end: 2445, speaker: 'SPEAKER_01' }, 250 + // 45s gap, new speaker 251 + { start: 2490, end: 3690, speaker: 'SPEAKER_02' }, 252 + ]; 253 + const diar = makeDiarization(talks); 254 + const result = segmentDiarization(diar, noZones()); 255 + expect(result).toHaveLength(3); 256 + expect(result[0].dominantSpeaker).toBe('SPEAKER_00'); 257 + expect(result[1].dominantSpeaker).toBe('SPEAKER_01'); 258 + expect(result[2].dominantSpeaker).toBe('SPEAKER_02'); 259 + expect(result[1].precedingGapS).toBe(45); 260 + expect(result[2].precedingGapS).toBe(45); 261 + }); 262 + }); 263 + 264 + describe('input ordering', () => { 265 + it('handles unsorted diarization segments', () => { 266 + // Provide segments out of order 267 + const diar = makeDiarization([ 268 + { start: 200, end: 250, speaker: 'SPEAKER_01' }, 269 + { start: 0, end: 50, speaker: 'SPEAKER_00' }, 270 + { start: 400, end: 450, speaker: 'SPEAKER_02' }, // 150s gap — session break 271 + ]); 272 + const result = segmentDiarization(diar, noZones()); 273 + // 200-50=150s gap between first and second would be a session break if sorted 274 + // But 200-50 = 150 > 60 so they split; then 400-250 = 150 > 60 so they split too 275 + expect(result).toHaveLength(3); 276 + expect(result[0].startS).toBe(0); 277 + expect(result[1].startS).toBe(200); 278 + expect(result[2].startS).toBe(400); 279 + }); 280 + }); 281 + });
+286
apps/ionosphere-appview/src/v7/__tests__/hallucination-detector.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { detectHallucinationZones } from '../hallucination-detector.js'; 3 + import type { DiarizationInput, TranscriptInput } from '../types.js'; 4 + 5 + // Helper: build a transcript with words spaced 1 second apart starting at `startS` 6 + function makeWords( 7 + phrase: string, 8 + repeatCount: number, 9 + startS: number, 10 + wordDurationS = 0.5, 11 + ): TranscriptInput['words'] { 12 + const wordList = phrase.split(/\s+/).filter(Boolean); 13 + const words: TranscriptInput['words'] = []; 14 + let t = startS; 15 + for (let i = 0; i < repeatCount; i++) { 16 + for (const word of wordList) { 17 + words.push({ word, start: t, end: t + wordDurationS }); 18 + t += wordDurationS + 0.1; 19 + } 20 + // Small gap between repeats 21 + t += 0.5; 22 + } 23 + return words; 24 + } 25 + 26 + // Helper: build a transcript with N consecutive "0" words 27 + function makeZeroWords(count: number, startS: number): TranscriptInput['words'] { 28 + return Array.from({ length: count }, (_, i) => ({ 29 + word: '0', 30 + start: startS + i * 0.3, 31 + end: startS + i * 0.3 + 0.2, 32 + })); 33 + } 34 + 35 + // Helper: empty diarization (no speech at all) 36 + function emptyDiarization(): DiarizationInput { 37 + return { speakers: [], segments: [], total_segments: 0 }; 38 + } 39 + 40 + // Helper: diarization covering a range 41 + function makeDiarization( 42 + segments: { start: number; end: number; speaker: string }[], 43 + ): DiarizationInput { 44 + const speakers = [...new Set(segments.map((s) => s.speaker))]; 45 + return { speakers, segments, total_segments: segments.length }; 46 + } 47 + 48 + // Helper: real speech words (non-hallucination content) 49 + function makeRealSpeech(startS: number): TranscriptInput['words'] { 50 + const sentence = 51 + 'Welcome to the AT Protocol conference today we are discussing distributed social networking'; 52 + return makeWords(sentence, 1, startS); 53 + } 54 + 55 + describe('detectHallucinationZones', () => { 56 + describe('pattern matching', () => { 57 + it('detects CastingWords attribution loops (3+ repeats)', () => { 58 + const words = makeWords('Transcription by CastingWords', 5, 100); 59 + const transcript: TranscriptInput = { 60 + stream: 'test', 61 + duration_seconds: 300, 62 + words, 63 + }; 64 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 65 + expect(zones.length).toBeGreaterThan(0); 66 + expect(zones[0].pattern).toMatch(/castingwords/i); 67 + }); 68 + 69 + it('detects otter.ai attribution loops (3+ repeats)', () => { 70 + const words = makeWords('otter.ai automatic transcription', 4, 200); 71 + const transcript: TranscriptInput = { 72 + stream: 'test', 73 + duration_seconds: 400, 74 + words, 75 + }; 76 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 77 + expect(zones.length).toBeGreaterThan(0); 78 + expect(zones[0].pattern).toMatch(/otter\.ai/i); 79 + }); 80 + 81 + it('does NOT flag a pattern that appears fewer than 3 times', () => { 82 + const words = makeWords('Transcription by CastingWords', 2, 100); 83 + const transcript: TranscriptInput = { 84 + stream: 'test', 85 + duration_seconds: 300, 86 + words, 87 + }; 88 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 89 + expect(zones.filter((z) => /castingwords/i.test(z.pattern))).toHaveLength(0); 90 + }); 91 + 92 + it('detects Thank you for watching loops', () => { 93 + const words = makeWords('Thank you for watching', 6, 50); 94 + const transcript: TranscriptInput = { 95 + stream: 'test', 96 + duration_seconds: 200, 97 + words, 98 + }; 99 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 100 + expect(zones.length).toBeGreaterThan(0); 101 + expect(zones[0].pattern).toMatch(/thank you for watching/i); 102 + }); 103 + }); 104 + 105 + describe('numeric zero loops', () => { 106 + it('detects 20+ consecutive zero words', () => { 107 + const words = makeZeroWords(25, 500); 108 + const transcript: TranscriptInput = { 109 + stream: 'test', 110 + duration_seconds: 1000, 111 + words, 112 + }; 113 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 114 + expect(zones.length).toBeGreaterThan(0); 115 + expect(zones[0].pattern).toBe('numeric zero loop'); 116 + expect(zones[0].startS).toBeCloseTo(500, 0); 117 + }); 118 + 119 + it('does NOT flag fewer than 20 consecutive zeros', () => { 120 + const words = makeZeroWords(15, 500); 121 + const transcript: TranscriptInput = { 122 + stream: 'test', 123 + duration_seconds: 1000, 124 + words, 125 + }; 126 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 127 + expect(zones.filter((z) => z.pattern === 'numeric zero loop')).toHaveLength(0); 128 + }); 129 + 130 + it('detects exactly 20 zeros', () => { 131 + const words = makeZeroWords(20, 100); 132 + const transcript: TranscriptInput = { 133 + stream: 'test', 134 + duration_seconds: 500, 135 + words, 136 + }; 137 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 138 + expect(zones.filter((z) => z.pattern === 'numeric zero loop')).toHaveLength(1); 139 + }); 140 + 141 + it('correctly identifies time range of zero loop', () => { 142 + const words = makeZeroWords(30, 1000); 143 + const transcript: TranscriptInput = { 144 + stream: 'test', 145 + duration_seconds: 2000, 146 + words, 147 + }; 148 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 149 + const zeroZone = zones.find((z) => z.pattern === 'numeric zero loop'); 150 + expect(zeroZone).toBeDefined(); 151 + expect(zeroZone!.startS).toBeGreaterThanOrEqual(1000); 152 + expect(zeroZone!.endS).toBeLessThanOrEqual(1000 + 30 * 0.5 + 5); 153 + }); 154 + }); 155 + 156 + describe('diarization silence mismatch', () => { 157 + it('detects transcript words during a 60s+ diarization gap', () => { 158 + // Diarization: speaker active 0-100, then gap, then 200-300 159 + const diarization = makeDiarization([ 160 + { start: 0, end: 100, speaker: 'SPEAKER_00' }, 161 + { start: 200, end: 300, speaker: 'SPEAKER_00' }, 162 + ]); 163 + 164 + // Transcript has words during the 100-200 gap 165 + const gapWords = makeWords('some garbled text here', 3, 120); 166 + const transcript: TranscriptInput = { 167 + stream: 'test', 168 + duration_seconds: 300, 169 + words: gapWords, 170 + }; 171 + 172 + const zones = detectHallucinationZones(transcript, diarization); 173 + expect(zones.length).toBeGreaterThan(0); 174 + expect(zones[0].pattern).toBe('diarization silence mismatch'); 175 + expect(zones[0].startS).toBeGreaterThanOrEqual(120); 176 + expect(zones[0].endS).toBeLessThanOrEqual(200); 177 + }); 178 + 179 + it('does NOT flag transcript words in a gap shorter than 60s', () => { 180 + // Diarization: gap of only 30s 181 + const diarization = makeDiarization([ 182 + { start: 0, end: 100, speaker: 'SPEAKER_00' }, 183 + { start: 130, end: 200, speaker: 'SPEAKER_00' }, 184 + ]); 185 + 186 + const gapWords = makeWords('some text in short gap', 1, 105); 187 + const transcript: TranscriptInput = { 188 + stream: 'test', 189 + duration_seconds: 200, 190 + words: gapWords, 191 + }; 192 + 193 + const zones = detectHallucinationZones(transcript, diarization); 194 + expect( 195 + zones.filter((z) => z.pattern === 'diarization silence mismatch'), 196 + ).toHaveLength(0); 197 + }); 198 + 199 + it('does NOT flag when transcript words are outside the silence gap', () => { 200 + const diarization = makeDiarization([ 201 + { start: 0, end: 100, speaker: 'SPEAKER_00' }, 202 + { start: 200, end: 300, speaker: 'SPEAKER_00' }, 203 + ]); 204 + 205 + // Words are BEFORE the gap starts 206 + const words = makeWords('real speech here', 2, 50); 207 + const transcript: TranscriptInput = { 208 + stream: 'test', 209 + duration_seconds: 300, 210 + words, 211 + }; 212 + 213 + const zones = detectHallucinationZones(transcript, diarization); 214 + expect( 215 + zones.filter((z) => z.pattern === 'diarization silence mismatch'), 216 + ).toHaveLength(0); 217 + }); 218 + }); 219 + 220 + describe('does NOT flag real speech as hallucination', () => { 221 + it('returns no zones for a clean transcript with matching diarization', () => { 222 + const words = makeRealSpeech(0); 223 + // Extend with more real speech 224 + const moreWords = makeRealSpeech(30); 225 + const allWords = [...words, ...moreWords]; 226 + 227 + const diarization = makeDiarization([ 228 + { start: 0, end: 60, speaker: 'SPEAKER_00' }, 229 + ]); 230 + 231 + const transcript: TranscriptInput = { 232 + stream: 'test', 233 + duration_seconds: 60, 234 + words: allWords, 235 + }; 236 + 237 + const zones = detectHallucinationZones(transcript, diarization); 238 + expect(zones).toHaveLength(0); 239 + }); 240 + 241 + it('returns no zones for empty transcript', () => { 242 + const transcript: TranscriptInput = { 243 + stream: 'test', 244 + duration_seconds: 300, 245 + words: [], 246 + }; 247 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 248 + expect(zones).toHaveLength(0); 249 + }); 250 + }); 251 + 252 + describe('zone merging', () => { 253 + it('merges overlapping zones from different detectors', () => { 254 + // Zero loop followed by CastingWords within 60s 255 + const zeroWords = makeZeroWords(25, 100); 256 + const castingWords = makeWords('Transcription by CastingWords', 4, 120); 257 + const allWords = [...zeroWords, ...castingWords]; 258 + 259 + const transcript: TranscriptInput = { 260 + stream: 'test', 261 + duration_seconds: 300, 262 + words: allWords, 263 + }; 264 + 265 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 266 + // Should be merged into 1 zone or at most 2 (within 60s merge window) 267 + expect(zones.length).toBeLessThanOrEqual(2); 268 + }); 269 + 270 + it('does NOT merge zones more than 60s apart', () => { 271 + // Two separate zero loops 120s apart 272 + const zeroWords1 = makeZeroWords(25, 100); 273 + const zeroWords2 = makeZeroWords(25, 300); // 300 - (100 + ~10) > 60s 274 + const allWords = [...zeroWords1, ...zeroWords2]; 275 + 276 + const transcript: TranscriptInput = { 277 + stream: 'test', 278 + duration_seconds: 500, 279 + words: allWords, 280 + }; 281 + 282 + const zones = detectHallucinationZones(transcript, emptyDiarization()); 283 + expect(zones.length).toBe(2); 284 + }); 285 + }); 286 + });
+277
apps/ionosphere-appview/src/v7/__tests__/schedule-reconciler.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { reconcileSchedule } from '../schedule-reconciler.js'; 3 + import type { 4 + BoundaryMatch, 5 + HallucinationZone, 6 + ScheduleTalk, 7 + TalkSegment, 8 + } from '../types.js'; 9 + 10 + // ─── Helpers ────────────────────────────────────────────────────────────────── 11 + 12 + function makeMatch( 13 + rkey: string, 14 + startTimestamp: number, 15 + confidence: BoundaryMatch['confidence'] = 'high', 16 + signals: string[] = ['self-intro:Alice'], 17 + ): BoundaryMatch { 18 + return { 19 + rkey, 20 + title: `Talk ${rkey}`, 21 + startTimestamp, 22 + endTimestamp: null, 23 + confidence, 24 + signals, 25 + panel: false, 26 + hallucinationZones: [], 27 + }; 28 + } 29 + 30 + function makeSegment( 31 + startS: number, 32 + endS: number, 33 + hallucinationZone = false, 34 + ): TalkSegment { 35 + return { 36 + startS, 37 + endS, 38 + speakers: [{ id: 'SPEAKER_00', durationS: endS - startS }], 39 + type: 'single-speaker', 40 + dominantSpeaker: 'SPEAKER_00', 41 + precedingGapS: 0, 42 + hallucinationZone, 43 + }; 44 + } 45 + 46 + function makeTalk( 47 + rkey: string, 48 + startsAt = '2026-03-28T09:00:00Z', 49 + endsAt = '2026-03-28T09:30:00Z', 50 + ): ScheduleTalk { 51 + return { 52 + rkey, 53 + title: `Talk ${rkey}`, 54 + starts_at: startsAt, 55 + ends_at: endsAt, 56 + speaker_names: 'Alice Smith', 57 + }; 58 + } 59 + 60 + function makeZone(startS: number, endS: number): HallucinationZone { 61 + return { startS, endS, pattern: 'test pattern' }; 62 + } 63 + 64 + // ─── Tests ──────────────────────────────────────────────────────────────────── 65 + 66 + describe('reconcileSchedule', () => { 67 + describe('empty inputs', () => { 68 + it('handles all-empty inputs gracefully', () => { 69 + const output = reconcileSchedule([], [], [], [], 3600, 'test-stream'); 70 + expect(output.stream).toBe('test-stream'); 71 + expect(output.results).toHaveLength(0); 72 + expect(output.unmatchedSegments).toHaveLength(0); 73 + expect(output.unmatchedSchedule).toHaveLength(0); 74 + expect(output.hallucinationZones).toHaveLength(0); 75 + }); 76 + 77 + it('handles matches with no schedule', () => { 78 + const matches = [makeMatch('talk-1', 100), makeMatch('talk-2', 500)]; 79 + const segments = [makeSegment(100, 490), makeSegment(500, 900)]; 80 + const output = reconcileSchedule(matches, segments, [], [], 3600, 'test-stream'); 81 + expect(output.results).toHaveLength(2); 82 + expect(output.unmatchedSchedule).toHaveLength(0); 83 + }); 84 + }); 85 + 86 + describe('deduplication', () => { 87 + it('deduplicates same rkey and keeps the highest confidence match', () => { 88 + const matches = [ 89 + makeMatch('talk-1', 100, 'low'), 90 + makeMatch('talk-1', 200, 'high'), 91 + makeMatch('talk-1', 150, 'medium'), 92 + ]; 93 + const segments = [makeSegment(100, 900)]; 94 + const output = reconcileSchedule(matches, segments, [], [], 3600, 'test-stream'); 95 + 96 + const talk1Results = output.results.filter((r) => r.rkey === 'talk-1'); 97 + expect(talk1Results).toHaveLength(1); 98 + expect(talk1Results[0].confidence).toBe('high'); 99 + }); 100 + 101 + it('deduplicates same rkey: same confidence keeps earlier start', () => { 102 + const matches = [ 103 + makeMatch('talk-1', 300, 'medium'), 104 + makeMatch('talk-1', 100, 'medium'), 105 + ]; 106 + const segments = [makeSegment(100, 900)]; 107 + const output = reconcileSchedule(matches, segments, [], [], 3600, 'test-stream'); 108 + 109 + const talk1Results = output.results.filter((r) => r.rkey === 'talk-1'); 110 + expect(talk1Results).toHaveLength(1); 111 + expect(talk1Results[0].startTimestamp).toBe(100); 112 + }); 113 + }); 114 + 115 + describe('end time computation', () => { 116 + it('computes end time from next talk start (gap <= 60s)', () => { 117 + // Gap of 30s between talks — should use next start, not segment end 118 + const matches = [makeMatch('talk-1', 100), makeMatch('talk-2', 130)]; 119 + const segments = [makeSegment(100, 125), makeSegment(130, 400)]; 120 + const output = reconcileSchedule(matches, segments, [], [], 3600, 'test-stream'); 121 + 122 + const talk1 = output.results.find((r) => r.rkey === 'talk-1'); 123 + expect(talk1?.endTimestamp).toBe(130); 124 + }); 125 + 126 + it('last talk gets stream duration as end (when segment end > stream duration)', () => { 127 + const matches = [makeMatch('talk-1', 100), makeMatch('talk-2', 500)]; 128 + const segments = [makeSegment(100, 490), makeSegment(500, 9999)]; 129 + const streamDurationS = 3600; 130 + const output = reconcileSchedule(matches, segments, [], [], streamDurationS, 'test-stream'); 131 + 132 + const talk2 = output.results.find((r) => r.rkey === 'talk-2'); 133 + expect(talk2?.endTimestamp).toBe(streamDurationS); 134 + }); 135 + 136 + it('last talk uses segment endS when smaller than stream duration', () => { 137 + const matches = [makeMatch('talk-1', 100), makeMatch('talk-2', 500)]; 138 + const segments = [makeSegment(100, 490), makeSegment(500, 800)]; 139 + const streamDurationS = 3600; 140 + const output = reconcileSchedule(matches, segments, [], [], streamDurationS, 'test-stream'); 141 + 142 + const talk2 = output.results.find((r) => r.rkey === 'talk-2'); 143 + expect(talk2?.endTimestamp).toBe(800); 144 + }); 145 + 146 + it('session break: last talk before break gets segment end, not next session start', () => { 147 + // talk-1 ends (segment) at 490, but next talk starts at 700 (gap = 600s > 60s) 148 + const matches = [ 149 + makeMatch('talk-1', 100), 150 + makeMatch('talk-3', 700), // next session after a large break 151 + ]; 152 + const segments = [ 153 + makeSegment(100, 490), // talk-1's segment 154 + makeSegment(700, 1100), 155 + ]; 156 + const output = reconcileSchedule(matches, segments, [], [], 3600, 'test-stream'); 157 + 158 + const talk1 = output.results.find((r) => r.rkey === 'talk-1'); 159 + // gap is 600s > SESSION_BREAK_GAP_S (60s) → should use segment endS (490) 160 + expect(talk1?.endTimestamp).toBe(490); 161 + 162 + // talk-3 is the last, should get min(streamDuration, segEnd) 163 + const talk3 = output.results.find((r) => r.rkey === 'talk-3'); 164 + expect(talk3?.endTimestamp).toBe(1100); 165 + }); 166 + 167 + it('no session break: small gap uses next talk start', () => { 168 + const matches = [ 169 + makeMatch('talk-1', 100), 170 + makeMatch('talk-2', 140), // gap = 40s, not a session break 171 + ]; 172 + const segments = [makeSegment(100, 135), makeSegment(140, 400)]; 173 + const output = reconcileSchedule(matches, segments, [], [], 3600, 'test-stream'); 174 + 175 + const talk1 = output.results.find((r) => r.rkey === 'talk-1'); 176 + expect(talk1?.endTimestamp).toBe(140); 177 + }); 178 + }); 179 + 180 + describe('unmatched schedule entries', () => { 181 + it('unmatched schedule talk outside hallucination zone → unmatchedSchedule list', () => { 182 + const schedule = [makeTalk('talk-1'), makeTalk('talk-unmatched')]; 183 + const matches = [makeMatch('talk-1', 100)]; 184 + const segments = [makeSegment(100, 500)]; 185 + 186 + const output = reconcileSchedule(matches, segments, schedule, [], 3600, 'test-stream'); 187 + 188 + expect(output.unmatchedSchedule).toContain('talk-unmatched'); 189 + expect(output.results.find((r) => r.rkey === 'talk-unmatched')).toBeUndefined(); 190 + }); 191 + 192 + it('unmatched schedule in hallucination zone → unverifiable result', () => { 193 + // Stream starts at Unix epoch 1743066000 (2026-03-27T09:00:00Z) 194 + // talk-unmatched starts_at = 2026-03-27T09:30:00Z = epoch +1800s 195 + const streamStartUnixS = 1743066000; // 2026-03-27T09:00:00Z 196 + 197 + // Hallucination zone at stream-relative 1800-2400s 198 + const zones: HallucinationZone[] = [makeZone(1800, 2400)]; 199 + 200 + // talk starts at 2026-03-27T09:30:00Z → relative offset 1800s 201 + const talkStartsAt = new Date(streamStartUnixS * 1000 + 1800 * 1000).toISOString(); 202 + const schedule = [makeTalk('talk-unmatched', talkStartsAt)]; 203 + const matches: BoundaryMatch[] = []; 204 + const segments: TalkSegment[] = []; 205 + 206 + const output = reconcileSchedule( 207 + matches, segments, schedule, zones, 3600, 'test-stream', streamStartUnixS, 208 + ); 209 + 210 + expect(output.unmatchedSchedule).not.toContain('talk-unmatched'); 211 + const unverifiable = output.results.find((r) => r.rkey === 'talk-unmatched'); 212 + expect(unverifiable).toBeDefined(); 213 + expect(unverifiable?.confidence).toBe('unverifiable'); 214 + }); 215 + 216 + it('unmatched schedule with no streamStartUnixS → goes to unmatchedSchedule (safe default)', () => { 217 + const zones: HallucinationZone[] = [makeZone(1800, 2400)]; 218 + const schedule = [makeTalk('talk-unmatched')]; 219 + const output = reconcileSchedule([], [], schedule, zones, 3600, 'test-stream'); 220 + // No streamStartUnixS means zone check is disabled 221 + expect(output.unmatchedSchedule).toContain('talk-unmatched'); 222 + }); 223 + }); 224 + 225 + describe('unmatched segments', () => { 226 + it('segments with no BoundaryMatch → unmatchedSegments', () => { 227 + const matches = [makeMatch('talk-1', 100)]; 228 + const segments = [ 229 + makeSegment(100, 500), // matched (talk-1) 230 + makeSegment(600, 900), // unmatched 231 + ]; 232 + const output = reconcileSchedule(matches, segments, [], [], 3600, 'test-stream'); 233 + 234 + expect(output.unmatchedSegments).toHaveLength(1); 235 + expect(output.unmatchedSegments[0].startS).toBe(600); 236 + }); 237 + 238 + it('all segments matched → empty unmatchedSegments', () => { 239 + const matches = [makeMatch('talk-1', 100), makeMatch('talk-2', 500)]; 240 + const segments = [makeSegment(100, 490), makeSegment(500, 900)]; 241 + const output = reconcileSchedule(matches, segments, [], [], 3600, 'test-stream'); 242 + 243 + expect(output.unmatchedSegments).toHaveLength(0); 244 + }); 245 + }); 246 + 247 + describe('output assembly', () => { 248 + it('results are sorted by startTimestamp', () => { 249 + const matches = [ 250 + makeMatch('talk-2', 500), 251 + makeMatch('talk-1', 100), 252 + makeMatch('talk-3', 900), 253 + ]; 254 + const segments = [ 255 + makeSegment(100, 490), 256 + makeSegment(500, 890), 257 + makeSegment(900, 1200), 258 + ]; 259 + const output = reconcileSchedule(matches, segments, [], [], 3600, 'test-stream'); 260 + 261 + const starts = output.results.map((r) => r.startTimestamp); 262 + expect(starts).toEqual([...starts].sort((a, b) => a - b)); 263 + }); 264 + 265 + it('includes hallucinationZones in output', () => { 266 + const zones = [makeZone(100, 200)]; 267 + const output = reconcileSchedule([], [], [], zones, 3600, 'test-stream'); 268 + expect(output.hallucinationZones).toHaveLength(1); 269 + expect(output.hallucinationZones[0].startS).toBe(100); 270 + }); 271 + 272 + it('sets stream name on output', () => { 273 + const output = reconcileSchedule([], [], [], [], 3600, 'great-hall-day-1'); 274 + expect(output.stream).toBe('great-hall-day-1'); 275 + }); 276 + }); 277 + });
+663
apps/ionosphere-appview/src/v7/__tests__/transcript-matcher.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { 3 + extractSignals, 4 + matchSegmentToSchedule, 5 + matchAllSegments, 6 + } from '../transcript-matcher.js'; 7 + import type { 8 + TranscriptInput, 9 + TalkSegment, 10 + ScheduleTalk, 11 + HallucinationZone, 12 + } from '../types.js'; 13 + 14 + // ─── Helpers ────────────────────────────────────────────────────────────────── 15 + 16 + function makeTranscript(words: { word: string; start: number; end: number }[]): TranscriptInput { 17 + return { 18 + stream: 'test-stream', 19 + duration_seconds: 3600, 20 + words, 21 + }; 22 + } 23 + 24 + /** Build a simple word-by-word transcript from a sentence starting at a given offset */ 25 + function wordsFromSentence( 26 + sentence: string, 27 + startS: number, 28 + wordDurationS = 0.5, 29 + ): { word: string; start: number; end: number }[] { 30 + return sentence.split(/\s+/).map((word, i) => ({ 31 + word, 32 + start: startS + i * wordDurationS, 33 + end: startS + i * wordDurationS + wordDurationS, 34 + })); 35 + } 36 + 37 + function makeTalk( 38 + rkey: string, 39 + title: string, 40 + speakerNames: string, 41 + startsAt = '2025-01-01T09:00:00Z', 42 + endsAt = '2025-01-01T09:30:00Z', 43 + ): ScheduleTalk { 44 + return { rkey, title, speaker_names: speakerNames, starts_at: startsAt, ends_at: endsAt }; 45 + } 46 + 47 + function makeSegment( 48 + startS: number, 49 + endS: number, 50 + type: TalkSegment['type'] = 'single-speaker', 51 + hallucinationZone = false, 52 + ): TalkSegment { 53 + return { 54 + startS, 55 + endS, 56 + speakers: [{ id: 'SPEAKER_00', durationS: endS - startS }], 57 + type, 58 + dominantSpeaker: type === 'single-speaker' ? 'SPEAKER_00' : undefined, 59 + precedingGapS: 0, 60 + hallucinationZone, 61 + }; 62 + } 63 + 64 + // ─── extractSignals tests ───────────────────────────────────────────────────── 65 + 66 + describe('extractSignals', () => { 67 + describe('self-introduction detection', () => { 68 + it('finds "my name is Justin" as self-intro signal', () => { 69 + const transcript = makeTranscript([ 70 + ...wordsFromSentence('Hello everyone my name is Justin and', 10), 71 + ]); 72 + const signals = extractSignals(transcript, 10, 200); 73 + const selfIntros = signals.filter((s) => s.type === 'self-intro'); 74 + expect(selfIntros).toHaveLength(1); 75 + expect((selfIntros[0] as { type: 'self-intro'; name: string }).name).toBe('Justin'); 76 + }); 77 + 78 + it('finds "my name\'s Bank" as self-intro signal', () => { 79 + // Use "Bank" as a capitalized name 80 + const words = [ 81 + { word: 'my', start: 5, end: 5.5 }, 82 + { word: "name's", start: 5.5, end: 6 }, 83 + { word: 'Bank', start: 6, end: 6.5 }, 84 + ]; 85 + const transcript = makeTranscript(words); 86 + const signals = extractSignals(transcript, 0, 200); 87 + const selfIntros = signals.filter((s) => s.type === 'self-intro'); 88 + expect(selfIntros).toHaveLength(1); 89 + expect((selfIntros[0] as { type: 'self-intro'; name: string }).name).toBe('Bank'); 90 + }); 91 + 92 + it('finds "I\'m Justin" as self-intro signal', () => { 93 + const words = [ 94 + { word: "I'm", start: 5, end: 5.5 }, 95 + { word: 'Justin', start: 5.5, end: 6 }, 96 + ]; 97 + const transcript = makeTranscript(words); 98 + const signals = extractSignals(transcript, 0, 200); 99 + const selfIntros = signals.filter((s) => s.type === 'self-intro'); 100 + expect(selfIntros).toHaveLength(1); 101 + expect((selfIntros[0] as { type: 'self-intro'; name: string }).name).toBe('Justin'); 102 + }); 103 + 104 + it('does NOT extract "I\'m So" (common word)', () => { 105 + const words = [ 106 + { word: "I'm", start: 5, end: 5.5 }, 107 + { word: 'So', start: 5.5, end: 6 }, 108 + ]; 109 + const transcript = makeTranscript(words); 110 + const signals = extractSignals(transcript, 0, 200); 111 + const selfIntros = signals.filter((s) => s.type === 'self-intro'); 112 + expect(selfIntros).toHaveLength(0); 113 + }); 114 + 115 + it('does NOT extract "I\'m Here" (common word)', () => { 116 + const words = [ 117 + { word: "I'm", start: 5, end: 5.5 }, 118 + { word: 'Here', start: 5.5, end: 6 }, 119 + ]; 120 + const transcript = makeTranscript(words); 121 + const signals = extractSignals(transcript, 0, 200); 122 + const selfIntros = signals.filter((s) => s.type === 'self-intro'); 123 + expect(selfIntros).toHaveLength(0); 124 + }); 125 + 126 + it('does NOT extract "I\'m Going" (common word)', () => { 127 + const words = [ 128 + { word: "I'm", start: 5, end: 5.5 }, 129 + { word: 'Going', start: 5.5, end: 6 }, 130 + ]; 131 + const transcript = makeTranscript(words); 132 + const signals = extractSignals(transcript, 0, 200); 133 + const selfIntros = signals.filter((s) => s.type === 'self-intro'); 134 + expect(selfIntros).toHaveLength(0); 135 + }); 136 + 137 + it('does NOT find self-intro outside the first 120s window', () => { 138 + const words = [ 139 + { word: 'my', start: 125, end: 125.5 }, 140 + { word: 'name', start: 125.5, end: 126 }, 141 + { word: 'is', start: 126, end: 126.5 }, 142 + { word: 'Justin', start: 126.5, end: 127 }, 143 + ]; 144 + const transcript = makeTranscript(words); 145 + // segment starts at 0, so 120s window is 0-120 — pattern is at 125 which is outside 146 + const signals = extractSignals(transcript, 0, 200); 147 + // 125 > 0 + 120, so not in window 148 + const selfIntros = signals.filter((s) => s.type === 'self-intro'); 149 + expect(selfIntros).toHaveLength(0); 150 + }); 151 + }); 152 + 153 + describe('MC handoff detection', () => { 154 + it('finds "please welcome Justin" as MC handoff', () => { 155 + const words = [ 156 + { word: 'please', start: 40, end: 40.5 }, 157 + { word: 'welcome', start: 40.5, end: 41 }, 158 + { word: 'Justin', start: 41, end: 41.5 }, 159 + ]; 160 + const transcript = makeTranscript(words); 161 + // Segment starts at 60, so MC window is 0-60 162 + const signals = extractSignals(transcript, 60, 300); 163 + const mcHandoffs = signals.filter((s) => s.type === 'mc-handoff'); 164 + expect(mcHandoffs).toHaveLength(1); 165 + expect((mcHandoffs[0] as { type: 'mc-handoff'; name: string }).name).toBe('Justin'); 166 + }); 167 + 168 + it('finds "next up is Justin" as MC handoff', () => { 169 + const words = [ 170 + { word: 'next', start: 40, end: 40.5 }, 171 + { word: 'up', start: 40.5, end: 41 }, 172 + { word: 'is', start: 41, end: 41.5 }, 173 + { word: 'Justin', start: 41.5, end: 42 }, 174 + ]; 175 + const transcript = makeTranscript(words); 176 + const signals = extractSignals(transcript, 60, 300); 177 + const mcHandoffs = signals.filter((s) => s.type === 'mc-handoff'); 178 + expect(mcHandoffs).toHaveLength(1); 179 + expect((mcHandoffs[0] as { type: 'mc-handoff'; name: string }).name).toBe('Justin'); 180 + }); 181 + 182 + it('finds "next up we have Justin" as MC handoff', () => { 183 + const words = [ 184 + { word: 'next', start: 40, end: 40.5 }, 185 + { word: 'up', start: 40.5, end: 41 }, 186 + { word: 'we', start: 41, end: 41.5 }, 187 + { word: 'have', start: 41.5, end: 42 }, 188 + { word: 'Justin', start: 42, end: 42.5 }, 189 + ]; 190 + const transcript = makeTranscript(words); 191 + const signals = extractSignals(transcript, 60, 300); 192 + const mcHandoffs = signals.filter((s) => s.type === 'mc-handoff'); 193 + expect(mcHandoffs).toHaveLength(1); 194 + expect((mcHandoffs[0] as { type: 'mc-handoff'; name: string }).name).toBe('Justin'); 195 + }); 196 + 197 + it('does NOT find MC handoff outside the 60s lookback window', () => { 198 + const words = [ 199 + { word: 'please', start: 5, end: 5.5 }, 200 + { word: 'welcome', start: 5.5, end: 6 }, 201 + { word: 'Justin', start: 6, end: 6.5 }, 202 + ]; 203 + const transcript = makeTranscript(words); 204 + // Segment starts at 120, so MC window is 60-120. Pattern is at 5, outside window. 205 + const signals = extractSignals(transcript, 120, 300); 206 + const mcHandoffs = signals.filter((s) => s.type === 'mc-handoff'); 207 + expect(mcHandoffs).toHaveLength(0); 208 + }); 209 + }); 210 + 211 + describe('topic keyword extraction', () => { 212 + it('extracts topic signal with words from transcript', () => { 213 + const words = [ 214 + ...wordsFromSentence('We talk about decentralized identity protocols today', 10), 215 + ]; 216 + const transcript = makeTranscript(words); 217 + const signals = extractSignals(transcript, 10, 200); 218 + const topicSignals = signals.filter((s) => s.type === 'topic'); 219 + expect(topicSignals).toHaveLength(1); 220 + const topicSig = topicSignals[0] as { type: 'topic'; keywords: string[] }; 221 + expect(topicSig.keywords).toContain('decentralized'); 222 + expect(topicSig.keywords).toContain('identity'); 223 + expect(topicSig.keywords).toContain('protocols'); 224 + }); 225 + 226 + it('filters out short words from topic keywords', () => { 227 + const words = [ 228 + ...wordsFromSentence('We talk about the big ideas here', 10), 229 + ]; 230 + const transcript = makeTranscript(words); 231 + const signals = extractSignals(transcript, 10, 200); 232 + const topicSignals = signals.filter((s) => s.type === 'topic'); 233 + const topicSig = topicSignals[0] as { type: 'topic'; keywords: string[] }; 234 + // "We", "the", "big" (3 chars) should not appear 235 + expect(topicSig.keywords).not.toContain('we'); 236 + expect(topicSig.keywords).not.toContain('the'); 237 + expect(topicSig.keywords).not.toContain('big'); 238 + }); 239 + }); 240 + 241 + describe('name-scan detection (broader name search)', () => { 242 + it('finds speaker name mentioned anywhere in intro window', () => { 243 + const words = [ 244 + ...wordsFromSentence('Today we have Natalie presenting about protocols', 10), 245 + ]; 246 + const transcript = makeTranscript(words); 247 + const signals = extractSignals(transcript, 10, 200, ['Natalie', 'Mullins']); 248 + const nameScans = signals.filter((s) => s.type === 'name-scan'); 249 + expect(nameScans.length).toBeGreaterThanOrEqual(1); 250 + expect(nameScans.some((s) => (s as { name: string }).name === 'Natalie')).toBe(true); 251 + }); 252 + 253 + it('finds speaker name in MC handoff window via name-scan', () => { 254 + const words = [ 255 + ...wordsFromSentence('And now Natalie will take the stage', 40), 256 + ...wordsFromSentence('Hello everyone welcome to my talk', 60), 257 + ]; 258 + const transcript = makeTranscript(words); 259 + // Segment starts at 60, MC window is 0-60 260 + const signals = extractSignals(transcript, 60, 300, ['Natalie']); 261 + const nameScans = signals.filter((s) => s.type === 'name-scan'); 262 + expect(nameScans.length).toBeGreaterThanOrEqual(1); 263 + }); 264 + 265 + it('does not duplicate names already found by self-intro patterns', () => { 266 + const words = [ 267 + { word: "I'm", start: 10, end: 10.5 }, 268 + { word: 'Justin', start: 10.5, end: 11 }, 269 + ]; 270 + const transcript = makeTranscript(words); 271 + const signals = extractSignals(transcript, 10, 200, ['Justin']); 272 + const selfIntros = signals.filter((s) => s.type === 'self-intro'); 273 + const nameScans = signals.filter((s) => s.type === 'name-scan'); 274 + expect(selfIntros).toHaveLength(1); 275 + // Should not produce a duplicate name-scan for the same name 276 + expect(nameScans.filter((s) => (s as { name: string }).name === 'Justin')).toHaveLength(0); 277 + }); 278 + 279 + it('does not produce name-scan when no known names provided', () => { 280 + const words = [ 281 + ...wordsFromSentence('Natalie talked about something interesting', 10), 282 + ]; 283 + const transcript = makeTranscript(words); 284 + const signals = extractSignals(transcript, 10, 200); 285 + const nameScans = signals.filter((s) => s.type === 'name-scan'); 286 + expect(nameScans).toHaveLength(0); 287 + }); 288 + }); 289 + }); 290 + 291 + // ─── matchSegmentToSchedule tests ───────────────────────────────────────────── 292 + 293 + describe('matchSegmentToSchedule', () => { 294 + it('returns null when no schedule entries', () => { 295 + const result = matchSegmentToSchedule([], []); 296 + expect(result).toBeNull(); 297 + }); 298 + 299 + it('returns null when no signals match any schedule entry', () => { 300 + const schedule = [makeTalk('rkey1', 'Quantum Computing Advances', 'Alice Smith')]; 301 + const signals = [{ type: 'topic' as const, keywords: ['dogs', 'cats', 'birds', 'fish'] }]; 302 + const result = matchSegmentToSchedule(signals, schedule); 303 + expect(result).toBeNull(); 304 + }); 305 + 306 + it('matches speaker name using phonetic codes (fuzzy)', () => { 307 + // "Justyn" sounds like "Justin" 308 + const schedule = [makeTalk('rkey1', 'Building Better Protocols', 'Justin Banks')]; 309 + const signals = [ 310 + { type: 'self-intro' as const, name: 'Justyn' }, // phonetic match 311 + ]; 312 + const result = matchSegmentToSchedule(signals, schedule); 313 + expect(result).not.toBeNull(); 314 + expect(result!.rkey).toBe('rkey1'); 315 + }); 316 + 317 + it('high confidence for name + topic match', () => { 318 + const schedule = [ 319 + makeTalk('rkey1', 'Building Decentralized Identity Systems', 'Justin Banks'), 320 + ]; 321 + const signals = [ 322 + { type: 'self-intro' as const, name: 'Justin' }, 323 + { type: 'topic' as const, keywords: ['building', 'decentralized', 'identity', 'systems'] }, 324 + ]; 325 + const result = matchSegmentToSchedule(signals, schedule); 326 + expect(result).not.toBeNull(); 327 + expect(result!.confidence).toBe('high'); 328 + expect(result!.signals.length).toBeGreaterThan(1); 329 + }); 330 + 331 + it('medium confidence for name-only match (self-intro)', () => { 332 + const schedule = [ 333 + makeTalk('rkey1', 'Building Decentralized Identity Systems', 'Justin Banks'), 334 + ]; 335 + const signals = [ 336 + { type: 'self-intro' as const, name: 'Justin' }, 337 + // No matching topic keywords 338 + { type: 'topic' as const, keywords: ['cooking', 'baking', 'pasta', 'sauce'] }, 339 + ]; 340 + const result = matchSegmentToSchedule(signals, schedule); 341 + expect(result).not.toBeNull(); 342 + expect(result!.confidence).toBe('medium'); 343 + }); 344 + 345 + it('medium confidence for topic-only match (2+ keywords)', () => { 346 + const schedule = [ 347 + makeTalk('rkey1', 'Building Decentralized Identity Systems', 'Justin Banks'), 348 + ]; 349 + const signals = [ 350 + { type: 'topic' as const, keywords: ['building', 'decentralized', 'identity', 'systems'] }, 351 + // No name signal 352 + ]; 353 + const result = matchSegmentToSchedule(signals, schedule); 354 + expect(result).not.toBeNull(); 355 + expect(result!.confidence).toBe('medium'); 356 + }); 357 + 358 + it('low confidence for mc-handoff name only (not self-intro)', () => { 359 + const schedule = [ 360 + makeTalk('rkey1', 'Building Decentralized Identity Systems', 'Justin Banks'), 361 + ]; 362 + const signals = [ 363 + { type: 'mc-handoff' as const, name: 'Justin' }, 364 + // No matching topic keywords 365 + { type: 'topic' as const, keywords: ['cooking', 'baking', 'pasta', 'sauce'] }, 366 + ]; 367 + const result = matchSegmentToSchedule(signals, schedule); 368 + expect(result).not.toBeNull(); 369 + expect(result!.confidence).toBe('low'); 370 + }); 371 + 372 + it('selects the best-matching schedule entry when multiple candidates exist', () => { 373 + const schedule = [ 374 + makeTalk('rkey1', 'Cooking Pasta Italian Methods', 'Alice Smith'), 375 + makeTalk('rkey2', 'Building Decentralized Identity Systems', 'Justin Banks'), 376 + ]; 377 + const signals = [ 378 + { type: 'self-intro' as const, name: 'Justin' }, 379 + { type: 'topic' as const, keywords: ['building', 'decentralized', 'identity', 'systems'] }, 380 + ]; 381 + const result = matchSegmentToSchedule(signals, schedule); 382 + expect(result).not.toBeNull(); 383 + expect(result!.rkey).toBe('rkey2'); 384 + }); 385 + 386 + it('matches last name phonetically', () => { 387 + const schedule = [makeTalk('rkey1', 'Protocol Design Patterns', 'Justin Banks')]; 388 + const signals = [ 389 + { type: 'self-intro' as const, name: 'Banks' }, // last name match 390 + ]; 391 + const result = matchSegmentToSchedule(signals, schedule); 392 + expect(result).not.toBeNull(); 393 + expect(result!.rkey).toBe('rkey1'); 394 + }); 395 + 396 + it('includes matched signals in result', () => { 397 + const schedule = [makeTalk('rkey1', 'Building Decentralized Identity Systems', 'Justin Banks')]; 398 + const signals = [ 399 + { type: 'self-intro' as const, name: 'Justin' }, 400 + { type: 'topic' as const, keywords: ['building', 'decentralized', 'identity', 'systems'] }, 401 + ]; 402 + const result = matchSegmentToSchedule(signals, schedule); 403 + expect(result!.signals.length).toBeGreaterThan(0); 404 + expect(result!.signals.some((s) => s.includes('self-intro'))).toBe(true); 405 + }); 406 + 407 + describe('time proximity', () => { 408 + it('boosts confidence when time proximity + name match', () => { 409 + const schedule = [ 410 + makeTalk('rkey1', 'Building Decentralized Identity Systems', 'Justin Banks'), 411 + ]; 412 + const signals = [ 413 + { type: 'self-intro' as const, name: 'Justin' }, 414 + { type: 'topic' as const, keywords: ['building'] }, // only 1 keyword 415 + ]; 416 + const timeProximities = new Map([['rkey1', 30]]); // 30s offset 417 + const result = matchSegmentToSchedule(signals, schedule, timeProximities); 418 + expect(result).not.toBeNull(); 419 + expect(result!.confidence).toBe('high'); // time + name + 1 keyword = high 420 + expect(result!.signals.some((s) => s.includes('time-proximity'))).toBe(true); 421 + }); 422 + 423 + it('time proximity alone does not match in normal mode', () => { 424 + const schedule = [ 425 + makeTalk('rkey1', 'Quantum Computing Advances', 'Alice Smith'), 426 + ]; 427 + const signals = [ 428 + { type: 'topic' as const, keywords: ['dogs', 'cats'] }, 429 + ]; 430 + const timeProximities = new Map([['rkey1', 30]]); 431 + const result = matchSegmentToSchedule(signals, schedule, timeProximities); 432 + expect(result).toBeNull(); 433 + }); 434 + 435 + it('time proximity alone matches in loose mode with single candidate', () => { 436 + const schedule = [ 437 + makeTalk('rkey1', 'Quantum Computing Advances', 'Alice Smith'), 438 + ]; 439 + const signals = [ 440 + { type: 'topic' as const, keywords: ['dogs', 'cats'] }, 441 + ]; 442 + const timeProximities = new Map([['rkey1', 30]]); 443 + const result = matchSegmentToSchedule(signals, schedule, timeProximities, true); 444 + expect(result).not.toBeNull(); 445 + expect(result!.rkey).toBe('rkey1'); 446 + expect(result!.confidence).toBe('low'); 447 + }); 448 + 449 + it('time proximity alone does NOT match in loose mode with multiple candidates', () => { 450 + const schedule = [ 451 + makeTalk('rkey1', 'Quantum Computing', 'Alice Smith'), 452 + makeTalk('rkey2', 'Machine Learning', 'Bob Jones'), 453 + ]; 454 + const signals = [ 455 + { type: 'topic' as const, keywords: ['dogs'] }, 456 + ]; 457 + const timeProximities = new Map([['rkey1', 30], ['rkey2', 60]]); 458 + const result = matchSegmentToSchedule(signals, schedule, timeProximities, true); 459 + expect(result).toBeNull(); 460 + }); 461 + }); 462 + 463 + describe('name-scan signal matching', () => { 464 + it('matches name-scan signal at medium confidence', () => { 465 + const schedule = [ 466 + makeTalk('rkey1', 'Building Better Protocols', 'Natalie Mullins'), 467 + ]; 468 + const signals = [ 469 + { type: 'name-scan' as const, name: 'Natalie' }, 470 + { type: 'topic' as const, keywords: ['dogs', 'cats'] }, 471 + ]; 472 + const result = matchSegmentToSchedule(signals, schedule); 473 + expect(result).not.toBeNull(); 474 + expect(result!.rkey).toBe('rkey1'); 475 + expect(result!.confidence).toBe('medium'); 476 + }); 477 + }); 478 + 479 + describe('loose mode', () => { 480 + it('accepts single keyword match in loose mode', () => { 481 + const schedule = [ 482 + makeTalk('rkey1', 'Building Decentralized Identity', 'Alice Smith'), 483 + ]; 484 + const signals = [ 485 + { type: 'topic' as const, keywords: ['decentralized'] }, 486 + ]; 487 + // In normal mode, 1 keyword doesn't qualify 488 + const normalResult = matchSegmentToSchedule(signals, schedule); 489 + expect(normalResult).toBeNull(); 490 + 491 + // In loose mode, 1 keyword qualifies 492 + const looseResult = matchSegmentToSchedule(signals, schedule, undefined, true); 493 + expect(looseResult).not.toBeNull(); 494 + expect(looseResult!.rkey).toBe('rkey1'); 495 + }); 496 + }); 497 + }); 498 + 499 + // ─── matchAllSegments tests ─────────────────────────────────────────────────── 500 + 501 + describe('matchAllSegments', () => { 502 + it('matches a single segment with a self-intro signal', () => { 503 + const introWords = [ 504 + { word: "I'm", start: 10, end: 10.5 }, 505 + { word: 'Justin', start: 10.5, end: 11 }, 506 + ...wordsFromSentence('today we talk about decentralized protocols', 12), 507 + ]; 508 + const transcript = makeTranscript(introWords); 509 + const segment = makeSegment(10, 300); 510 + const schedule = [ 511 + makeTalk('rkey1', 'Decentralized Protocol Design Patterns', 'Justin Banks'), 512 + ]; 513 + const results = matchAllSegments([segment], transcript, schedule, []); 514 + expect(results).toHaveLength(1); 515 + expect(results[0].rkey).toBe('rkey1'); 516 + }); 517 + 518 + it('sets panel: false for single-speaker segments', () => { 519 + const words = [ 520 + { word: "I'm", start: 10, end: 10.5 }, 521 + { word: 'Justin', start: 10.5, end: 11 }, 522 + ]; 523 + const transcript = makeTranscript(words); 524 + const segment = makeSegment(10, 300, 'single-speaker'); 525 + const schedule = [makeTalk('rkey1', 'Some Protocol Talk', 'Justin Banks')]; 526 + const results = matchAllSegments([segment], transcript, schedule, []); 527 + if (results.length > 0) { 528 + expect(results[0].panel).toBe(false); 529 + } 530 + }); 531 + 532 + it('handles panel segments producing multiple matches', () => { 533 + const words = [ 534 + { word: 'Justin', start: 10, end: 10.5 }, 535 + { word: 'Alice', start: 15, end: 15.5 }, 536 + ...wordsFromSentence('decentralized protocols identity systems', 20), 537 + ]; 538 + // Put MC handoff for both before segment start 539 + const mcWords = [ 540 + { word: 'please', start: 5, end: 5.5 }, 541 + { word: 'welcome', start: 5.5, end: 6 }, 542 + { word: 'Justin', start: 6, end: 6.5 }, 543 + { word: 'please', start: 7, end: 7.5 }, 544 + { word: 'welcome', start: 7.5, end: 8 }, 545 + { word: 'Alice', start: 8, end: 8.5 }, 546 + ]; 547 + const transcript = makeTranscript([...mcWords, ...words]); 548 + const panelSegment = makeSegment(60, 3660, 'panel'); 549 + const schedule = [ 550 + makeTalk('rkey1', 'Building Decentralized Identity Systems', 'Justin Banks'), 551 + makeTalk('rkey2', 'Open Protocol Design Patterns', 'Alice Smith'), 552 + ]; 553 + 554 + const results = matchAllSegments([panelSegment], transcript, schedule, []); 555 + // Panel can produce multiple matches 556 + const panelResults = results.filter((r) => r.panel === true); 557 + if (panelResults.length > 0) { 558 + // All panel results should have panel: true 559 + panelResults.forEach((r) => expect(r.panel).toBe(true)); 560 + } 561 + }); 562 + 563 + it('marks confidence as unverifiable when segment in hallucination zone and no signals', () => { 564 + const transcript = makeTranscript([]); // no words 565 + const segment: TalkSegment = { 566 + ...makeSegment(100, 400, 'single-speaker', true), 567 + hallucinationZone: true, 568 + }; 569 + const zone: HallucinationZone = { startS: 100, endS: 400, pattern: 'test' }; 570 + const schedule = [makeTalk('rkey1', 'Protocol Design', 'Justin Banks')]; 571 + 572 + const results = matchAllSegments([segment], transcript, schedule, [zone]); 573 + // With no signals and hallucination zone, should not produce a confident match 574 + // (or should produce unverifiable if it falls through) 575 + // The key thing is it shouldn't return 'high' or 'medium' without signals 576 + if (results.length > 0) { 577 + expect(['low', 'unverifiable']).toContain(results[0].confidence); 578 + } 579 + }); 580 + 581 + it('returns empty array when no segments', () => { 582 + const transcript = makeTranscript([]); 583 + const results = matchAllSegments([], transcript, [], []); 584 + expect(results).toHaveLength(0); 585 + }); 586 + 587 + it('populates hallucinationZones in result when segment overlaps zone', () => { 588 + const words = [ 589 + { word: "I'm", start: 10, end: 10.5 }, 590 + { word: 'Justin', start: 10.5, end: 11 }, 591 + ]; 592 + const transcript = makeTranscript(words); 593 + const segment: TalkSegment = { 594 + ...makeSegment(10, 300, 'single-speaker', true), 595 + hallucinationZone: true, 596 + }; 597 + const zone: HallucinationZone = { startS: 150, endS: 200, pattern: 'test-hallucination' }; 598 + const schedule = [makeTalk('rkey1', 'Some Protocol Talk', 'Justin Banks')]; 599 + 600 + const results = matchAllSegments([segment], transcript, schedule, [zone]); 601 + if (results.length > 0) { 602 + expect(results[0].hallucinationZones).toContainEqual(zone); 603 + } 604 + }); 605 + 606 + describe('second pass (process of elimination)', () => { 607 + it('matches remaining segments in second pass using loose criteria', () => { 608 + // First segment matches normally via self-intro 609 + const words = [ 610 + { word: "I'm", start: 10, end: 10.5 }, 611 + { word: 'Justin', start: 10.5, end: 11 }, 612 + // Second segment: no self-intro but has a single keyword match 613 + ...wordsFromSentence('Welcome to this session about quantum mechanics', 1810), 614 + ]; 615 + const transcript = makeTranscript(words); 616 + 617 + const segment1 = makeSegment(10, 1800); 618 + const segment2 = makeSegment(1810, 3600); 619 + 620 + const schedule = [ 621 + makeTalk('rkey1', 'Building Better Protocols', 'Justin Banks', 622 + '2025-01-01T09:00:00Z', '2025-01-01T09:30:00Z'), 623 + makeTalk('rkey2', 'Quantum Computing Applications', 'Alice Smith', 624 + '2025-01-01T09:30:00Z', '2025-01-01T10:00:00Z'), 625 + ]; 626 + 627 + const results = matchAllSegments([segment1, segment2], transcript, schedule, []); 628 + // Should match both: first pass gets rkey1, second pass should get rkey2 629 + expect(results.length).toBeGreaterThanOrEqual(2); 630 + const rkeys = results.map((r) => r.rkey); 631 + expect(rkeys).toContain('rkey1'); 632 + expect(rkeys).toContain('rkey2'); 633 + }); 634 + 635 + it('second pass marks results with second-pass signal', () => { 636 + const words = [ 637 + { word: "I'm", start: 10, end: 10.5 }, 638 + { word: 'Justin', start: 10.5, end: 11 }, 639 + ...wordsFromSentence('quantum computing applications', 1810), 640 + ]; 641 + const transcript = makeTranscript(words); 642 + 643 + const segment1 = makeSegment(10, 1800); 644 + const segment2 = makeSegment(1810, 3600); 645 + 646 + const schedule = [ 647 + makeTalk('rkey1', 'Building Better Protocols', 'Justin Banks', 648 + '2025-01-01T09:00:00Z', '2025-01-01T09:30:00Z'), 649 + makeTalk('rkey2', 'Quantum Computing Applications', 'Alice Smith', 650 + '2025-01-01T09:30:00Z', '2025-01-01T10:00:00Z'), 651 + ]; 652 + 653 + const results = matchAllSegments([segment1, segment2], transcript, schedule, []); 654 + const secondPassResults = results.filter((r) => 655 + r.signals.includes('second-pass') || r.signals.includes('order-match'), 656 + ); 657 + // The second segment should be matched in second pass 658 + if (secondPassResults.length > 0) { 659 + expect(secondPassResults[0].rkey).toBe('rkey2'); 660 + } 661 + }); 662 + }); 663 + });
+178
apps/ionosphere-appview/src/v7/diarization-segmenter.ts
··· 1 + import type { DiarizationInput, HallucinationZone, TalkSegment } from './types.js'; 2 + 3 + // Thresholds for gap classification 4 + const MERGE_GAP_S = 5; // merge same-speaker segments with gaps < 5s into speech blocks 5 + const SESSION_BREAK_S = 60; // gaps > 60s = session break 6 + const TALK_BOUNDARY_S = 30; // gaps 30-60s with speaker change = talk boundary 7 + const DOMINANT_SPEAKER_THRESHOLD = 0.7; // 70% of duration = single-speaker 8 + 9 + interface SpeechBlock { 10 + startS: number; 11 + endS: number; 12 + speaker: string; 13 + } 14 + 15 + /** 16 + * Step 1+2: Sort diarization segments and merge adjacent same-speaker segments 17 + * with gaps < MERGE_GAP_S into contiguous speech blocks. 18 + */ 19 + function buildSpeechBlocks(diarization: DiarizationInput): SpeechBlock[] { 20 + if (diarization.segments.length === 0) return []; 21 + 22 + const sorted = [...diarization.segments].sort((a, b) => a.start - b.start); 23 + const blocks: SpeechBlock[] = []; 24 + 25 + let current: SpeechBlock = { 26 + startS: sorted[0].start, 27 + endS: sorted[0].end, 28 + speaker: sorted[0].speaker, 29 + }; 30 + 31 + for (let i = 1; i < sorted.length; i++) { 32 + const seg = sorted[i]; 33 + const gap = seg.start - current.endS; 34 + 35 + if (seg.speaker === current.speaker && gap < MERGE_GAP_S) { 36 + // Extend the current block 37 + current.endS = Math.max(current.endS, seg.end); 38 + } else { 39 + blocks.push(current); 40 + current = { startS: seg.start, endS: seg.end, speaker: seg.speaker }; 41 + } 42 + } 43 + blocks.push(current); 44 + 45 + return blocks; 46 + } 47 + 48 + /** 49 + * Determine the dominant speaker of a set of blocks. 50 + * Returns the speaker ID if one speaker covers > DOMINANT_SPEAKER_THRESHOLD of total, 51 + * otherwise null. 52 + */ 53 + function getDominantSpeaker( 54 + blocks: SpeechBlock[], 55 + ): { speaker: string; fraction: number } | null { 56 + const durations = new Map<string, number>(); 57 + let total = 0; 58 + 59 + for (const block of blocks) { 60 + const d = block.endS - block.startS; 61 + durations.set(block.speaker, (durations.get(block.speaker) ?? 0) + d); 62 + total += d; 63 + } 64 + 65 + if (total === 0) return null; 66 + 67 + for (const [speaker, duration] of durations) { 68 + if (duration / total > DOMINANT_SPEAKER_THRESHOLD) { 69 + return { speaker, fraction: duration / total }; 70 + } 71 + } 72 + 73 + return null; 74 + } 75 + 76 + /** 77 + * Build a TalkSegment from a group of speech blocks. 78 + */ 79 + function buildTalkSegment( 80 + blocks: SpeechBlock[], 81 + precedingGapS: number, 82 + hallucinationZones: HallucinationZone[], 83 + ): TalkSegment { 84 + const startS = blocks[0].startS; 85 + const endS = blocks[blocks.length - 1].endS; 86 + 87 + // Aggregate speaker durations 88 + const speakerDurations = new Map<string, number>(); 89 + for (const block of blocks) { 90 + const d = block.endS - block.startS; 91 + speakerDurations.set(block.speaker, (speakerDurations.get(block.speaker) ?? 0) + d); 92 + } 93 + 94 + const speakers = [...speakerDurations.entries()].map(([id, durationS]) => ({ 95 + id, 96 + durationS, 97 + })); 98 + 99 + // Classify segment type 100 + const dominant = getDominantSpeaker(blocks); 101 + let type: TalkSegment['type']; 102 + let dominantSpeaker: string | undefined; 103 + 104 + if (dominant) { 105 + type = 'single-speaker'; 106 + dominantSpeaker = dominant.speaker; 107 + } else { 108 + type = 'panel'; 109 + } 110 + 111 + // Check hallucination zone overlap 112 + const hallucinationZone = hallucinationZones.some( 113 + (zone) => zone.startS < endS && zone.endS > startS, 114 + ); 115 + 116 + return { 117 + startS, 118 + endS, 119 + speakers, 120 + type, 121 + dominantSpeaker, 122 + precedingGapS, 123 + hallucinationZone, 124 + }; 125 + } 126 + 127 + /** 128 + * Segment diarization data into talk-shaped blocks. 129 + * 130 + * Algorithm: 131 + * 1. Sort diarization segments by start time 132 + * 2. Merge adjacent same-speaker segments with < 5s gaps into speech blocks 133 + * 3. Find gaps between blocks and classify: 134 + * - > 60s = session break (boundary) 135 + * - 30-60s with speaker change = talk boundary 136 + * - < 30s or no speaker change = within-talk pause (merge) 137 + * 4. Group blocks between talk boundaries into TalkSegments 138 + * 5. Classify each segment (single-speaker or panel) 139 + */ 140 + export function segmentDiarization( 141 + diarization: DiarizationInput, 142 + hallucinationZones: HallucinationZone[], 143 + ): TalkSegment[] { 144 + const blocks = buildSpeechBlocks(diarization); 145 + 146 + if (blocks.length === 0) return []; 147 + 148 + // Group blocks into talk segments by detecting boundaries between consecutive blocks 149 + const segments: TalkSegment[] = []; 150 + let currentGroup: SpeechBlock[] = [blocks[0]]; 151 + let groupStartGap = 0; // preceding gap before the current group 152 + 153 + for (let i = 1; i < blocks.length; i++) { 154 + const prev = blocks[i - 1]; 155 + const curr = blocks[i]; 156 + const gap = curr.startS - prev.endS; 157 + 158 + const isBoundary = 159 + gap > SESSION_BREAK_S || 160 + (gap >= TALK_BOUNDARY_S && curr.speaker !== prev.speaker); 161 + 162 + if (isBoundary) { 163 + // Finalize the current group as a TalkSegment 164 + segments.push(buildTalkSegment(currentGroup, groupStartGap, hallucinationZones)); 165 + // Start a new group 166 + groupStartGap = gap; 167 + currentGroup = [curr]; 168 + } else { 169 + // Within-talk pause — keep merging into current group 170 + currentGroup.push(curr); 171 + } 172 + } 173 + 174 + // Finalize the last group 175 + segments.push(buildTalkSegment(currentGroup, groupStartGap, hallucinationZones)); 176 + 177 + return segments; 178 + }
+234
apps/ionosphere-appview/src/v7/hallucination-detector.ts
··· 1 + /** 2 + * Hallucination detector for v7 boundary detection pipeline. 3 + * 4 + * Whisper (speech-to-text) produces garbage output during silence, often 5 + * repeating known phrases ("Transcription by CastingWords", "0 0 0 0", etc.). 6 + * This module identifies those zones so the boundary detector can discount 7 + * transcript evidence from those time ranges. 8 + * 9 + * Three detection methods: 10 + * 1. Pattern matching: known repeating attribution phrases 11 + * 2. Numeric zero loops: 20+ consecutive "0" words 12 + * 3. Diarization silence mismatch: diarization gap > 60s but transcript 13 + * has words in that range 14 + */ 15 + 16 + import type { DiarizationInput, HallucinationZone, TranscriptInput } from './types.js'; 17 + 18 + // Known hallucination patterns (Whisper repeats these during silence). 19 + // `minWords` is the minimum number of words needed to match (for window sizing). 20 + const HALLUCINATION_PATTERNS: { pattern: RegExp; name: string; minWords: number }[] = [ 21 + { pattern: /transcription\s+by\s+castingwords/i, name: 'CastingWords attribution', minWords: 3 }, 22 + { pattern: /otter\.ai/i, name: 'otter.ai attribution', minWords: 1 }, 23 + { pattern: /eso\s+translation/i, name: 'ESO Translation attribution', minWords: 2 }, 24 + { pattern: /msword\s+document/i, name: 'MSWord Document attribution', minWords: 2 }, 25 + { pattern: /transcription\s+outsourcing/i, name: 'Transcription Outsourcing attribution', minWords: 2 }, 26 + { pattern: /uga\s+extension/i, name: 'UGA Extension attribution', minWords: 2 }, 27 + { pattern: /thank\s+you\s+for\s+watching/i, name: 'Thank you for watching', minWords: 4 }, 28 + { pattern: /fema\.gov/i, name: 'fema.gov attribution', minWords: 1 }, 29 + { pattern: /subtitles?\s+by/i, name: 'subtitle attribution', minWords: 2 }, 30 + { pattern: /subtitle\s+translation/i, name: 'subtitle translation', minWords: 2 }, 31 + { pattern: /closed\s+caption(?:ing|s?)\s+by/i, name: 'closed captioning attribution', minWords: 3 }, 32 + ]; 33 + 34 + // Minimum repeats of a pattern to count as a hallucination zone 35 + const MIN_PATTERN_REPEATS = 3; 36 + 37 + // Number of consecutive zero words to trigger detection 38 + const ZERO_LOOP_THRESHOLD = 20; 39 + 40 + // Diarization gap that counts as silence 41 + const DIARIZATION_SILENCE_GAP_S = 60; 42 + 43 + // Merge zones within this many seconds of each other 44 + const MERGE_WITHIN_S = 60; 45 + 46 + /** 47 + * Find individual occurrences of a phrase pattern in a word stream. 48 + * 49 + * For each pattern, scans word-by-word using a sliding window of `minWords` 50 + * words. When the pattern matches, records the occurrence and advances past 51 + * those words to avoid double-counting the same phrase occurrence. 52 + * 53 + * Returns zones where the phrase repeats >= MIN_PATTERN_REPEATS times. 54 + */ 55 + function detectPatternZones( 56 + words: TranscriptInput['words'], 57 + ): HallucinationZone[] { 58 + const zones: HallucinationZone[] = []; 59 + 60 + for (const { pattern, name, minWords } of HALLUCINATION_PATTERNS) { 61 + // Collect each individual match occurrence as { startS, endS } 62 + const occurrences: { startS: number; endS: number }[] = []; 63 + 64 + let i = 0; 65 + while (i < words.length) { 66 + const windowEnd = Math.min(words.length, i + minWords); 67 + const windowText = words 68 + .slice(i, windowEnd) 69 + .map((w) => w.word) 70 + .join(' '); 71 + 72 + if (pattern.test(windowText)) { 73 + occurrences.push({ 74 + startS: words[i].start, 75 + endS: words[windowEnd - 1].end, 76 + }); 77 + // Advance by minWords to count each phrase occurrence separately 78 + i += minWords; 79 + } else { 80 + i++; 81 + } 82 + } 83 + 84 + if (occurrences.length >= MIN_PATTERN_REPEATS) { 85 + // Emit one zone spanning all occurrences 86 + zones.push({ 87 + startS: occurrences[0].startS, 88 + endS: occurrences[occurrences.length - 1].endS, 89 + pattern: name, 90 + }); 91 + } 92 + } 93 + 94 + return zones; 95 + } 96 + 97 + // Maximum gap between consecutive zero words to stay in the same run (seconds) 98 + const ZERO_RUN_MAX_GAP_S = 5; 99 + 100 + /** 101 + * Detect runs of 20+ consecutive "0" words (numeric zero loops). 102 + * Breaks runs when consecutive zeros have a gap > ZERO_RUN_MAX_GAP_S. 103 + */ 104 + function detectZeroLoopZones(words: TranscriptInput['words']): HallucinationZone[] { 105 + const zones: HallucinationZone[] = []; 106 + let runStart = -1; 107 + let runLength = 0; 108 + 109 + const endRun = (lastIdx: number) => { 110 + if (runLength >= ZERO_LOOP_THRESHOLD) { 111 + zones.push({ 112 + startS: words[runStart].start, 113 + endS: words[lastIdx].end, 114 + pattern: 'numeric zero loop', 115 + }); 116 + } 117 + runStart = -1; 118 + runLength = 0; 119 + }; 120 + 121 + for (let i = 0; i < words.length; i++) { 122 + const w = words[i].word.trim(); 123 + if (w === '0') { 124 + if (runStart === -1) { 125 + runStart = i; 126 + runLength = 1; 127 + } else { 128 + // Check for time gap that would break the run 129 + const gap = words[i].start - words[i - 1].end; 130 + if (gap > ZERO_RUN_MAX_GAP_S) { 131 + endRun(i - 1); 132 + runStart = i; 133 + runLength = 1; 134 + } else { 135 + runLength++; 136 + } 137 + } 138 + } else { 139 + if (runStart !== -1) { 140 + endRun(i - 1); 141 + } 142 + } 143 + } 144 + 145 + // Handle trailing run 146 + if (runStart !== -1) { 147 + endRun(words.length - 1); 148 + } 149 + 150 + return zones; 151 + } 152 + 153 + /** 154 + * Detect zones where diarization shows silence (gap > 60s) but the 155 + * transcript has words during that period. 156 + */ 157 + function detectDiarizationSilenceMismatch( 158 + words: TranscriptInput['words'], 159 + diarization: DiarizationInput, 160 + ): HallucinationZone[] { 161 + const zones: HallucinationZone[] = []; 162 + const segments = diarization.segments; 163 + 164 + if (segments.length < 2) return zones; 165 + 166 + for (let i = 0; i < segments.length - 1; i++) { 167 + const gapStart = segments[i].end; 168 + const gapEnd = segments[i + 1].start; 169 + const gapDuration = gapEnd - gapStart; 170 + 171 + if (gapDuration < DIARIZATION_SILENCE_GAP_S) continue; 172 + 173 + // Find words that fall within this silence gap 174 + const wordsInGap = words.filter( 175 + (w) => w.start >= gapStart && w.end <= gapEnd, 176 + ); 177 + 178 + if (wordsInGap.length > 0) { 179 + zones.push({ 180 + startS: wordsInGap[0].start, 181 + endS: wordsInGap[wordsInGap.length - 1].end, 182 + pattern: 'diarization silence mismatch', 183 + }); 184 + } 185 + } 186 + 187 + return zones; 188 + } 189 + 190 + /** 191 + * Merge overlapping or near-adjacent zones (within MERGE_WITHIN_S seconds). 192 + */ 193 + function mergeZones(zones: HallucinationZone[]): HallucinationZone[] { 194 + if (zones.length === 0) return []; 195 + 196 + const sorted = [...zones].sort((a, b) => a.startS - b.startS); 197 + const merged: HallucinationZone[] = [{ ...sorted[0] }]; 198 + 199 + for (let i = 1; i < sorted.length; i++) { 200 + const current = sorted[i]; 201 + const last = merged[merged.length - 1]; 202 + 203 + if (current.startS <= last.endS + MERGE_WITHIN_S) { 204 + // Merge: extend end, concatenate patterns if different 205 + last.endS = Math.max(last.endS, current.endS); 206 + if (!last.pattern.includes(current.pattern)) { 207 + last.pattern = `${last.pattern}; ${current.pattern}`; 208 + } 209 + } else { 210 + merged.push({ ...current }); 211 + } 212 + } 213 + 214 + return merged; 215 + } 216 + 217 + /** 218 + * Detect all hallucination zones in the given transcript, cross-referenced 219 + * with diarization data. 220 + */ 221 + export function detectHallucinationZones( 222 + transcript: TranscriptInput, 223 + diarization: DiarizationInput, 224 + ): HallucinationZone[] { 225 + const { words } = transcript; 226 + if (words.length === 0) return []; 227 + 228 + const patternZones = detectPatternZones(words); 229 + const zeroZones = detectZeroLoopZones(words); 230 + const silenceZones = detectDiarizationSilenceMismatch(words, diarization); 231 + 232 + const allZones = [...patternZones, ...zeroZones, ...silenceZones]; 233 + return mergeZones(allZones); 234 + }
+245
apps/ionosphere-appview/src/v7/schedule-reconciler.ts
··· 1 + /** 2 + * Schedule Reconciler — Stage 3 of the v7 boundary detection pipeline. 3 + * 4 + * Takes matched boundaries from Stage 2 and: 5 + * 1. Deduplicates: if the same rkey appears multiple times, keeps highest confidence 6 + * 2. Computes end times: uses the next talk's start (with gap handling) 7 + * 3. Handles unmatched schedule entries (hallucination zones → unverifiable, else unmatchedSchedule) 8 + * 4. Collects unmatched segments (diarization segments that got no match) 9 + * 5. Assembles the final V7Output 10 + */ 11 + 12 + import type { 13 + BoundaryMatch, 14 + HallucinationZone, 15 + ScheduleTalk, 16 + TalkSegment, 17 + V7Output, 18 + } from './types.js'; 19 + 20 + // A gap larger than this (seconds) between consecutive matched talks is treated 21 + // as a session break. The last talk before the break gets its end time from 22 + // the diarization segment rather than the next talk's start. 23 + const SESSION_BREAK_GAP_S = 60; 24 + 25 + // Numeric rank for confidence levels (higher = better) 26 + const CONFIDENCE_RANK: Record<BoundaryMatch['confidence'], number> = { 27 + high: 4, 28 + medium: 3, 29 + low: 2, 30 + unverifiable: 1, 31 + }; 32 + 33 + /** 34 + * Deduplicate matches: when the same rkey appears more than once, keep only 35 + * the instance with the highest confidence. Ties are broken by earlier start. 36 + */ 37 + function deduplicateMatches(matches: BoundaryMatch[]): BoundaryMatch[] { 38 + const best = new Map<string, BoundaryMatch>(); 39 + 40 + for (const match of matches) { 41 + const existing = best.get(match.rkey); 42 + if (!existing) { 43 + best.set(match.rkey, match); 44 + } else { 45 + const existingRank = CONFIDENCE_RANK[existing.confidence]; 46 + const newRank = CONFIDENCE_RANK[match.confidence]; 47 + if (newRank > existingRank) { 48 + best.set(match.rkey, match); 49 + } else if (newRank === existingRank && match.startTimestamp < existing.startTimestamp) { 50 + best.set(match.rkey, match); 51 + } 52 + } 53 + } 54 + 55 + return [...best.values()]; 56 + } 57 + 58 + /** 59 + * Find the TalkSegment whose time range best covers a given start timestamp. 60 + * Returns the segment whose startS is closest (from below) to startTimestamp. 61 + */ 62 + function findSegmentForMatch( 63 + match: BoundaryMatch, 64 + segments: TalkSegment[], 65 + ): TalkSegment | undefined { 66 + // Find the segment that contains or immediately precedes the match start 67 + let best: TalkSegment | undefined; 68 + for (const seg of segments) { 69 + if (seg.startS <= match.startTimestamp + 1) { 70 + if (!best || seg.startS > best.startS) { 71 + best = seg; 72 + } 73 + } 74 + } 75 + return best; 76 + } 77 + 78 + /** 79 + * Check whether a scheduled time overlaps any hallucination zone. 80 + */ 81 + function isInHallucinationZone( 82 + startsAt: string, 83 + hallucinationZones: HallucinationZone[], 84 + streamStartUnixS: number, 85 + ): boolean { 86 + // We work in stream-relative seconds. The schedule's starts_at is an 87 + // ISO timestamp; without an absolute anchor we can't convert it. Instead 88 + // we rely on the caller to pass absolute seconds when the zone was detected, 89 + // but since ScheduleTalk carries ISO strings, we do a best-effort check: 90 + // if ANY hallucination zone exists and the schedule entry has no matched 91 + // boundary, we use the zone's stream-relative window. 92 + // 93 + // In practice, callers who know the stream's wall-clock start can compare 94 + // properly. For the common case we just check all zones. 95 + if (hallucinationZones.length === 0) return false; 96 + 97 + // Parse the ISO string into a Date epoch seconds 98 + const talkEpochS = Date.parse(startsAt) / 1000; 99 + if (isNaN(talkEpochS)) return false; 100 + 101 + // Convert to stream-relative offset 102 + const relativeS = talkEpochS - streamStartUnixS; 103 + 104 + return hallucinationZones.some( 105 + (z) => relativeS >= z.startS && relativeS <= z.endS, 106 + ); 107 + } 108 + 109 + /** 110 + * Compute end times for each deduplicated match. 111 + * 112 + * Rules (in order): 113 + * 1. Sort matches by startTimestamp. 114 + * 2. For each match, the default end = next match's startTimestamp. 115 + * 3. If the gap to the next match > SESSION_BREAK_GAP_S, treat this as the 116 + * last talk before a break → use the diarization segment's endS. 117 + * 4. For the absolute last match: use min(streamDurationS, segment.endS). 118 + */ 119 + function computeEndTimes( 120 + matches: BoundaryMatch[], 121 + segments: TalkSegment[], 122 + streamDurationS: number, 123 + ): BoundaryMatch[] { 124 + const sorted = [...matches].sort((a, b) => a.startTimestamp - b.startTimestamp); 125 + 126 + return sorted.map((match, idx) => { 127 + const seg = findSegmentForMatch(match, segments); 128 + const segEndS = seg?.endS ?? streamDurationS; 129 + 130 + let endTimestamp: number; 131 + 132 + if (idx < sorted.length - 1) { 133 + const nextStart = sorted[idx + 1].startTimestamp; 134 + const gap = nextStart - match.startTimestamp; 135 + 136 + if (gap > SESSION_BREAK_GAP_S) { 137 + // Last talk before a session break — use segment end 138 + endTimestamp = segEndS; 139 + } else { 140 + // Normal case: end at the next talk's start 141 + endTimestamp = nextStart; 142 + } 143 + } else { 144 + // Absolute last match 145 + endTimestamp = Math.min(streamDurationS, segEndS); 146 + } 147 + 148 + return { ...match, endTimestamp }; 149 + }); 150 + } 151 + 152 + /** 153 + * Collect TalkSegments that have no BoundaryMatch assigned to them. 154 + * 155 + * A segment is considered "assigned" if any match's startTimestamp falls 156 + * within [seg.startS - 5, seg.endS + 5]. 157 + */ 158 + function collectUnmatchedSegments( 159 + segments: TalkSegment[], 160 + matches: BoundaryMatch[], 161 + ): TalkSegment[] { 162 + return segments.filter((seg) => { 163 + return !matches.some( 164 + (m) => m.startTimestamp >= seg.startS - 5 && m.startTimestamp <= seg.endS + 5, 165 + ); 166 + }); 167 + } 168 + 169 + // ─── Public API ─────────────────────────────────────────────────────────────── 170 + 171 + /** 172 + * Reconcile matched boundaries, fill gaps, and assemble the final V7Output. 173 + * 174 + * @param matches Raw BoundaryMatch list from Stage 2 175 + * @param segments TalkSegments from Stage 1 (diarization-segmenter) 176 + * @param schedule Full schedule for this stream from the DB 177 + * @param hallucinationZones Zones detected in Stage 0 178 + * @param streamDurationS Total stream duration in seconds 179 + * @param streamName Human-readable stream name (for V7Output.stream) 180 + * @param streamStartUnixS Optional: wall-clock start of the stream as Unix seconds. 181 + * Used to map schedule ISO timestamps into stream-relative 182 + * offsets when checking hallucination zones. 183 + * Defaults to 0 (disables the check — all unmatched 184 + * schedule entries go to unmatchedSchedule). 185 + */ 186 + export function reconcileSchedule( 187 + matches: BoundaryMatch[], 188 + segments: TalkSegment[], 189 + schedule: ScheduleTalk[], 190 + hallucinationZones: HallucinationZone[], 191 + streamDurationS: number, 192 + streamName = 'unknown', 193 + streamStartUnixS = 0, 194 + ): V7Output { 195 + // 1. Deduplicate 196 + const deduped = deduplicateMatches(matches); 197 + 198 + // 2. Compute end times 199 + const withEnds = computeEndTimes(deduped, segments, streamDurationS); 200 + 201 + // 3. Unmatched schedule 202 + const matchedRkeys = new Set(withEnds.map((m) => m.rkey)); 203 + const unmatchedScheduleEntries: BoundaryMatch[] = []; 204 + const unmatchedScheduleRkeys: string[] = []; 205 + 206 + for (const talk of schedule) { 207 + if (matchedRkeys.has(talk.rkey)) continue; 208 + 209 + if ( 210 + streamStartUnixS > 0 && 211 + isInHallucinationZone(talk.starts_at, hallucinationZones, streamStartUnixS) 212 + ) { 213 + // In a hallucination zone — mark as unverifiable result 214 + unmatchedScheduleEntries.push({ 215 + rkey: talk.rkey, 216 + title: talk.title, 217 + startTimestamp: 0, 218 + endTimestamp: null, 219 + confidence: 'unverifiable', 220 + signals: [], 221 + panel: false, 222 + hallucinationZones, 223 + }); 224 + } else { 225 + unmatchedScheduleRkeys.push(talk.rkey); 226 + } 227 + } 228 + 229 + // 4. Unmatched segments 230 + const unmatchedSegments = collectUnmatchedSegments(segments, withEnds); 231 + 232 + // 5. Assemble V7Output 233 + const allResults = [ 234 + ...withEnds, 235 + ...unmatchedScheduleEntries, 236 + ].sort((a, b) => a.startTimestamp - b.startTimestamp); 237 + 238 + return { 239 + stream: streamName, 240 + results: allResults, 241 + hallucinationZones, 242 + unmatchedSegments, 243 + unmatchedSchedule: unmatchedScheduleRkeys, 244 + }; 245 + }
+713
apps/ionosphere-appview/src/v7/transcript-matcher.ts
··· 1 + import type { 2 + TranscriptInput, 3 + TalkSegment, 4 + ScheduleTalk, 5 + HallucinationZone, 6 + BoundaryMatch, 7 + } from './types.js'; 8 + import { phoneticCode, phoneticSearch } from '../phonetic.js'; 9 + 10 + // ─── Signal types ──────────────────────────────────────────────────────────── 11 + 12 + export type Signal = 13 + | { type: 'self-intro'; name: string } 14 + | { type: 'mc-handoff'; name: string } 15 + | { type: 'topic'; keywords: string[] } 16 + | { type: 'time-proximity'; offsetDiffS: number } 17 + | { type: 'name-scan'; name: string }; 18 + 19 + // ─── Constants ─────────────────────────────────────────────────────────────── 20 + 21 + const SELF_INTRO_WINDOW_S = 120; 22 + const MC_HANDOFF_LOOKBACK_S = 60; 23 + const NAME_SCAN_WINDOW_S = 120; 24 + const TIME_PROXIMITY_THRESHOLD_S = 600; // ±10 minutes 25 + 26 + /** Words that should not be interpreted as names after "I'm" */ 27 + const COMMON_WORDS_AFTER_IM = new Set([ 28 + 'So', 'And', 'The', 'It', 'We', 'Not', 'But', 'All', 'Just', 'Very', 29 + 'Really', 'Here', 'Going', 'Sure', 'Like', 'One', 'How', 'Also', 'Super', 30 + 'Trying', 'Kind', 'From', 'A', 31 + ]); 32 + 33 + /** Short common words to skip when extracting topic keywords from titles */ 34 + const TOPIC_STOP_WORDS = new Set([ 35 + 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 36 + 'of', 'with', 'by', 'is', 'it', 'as', 'be', 'was', 'are', 'not', 37 + ]); 38 + 39 + // ─── Helpers ───────────────────────────────────────────────────────────────── 40 + 41 + /** Get transcript text for a time window */ 42 + function getTranscriptText( 43 + transcript: TranscriptInput, 44 + startS: number, 45 + endS: number, 46 + ): string { 47 + return transcript.words 48 + .filter((w) => w.start >= startS && w.end <= endS) 49 + .map((w) => w.word) 50 + .join(' '); 51 + } 52 + 53 + /** Get transcript words (raw tokens) for a time window */ 54 + function getTranscriptWords( 55 + transcript: TranscriptInput, 56 + startS: number, 57 + endS: number, 58 + ): string[] { 59 + return transcript.words 60 + .filter((w) => w.start >= startS && w.end <= endS) 61 + .map((w) => w.word); 62 + } 63 + 64 + /** Extract meaningful keywords from a talk title (skip short stop words) */ 65 + function extractTitleKeywords(title: string): string[] { 66 + return title 67 + .replace(/[^\w\s]/g, ' ') 68 + .split(/\s+/) 69 + .filter((w) => w.length >= 4 && !TOPIC_STOP_WORDS.has(w.toLowerCase())) 70 + .map((w) => w.toLowerCase()); 71 + } 72 + 73 + /** Check if two name tokens sound alike using phonetic codes */ 74 + function namesMatch(a: string, b: string): boolean { 75 + if (!a || !b) return false; 76 + if (a.length < 3 || b.length < 3) return false; 77 + const codeA = phoneticCode(a); 78 + const codeB = phoneticCode(b); 79 + if (!codeA || !codeB) return false; 80 + if (codeA === codeB) return true; 81 + // Prefix match (3 chars) 82 + const minLen = Math.min(codeA.length, codeB.length); 83 + if (minLen >= 3 && codeA.slice(0, 3) === codeB.slice(0, 3)) return true; 84 + return false; 85 + } 86 + 87 + /** Check if an extracted name matches any speaker in the schedule entry. 88 + * Tries first and last name of each comma-separated speaker. */ 89 + function nameMatchesScheduleSpeakers(name: string, speakerNames: string): boolean { 90 + const speakers = speakerNames.split(',').map((s) => s.trim()); 91 + for (const speaker of speakers) { 92 + const parts = speaker.split(/\s+/).filter(Boolean); 93 + for (const part of parts) { 94 + if (namesMatch(name, part)) return true; 95 + } 96 + } 97 + return false; 98 + } 99 + 100 + /** Extract all unique speaker name parts from the full schedule */ 101 + function extractAllSpeakerNames(schedule: ScheduleTalk[]): string[] { 102 + const names = new Set<string>(); 103 + for (const talk of schedule) { 104 + const speakers = talk.speaker_names.split(',').map((s) => s.trim()); 105 + for (const speaker of speakers) { 106 + const parts = speaker.split(/\s+/).filter(Boolean); 107 + for (const part of parts) { 108 + if (part.length >= 3) names.add(part); 109 + } 110 + } 111 + } 112 + return [...names]; 113 + } 114 + 115 + // ─── extractSignals ─────────────────────────────────────────────────────────── 116 + 117 + /** 118 + * Extract identity signals from transcript text in a time range. 119 + * Self-intro and topic signals come from [startS, startS+120s]. 120 + * MC-handoff signals come from [startS-60s, startS]. 121 + * Name-scan signals search for known speaker names in the first 120s. 122 + * 123 + * @param knownSpeakerNames Optional list of all speaker name parts from the schedule. 124 + * When provided, searches the intro window and MC handoff window for phonetic matches. 125 + */ 126 + export function extractSignals( 127 + transcript: TranscriptInput, 128 + startS: number, 129 + endS: number, 130 + knownSpeakerNames?: string[], 131 + ): Signal[] { 132 + const signals: Signal[] = []; 133 + 134 + const introEnd = Math.min(startS + SELF_INTRO_WINDOW_S, endS); 135 + const introText = getTranscriptText(transcript, startS, introEnd); 136 + 137 + // ── Self-introduction patterns ── 138 + // "my name is {WORD}" or "my name's {WORD}" 139 + const myNamePattern = /\bmy name(?:'s| is)\s+([A-Z][a-z]+)/g; 140 + let m: RegExpExecArray | null; 141 + while ((m = myNamePattern.exec(introText)) !== null) { 142 + const name = m[1]; 143 + if (name) signals.push({ type: 'self-intro', name }); 144 + } 145 + 146 + // "I'm {WORD}" — only if WORD starts uppercase and isn't a common word 147 + const imPattern = /\bI'm\s+([A-Z][a-z]+)/g; 148 + while ((m = imPattern.exec(introText)) !== null) { 149 + const name = m[1]; 150 + if (name && !COMMON_WORDS_AFTER_IM.has(name)) { 151 + signals.push({ type: 'self-intro', name }); 152 + } 153 + } 154 + 155 + // ── MC handoff patterns (in the gap before segment) ── 156 + const mcStart = Math.max(0, startS - MC_HANDOFF_LOOKBACK_S); 157 + const mcText = getTranscriptText(transcript, mcStart, startS); 158 + 159 + const pleaseWelcomePattern = /\bplease welcome\s+([A-Z][a-z]+)/gi; 160 + while ((m = pleaseWelcomePattern.exec(mcText)) !== null) { 161 + const name = m[1]; 162 + if (name) signals.push({ type: 'mc-handoff', name }); 163 + } 164 + 165 + const nextUpIsPattern = /\bnext up (?:is|we have)\s+([A-Z][a-z]+)/gi; 166 + while ((m = nextUpIsPattern.exec(mcText)) !== null) { 167 + const name = m[1]; 168 + if (name) signals.push({ type: 'mc-handoff', name }); 169 + } 170 + 171 + // "setting up next" — no name, but note as MC handoff marker (name = '') 172 + if (/\bsetting up next\b/i.test(mcText)) { 173 + signals.push({ type: 'mc-handoff', name: '' }); 174 + } 175 + 176 + // ── Broader name scanning (Improvement #2) ── 177 + // Search intro window and MC handoff window for any known speaker name 178 + if (knownSpeakerNames && knownSpeakerNames.length > 0) { 179 + const nameScanEnd = Math.min(startS + NAME_SCAN_WINDOW_S, endS); 180 + const introWords = getTranscriptWords(transcript, startS, nameScanEnd); 181 + const mcWords = getTranscriptWords(transcript, mcStart, startS); 182 + 183 + // Track names already found by self-intro/mc-handoff patterns to avoid duplicates 184 + const alreadyFoundNames = new Set( 185 + signals 186 + .filter((s) => s.type === 'self-intro' || s.type === 'mc-handoff') 187 + .map((s) => (s as { name: string }).name.toLowerCase()), 188 + ); 189 + 190 + for (const speakerName of knownSpeakerNames) { 191 + if (alreadyFoundNames.has(speakerName.toLowerCase())) continue; 192 + 193 + // Check intro window 194 + if (phoneticSearch(speakerName, introWords)) { 195 + signals.push({ type: 'name-scan', name: speakerName }); 196 + continue; 197 + } 198 + 199 + // Check MC handoff window 200 + if (phoneticSearch(speakerName, mcWords)) { 201 + signals.push({ type: 'name-scan', name: speakerName }); 202 + } 203 + } 204 + } 205 + 206 + // ── Topic keywords ── 207 + // This is computed per-schedule entry in matchSegmentToSchedule, 208 + // but we can return a signal with all words from the intro window. 209 + // We store a pre-tokenized bag of lowercased words for matching. 210 + const introWords = introText 211 + .toLowerCase() 212 + .replace(/[^\w\s]/g, ' ') 213 + .split(/\s+/) 214 + .filter((w) => w.length >= 4); 215 + 216 + if (introWords.length > 0) { 217 + signals.push({ type: 'topic', keywords: introWords }); 218 + } 219 + 220 + return signals; 221 + } 222 + 223 + // ─── matchSegmentToSchedule ─────────────────────────────────────────────────── 224 + 225 + /** 226 + * Match extracted signals against schedule candidates. 227 + * Returns the best match or null. 228 + * 229 + * @param timeProximities Optional map from rkey to offset difference in seconds. 230 + * Used when schedule start times are available to add time-proximity as a signal. 231 + * @param looseMode When true, accepts weaker matches (single keyword, time-only with 232 + * one candidate). Used in second-pass matching. 233 + */ 234 + export function matchSegmentToSchedule( 235 + signals: Signal[], 236 + schedule: ScheduleTalk[], 237 + timeProximities?: Map<string, number>, 238 + looseMode = false, 239 + ): { 240 + rkey: string; 241 + title: string; 242 + confidence: 'high' | 'medium' | 'low'; 243 + signals: string[]; 244 + } | null { 245 + if (schedule.length === 0) return null; 246 + 247 + // Collect name signals (self-intro, mc-handoff, and name-scan) 248 + const nameSignals = signals.filter( 249 + (s): s is { type: 'self-intro' | 'mc-handoff' | 'name-scan'; name: string } => 250 + (s.type === 'self-intro' || s.type === 'mc-handoff' || s.type === 'name-scan') && 251 + s.name.length > 0, 252 + ); 253 + 254 + // Collect transcript word bag from topic signals 255 + const transcriptWords: string[] = []; 256 + for (const s of signals) { 257 + if (s.type === 'topic') transcriptWords.push(...s.keywords); 258 + } 259 + 260 + type Candidate = { 261 + talk: ScheduleTalk; 262 + nameMatch: boolean; 263 + nameSource: 'self-intro' | 'mc-handoff' | 'name-scan' | null; 264 + topicMatchCount: number; 265 + timeProximity: boolean; 266 + timeOffsetS: number; 267 + matchedSignals: string[]; 268 + }; 269 + 270 + const candidates: Candidate[] = []; 271 + 272 + for (const talk of schedule) { 273 + const matchedSignals: string[] = []; 274 + let nameMatch = false; 275 + let nameSource: 'self-intro' | 'mc-handoff' | 'name-scan' | null = null; 276 + 277 + // Check name signals against this talk's speakers 278 + for (const sig of nameSignals) { 279 + if (nameMatchesScheduleSpeakers(sig.name, talk.speaker_names)) { 280 + nameMatch = true; 281 + if (sig.type === 'self-intro' && nameSource !== 'self-intro') { 282 + nameSource = 'self-intro'; 283 + } else if (sig.type === 'name-scan' && nameSource !== 'self-intro') { 284 + nameSource = 'name-scan'; 285 + } else if (sig.type === 'mc-handoff' && nameSource === null) { 286 + nameSource = 'mc-handoff'; 287 + } 288 + matchedSignals.push(`${sig.type}:${sig.name}`); 289 + break; // one name match per talk is enough 290 + } 291 + } 292 + 293 + // Check topic keywords 294 + const titleKeywords = extractTitleKeywords(talk.title); 295 + const matchedKeywords = titleKeywords.filter((kw) => transcriptWords.includes(kw)); 296 + const topicThreshold = looseMode ? 1 : 2; 297 + if (matchedKeywords.length >= topicThreshold) { 298 + matchedSignals.push(`topic:${matchedKeywords.slice(0, 5).join(',')}`); 299 + } 300 + 301 + // Check time proximity 302 + let timeProximity = false; 303 + let timeOffsetS = Infinity; 304 + if (timeProximities) { 305 + const offsetDiff = timeProximities.get(talk.rkey); 306 + if (offsetDiff !== undefined && Math.abs(offsetDiff) <= TIME_PROXIMITY_THRESHOLD_S) { 307 + timeProximity = true; 308 + timeOffsetS = Math.abs(offsetDiff); 309 + matchedSignals.push(`time-proximity:${offsetDiff > 0 ? '+' : ''}${Math.round(offsetDiff)}s`); 310 + } 311 + } 312 + 313 + // Determine if this candidate qualifies 314 + const hasName = nameMatch; 315 + const hasTopic = matchedKeywords.length >= topicThreshold; 316 + const hasTime = timeProximity; 317 + 318 + // In normal mode: need name or 2+ topic keywords (existing behavior) or time+any 319 + // In loose mode: accept time-only if single candidate, or single keyword match 320 + const qualifies = hasName || hasTopic || (hasTime && (hasName || hasTopic)) || 321 + (looseMode && hasTime); 322 + 323 + if (qualifies) { 324 + candidates.push({ 325 + talk, 326 + nameMatch, 327 + nameSource, 328 + topicMatchCount: matchedKeywords.length, 329 + timeProximity, 330 + timeOffsetS, 331 + matchedSignals, 332 + }); 333 + } 334 + } 335 + 336 + if (candidates.length === 0) return null; 337 + 338 + // In loose mode with time-only matches, only accept if there's a single candidate 339 + if (looseMode) { 340 + const timeOnlyCandidates = candidates.filter( 341 + (c) => !c.nameMatch && c.topicMatchCount < 1 && c.timeProximity, 342 + ); 343 + if (timeOnlyCandidates.length > 0 && candidates.length === timeOnlyCandidates.length) { 344 + // All candidates are time-only — only accept if exactly one 345 + if (candidates.length > 1) return null; 346 + } 347 + } 348 + 349 + // Sort: high confidence first 350 + candidates.sort((a, b) => { 351 + const scoreA = confidenceScore(a.nameMatch, a.nameSource, a.topicMatchCount, a.timeProximity, a.timeOffsetS); 352 + const scoreB = confidenceScore(b.nameMatch, b.nameSource, b.topicMatchCount, b.timeProximity, b.timeOffsetS); 353 + return scoreB - scoreA; 354 + }); 355 + 356 + const best = candidates[0]; 357 + const confidence = getConfidence(best.nameMatch, best.nameSource, best.topicMatchCount, best.timeProximity); 358 + 359 + return { 360 + rkey: best.talk.rkey, 361 + title: best.talk.title, 362 + confidence, 363 + signals: best.matchedSignals, 364 + }; 365 + } 366 + 367 + function confidenceScore( 368 + nameMatch: boolean, 369 + nameSource: 'self-intro' | 'mc-handoff' | 'name-scan' | null, 370 + topicMatchCount: number, 371 + timeProximity: boolean, 372 + timeOffsetS: number, 373 + ): number { 374 + let score = 0; 375 + if (nameMatch) { 376 + if (nameSource === 'self-intro') score += 3; 377 + else if (nameSource === 'name-scan') score += 2.5; 378 + else score += 2; // mc-handoff 379 + } 380 + if (topicMatchCount >= 2) score += 2; 381 + else if (topicMatchCount === 1) score += 1; 382 + if (timeProximity) { 383 + // Closer time = higher score, max 1.5 points 384 + score += Math.max(0, 1.5 - (timeOffsetS / TIME_PROXIMITY_THRESHOLD_S)); 385 + } 386 + return score; 387 + } 388 + 389 + function getConfidence( 390 + nameMatch: boolean, 391 + nameSource: 'self-intro' | 'mc-handoff' | 'name-scan' | null, 392 + topicMatchCount: number, 393 + timeProximity: boolean, 394 + ): 'high' | 'medium' | 'low' { 395 + // High: speaker name match + topic keyword match (2+ keywords) 396 + // Also high: time proximity + name + topic (any count) 397 + if (nameMatch && topicMatchCount >= 2) return 'high'; 398 + if (timeProximity && nameMatch && topicMatchCount >= 1) return 'high'; 399 + 400 + // Medium: self-intro or name-scan match only, OR 2+ topic keywords only 401 + // Also medium: time proximity + any other signal 402 + if (nameMatch && (nameSource === 'self-intro' || nameSource === 'name-scan')) return 'medium'; 403 + if (topicMatchCount >= 2) return 'medium'; 404 + if (timeProximity && (nameMatch || topicMatchCount >= 1)) return 'medium'; 405 + 406 + // Low: mc-handoff name only, or 1 keyword only, or time-proximity only 407 + return 'low'; 408 + } 409 + 410 + // ─── Time proximity computation ───────────────────────────────────────────── 411 + 412 + /** 413 + * Compute time proximity map: for each schedule talk, how far its expected 414 + * stream offset is from the segment's actual start time. 415 + * 416 + * Uses the first scheduled talk's starts_at as the reference point and the 417 + * first segment's startS as time zero. 418 + */ 419 + function computeTimeProximities( 420 + segmentStartS: number, 421 + schedule: ScheduleTalk[], 422 + firstScheduleUnixS: number, 423 + firstSegmentStartS: number, 424 + ): Map<string, number> { 425 + const proximities = new Map<string, number>(); 426 + 427 + for (const talk of schedule) { 428 + const talkUnixS = Date.parse(talk.starts_at) / 1000; 429 + if (isNaN(talkUnixS)) continue; 430 + 431 + // Expected offset into stream based on schedule 432 + const expectedOffsetS = talkUnixS - firstScheduleUnixS + firstSegmentStartS; 433 + // Actual offset of this segment 434 + const diffS = segmentStartS - expectedOffsetS; 435 + proximities.set(talk.rkey, diffS); 436 + } 437 + 438 + return proximities; 439 + } 440 + 441 + // ─── matchAllSegments ───────────────────────────────────────────────────────── 442 + 443 + /** 444 + * Orchestrate: match all TalkSegments against schedule, returning BoundaryMatches. 445 + * Includes a second pass for process-of-elimination matching. 446 + */ 447 + export function matchAllSegments( 448 + segments: TalkSegment[], 449 + transcript: TranscriptInput, 450 + schedule: ScheduleTalk[], 451 + hallucinationZones: HallucinationZone[], 452 + ): BoundaryMatch[] { 453 + const results: BoundaryMatch[] = []; 454 + 455 + // Pre-compute schedule reference time and known speaker names 456 + const sortedSchedule = [...schedule].sort( 457 + (a, b) => Date.parse(a.starts_at) - Date.parse(b.starts_at), 458 + ); 459 + const firstScheduleUnixS = 460 + sortedSchedule.length > 0 ? Date.parse(sortedSchedule[0].starts_at) / 1000 : 0; 461 + 462 + const sortedSegments = [...segments].sort((a, b) => a.startS - b.startS); 463 + const firstSegmentStartS = sortedSegments.length > 0 ? sortedSegments[0].startS : 0; 464 + 465 + const knownSpeakerNames = extractAllSpeakerNames(schedule); 466 + 467 + // ── First pass: standard matching ── 468 + const matchedRkeys = new Set<string>(); 469 + 470 + for (const segment of segments) { 471 + const signals = extractSignals(transcript, segment.startS, segment.endS, knownSpeakerNames); 472 + 473 + // Compute time proximities for this segment 474 + const timeProximities = firstScheduleUnixS > 0 475 + ? computeTimeProximities(segment.startS, schedule, firstScheduleUnixS, firstSegmentStartS) 476 + : undefined; 477 + 478 + if (segment.type === 'panel') { 479 + // For panel segments, try to match multiple schedule entries 480 + const matches = matchPanelSegment( 481 + signals, segment, schedule, hallucinationZones, timeProximities, 482 + ); 483 + for (const match of matches) { 484 + matchedRkeys.add(match.rkey); 485 + } 486 + results.push(...matches); 487 + } else { 488 + // Single-speaker or unknown 489 + const match = matchSegmentToSchedule(signals, schedule, timeProximities); 490 + const overlappingZones = hallucinationZones.filter( 491 + (z) => z.startS < segment.endS && z.endS > segment.startS, 492 + ); 493 + 494 + if (match) { 495 + const isHallucinationAffected = 496 + segment.hallucinationZone && match.signals.length === 0; 497 + 498 + matchedRkeys.add(match.rkey); 499 + results.push({ 500 + rkey: match.rkey, 501 + title: match.title, 502 + startTimestamp: segment.startS, 503 + endTimestamp: segment.endS, 504 + confidence: isHallucinationAffected ? 'unverifiable' : match.confidence, 505 + signals: match.signals, 506 + panel: false, 507 + hallucinationZones: overlappingZones, 508 + }); 509 + } 510 + } 511 + } 512 + 513 + // ── Second pass: process of elimination (Improvement #3) ── 514 + const unmatchedSegments = segments.filter( 515 + (seg) => !results.some( 516 + (r) => Math.abs(r.startTimestamp - seg.startS) < 5, 517 + ), 518 + ); 519 + const unmatchedSchedule = schedule.filter((t) => !matchedRkeys.has(t.rkey)); 520 + 521 + if (unmatchedSegments.length > 0 && unmatchedSchedule.length > 0) { 522 + const secondPassResults = secondPassMatching( 523 + unmatchedSegments, 524 + unmatchedSchedule, 525 + transcript, 526 + hallucinationZones, 527 + knownSpeakerNames, 528 + firstScheduleUnixS, 529 + firstSegmentStartS, 530 + ); 531 + 532 + results.push(...secondPassResults); 533 + } 534 + 535 + return results; 536 + } 537 + 538 + /** 539 + * Second pass: match remaining unmatched segments to unmatched schedule entries 540 + * using looser criteria and order-based matching. 541 + */ 542 + function secondPassMatching( 543 + unmatchedSegments: TalkSegment[], 544 + unmatchedSchedule: ScheduleTalk[], 545 + transcript: TranscriptInput, 546 + hallucinationZones: HallucinationZone[], 547 + knownSpeakerNames: string[], 548 + firstScheduleUnixS: number, 549 + firstSegmentStartS: number, 550 + ): BoundaryMatch[] { 551 + const results: BoundaryMatch[] = []; 552 + const usedRkeys = new Set<string>(); 553 + const usedSegments = new Set<number>(); // index into unmatchedSegments 554 + 555 + // Sort both by time for order-based matching 556 + const sortedSegments = [...unmatchedSegments].sort((a, b) => a.startS - b.startS); 557 + const sortedSchedule = [...unmatchedSchedule].sort( 558 + (a, b) => Date.parse(a.starts_at) - Date.parse(b.starts_at), 559 + ); 560 + 561 + // Attempt 1: try matching with loose criteria (single keyword, name-scan matches) 562 + for (let i = 0; i < sortedSegments.length; i++) { 563 + const segment = sortedSegments[i]; 564 + const signals = extractSignals(transcript, segment.startS, segment.endS, knownSpeakerNames); 565 + 566 + const remainingSchedule = sortedSchedule.filter((t) => !usedRkeys.has(t.rkey)); 567 + 568 + const timeProximities = firstScheduleUnixS > 0 569 + ? computeTimeProximities(segment.startS, remainingSchedule, firstScheduleUnixS, firstSegmentStartS) 570 + : undefined; 571 + 572 + const match = matchSegmentToSchedule(signals, remainingSchedule, timeProximities, true); 573 + 574 + if (match) { 575 + const overlappingZones = hallucinationZones.filter( 576 + (z) => z.startS < segment.endS && z.endS > segment.startS, 577 + ); 578 + 579 + usedRkeys.add(match.rkey); 580 + usedSegments.add(i); 581 + results.push({ 582 + rkey: match.rkey, 583 + title: match.title, 584 + startTimestamp: segment.startS, 585 + endTimestamp: segment.endS, 586 + confidence: match.confidence, 587 + signals: [...match.signals, 'second-pass'], 588 + panel: segment.type === 'panel', 589 + hallucinationZones: overlappingZones, 590 + }); 591 + } 592 + } 593 + 594 + // Attempt 2: order-based matching for remaining unmatched 595 + // If segments and schedule entries are both sorted by time, the Nth unmatched 596 + // segment likely corresponds to the Nth unmatched schedule entry when they 597 + // are in roughly the right time region. 598 + const stillUnmatchedSegments = sortedSegments.filter((_, i) => !usedSegments.has(i)); 599 + const stillUnmatchedSchedule = sortedSchedule.filter((t) => !usedRkeys.has(t.rkey)); 600 + 601 + if (stillUnmatchedSegments.length > 0 && stillUnmatchedSchedule.length > 0) { 602 + const minLen = Math.min(stillUnmatchedSegments.length, stillUnmatchedSchedule.length); 603 + for (let i = 0; i < minLen; i++) { 604 + const segment = stillUnmatchedSegments[i]; 605 + const talk = stillUnmatchedSchedule[i]; 606 + 607 + // Only accept order-based match if time proximity is reasonable 608 + const talkUnixS = Date.parse(talk.starts_at) / 1000; 609 + if (isNaN(talkUnixS)) continue; 610 + 611 + const expectedOffsetS = talkUnixS - firstScheduleUnixS + firstSegmentStartS; 612 + const diffS = Math.abs(segment.startS - expectedOffsetS); 613 + 614 + // Allow wider window for order-based matching (20 minutes) 615 + if (diffS > 1200) continue; 616 + 617 + const overlappingZones = hallucinationZones.filter( 618 + (z) => z.startS < segment.endS && z.endS > segment.startS, 619 + ); 620 + 621 + results.push({ 622 + rkey: talk.rkey, 623 + title: talk.title, 624 + startTimestamp: segment.startS, 625 + endTimestamp: segment.endS, 626 + confidence: 'low', 627 + signals: [`order-match`, `time-proximity:${Math.round(segment.startS - expectedOffsetS)}s`], 628 + panel: segment.type === 'panel', 629 + hallucinationZones: overlappingZones, 630 + }); 631 + } 632 + } 633 + 634 + return results; 635 + } 636 + 637 + /** 638 + * Match a panel segment against schedule entries, producing multiple matches. 639 + */ 640 + function matchPanelSegment( 641 + signals: Signal[], 642 + segment: TalkSegment, 643 + schedule: ScheduleTalk[], 644 + hallucinationZones: HallucinationZone[], 645 + timeProximities?: Map<string, number>, 646 + ): BoundaryMatch[] { 647 + const matches: BoundaryMatch[] = []; 648 + 649 + // Collect name signals (self-intro, mc-handoff, and name-scan) 650 + const nameSignals = signals.filter( 651 + (s): s is { type: 'self-intro' | 'mc-handoff' | 'name-scan'; name: string } => 652 + (s.type === 'self-intro' || s.type === 'mc-handoff' || s.type === 'name-scan') && 653 + s.name.length > 0, 654 + ); 655 + const transcriptWords: string[] = []; 656 + for (const s of signals) { 657 + if (s.type === 'topic') transcriptWords.push(...s.keywords); 658 + } 659 + 660 + for (const talk of schedule) { 661 + const matchedSignals: string[] = []; 662 + let nameMatch = false; 663 + let nameSource: 'self-intro' | 'mc-handoff' | 'name-scan' | null = null; 664 + 665 + for (const sig of nameSignals) { 666 + if (nameMatchesScheduleSpeakers(sig.name, talk.speaker_names)) { 667 + nameMatch = true; 668 + if (sig.type === 'self-intro') nameSource = 'self-intro'; 669 + else if (sig.type === 'name-scan' && nameSource !== 'self-intro') nameSource = 'name-scan'; 670 + else if (nameSource === null) nameSource = 'mc-handoff'; 671 + matchedSignals.push(`${sig.type}:${sig.name}`); 672 + } 673 + } 674 + 675 + const titleKeywords = extractTitleKeywords(talk.title); 676 + const matchedKeywords = titleKeywords.filter((kw) => transcriptWords.includes(kw)); 677 + if (matchedKeywords.length >= 2) { 678 + matchedSignals.push(`topic:${matchedKeywords.slice(0, 5).join(',')}`); 679 + } 680 + 681 + // Check time proximity 682 + let timeProximity = false; 683 + if (timeProximities) { 684 + const offsetDiff = timeProximities.get(talk.rkey); 685 + if (offsetDiff !== undefined && Math.abs(offsetDiff) <= TIME_PROXIMITY_THRESHOLD_S) { 686 + timeProximity = true; 687 + matchedSignals.push(`time-proximity:${offsetDiff > 0 ? '+' : ''}${Math.round(offsetDiff)}s`); 688 + } 689 + } 690 + 691 + if (nameMatch || matchedKeywords.length >= 2 || (timeProximity && (nameMatch || matchedKeywords.length >= 1))) { 692 + const confidence = getConfidence(nameMatch, nameSource, matchedKeywords.length, timeProximity); 693 + const overlappingZones = hallucinationZones.filter( 694 + (z) => z.startS < segment.endS && z.endS > segment.startS, 695 + ); 696 + 697 + matches.push({ 698 + rkey: talk.rkey, 699 + title: talk.title, 700 + startTimestamp: segment.startS, 701 + endTimestamp: segment.endS, 702 + confidence: segment.hallucinationZone && matchedSignals.length === 0 703 + ? 'unverifiable' 704 + : confidence, 705 + signals: matchedSignals, 706 + panel: true, 707 + hallucinationZones: overlappingZones, 708 + }); 709 + } 710 + } 711 + 712 + return matches; 713 + }
+54
apps/ionosphere-appview/src/v7/types.ts
··· 1 + export interface DiarizationInput { 2 + speakers: string[]; 3 + segments: { start: number; end: number; speaker: string }[]; 4 + total_segments: number; 5 + } 6 + 7 + export interface TranscriptInput { 8 + stream: string; 9 + duration_seconds: number; 10 + words: { word: string; start: number; end: number; speaker?: string; confidence?: number }[]; 11 + } 12 + 13 + export interface TalkSegment { 14 + startS: number; 15 + endS: number; 16 + speakers: { id: string; durationS: number }[]; 17 + type: 'single-speaker' | 'panel' | 'unknown'; 18 + dominantSpeaker?: string; 19 + precedingGapS: number; 20 + hallucinationZone: boolean; 21 + } 22 + 23 + export interface HallucinationZone { 24 + startS: number; 25 + endS: number; 26 + pattern: string; 27 + } 28 + 29 + export interface ScheduleTalk { 30 + rkey: string; 31 + title: string; 32 + starts_at: string; 33 + ends_at: string; 34 + speaker_names: string; 35 + } 36 + 37 + export interface BoundaryMatch { 38 + rkey: string; 39 + title: string; 40 + startTimestamp: number; 41 + endTimestamp: number | null; 42 + confidence: 'high' | 'medium' | 'low' | 'unverifiable'; 43 + signals: string[]; 44 + panel: boolean; 45 + hallucinationZones: HallucinationZone[]; 46 + } 47 + 48 + export interface V7Output { 49 + stream: string; 50 + results: BoundaryMatch[]; 51 + hallucinationZones: HallucinationZone[]; 52 + unmatchedSegments: TalkSegment[]; 53 + unmatchedSchedule: string[]; 54 + }
+311
docs/alignment-verification-notes.md
··· 1 + # Talk Alignment Verification Notes 2 + 3 + Notes collected during manual verification of all 7 tracks. 4 + Goal: inform design of a better automated alignment system. 5 + 6 + ## Observations 7 + 8 + ### Patterns in Misalignment 9 + 1. **Wrong-room assignments**: Boundary detection matched concurrent talks from other rooms to the same time slots. PT D1 had 6 wrong-room talks. Caused by schedule-based room+day matching. 10 + 2. **Hallucination zone boundaries**: Talks whose boundaries fall inside hallucination zones get wrong timestamps. The detector finds "gaps" between hallucination loops and treats them as talk boundaries. 11 + 3. **Round-number defaults**: Keynote end at exactly 1:00:00 was a default, not a real boundary. 12 + 4. **Panel boundary confusion**: Panel sessions (PT D1 media track, GH D2 design panel) don't have clear per-talk boundaries — speakers rotate within continuous discussions. 13 + 5. **Lightning talk compression**: Multiple lightning talks get the same timestamp when detection can't resolve the short gaps between them. 14 + 6. **Missing end times**: Last talk on every stream has endOffsetNs=0. 15 + 7. **Duplicate start times**: 5B02jaM and QKNkKMX both at 5:00:33 on R2301 D2 — detection couldn't split them. 16 + 17 + ### Whisper Hallucination Signatures 18 + - "Transcription Outsourcing LLC" — repeating during silence (GH D2 lunch) 19 + - "Transcribed by https://otter ai" — repeating during silence (GH D2, PT D2) 20 + - "Thank you for watching" — repeating during late-day breaks (GH D2) 21 + - Welsh text "Rwy n gobeithio eich bod chi n gweithio n fawr iawn" — repeating loops (ATScience lunch) 22 + - Song lyrics from DJ music — transcribed as speech (GH D2: "give it away now", "I lose my mind", "candy she's sweet like candy") 23 + - "www fema gov", "Subs by www zeoranger co uk" — hallucination markers (R2301 D2) 24 + - Key pattern: repeating phrases in ~30s loops with gaps between them 25 + - Hallucinations often get assigned to a real speaker ID by diarization 26 + 27 + ### Boundary Detection Signals That Work 28 + - Self-introductions: "My name is X", "I'm X from Y" — very reliable talk start marker 29 + - MC handoffs: "Please welcome X", "Next up is X", "Let's give a round of applause" 30 + - "Thank you" followed by gap — reliable talk end 31 + - Speaker ID changes with gap — strong signal when combined with content change 32 + - Explicit topic statements: "I'm going to talk about X", "Today's talk is about X" 33 + - **Diarization data** — tracks AUDIO not Whisper, so it shows real speech patterns even through hallucination zones. A 94-minute silence gap in diarization at PT D2 116-210m confirmed the exact break location that Whisper completely filled with hallucinations. Diarization speaker changes also confirm talk boundaries. 34 + 35 + ### Boundary Detection Signals That Fail 36 + - Round-number timestamps (exactly 1:00:00) — likely defaults, not real boundaries 37 + - Gaps during hallucination zones — Whisper creates artificial gaps between hallucination loops 38 + - Panel sessions — no clear boundary between speakers in the same panel 39 + - Schedule-based room+day matching — talks from wrong rooms get assigned (GH D2: PdNOkAP, ob8N65V at wrong timestamps) 40 + - Lunch break boundaries — hallucination content fills the gap, making it look like continuous speech 41 + 42 + ### Cross-Track Assignment Errors 43 + - GH D2: PdNOkAP (Rewilding) — not on this stream at all ("rewilding" never appears in transcript) 44 + - GH D2: ob8N65V placed at 372.4m shows Daniel Holmgren content, real talk is at 461.5m 45 + - GH D2: aQJAWl9 placed at 374m shows Daniel Holmgren content, real talk is at 451.5m 46 + - PT D1: 6 wrong-room talks (EkexvrN, LZ4oWrj, J9EgEdX, OD6Gd0A, dWGJ41y, eqVMWz0) — all from other rooms, assigned because of matching schedule times 47 + - R2301 D1: q4qlVLY (Community privacy) assigned but content is Hypercerts 48 + - Cause: schedule data had concurrent talks in multiple rooms, boundary detection matched by time not content 49 + 50 + ### Data Quality Issues 51 + - Dual DID issue: every talk exists under 2 DIDs (did:plc:lkeq4oghyhnztbu4dxr3joff and did:plc:dzqydlr5liucjrw3g43cccng). API deduplicates via GROUP BY rkey but DB is messy. The two DIDs sometimes have DIFFERENT video_segments values. 52 + - Missing endOffsetNs on last talk of most tracks 53 + - Some talks have endOffsetNs = 0 instead of null (shows as negative duration) 54 + - "extrapolated" confidence talks are often wrong (timestamps past stream end) 55 + - Zero-duration talks (startTimestamp == endTimestamp) on PT D2 56 + 57 + ## Per-Track Findings 58 + 59 + ### ATScience (Friday) 60 + 61 + **Corrections needed:** 62 + 1. Talk 2 (ats26-keynote): End 3600s → 4242s (Q&A runs until 70.7m, "That's all we've got time for") 63 + 2. Talk 3 (ats26-semble): Start 3678s → 4266s (actual intro at 71.1m, "heard about Semble already") 64 + 3. Talk 3 end / Talk 4 start: Semble end → 5487s, Lea start stays 5487s (removes 23s overlap) 65 + 4. Talk 10 (ats26-astrosky): Welsh hallucination 264.5-280m, English emerges ~16800s. Keep start, note bad transcript. 66 + 5. Talk 18 (000NewDirections): Add end at 29664s (last word in stream) 67 + 6. Talk 19 (3mhzjk45462rg): REMOVE — timestamp 35991s is past stream end 29675s 68 + 69 + **Observations:** 70 + - Round-number boundaries (exactly 1:00:00) are likely defaults, not real boundaries 71 + - Welsh hallucination: repeating "Rwy n gobeithio..." in 30s loops during silence 72 + - Travis Simpson "SkySquare" demo played 3x due to AV issues — creates repeating intros that mimic hallucination patterns but are real speech 73 + - 20min transcript gap at 180-200m makes talks 7-9 unverifiable from transcript alone 74 + - Talks 7-9 boundary positions seem plausible from gap structure but can't confirm content match 75 + 76 + ### Great Hall Day 1 (Saturday) 77 + 78 + **Status: Mostly clean — 3 fixes needed** 79 + 80 + 1. Talk 8/9 overlap (LZxV6dv→Y561Qv6): Consent Before Crypto end extends 42s into Expo panel intro. Fix: LZxV6dv end → ~15368s, Y561Qv6 start → ~15369s ("All right Welcome") 81 + 2. Talk 12/13 boundary (2EG4YMj→000Jer): Tori still talking at 21960s+, current Jer start 21870s is mid-Tori. Jeremy actually starts ~22070s ("I had the good fortune... Tim O Reilly"). Fix: 2EG4YMj end → ~22070s, 000Jer start → ~22070s 82 + 3. Talk 16 (rj8Xv62): Missing end. Last word at 28433s. Fix: set end to 28433s. 83 + 84 + **Observations:** 85 + - Lightning talk transitions are the hardest boundaries — short gaps, fast speaker swaps, AV setup noise 86 + - All main session talks (1-10) had clear speaker intros that matched titles perfectly 87 + - Speaker diarization IDs are consistent within the stream (SPEAKER_10 = Tori throughout) 88 + 89 + ### Great Hall Day 2 (Sunday) 90 + 91 + **Status: Multiple issues — needs significant rework** 92 + 93 + **Actual talk sequence verified from transcript:** 94 + 1. MeWBzWX (npmx) — 24m, OK ✓ 95 + 2. VLgVWM6 (tangled) — 70m, OK ✓ 96 + 3. gDPMMa1 (Graze) — 101m, OK ✓ 97 + 4. ODqQQJA (Waiting for Future) — 129m, OK ✓ 98 + 5. kdobWjj (Resonant Computing) — 165m, OK ✓ 99 + 6. [LUNCH BREAK with "Transcription Outsourcing LLC" hallucination 181-197m, DJ music 220-254m] 100 + 7. 9q8ZX5Q (Social Components) — ~263m, start in hallucination zone, mid-talk content 101 + 8. GxEe0Vz (Designing for social web) — panel, ~310m 102 + 9. xXWE9Dv (Data Sovereignty Games) — part of same panel 103 + 10. rjQ96kl (Protocol Governance) — Daniel Holmgren at 372.4m, "My name is Daniel Holmgren" ✓ 104 + 11. [BREAK with DJ music 427-436m, "Thank you for watching" hallucination] 105 + 12. 81gXlXP (Building Bridgy) — ~436m, "building Bridgy" ✓ 106 + 13. aQJAWl9 (Affordances of the Atmosphere) — 451.5m, "Let's talk some design affordances" by Tyne ✓ (currently at 6:14:00=374m WRONG) 107 + 14. ob8N65V (How to use Bluesky to preview software) — 461.5m, "Please welcome Tim" ✓ (currently at 6:12:24=372.4m WRONG — shows Daniel Holmgren content) 108 + 15. RG6Nepp (Podcasts) — 473.1m, "Hi I'm Roscoe I'm 16" ✓ 109 + 110 + **Corrections needed:** 111 + 1. ob8N65V: Move from 22344s to ~27690s (461.5m) 112 + 2. aQJAWl9: Move from 22440s to ~27090s (451.5m) 113 + 3. PdNOkAP (Rewilding): REMOVE — "rewilding" doesn't appear anywhere in stream transcript. Wrong-stream assignment. 114 + 4. rjQ96kl end: Currently 22320s (6:12:00). Daniel's talk continues well past this — need to find actual end. 115 + 5. 9q8ZX5Q start: Currently in hallucination zone after lunch. Start text is mid-talk. Likely OK but first ~seconds are in hallucination. 116 + 6. All NO END talks need end times. 117 + 7. End times for 81gXlXP: ~27090s (Affordances start). Currently 27300s — close but should match. 118 + 119 + **Observations:** 120 + - DJ music between talks creates song lyrics in transcript (RHCP "give it away", various pop songs) 121 + - "Transcription Outsourcing LLC" is a recurring hallucination during lunch break silence 122 + - "Transcribed by https://otter ai" appears at breaks 123 + - "Thank you for watching" hallucination during late-day breaks 124 + - Panel sessions (Designing for social web) have no clear talk-to-talk boundaries — it's one continuous conversation 125 + - Talks at end of day were ordered differently in the stream than in the schedule 126 + 127 + ### Room 2301 Day 1 (Saturday) 128 + 129 + **Status: Several fixes needed, mostly in lightning talk section** 130 + 131 + **Verified talk sequence:** 132 + 1. BzrpDQK (Feature/Product/Business) — 0m, "How are you" ✓ 133 + 2. gDPLaGd (Lexicon enterprise) — 34m, "excellent thank you" ✓ 134 + 3. 9qkWqPG (AI in Atmosphere) — 64m, "Hello everybody My name is Cameron" ✓ 135 + 4. VLerG2y (Custom Feeds) — 100m, "hello everyone I'm a phd student" ✓ 136 + 5. [BREAK: "0 0 0 0" hallucination 140-200m, then "AtmosphereConf 2026 Room 2301" hallucination] 137 + 6. XxP4RzL (Building Cirrus) — 200m (12000s), real speech resumes with "the whole point of a PDS". Current start 198.2m (11892s) is in hallucination zone. Fix: start → 12000s 138 + 7. MelQ8Ak (Rethinking Client) — 228m, "Hello I'm Tyler Fisher" ✓ 139 + 8. gDP6A8N (Account logic TEE) — 259.5m, "Hi I'm Kobi" ✓ 140 + 9. [BREAK: "Thank you" loops 289-301m, "Session concluded" at 301.5m, then more hallucination 346-350m] 141 + 10. 9qP16Kp (Stop Hallucinating) — 350.5m, "Welcome to the lightning talk section My name is Jessie" ✓ 142 + 11. QKZoLBX (How decentralized) — 359.3m ✓ 143 + 12. Xxd7xqj (E2EE DMs) — 372m, "I'm Ranga Krishnan with Solidarity Social" ✓ 144 + 13. 7Rr2zW6 (Pollen) — 382.5m ✓ 145 + 14. RGqppqd (How Streamplace Works) — 393.7m, "My name is Eli Mellon" ✓ 146 + 15. J9EgEdX (Hypercerts) — 427.5m, "funding impact and resource allocation... Hypersense Foundation" ✓ (currently DUPLICATE at 350.5m — wrong!) 147 + 16. q4qlVLY (Community privacy) — NOT FOUND on this stream. Currently assigned at 427.5m with Hypercerts content. 148 + 149 + **Corrections needed:** 150 + 1. XxP4RzL start: Move from 11892s to ~12000s (past hallucination) 151 + 2. J9EgEdX (Hypercerts): Move from 21031s (350.5m) to 25651s (427.5m). It's the LAST talk, not a lightning talk. 152 + 3. J9EgEdX end: Set to stream end ~27419s 153 + 4. q4qlVLY (Community privacy): REMOVE from this stream — not on this stream. May be on PT D2. 154 + 5. 9qP16Kp start: Verify — currently 21031s which is same as old J9EgEdX. Should be 350.5m (21030s) which actually matches. 155 + 156 + **Observations:** 157 + - "0 0 0 0 0" hallucination is a distinct pattern — numeric zeros in loops 158 + - "AtmosphereConf 2026 Room 2301 Saturday" — Whisper hallucinates room/event metadata during silence 159 + - "Session concluded We will be back in just a few moments" — interesting hallucination that mimics a real conference announcement 160 + - Lightning talk section starts cleanly with MC intro ("Welcome to the lightning talk section") 161 + 162 + ### Room 2301 Day 2 (Sunday) 163 + 164 + **Status: Major rework needed — massive hallucination zone destroys 100-208m** 165 + 166 + **Actual talk sequence from transcript:** 167 + 1. 0.1m: Mej2N5X "Roomy" — "Hi I'm Meri... creators of Roomie... Melbourne Australia" ✓ 168 + 2. 34.2m: "I will speak about how to have more non English speakers" — CONTENT is about non-English users, but currently mapped to Mej2N5X. Likely this IS WOkL11Q (talk order in schedule may be wrong or Mej2N5X extends into this) 169 + 3. 67.2m: WOkL11Q — "My name is Stan but call me Stan" ✓ 170 + 4. ~96m: zxRkxk8 "Blousques" (ends before hallucination at 100m) 171 + 5. **100-208m: HALLUCINATION ZONE (108 minutes!)** 172 + - 100-120m: "Microsoft Office Word Document MSWordDoc Word Document 8" (new pattern!) 173 + - 121-125m: "Transcription by CastingWords" 174 + - 141m: "Thank you for watching" / "6 The vote is now closed" 175 + - 162m: "Transcription by CastingWords" 176 + - 183m: "Subs by www zeoranger co uk" 177 + - Talks 4-6 (Content Mod Futures, Blacksky, part of Skywatch) HAPPENED during this zone but transcript is garbage 178 + 6. 208.7m: 2E9XG1b "Blacksky" MID-TALK — "the work we do at Black Sky" (start lost in hallucination) 179 + 7. 239.7m: LZ4oWrj "Skywatch" — "Ms Julia the next presenter... two years of Skywatch" ✓ 180 + 8. 271.4m: 0Qq9NlZ "Coop" — "Welcome to KOOP Open Source Trust and Safety" ✓ 181 + 9. 304.8m: 000WebTiles "WebTiles" — "welcome to the impromptu slightly impromptu tiles talk" ✓ 182 + 10. [BREAK + hallucination 333-350m] 183 + 11. 357.6m: 5B02jaM "Keywords vs Embeddings" — "keywords versus embeddings I can tell you already" ✓ 184 + 12. 368.8m: QKNkKMX "Scaling the Atmosphere" — "I'm Jim I run the platform team at Blue Sky" ✓ 185 + 13. 380.5m: xX5yRJr "Skylimit" — "project called SkyLimit" ✓ 186 + 14. 391.1m: 000TLog "AT Transparency Logs" — "Hi I'm Filippo... transparency logs" ✓ 187 + 15. 402.4m: ZjMOl7o "Matadata" — "talk about MataDisco... My name is actually Volker" ✓ 188 + 16. ~418m: QKqWDrG "DID:PLC War Games" — "DID PLC... trusted sequencer" ✓ (SPEAKER_07 discussing blockchain/RPC) 189 + 17. 449.6m: "Thank you for closing out the conference with this particular talk" — stream end 190 + 191 + **Corrections needed:** 192 + 1. Mej2N5X start: Currently 34.4m but Roomy intro is at 0.1m. Fix: start → 0s 193 + 2. Talks at 34m and 67m: Need to verify which is WOkL11Q vs a continuation of Mej2N5X 194 + 3. eqVMWz0 "Content Mod Futures" (currently 121m): In hallucination zone. Keep timestamp but note bad transcript. 195 + 4. 2E9XG1b "Blacksky" (currently 152m): In hallucination zone. Real content emerges at 208.7m (12522s). 196 + 5. LZ4oWrj "Skywatch": Move start to 239.7m (14382s) 197 + 6. 0Qq9NlZ "Coop": Move start to 271.4m (16284s) 198 + 7. 000WebTiles: Move start to 304.8m (18288s) 199 + 8. 5B02jaM "Keywords": Currently 5:00:33 (18033s) — move to 357.6m (21456s) 200 + 9. QKNkKMX "Scaling": Currently 5:00:33 (same as above!) — move to 368.8m (22128s) 201 + 10. xX5yRJr "Skylimit": Move to 380.5m (22830s) 202 + 11. 000TLog: Currently 5:33:44 with 0 duration — move to 391.1m (23466s), set proper end 203 + 12. ZjMOl7o "Matadata": Move to 402.4m (24144s) 204 + 13. QKqWDrG "War Games": Move to ~418m (25080s), end at ~449.6m (26976s) 205 + 14. All end times need recalculating based on next talk's start 206 + 207 + **New hallucination pattern:** 208 + - "Microsoft Office Word Document MSWordDoc Word Document 8" — repeating in 30s loops (100-120m) 209 + - "6 The vote is now closed Thank you for watching" — mixed hallucination at 141m 210 + 211 + **Observations:** 212 + - 108-minute hallucination zone is the longest in any stream 213 + - 3+ talks completely lost to hallucination (Content Mod Futures, start of Blacksky) 214 + - First real speech after hallucination is MID-TALK (no intro visible) 215 + - Lightning talks (357-420m) are correctly ordered but all have wrong timestamps 216 + - The conference MC ("Thank you for closing out the conference") confirms DID:PLC War Games is the last talk 217 + 218 + ### Performance Theatre Day 1 (Saturday) 219 + 220 + **Status: NEEDS FULL REBUILD — wrong-room talks throughout** 221 + 222 + This was a curated **Media & Civics track** (MC: Chad Koholik, Protocols for Publishers). 223 + 224 + **Actual talk sequence from transcript:** 225 + 1. 0m: Chad Koholik MC intro — "media and civics track" 226 + 2. ~2m: Panel 1 — Justin Bank (Independent Journalism Atlas), Natalie (Bain Capital) 227 + - J9yOpYz "Aggregation Era burned journalism" ✓ 228 + - EkGROKB "Free Press needs Free Protocols" ✓ (same panel) 229 + 3. ~32m: Natalie continues, sovereign media discussion 230 + - obLbvQV "Economics of Sovereign Media" ✓ 231 + 4. ~63m: Josh Chiquette intro, then Hilke (musician) 232 + - 81VNEBO "Creators First" ✓ 233 + - XxPK17j "Atmospheric Publishing Discussion" ✓ (same panel) 234 + 5. ~94m: Justin again — VLa69bl "Discussion with news creators" ✓ 235 + 6. [BREAK 141-200m: "Thank you" loops, "For more UN videos visit www.un.org" hallucination] 236 + 7. 200m: Chad Koholik returns — "afternoon section" 237 + - QKlrLXG "Digital Sovereignty" ✓ (201m) 238 + 8. 233m: OD2PpYA "Open social tech and geopolitical risk" ✓ 239 + 9. 260m: Sebastian Vogelsang — ja4ooAa "Public Interest Infrastructure" OR Bzr448Q "Gander Social" 240 + 10. [BREAK 286-348m] 241 + 11. 348-395m: Lightning talks section: 242 + - PdJ6Q8d "Journalism must create its own algorithms" (Chad MC) 243 + - 000WSocial "WSocial" (Jan Lindblad, 361m) 244 + - 000Ryo "Sky Follower Bridge" (Ryo, 371m) 245 + - lbkWPeN "OakLog" (381m) 246 + 12. 395m: Joe Germuska — ZjMOl7o "Matadata" ✓ 247 + 13. 428m: Last talk — ZjL74D0 "Jacquard Magic"? 248 + 249 + **Talks to REMOVE (wrong-room assignments):** 250 + - EkexvrN "Cooperate and Succeed" — belongs to PT D2 251 + - LZ4oWrj "Skywatch" — belongs to R2301 D2 252 + - J9EgEdX "Hypercerts" — belongs to R2301 D1 253 + - OD6Gd0A "Semble: Trails" — belongs to GH D1 (lightning talk) 254 + - dWGJ41y "Abstracting AppView" — belongs to PT D2 255 + - eqVMWz0 "Content Mod Futures" — belongs to R2301 D2 256 + 257 + **Root cause:** Boundary detection assigned concurrent-room talks to this stream because they had matching scheduled times. The Media & Civics track ran in panels, so each "slot" had ONE panel, not multiple separate talks. 258 + 259 + **Observations:** 260 + - Panel format makes individual talk boundaries fuzzy — speakers rotate within panels 261 + - MC intros are the most reliable boundary marker: "Welcome to the afternoon section" 262 + - "For more UN videos visit www.un.org" is a new hallucination pattern (160m break) 263 + - "Transcripts provided by Transcription Outsourcing LLC" also appears here (21-31m, between panels) 264 + 265 + ### Performance Theatre Day 2 (Sunday) 266 + 267 + **Status: Significant rework needed — hallucination zones, zero-duration talks** 268 + 269 + **Actual talk sequence from transcript:** 270 + 1. 0.9m: Jim (Blue Sky DevRel) — VLXBbzJ "How and Why News Organizations Should Build on ATProtocol" ✓ 271 + 2. [hallucination at 25.6m: "Transcribed by https://otter ai"] 272 + 3. 61.1m: Jonathan Warden — jaAWVRY "Bringing Self Sovereign Identities" ✓ 273 + 4. 91.0m: "can people hear me... North Sky Social" — 1AzdYWM "Bluenotes: Community Notes for ATProto" ✓ 274 + 5. [break/hallucination 141-161m: ESO, UGA Extension Office] 275 + 6. 161-203m: Hallucination zone — q4qlVLY "Community privacy" and EkexvrN "Cooperate and Succeed" HAPPENED here but transcript is garbage 276 + 7. [hallucination 203-237m: numbers, otter ai] 277 + 8. ~240m: Real speech resumes — some talk mid-content 278 + 9. 272m: Baldemar — WObY04Q "furryli.st" ✓ (currently at 4:00:03=240m, should be ~272m) 279 + 10. 303.9m: Dame — 9qjDJZG "From Toilets to Moths" ✓ (currently at 4:32:04=272m, should be ~304m) 280 + 11. [break/hallucination 346-360m: ESO] 281 + 12. 360.8m: MC — "lightning rounds... first up Nick Perez with the design philosophy of BookHive" 282 + - q4QdXj7 "ATProto design philosophy behind BookHive" ✓ (currently ZERO DURATION at 5:03:54) 283 + 13. ~375m: dWGJ41y "Abstracting the AppView"? (one of the zero-duration talks) 284 + 14. 381.8m: 686gZde "Using GraphQL to build with ATProto" ✓ 285 + 15. 393.3m: "Jekard Magic" — ZjL74D0 "Jacquard Magic" ✓ (Orwal/"Jekard is the AT Proto library") 286 + 16. ~421m: Hilke — A7YLlpl "An artist dreaming in the Atmosphere" ✓ ("I'm here to dream about the future") 287 + 17. Stream ends ~454.8m 288 + 289 + **Corrections needed:** 290 + 1. VLXBbzJ start: Currently in "otter ai" hallucination. Actual Jim intro at 0.9m (54s). Fix start. 291 + 2. q4qlVLY + EkexvrN: In hallucination zone (141-237m). Keep approximate timestamps, note bad transcript. 292 + 3. WObY04Q: Move from 240m (14403s) to 272m (16326s) 293 + 4. 9qjDJZG: Move from 272m (16324s) to 304m (18234s) 294 + 5. q4QdXj7 (BookHive): Currently ZERO DURATION at 5:03:54. Fix: start ~361m (21648s) per MC intro 295 + 6. dWGJ41y (Abstracting AppView): Currently ZERO DURATION. Needs proper timestamps if on this stream. 296 + 7. 686gZde (GraphQL): Move from 5:03:54 to ~382m (22908s) 297 + 8. ZjL74D0 (Jacquard): Move from extrapolated 5:15:46 to ~393m (23598s) 298 + 9. A7YLlpl: Start at ~421m (25260s), end at ~454.8m (27288s) 299 + 10. All end times need recalculating. 300 + 301 + **New hallucination patterns:** 302 + - "Transcription by ESO Translation by —" (141m, 346m) 303 + - "© 2016 University of Georgia College of Agricultural and Environmental Sciences UGA Extension Office" (161m) 304 + - Random numbers: "3 13 25 29 30 31 32 33 34 35 36 37 38 39 41 43 54" (203m) 305 + - "0 0 0 0 0" (207m) 306 + 307 + **Observations:** 308 + - MC lightning talk intro is gold: "First up we have Nick Perez with the design philosophy of BookHive" — gives us exact talk-to-speaker mapping 309 + - "Jekard Magic" is Whisper's rendering of "Jacquard Magic" — phonetic matching could catch this 310 + - Zero-duration talks cluster where the boundary detection couldn't find a gap 311 + - Hallucination zones on PT D2 are shorter but more varied (5+ different patterns)
+733
docs/superpowers/plans/2026-04-12-boundary-detection-v7.md
··· 1 + # Boundary Detection v7 Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Replace transcript-gap-based boundary detection with a diarization-first pipeline that avoids hallucination zones and cross-track errors. 6 + 7 + **Architecture:** Three-stage pipeline — diarization segmentation builds talk-shaped segments from audio, transcript content matching identifies which talk each segment contains, schedule reconciliation fills gaps and outputs v6-compatible JSON. Reuses `phonetic.ts` and `db.ts` from existing codebase. 8 + 9 + **Tech Stack:** TypeScript, better-sqlite3 (existing DB), vitest (existing test runner) 10 + 11 + **Spec:** `docs/superpowers/specs/2026-04-12-boundary-detection-v7-design.md` 12 + **Verification notes:** `docs/alignment-verification-notes.md` 13 + 14 + --- 15 + 16 + ## File Structure 17 + 18 + | File | Responsibility | 19 + |------|---------------| 20 + | `src/detect-boundaries-v7.ts` | CLI entry point, orchestrates pipeline | 21 + | `src/v7/diarization-segmenter.ts` | Stage 1: parse diarization, build talk segments, detect hallucination | 22 + | `src/v7/transcript-matcher.ts` | Stage 2: extract identity signals, match segments to schedule | 23 + | `src/v7/schedule-reconciler.ts` | Stage 3: resolve conflicts, fill gaps, compute end times | 24 + | `src/v7/hallucination-detector.ts` | Detect known hallucination patterns in transcript | 25 + | `src/v7/types.ts` | Shared interfaces: TalkSegment, BoundaryMatch, HallucinationZone, etc. | 26 + | `src/v7/__tests__/diarization-segmenter.test.ts` | Tests for Stage 1 | 27 + | `src/v7/__tests__/transcript-matcher.test.ts` | Tests for Stage 2 | 28 + | `src/v7/__tests__/schedule-reconciler.test.ts` | Tests for Stage 3 | 29 + | `src/v7/__tests__/hallucination-detector.test.ts` | Tests for hallucination detection | 30 + 31 + Reused from existing code: 32 + - `src/phonetic.ts` — fuzzy speaker name matching 33 + - `src/db.ts` — SQLite access for schedule data 34 + - `src/tracks.ts` — `STREAM_MATCH` config, `STREAMS` config, `DAY_DATES` 35 + 36 + --- 37 + 38 + ## Chunk 1: Types + Hallucination Detection 39 + 40 + ### Task 1: Shared Types 41 + 42 + **Files:** 43 + - Create: `src/v7/types.ts` 44 + 45 + - [ ] **Step 1: Create type definitions** 46 + 47 + ```ts 48 + // src/v7/types.ts 49 + 50 + export interface DiarizationInput { 51 + speakers: string[]; 52 + segments: { start: number; end: number; speaker: string }[]; 53 + total_segments: number; 54 + } 55 + 56 + export interface TranscriptInput { 57 + stream: string; 58 + duration_seconds: number; 59 + words: { word: string; start: number; end: number; speaker?: string; confidence?: number }[]; 60 + } 61 + 62 + export interface TalkSegment { 63 + startS: number; 64 + endS: number; 65 + speakers: { id: string; durationS: number }[]; 66 + type: 'single-speaker' | 'panel' | 'unknown'; 67 + dominantSpeaker?: string; 68 + precedingGapS: number; 69 + hallucinationZone: boolean; 70 + } 71 + 72 + export interface HallucinationZone { 73 + startS: number; 74 + endS: number; 75 + pattern: string; 76 + } 77 + 78 + export interface ScheduleTalk { 79 + rkey: string; 80 + title: string; 81 + starts_at: string; 82 + ends_at: string; 83 + speaker_names: string; 84 + } 85 + 86 + export interface BoundaryMatch { 87 + rkey: string; 88 + title: string; 89 + startTimestamp: number; // seconds — v6 compat field name 90 + endTimestamp: number | null; 91 + confidence: 'high' | 'medium' | 'low' | 'unverifiable'; 92 + signals: string[]; 93 + panel: boolean; 94 + hallucinationZones: HallucinationZone[]; 95 + } 96 + 97 + export interface V7Output { 98 + stream: string; 99 + results: BoundaryMatch[]; 100 + hallucinationZones: HallucinationZone[]; 101 + unmatchedSegments: TalkSegment[]; 102 + unmatchedSchedule: string[]; 103 + } 104 + ``` 105 + 106 + - [ ] **Step 2: Commit** 107 + 108 + ```bash 109 + git add src/v7/types.ts 110 + git commit -m "feat(v7): shared type definitions for boundary detection pipeline" 111 + ``` 112 + 113 + ### Task 2: Hallucination Detector 114 + 115 + **Files:** 116 + - Create: `src/v7/hallucination-detector.ts` 117 + - Create: `src/v7/__tests__/hallucination-detector.test.ts` 118 + 119 + - [ ] **Step 1: Write failing tests** 120 + 121 + ```ts 122 + // src/v7/__tests__/hallucination-detector.test.ts 123 + import { describe, it, expect } from "vitest"; 124 + import { detectHallucinationZones } from "../hallucination-detector.js"; 125 + import type { TranscriptInput, DiarizationInput } from "../types.js"; 126 + 127 + describe("detectHallucinationZones", () => { 128 + it("detects CastingWords loops", () => { 129 + const words = Array.from({ length: 30 }, (_, i) => ({ 130 + word: ["Transcription", "by", "CastingWords"][i % 3], 131 + start: 100 + i * 0.5, 132 + end: 100 + i * 0.5 + 0.3, 133 + })); 134 + const zones = detectHallucinationZones( 135 + { stream: "test", duration_seconds: 200, words } as TranscriptInput, 136 + { speakers: [], segments: [], total_segments: 0 } // no diarization speech 137 + ); 138 + expect(zones.length).toBeGreaterThan(0); 139 + expect(zones[0].pattern).toContain("CastingWords"); 140 + }); 141 + 142 + it("detects numeric zero loops", () => { 143 + const words = Array.from({ length: 50 }, (_, i) => ({ 144 + word: "0", 145 + start: 200 + i * 0.2, 146 + end: 200 + i * 0.2 + 0.1, 147 + })); 148 + const zones = detectHallucinationZones( 149 + { stream: "test", duration_seconds: 300, words } as TranscriptInput, 150 + { speakers: [], segments: [], total_segments: 0 } 151 + ); 152 + expect(zones.length).toBeGreaterThan(0); 153 + expect(zones[0].pattern).toContain("zero"); 154 + }); 155 + 156 + it("detects diarization silence with transcript words", () => { 157 + // Transcript has words from 100-200s, but diarization has no segments there 158 + const words = Array.from({ length: 20 }, (_, i) => ({ 159 + word: "hello", 160 + start: 100 + i * 5, 161 + end: 100 + i * 5 + 1, 162 + })); 163 + const diarization: DiarizationInput = { 164 + speakers: ["SPEAKER_00"], 165 + segments: [ 166 + { start: 0, end: 90, speaker: "SPEAKER_00" }, 167 + { start: 210, end: 300, speaker: "SPEAKER_00" }, 168 + ], 169 + total_segments: 2, 170 + }; 171 + const zones = detectHallucinationZones( 172 + { stream: "test", duration_seconds: 300, words } as TranscriptInput, 173 + diarization 174 + ); 175 + expect(zones.length).toBeGreaterThan(0); 176 + expect(zones[0].startS).toBeLessThanOrEqual(100); 177 + expect(zones[0].endS).toBeGreaterThanOrEqual(195); 178 + }); 179 + 180 + it("does not flag real speech as hallucination", () => { 181 + const words = [ 182 + { word: "Hello", start: 10, end: 11 }, 183 + { word: "my", start: 11, end: 11.5 }, 184 + { word: "name", start: 11.5, end: 12 }, 185 + { word: "is", start: 12, end: 12.5 }, 186 + { word: "Justin", start: 12.5, end: 13 }, 187 + ]; 188 + const diarization: DiarizationInput = { 189 + speakers: ["SPEAKER_00"], 190 + segments: [{ start: 9, end: 14, speaker: "SPEAKER_00" }], 191 + total_segments: 1, 192 + }; 193 + const zones = detectHallucinationZones( 194 + { stream: "test", duration_seconds: 30, words } as TranscriptInput, 195 + diarization 196 + ); 197 + expect(zones.length).toBe(0); 198 + }); 199 + }); 200 + ``` 201 + 202 + - [ ] **Step 2: Run tests to verify they fail** 203 + 204 + ```bash 205 + cd apps/ionosphere-appview && npx vitest run src/v7/__tests__/hallucination-detector.test.ts 206 + ``` 207 + 208 + Expected: FAIL — module not found 209 + 210 + - [ ] **Step 3: Implement hallucination detector** 211 + 212 + ```ts 213 + // src/v7/hallucination-detector.ts 214 + import type { TranscriptInput, DiarizationInput, HallucinationZone } from "./types.js"; 215 + 216 + /** Known repeating hallucination phrases. Each entry: [pattern words, label]. */ 217 + const HALLUCINATION_PATTERNS: [string[], string][] = [ 218 + [["Transcription", "by", "CastingWords"], "CastingWords loop"], 219 + [["Transcribed", "by", "https://otter"], "otter.ai loop"], 220 + [["Transcription", "by", "ESO"], "ESO Translation loop"], 221 + [["Microsoft", "Office", "Word", "Document"], "MSWord loop"], 222 + [["Transcripts", "provided", "by", "Transcription", "Outsourcing"], "Transcription Outsourcing loop"], 223 + [["UGA", "Extension", "Office"], "UGA Extension loop"], 224 + [["Thank", "you", "for", "watching"], "Thank you for watching loop"], 225 + [["Subs", "by", "www"], "subtitle attribution loop"], 226 + [["www", "fema", "gov"], "fema.gov loop"], 227 + ]; 228 + 229 + /** Minimum consecutive zeros to flag as hallucination. */ 230 + const ZERO_LOOP_THRESHOLD = 20; 231 + 232 + /** Minimum repetitions of a phrase to flag as hallucination loop. */ 233 + const PHRASE_REPEAT_THRESHOLD = 3; 234 + 235 + /** 236 + * Detect hallucination zones using two methods: 237 + * 1. Pattern matching: known repeating phrases in transcript 238 + * 2. Silence mismatch: diarization shows no speech but transcript has words 239 + */ 240 + export function detectHallucinationZones( 241 + transcript: TranscriptInput, 242 + diarization: DiarizationInput, 243 + ): HallucinationZone[] { 244 + const zones: HallucinationZone[] = []; 245 + 246 + // Method 1: Known phrase patterns 247 + zones.push(...detectPhrasePatterns(transcript)); 248 + 249 + // Method 2: Numeric zero loops 250 + zones.push(...detectZeroLoops(transcript)); 251 + 252 + // Method 3: Diarization silence with transcript words 253 + zones.push(...detectSilenceMismatch(transcript, diarization)); 254 + 255 + // Merge overlapping zones 256 + return mergeZones(zones); 257 + } 258 + 259 + function detectPhrasePatterns(transcript: TranscriptInput): HallucinationZone[] { 260 + const zones: HallucinationZone[] = []; 261 + const words = transcript.words; 262 + 263 + for (const [pattern, label] of HALLUCINATION_PATTERNS) { 264 + let matchCount = 0; 265 + let firstMatchStart: number | null = null; 266 + let lastMatchEnd: number | null = null; 267 + 268 + for (let i = 0; i <= words.length - pattern.length; i++) { 269 + const matches = pattern.every((p, j) => words[i + j].word === p); 270 + if (matches) { 271 + matchCount++; 272 + if (firstMatchStart === null) firstMatchStart = words[i].start; 273 + lastMatchEnd = words[i + pattern.length - 1].end; 274 + } else if (matchCount >= PHRASE_REPEAT_THRESHOLD && firstMatchStart !== null && lastMatchEnd !== null) { 275 + zones.push({ startS: firstMatchStart, endS: lastMatchEnd, pattern: label }); 276 + matchCount = 0; 277 + firstMatchStart = null; 278 + lastMatchEnd = null; 279 + } 280 + } 281 + 282 + if (matchCount >= PHRASE_REPEAT_THRESHOLD && firstMatchStart !== null && lastMatchEnd !== null) { 283 + zones.push({ startS: firstMatchStart, endS: lastMatchEnd, pattern: label }); 284 + } 285 + } 286 + 287 + return zones; 288 + } 289 + 290 + function detectZeroLoops(transcript: TranscriptInput): HallucinationZone[] { 291 + const zones: HallucinationZone[] = []; 292 + let runStart: number | null = null; 293 + let runCount = 0; 294 + 295 + for (const w of transcript.words) { 296 + if (w.word === "0") { 297 + if (runStart === null) runStart = w.start; 298 + runCount++; 299 + } else { 300 + if (runCount >= ZERO_LOOP_THRESHOLD && runStart !== null) { 301 + zones.push({ startS: runStart, endS: w.start, pattern: "numeric zeros" }); 302 + } 303 + runStart = null; 304 + runCount = 0; 305 + } 306 + } 307 + 308 + if (runCount >= ZERO_LOOP_THRESHOLD && runStart !== null) { 309 + const last = transcript.words[transcript.words.length - 1]; 310 + zones.push({ startS: runStart, endS: last.end, pattern: "numeric zeros" }); 311 + } 312 + 313 + return zones; 314 + } 315 + 316 + function detectSilenceMismatch( 317 + transcript: TranscriptInput, 318 + diarization: DiarizationInput, 319 + ): HallucinationZone[] { 320 + if (diarization.segments.length === 0) return []; 321 + 322 + const zones: HallucinationZone[] = []; 323 + 324 + // Find diarization silence gaps > 60s 325 + const sortedSegs = [...diarization.segments].sort((a, b) => a.start - b.start); 326 + const silenceGaps: { start: number; end: number }[] = []; 327 + 328 + for (let i = 1; i < sortedSegs.length; i++) { 329 + const gap = sortedSegs[i].start - sortedSegs[i - 1].end; 330 + if (gap > 60) { 331 + silenceGaps.push({ start: sortedSegs[i - 1].end, end: sortedSegs[i].start }); 332 + } 333 + } 334 + 335 + // Check if transcript has words during these silence gaps 336 + for (const gap of silenceGaps) { 337 + const wordsInGap = transcript.words.filter( 338 + (w) => w.start >= gap.start && w.end <= gap.end 339 + ); 340 + if (wordsInGap.length > 10) { 341 + zones.push({ 342 + startS: gap.start, 343 + endS: gap.end, 344 + pattern: "diarization silence with transcript words", 345 + }); 346 + } 347 + } 348 + 349 + return zones; 350 + } 351 + 352 + function mergeZones(zones: HallucinationZone[]): HallucinationZone[] { 353 + if (zones.length === 0) return []; 354 + const sorted = [...zones].sort((a, b) => a.startS - b.startS); 355 + const merged: HallucinationZone[] = [sorted[0]]; 356 + 357 + for (let i = 1; i < sorted.length; i++) { 358 + const prev = merged[merged.length - 1]; 359 + const curr = sorted[i]; 360 + // Merge if overlapping or within 60s 361 + if (curr.startS <= prev.endS + 60) { 362 + prev.endS = Math.max(prev.endS, curr.endS); 363 + if (!prev.pattern.includes(curr.pattern)) { 364 + prev.pattern += " + " + curr.pattern; 365 + } 366 + } else { 367 + merged.push({ ...curr }); 368 + } 369 + } 370 + 371 + return merged; 372 + } 373 + ``` 374 + 375 + - [ ] **Step 4: Run tests to verify they pass** 376 + 377 + ```bash 378 + cd apps/ionosphere-appview && npx vitest run src/v7/__tests__/hallucination-detector.test.ts 379 + ``` 380 + 381 + Expected: PASS (all 4 tests) 382 + 383 + - [ ] **Step 5: Commit** 384 + 385 + ```bash 386 + git add src/v7/ 387 + git commit -m "feat(v7): hallucination detector with pattern + diarization silence detection" 388 + ``` 389 + 390 + --- 391 + 392 + ## Chunk 2: Diarization Segmenter (Stage 1) 393 + 394 + ### Task 3: Diarization Segmenter 395 + 396 + **Files:** 397 + - Create: `src/v7/diarization-segmenter.ts` 398 + - Create: `src/v7/__tests__/diarization-segmenter.test.ts` 399 + 400 + - [ ] **Step 1: Write failing tests** 401 + 402 + ```ts 403 + // src/v7/__tests__/diarization-segmenter.test.ts 404 + import { describe, it, expect } from "vitest"; 405 + import { segmentDiarization } from "../diarization-segmenter.js"; 406 + import type { DiarizationInput, HallucinationZone } from "../types.js"; 407 + 408 + function makeDiarization(segments: { start: number; end: number; speaker: string }[]): DiarizationInput { 409 + const speakers = [...new Set(segments.map(s => s.speaker))]; 410 + return { speakers, segments, total_segments: segments.length }; 411 + } 412 + 413 + describe("segmentDiarization", () => { 414 + it("creates single-speaker segments from continuous speech with gaps", () => { 415 + const diar = makeDiarization([ 416 + { start: 0, end: 1800, speaker: "SPEAKER_00" }, // Talk 1: 0-30m 417 + { start: 1860, end: 3600, speaker: "SPEAKER_01" }, // Talk 2: 31-60m (60s gap) 418 + ]); 419 + const result = segmentDiarization(diar, []); 420 + expect(result.length).toBe(2); 421 + expect(result[0].dominantSpeaker).toBe("SPEAKER_00"); 422 + expect(result[1].dominantSpeaker).toBe("SPEAKER_01"); 423 + expect(result[1].precedingGapS).toBeCloseTo(60, 0); 424 + }); 425 + 426 + it("detects session breaks at gaps > 60s", () => { 427 + const diar = makeDiarization([ 428 + { start: 0, end: 1800, speaker: "SPEAKER_00" }, 429 + // 76-minute gap (break) 430 + { start: 6360, end: 8000, speaker: "SPEAKER_01" }, 431 + ]); 432 + const result = segmentDiarization(diar, []); 433 + expect(result.length).toBe(2); 434 + expect(result[1].precedingGapS).toBeGreaterThan(4000); 435 + }); 436 + 437 + it("identifies panels (multiple balanced speakers)", () => { 438 + const diar = makeDiarization([ 439 + { start: 0, end: 600, speaker: "SPEAKER_00" }, 440 + { start: 600, end: 1200, speaker: "SPEAKER_01" }, 441 + { start: 1200, end: 1800, speaker: "SPEAKER_02" }, 442 + { start: 1800, end: 2400, speaker: "SPEAKER_00" }, 443 + ]); 444 + const result = segmentDiarization(diar, []); 445 + expect(result.length).toBe(1); 446 + expect(result[0].type).toBe("panel"); 447 + expect(result[0].speakers.length).toBe(3); 448 + }); 449 + 450 + it("marks segments overlapping hallucination zones", () => { 451 + const diar = makeDiarization([ 452 + { start: 0, end: 1800, speaker: "SPEAKER_00" }, 453 + // no diarization segments from 1800-5000 (hallucination zone) 454 + { start: 5000, end: 7000, speaker: "SPEAKER_01" }, 455 + ]); 456 + const hallucinationZones: HallucinationZone[] = [ 457 + { startS: 1800, endS: 5000, pattern: "CastingWords loop" }, 458 + ]; 459 + const result = segmentDiarization(diar, hallucinationZones); 460 + // Should not create a segment in the hallucination zone 461 + expect(result.length).toBe(2); 462 + expect(result[0].hallucinationZone).toBe(false); 463 + expect(result[1].hallucinationZone).toBe(false); 464 + }); 465 + 466 + it("splits talk boundaries at 30-60s gaps with speaker changes", () => { 467 + const diar = makeDiarization([ 468 + { start: 0, end: 1500, speaker: "SPEAKER_00" }, 469 + // 45s gap + speaker change 470 + { start: 1545, end: 3000, speaker: "SPEAKER_01" }, 471 + ]); 472 + const result = segmentDiarization(diar, []); 473 + expect(result.length).toBe(2); 474 + expect(result[0].type).toBe("single-speaker"); 475 + expect(result[1].type).toBe("single-speaker"); 476 + }); 477 + }); 478 + ``` 479 + 480 + - [ ] **Step 2: Run tests to verify they fail** 481 + 482 + ```bash 483 + cd apps/ionosphere-appview && npx vitest run src/v7/__tests__/diarization-segmenter.test.ts 484 + ``` 485 + 486 + - [ ] **Step 3: Implement diarization segmenter** 487 + 488 + The segmenter should: 489 + 1. Sort diarization segments by start time 490 + 2. Merge same-speaker segments with < 5s gaps into speech blocks 491 + 3. Find gaps between blocks, classifying as break (>60s), boundary (30-60s + speaker change), or pause (<30s) 492 + 4. Group blocks between breaks into sessions 493 + 5. Within each session, group blocks between boundary gaps into talk segments 494 + 6. For each talk segment, compute speaker distribution and classify as single-speaker (one speaker > 70%) or panel 495 + 7. Mark any segment that overlaps a hallucination zone 496 + 497 + Implementation in `src/v7/diarization-segmenter.ts`. Core function signature: 498 + 499 + ```ts 500 + export function segmentDiarization( 501 + diarization: DiarizationInput, 502 + hallucinationZones: HallucinationZone[], 503 + ): TalkSegment[] 504 + ``` 505 + 506 + - [ ] **Step 4: Run tests to verify they pass** 507 + 508 + ```bash 509 + cd apps/ionosphere-appview && npx vitest run src/v7/__tests__/diarization-segmenter.test.ts 510 + ``` 511 + 512 + - [ ] **Step 5: Commit** 513 + 514 + ```bash 515 + git add src/v7/ 516 + git commit -m "feat(v7): diarization segmenter — stage 1 of pipeline" 517 + ``` 518 + 519 + --- 520 + 521 + ## Chunk 3: Transcript Matcher (Stage 2) 522 + 523 + ### Task 4: Transcript Matcher 524 + 525 + **Files:** 526 + - Create: `src/v7/transcript-matcher.ts` 527 + - Create: `src/v7/__tests__/transcript-matcher.test.ts` 528 + 529 + - [ ] **Step 1: Write failing tests** 530 + 531 + ```ts 532 + // src/v7/__tests__/transcript-matcher.test.ts 533 + import { describe, it, expect } from "vitest"; 534 + import { extractSignals, matchSegmentToSchedule } from "../transcript-matcher.js"; 535 + import type { TranscriptInput, TalkSegment, ScheduleTalk } from "../types.js"; 536 + 537 + describe("extractSignals", () => { 538 + it("finds self-introductions", () => { 539 + const words = "Hello my name is Justin Bank I am a journalist".split(" ").map((w, i) => ({ 540 + word: w, start: 100 + i, end: 101 + i, 541 + })); 542 + const signals = extractSignals({ words } as TranscriptInput, 99, 115); 543 + expect(signals).toContainEqual(expect.objectContaining({ type: "self-intro" })); 544 + expect(signals.find(s => s.type === "self-intro")?.name).toContain("Justin"); 545 + }); 546 + 547 + it("finds MC handoffs", () => { 548 + const words = "please welcome Justin for his talk".split(" ").map((w, i) => ({ 549 + word: w, start: 90 + i, end: 91 + i, 550 + })); 551 + const signals = extractSignals({ words } as TranscriptInput, 89, 100); 552 + expect(signals).toContainEqual(expect.objectContaining({ type: "mc-handoff" })); 553 + }); 554 + 555 + it("extracts topic keywords", () => { 556 + const words = "I will talk about sovereign media and how publishers can".split(" ").map((w, i) => ({ 557 + word: w, start: 100 + i, end: 101 + i, 558 + })); 559 + const signals = extractSignals({ words } as TranscriptInput, 99, 115); 560 + expect(signals).toContainEqual(expect.objectContaining({ type: "topic" })); 561 + }); 562 + }); 563 + 564 + describe("matchSegmentToSchedule", () => { 565 + const schedule: ScheduleTalk[] = [ 566 + { rkey: "talk1", title: "Sovereign Media Economics", starts_at: "2026-03-28T17:30:00Z", ends_at: "2026-03-28T18:00:00Z", speaker_names: "Natalie Mullins" }, 567 + { rkey: "talk2", title: "AI in the Atmosphere", starts_at: "2026-03-28T18:00:00Z", ends_at: "2026-03-28T18:30:00Z", speaker_names: "Cameron Stream" }, 568 + ]; 569 + 570 + it("matches by speaker name + topic", () => { 571 + const signals = [ 572 + { type: "self-intro" as const, name: "Natalie" }, 573 + { type: "topic" as const, keywords: ["sovereign", "media"] }, 574 + ]; 575 + const match = matchSegmentToSchedule(signals, schedule); 576 + expect(match?.rkey).toBe("talk1"); 577 + expect(match?.confidence).toBe("high"); 578 + }); 579 + 580 + it("returns medium confidence for speaker-only match", () => { 581 + const signals = [{ type: "self-intro" as const, name: "Cameron" }]; 582 + const match = matchSegmentToSchedule(signals, schedule); 583 + expect(match?.rkey).toBe("talk2"); 584 + expect(match?.confidence).toBe("medium"); 585 + }); 586 + 587 + it("returns null for no match", () => { 588 + const signals = [{ type: "self-intro" as const, name: "Unknown Person" }]; 589 + const match = matchSegmentToSchedule(signals, schedule); 590 + expect(match).toBeNull(); 591 + }); 592 + }); 593 + ``` 594 + 595 + - [ ] **Step 2: Run tests to verify they fail** 596 + 597 + ```bash 598 + cd apps/ionosphere-appview && npx vitest run src/v7/__tests__/transcript-matcher.test.ts 599 + ``` 600 + 601 + - [ ] **Step 3: Implement transcript matcher** 602 + 603 + Core functions: 604 + - `extractSignals(transcript, startS, endS)` — scan transcript words in range for self-intros, MC handoffs, topic keywords 605 + - `matchSegmentToSchedule(signals, schedule)` — fuzzy match signals against schedule using `phoneticCode` from `../phonetic.js` 606 + - `matchAllSegments(segments, transcript, schedule, hallucinationZones)` — orchestrate matching for all segments 607 + 608 + Speaker name matching should use phonetic codes for fuzzy matching (handles "Jekard"/"Jacquard", "Wardmuller"/"Werdmuller"). Topic matching should tokenize talk titles and look for 2+ matching keywords in the first 2 minutes of transcript. 609 + 610 + - [ ] **Step 4: Run tests to verify they pass** 611 + 612 + ```bash 613 + cd apps/ionosphere-appview && npx vitest run src/v7/__tests__/transcript-matcher.test.ts 614 + ``` 615 + 616 + - [ ] **Step 5: Commit** 617 + 618 + ```bash 619 + git add src/v7/ 620 + git commit -m "feat(v7): transcript matcher — stage 2 of pipeline" 621 + ``` 622 + 623 + --- 624 + 625 + ## Chunk 4: Schedule Reconciler (Stage 3) + CLI 626 + 627 + ### Task 5: Schedule Reconciler 628 + 629 + **Files:** 630 + - Create: `src/v7/schedule-reconciler.ts` 631 + - Create: `src/v7/__tests__/schedule-reconciler.test.ts` 632 + 633 + - [ ] **Step 1: Write failing tests** 634 + 635 + Tests should cover: 636 + - Duplicate assignment resolution (same rkey matched to multiple segments → keep highest confidence) 637 + - Unmatched schedule entries in hallucination zones → `unverifiable` 638 + - Unmatched segments → `unmatchedSegments` output 639 + - End time calculation: next talk start minus gap, or diarization silence onset 640 + - Last talk in session: ends at last speech, not next session start 641 + 642 + - [ ] **Step 2: Run tests, verify fail** 643 + - [ ] **Step 3: Implement reconciler** 644 + 645 + Core function: 646 + ```ts 647 + export function reconcileSchedule( 648 + matches: BoundaryMatch[], 649 + segments: TalkSegment[], 650 + schedule: ScheduleTalk[], 651 + hallucinationZones: HallucinationZone[], 652 + streamDurationS: number, 653 + ): V7Output 654 + ``` 655 + 656 + - [ ] **Step 4: Run tests, verify pass** 657 + - [ ] **Step 5: Commit** 658 + 659 + ```bash 660 + git add src/v7/ 661 + git commit -m "feat(v7): schedule reconciler — stage 3 of pipeline" 662 + ``` 663 + 664 + ### Task 6: CLI Entry Point 665 + 666 + **Files:** 667 + - Create: `src/detect-boundaries-v7.ts` 668 + 669 + - [ ] **Step 1: Implement CLI** 670 + 671 + Wire together all three stages: 672 + 1. Parse args: `<transcript.json> --diarization <diarization.json> --stream-slug <slug>` 673 + 2. Load transcript JSON and diarization JSON 674 + 3. Load schedule from DB using `STREAM_MATCH[slug]` (reuse pattern from `tracks.ts`) 675 + 4. Run pipeline: `detectHallucinationZones` → `segmentDiarization` → `matchAllSegments` → `reconcileSchedule` 676 + 5. Print summary table (like v6) 677 + 6. Write output to `<transcript>-boundaries-v7.json` 678 + 679 + - [ ] **Step 2: Test manually against one stream** 680 + 681 + ```bash 682 + cd apps/ionosphere-appview 683 + npx tsx src/detect-boundaries-v7.ts \ 684 + data/fullday/ATScience/transcript-enriched.json \ 685 + --diarization data/fullday/ATScience/diarization.json \ 686 + --stream-slug atscience 687 + ``` 688 + 689 + Compare output against manually verified ground truth from April 12 audit. 690 + 691 + - [ ] **Step 3: Commit** 692 + 693 + ```bash 694 + git add src/detect-boundaries-v7.ts 695 + git commit -m "feat(v7): CLI entry point for boundary detection pipeline" 696 + ``` 697 + 698 + --- 699 + 700 + ## Chunk 5: Validation Against Ground Truth 701 + 702 + ### Task 7: Run Against All 7 Streams 703 + 704 + - [ ] **Step 1: Run v7 on all streams** 705 + 706 + ```bash 707 + cd apps/ionosphere-appview 708 + for dir in ATScience Great_Hall___Day_1 Great_Hall___Day_2 Room_2301___Day_1 Room_2301___Day_2 Performance_Theater___Day_1 Performance_Theater___Day_2; do 709 + slug=$(echo "$dir" | sed 's/Great_Hall___Day_1/great-hall-day-1/' | sed 's/Great_Hall___Day_2/great-hall-day-2/' | sed 's/Room_2301___Day_1/room-2301-day-1/' | sed 's/Room_2301___Day_2/room-2301-day-2/' | sed 's/Performance_Theater___Day_1/performance-theatre-day-1/' | sed 's/Performance_Theater___Day_2/performance-theatre-day-2/' | sed 's/ATScience/atscience/') 710 + echo "=== $slug ===" 711 + npx tsx src/detect-boundaries-v7.ts \ 712 + "data/fullday/$dir/transcript-enriched.json" \ 713 + --diarization "data/fullday/$dir/diarization.json" \ 714 + --stream-slug "$slug" 715 + echo 716 + done 717 + ``` 718 + 719 + - [ ] **Step 2: Compare against current DB state (ground truth)** 720 + 721 + Write a quick comparison script that loads the v7 output and the current DB video_segments, computing: 722 + - Talks matched correctly (same rkey, start within 60s) 723 + - Talks missed by v7 724 + - Talks v7 found that aren't in ground truth 725 + - Average start time error for matched talks 726 + 727 + - [ ] **Step 3: Fix any systematic issues found** 728 + - [ ] **Step 4: Commit final version** 729 + 730 + ```bash 731 + git add -A 732 + git commit -m "feat(v7): boundary detection v7 complete — diarization-first pipeline" 733 + ```
+216
docs/superpowers/plans/2026-04-12-retranscribe-hallucinations.md
··· 1 + # Re-transcribe Hallucination Zones Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Re-transcribe Whisper hallucination zones using diarization-aligned chunk boundaries, splicing clean results back into `transcript-enriched.json`. 6 + 7 + **Architecture:** Single CLI tool that reads v7 boundary output for hallucination zones, extracts audio from HLS for each zone, re-transcribes with OpenAI Whisper API using diarization-derived chunk points, and patches the transcript in-place. 8 + 9 + **Tech Stack:** TypeScript, OpenAI Whisper API, ffmpeg (audio extraction), existing `transcript-enriched.json` format 10 + 11 + **Spec:** `docs/superpowers/specs/2026-04-12-retranscribe-hallucinations-design.md` 12 + 13 + --- 14 + 15 + ## File Structure 16 + 17 + | File | Responsibility | 18 + |------|---------------| 19 + | `src/retranscribe-hallucinations.ts` | CLI tool: parse args, orchestrate extraction/transcription/splicing | 20 + 21 + Reused from existing code: 22 + - `src/transcribe-fullday.ts` — reference for `extractChunk`, `transcribeChunk` patterns (copy/adapt, don't import — that file has hardcoded stream configs) 23 + - `src/v7/types.ts` — `HallucinationZone`, `DiarizationInput` 24 + - `src/tracks.ts` — `STREAMS` config for stream URIs 25 + 26 + --- 27 + 28 + ## Chunk 1: The Tool 29 + 30 + ### Task 1: retranscribe-hallucinations.ts 31 + 32 + **Files:** 33 + - Create: `apps/ionosphere-appview/src/retranscribe-hallucinations.ts` 34 + 35 + - [ ] **Step 1: Implement the CLI tool** 36 + 37 + The tool needs these parts: 38 + 39 + **CLI arg parsing:** 40 + ``` 41 + npx tsx src/retranscribe-hallucinations.ts \ 42 + --stream-slug <slug> \ 43 + --boundaries <path-to-v7-boundaries.json> \ 44 + --diarization <path-to-diarization.json> 45 + ``` 46 + 47 + Also needs `OPENAI_API_KEY` from environment (load via `./env.js` like existing code). 48 + 49 + **Core logic:** 50 + 51 + ```ts 52 + import "./env.js"; 53 + import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; 54 + import { execSync } from "node:child_process"; 55 + import path from "node:path"; 56 + import OpenAI from "openai"; 57 + import type { HallucinationZone, DiarizationInput } from "./v7/types.js"; 58 + ``` 59 + 60 + 1. **Load inputs:** 61 + - Parse v7 boundaries JSON → extract `hallucinationZones` array 62 + - Load diarization JSON 63 + - Load existing `transcript-enriched.json` from the stream's fullday dir 64 + - Get stream URI from `STREAMS` config (import from tracks.ts or inline) 65 + 66 + 2. **For each hallucination zone:** 67 + - Find diarization segments that overlap the zone 68 + - If no diarization speech → skip (log "no speech in zone, skipping") 69 + - Compute chunk boundaries from diarization: 70 + - Start at first speech segment onset within zone 71 + - End at last speech segment offset within zone 72 + - If total duration > 20 min (Whisper's practical limit at 32kbps), split at diarization gaps > 5s 73 + - Extract audio for each chunk via ffmpeg: 74 + ``` 75 + ffmpeg -ss <startS> -i "<playlistUrl>" -t <durationS> -vn -acodec libmp3lame -ar 16000 -ac 1 -b:a 32k "<tmpFile>" -y 76 + ``` 77 + Use a temp directory: `<streamDir>/retranscribe-chunks/` 78 + - Transcribe each chunk via OpenAI Whisper API (same params as `transcribe-fullday.ts`): 79 + ```ts 80 + const response = await client.audio.transcriptions.create({ 81 + model: "whisper-1", 82 + file: createReadStream(chunkPath), 83 + response_format: "verbose_json", 84 + timestamp_granularities: ["word"], 85 + }); 86 + ``` 87 + - Adjust word timestamps: Whisper returns timestamps relative to chunk start, so add the chunk's absolute start time: `word.start += chunkStartS; word.end += chunkStartS;` 88 + 89 + 3. **Splice into transcript:** 90 + - Load `transcript-enriched.json` 91 + - For each re-transcribed zone: 92 + - Remove all words where `word.start >= zone.startS && word.start <= zone.endS` 93 + - Insert new words 94 + - Sort all words by start time 95 + - Update `total_words` 96 + - Write back to `transcript-enriched.json` 97 + - Also back up original as `transcript-enriched.json.bak` before first write 98 + 99 + **Console output:** 100 + ``` 101 + === Re-transcribing hallucination zones for room-2301-day-2 === 102 + Loaded 9 hallucination zones 103 + Zone 1: 96.0m - 209.0m (113.0m) 104 + Diarization speech: 5 segments, 12.3m total 105 + Chunks: 1 (12.3m) 106 + Extracting audio... done 107 + Transcribing chunk 1/1... 847 words 108 + Zone 2: ... 109 + ... 110 + Splicing 2,341 new words into transcript 111 + Removed 8,542 hallucinated words 112 + Wrote transcript-enriched.json (backup: .bak) 113 + ``` 114 + 115 + **Key references in existing code:** 116 + - `transcribe-fullday.ts:64-76` — `extractChunk` function (ffmpeg command) 117 + - `transcribe-fullday.ts:81-98` — `transcribeChunk` function (OpenAI API call) 118 + - `transcribe-fullday.ts:38-41` — `FULLDAY_STREAMS` for stream URIs 119 + - `tracks.ts:27-34` — `STREAMS` config with URIs and dir names 120 + 121 + **Stream URI → playlist URL:** 122 + ```ts 123 + const VOD_ENDPOINT = "https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist"; 124 + const playlistUrl = `${VOD_ENDPOINT}?uri=${encodeURIComponent(streamUri)}`; 125 + ``` 126 + 127 + **STREAMS config for slug → URI + dirName mapping** (from tracks.ts): 128 + ```ts 129 + const STREAMS = [ 130 + { slug: "great-hall-day-1", uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadw52j22", dirName: "Great_Hall___Day_1" }, 131 + { slug: "great-hall-day-2", uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miighlz53o22", dirName: "Great_Hall___Day_2" }, 132 + { slug: "room-2301-day-1", uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadx2dj22", dirName: "Room_2301___Day_1" }, 133 + { slug: "room-2301-day-2", uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadxeqn22", dirName: "Room_2301___Day_2" }, 134 + { slug: "performance-theatre-day-1", uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadwgvz22", dirName: "Performance_Theater___Day_1" }, 135 + { slug: "performance-theatre-day-2", uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadwqgy22", dirName: "Performance_Theater___Day_2" }, 136 + { slug: "atscience", uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadvruo22", dirName: "ATScience" }, 137 + ]; 138 + ``` 139 + 140 + - [ ] **Step 2: Test on a small hallucination zone first** 141 + 142 + Pick ATScience (Welsh hallucination, only 16 min zone) as a test case: 143 + 144 + ```bash 145 + cd apps/ionosphere-appview 146 + source .env && export OPENAI_API_KEY 147 + 148 + # First generate v7 boundaries if not already present 149 + npx tsx src/detect-boundaries-v7.ts \ 150 + data/fullday/ATScience/transcript-enriched.json \ 151 + --diarization data/fullday/ATScience/diarization.json \ 152 + --stream-slug atscience 153 + 154 + # Then re-transcribe 155 + npx tsx src/retranscribe-hallucinations.ts \ 156 + --stream-slug atscience \ 157 + --boundaries data/fullday/ATScience/transcript-enriched-boundaries-v7.json \ 158 + --diarization data/fullday/ATScience/diarization.json 159 + ``` 160 + 161 + Verify: 162 + - Check that `transcript-enriched.json.bak` was created 163 + - Compare word count before and after 164 + - Spot-check the re-transcribed zone: are the Welsh hallucinations gone? Is there English content now? 165 + 166 + - [ ] **Step 3: Commit** 167 + 168 + ```bash 169 + git add src/retranscribe-hallucinations.ts 170 + git commit -m "feat: retranscribe hallucination zones using diarization-aligned chunks" 171 + ``` 172 + 173 + ### Task 2: Run on All Streams 174 + 175 + - [ ] **Step 1: Generate v7 boundaries for all streams** (if not already done) 176 + 177 + - [ ] **Step 2: Re-transcribe each stream** 178 + 179 + ```bash 180 + cd apps/ionosphere-appview 181 + source .env && export OPENAI_API_KEY 182 + 183 + for slug in atscience great-hall-day-1 great-hall-day-2 room-2301-day-1 room-2301-day-2 performance-theatre-day-1 performance-theatre-day-2; do 184 + dir=$(echo "$slug" | sed 's/great-hall-day-1/Great_Hall___Day_1/' | sed 's/great-hall-day-2/Great_Hall___Day_2/' | sed 's/room-2301-day-1/Room_2301___Day_1/' | sed 's/room-2301-day-2/Room_2301___Day_2/' | sed 's/performance-theatre-day-1/Performance_Theater___Day_1/' | sed 's/performance-theatre-day-2/Performance_Theater___Day_2/' | sed 's/atscience/ATScience/') 185 + echo "=== $slug ===" 186 + npx tsx src/retranscribe-hallucinations.ts \ 187 + --stream-slug "$slug" \ 188 + --boundaries "data/fullday/$dir/transcript-enriched-boundaries-v7.json" \ 189 + --diarization "data/fullday/$dir/diarization.json" 190 + echo 191 + done 192 + ``` 193 + 194 + - [ ] **Step 3: Re-run v7 detection on updated transcripts** 195 + 196 + Run v7 again with the patched transcripts to see if match accuracy improves: 197 + 198 + ```bash 199 + for slug in atscience great-hall-day-1 great-hall-day-2 room-2301-day-1 room-2301-day-2 performance-theatre-day-1 performance-theatre-day-2; do 200 + dir=$(...) 201 + echo "=== $slug ===" 202 + npx tsx src/detect-boundaries-v7.ts \ 203 + "data/fullday/$dir/transcript-enriched.json" \ 204 + --diarization "data/fullday/$dir/diarization.json" \ 205 + --stream-slug "$slug" 2>&1 | grep -E "^(Results|Unmatched sch)" 206 + done 207 + ``` 208 + 209 + Expected: match accuracy improves from 90% toward 95%+ as previously-unverifiable talks become matchable. 210 + 211 + - [ ] **Step 4: Commit updated transcripts** 212 + 213 + ```bash 214 + git add -A 215 + git commit -m "data: re-transcribed hallucination zones across all 7 streams" 216 + ```
+159
docs/superpowers/specs/2026-04-12-boundary-detection-v7-design.md
··· 1 + # Boundary Detection v7 — Diarization-First Pipeline 2 + 3 + **Date**: 2026-04-12 4 + **Status**: Approved 5 + **File**: `apps/ionosphere-appview/src/detect-boundaries-v7.ts` 6 + 7 + ## Problem 8 + 9 + The v6 boundary detector uses transcript gaps and Whisper speaker labels as primary signals. During manual verification of all 7 streams, we found this approach fails badly in hallucination zones (Whisper fills silence with repeating text like "Transcription by CastingWords", "0 0 0 0", song lyrics) and causes cross-track assignment errors when matching by schedule time + room. 10 + 11 + Key finding: **diarization data tracks actual audio** and shows real speech boundaries even through hallucination zones. A 94-minute silence gap on PT D2 was completely invisible in the transcript but obvious in diarization. 12 + 13 + ## Approach 14 + 15 + **Diarization first, transcript second.** Build a timeline of talk-shaped segments from diarization, then use transcript content to identify which talk each segment contains. Schedule provides candidate talks but never dictates timing. 16 + 17 + ## Pipeline 18 + 19 + ``` 20 + Diarization JSON + Transcript JSON + Schedule (DB) 21 + 22 + Stage 1: Diarization Segmentation 23 + 24 + Stage 2: Transcript Content Matching 25 + 26 + Stage 3: Schedule Reconciliation 27 + 28 + Boundary JSON (v6-compatible format) 29 + ``` 30 + 31 + ## Stage 1: Diarization Segmentation 32 + 33 + **Input**: `diarization.json` (`{segments: [{start, end, speaker}], speakers: []}`) 34 + 35 + **Process**: 36 + - Merge adjacent same-speaker segments with < 5s gaps into speech blocks 37 + - Classify gaps between blocks: 38 + - \> 60s = **session break** 39 + - 30-60s = **likely talk boundary** 40 + - < 30s = **within-talk pause** 41 + - Group blocks between session breaks into sessions 42 + - Within each session, classify by speaker distribution: 43 + - One speaker > 70% duration = **single-speaker talk** 44 + - Multiple speakers with balanced time = **panel** 45 + - **Hallucination detection**: Where diarization shows silence but transcript has words, mark as hallucination zone. Also detect known patterns: 46 + - Repeating phrases in ~30s loops ("Transcription by CastingWords", "Transcribed by https://otter.ai") 47 + - Numeric zeros ("0 0 0 0 0") 48 + - "Microsoft Office Word Document MSWordDoc" 49 + - "Transcription by ESO Translation by --" 50 + - "UGA Extension Office of Communications and Creative Services" 51 + - Non-English loops (Welsh "Rwy n gobeithio...") 52 + - Song lyrics between known gaps (DJ music on GH D2) 53 + - URLs/attribution ("Subs by www.zeoranger.co.uk", "www.fema.gov") 54 + - "Thank you for watching" / "Thank you" loops 55 + 56 + **Output**: 57 + ```ts 58 + interface TalkSegment { 59 + startS: number; 60 + endS: number; 61 + speakers: { id: string; durationS: number }[]; 62 + type: 'single-speaker' | 'panel' | 'unknown'; 63 + dominantSpeaker?: string; 64 + precedingGapS: number; 65 + hallucinationZone: boolean; 66 + } 67 + 68 + interface HallucinationZone { 69 + startS: number; 70 + endS: number; 71 + pattern: string; 72 + } 73 + ``` 74 + 75 + ## Stage 2: Transcript Content Matching 76 + 77 + For each `TalkSegment`, extract identity signals from transcript text in that time range. 78 + 79 + **Signals (ordered by reliability)**: 80 + 81 + 1. **MC handoffs**: "please welcome {NAME}", "next up is {NAME}", "setting up next". Found in the 30-60s before a talk starts. 82 + 2. **Self-introductions**: "my name is {NAME}", "I'm {NAME} I'm from/at/with {ORG}". First 60s of a talk. Strongest identity signal. 83 + 3. **Topic keywords**: Nouns/phrases from first 2 minutes matched against talk titles. 84 + 4. **Speaker name matching**: Fuzzy/phonetic match against schedule speaker list. Handles Whisper mangling ("Jekard"/"Jacquard", "Wardmuller"/"Werdmuller"). 85 + 86 + **Matching logic**: 87 + - Hallucination zone segments: `confidence = 'unverifiable'`, candidates from schedule by time window 88 + - Speaker name + topic keyword match: `confidence = 'high'` 89 + - Speaker name OR topic keyword match: `confidence = 'medium'` 90 + - No match: `confidence = 'low'` 91 + 92 + **Panel handling**: When segment type is `panel`, extract ALL speaker names and match multiple schedule entries to the same time range. Flag as `panel: true`. 93 + 94 + **Output**: 95 + ```ts 96 + interface BoundaryMatch { 97 + rkey: string; 98 + title: string; 99 + startS: number; 100 + endS: number; 101 + confidence: 'high' | 'medium' | 'low' | 'unverifiable'; 102 + signals: string[]; 103 + panel: boolean; 104 + hallucinationZones: HallucinationZone[]; 105 + } 106 + ``` 107 + 108 + ## Stage 3: Schedule Reconciliation 109 + 110 + 1. **Validate matches**: Resolve duplicate assignments (same rkey to multiple segments). Pick highest confidence. 111 + 2. **Unmatched schedule entries**: If scheduled time falls in hallucination zone, assign as `unverifiable`. If within a panel's range, assign with `low` confidence. Otherwise omit with log message. 112 + 3. **Unmatched segments**: Real speech with no schedule match. Output as `unknown-talk` for manual review. 113 + 4. **End time calculation**: Each talk ends at next talk's start minus gap, or diarization silence onset. Last talk in session ends at last diarization speech. Absolute last talk ends at stream duration or last speech. 114 + 115 + ## Output Format 116 + 117 + Compatible with v6 for downstream use by `refine-boundaries-llm.ts` and `apply-boundaries.ts`: 118 + 119 + ```ts 120 + { 121 + stream: string; 122 + results: BoundaryMatch[]; 123 + hallucinationZones: HallucinationZone[]; 124 + unmatchedSegments: TalkSegment[]; 125 + unmatchedSchedule: string[]; 126 + } 127 + ``` 128 + 129 + ## CLI Interface 130 + 131 + ```bash 132 + npx tsx src/detect-boundaries-v7.ts \ 133 + data/fullday/<Dir>/transcript-enriched.json \ 134 + --diarization data/fullday/<Dir>/diarization.json \ 135 + --stream-slug great-hall-day-1 136 + ``` 137 + 138 + `--diarization` is required. Stream slug pulls schedule from DB. 139 + 140 + ## Confidence Tiers 141 + 142 + | Tier | Meaning | Action | 143 + |------|---------|--------| 144 + | high | Diarization boundary + transcript confirms speaker and topic | Auto-accept | 145 + | medium | Diarization boundary exists, partial transcript match | Review recommended | 146 + | low | Weak match, possibly wrong assignment | Manual verification needed | 147 + | unverifiable | Talk in hallucination zone, no audio evidence | Check video or remove | 148 + 149 + ## Future: Hallucination Re-transcription (Phase C) 150 + 151 + Marked hallucination zones enable a future pipeline stage: re-transcribe those audio regions using diarization-derived boundaries as chunking points, giving Whisper clean context without the rotted pre-context that causes hallucination cascading. This is out of scope for v7 but the data model supports it. 152 + 153 + ## Validation 154 + 155 + Run v7 on all 7 streams and compare output against the manually verified ground truth from the April 12 audit. Success criteria: 156 + - All `high` confidence results match ground truth 157 + - No cross-track assignment errors 158 + - All hallucination zones correctly detected 159 + - Unmatched segments/schedule entries are legitimate (talks not recorded, etc.)
+97
docs/superpowers/specs/2026-04-12-retranscribe-hallucinations-design.md
··· 1 + # Re-transcribe Hallucination Zones 2 + 3 + **Date**: 2026-04-12 4 + **Status**: Approved 5 + **File**: `apps/ionosphere-appview/src/retranscribe-hallucinations.ts` 6 + 7 + ## Problem 8 + 9 + Whisper hallucinates during silence/break periods in full-day conference streams, producing repeating phrases ("Transcription by CastingWords", "0 0 0 0", Welsh text, song lyrics, etc.). These hallucination zones cover ~20% of stream content and obscure real talk boundaries and content. The root cause: fixed 20-minute chunking means Whisper's context window fills with garbage from previous silence, causing cascading hallucination. 10 + 11 + ## Approach 12 + 13 + Re-transcribe only the hallucination zones using diarization-aligned chunk boundaries. The diarization data shows exactly when real speech starts and stops, so we can give Whisper chunks that begin with real speech — clean context from the first word. 14 + 15 + Uses existing OpenAI Whisper API (same as original transcription). Splices results back into `transcript-enriched.json`, replacing the hallucinated words. 16 + 17 + ## Pipeline 18 + 19 + ``` 20 + v7 boundary JSON (hallucinationZones) + HLS stream + diarization 21 + 22 + 1. For each hallucination zone with diarization speech: 23 + a. Extract audio from HLS via ffmpeg (zone.startS → zone.endS) 24 + b. Split at diarization gaps if > 25 min (Whisper's limit) 25 + c. Transcribe via OpenAI Whisper API (word timestamps) 26 + 27 + 2. Load transcript-enriched.json 28 + 29 + 3. For each zone: remove hallucinated words, insert new words 30 + 31 + 4. Write updated transcript-enriched.json 32 + ``` 33 + 34 + ## Chunking Strategy 35 + 36 + For each hallucination zone: 37 + 1. Load diarization segments overlapping the zone 38 + 2. Find first speech onset and last speech offset 39 + 3. If no speech in zone → skip (genuinely silent) 40 + 4. If speech < 25 min → one chunk 41 + 5. If speech > 25 min → split at diarization gaps > 5s 42 + 43 + Each chunk starts at a diarization speech onset, ensuring Whisper gets clean context. 44 + 45 + ## Audio Extraction 46 + 47 + Use ffmpeg to extract audio from the HLS VOD endpoint: 48 + ```bash 49 + ffmpeg -ss <startS> -t <durationS> -i "<playlist_url>" -vn -ac 1 -ar 16000 -f mp3 <output.mp3> 50 + ``` 51 + 52 + Stream URIs come from the `STREAMS` config (same as existing transcription pipeline). Playlist URL: `https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist?uri=<uri>`. 53 + 54 + ## Transcript Splicing 55 + 56 + After re-transcription of a zone: 57 + 1. Filter out existing words where `startS >= zone.startS && endS <= zone.endS` 58 + 2. Insert new words (with timestamps relative to zone start, adjusted to absolute stream time) 59 + 3. Re-sort word array by start time 60 + 4. Recalculate `total_words` 61 + 62 + ## CLI 63 + 64 + ```bash 65 + npx tsx src/retranscribe-hallucinations.ts \ 66 + --stream-slug room-2301-day-2 \ 67 + --boundaries data/fullday/Room_2301___Day_2/transcript-enriched-boundaries-v7.json \ 68 + --diarization data/fullday/Room_2301___Day_2/diarization.json 69 + ``` 70 + 71 + Requires: `OPENAI_API_KEY` in environment (from `.env`). 72 + 73 + Reads stream URI from `STREAMS` config. Writes updated `transcript-enriched.json` in the same fullday directory. 74 + 75 + ## Scope 76 + 77 + **Does:** 78 + - Extract audio for hallucination zones from HLS 79 + - Re-transcribe with diarization-aligned chunks 80 + - Splice new words into existing transcript 81 + 82 + **Does not:** 83 + - Re-run diarization (existing is good) 84 + - Re-run v7 boundary detection (separate step) 85 + - Publish to PDS (separate step) 86 + 87 + ## Expected Impact 88 + 89 + Hallucination zones covering actual talks (where diarization shows real speech): 90 + - R2301 D2: 96-209m (Content Mod Futures, start of Blacksky) 91 + - PT D2: 117-210m (Community Privacy, Cooperate & Succeed) 92 + - PT D1: 124-200m (end of morning session) 93 + - GH D2: 180-267m (lunch period — mostly DJ music, limited real speech) 94 + - ATScience: 264-280m (Welsh hallucination over Astrosky start) 95 + - Various short zones (< 5 min) 96 + 97 + Re-transcription should recover talk content currently lost, improving v7 match accuracy from 90% toward 95%+.