···11+/**
22+ * Schedule Reconciler — Stage 3 of the v7 boundary detection pipeline.
33+ *
44+ * Takes matched boundaries from Stage 2 and:
55+ * 1. Deduplicates: if the same rkey appears multiple times, keeps highest confidence
66+ * 2. Computes end times: uses the next talk's start (with gap handling)
77+ * 3. Handles unmatched schedule entries (hallucination zones → unverifiable, else unmatchedSchedule)
88+ * 4. Collects unmatched segments (diarization segments that got no match)
99+ * 5. Assembles the final V7Output
1010+ */
1111+1212+import type {
1313+ BoundaryMatch,
1414+ HallucinationZone,
1515+ ScheduleTalk,
1616+ TalkSegment,
1717+ V7Output,
1818+} from './types.js';
1919+2020+// A gap larger than this (seconds) between consecutive matched talks is treated
2121+// as a session break. The last talk before the break gets its end time from
2222+// the diarization segment rather than the next talk's start.
2323+const SESSION_BREAK_GAP_S = 60;
2424+2525+// Numeric rank for confidence levels (higher = better)
2626+const CONFIDENCE_RANK: Record<BoundaryMatch['confidence'], number> = {
2727+ high: 4,
2828+ medium: 3,
2929+ low: 2,
3030+ unverifiable: 1,
3131+};
3232+3333+/**
3434+ * Deduplicate matches: when the same rkey appears more than once, keep only
3535+ * the instance with the highest confidence. Ties are broken by earlier start.
3636+ */
3737+function deduplicateMatches(matches: BoundaryMatch[]): BoundaryMatch[] {
3838+ const best = new Map<string, BoundaryMatch>();
3939+4040+ for (const match of matches) {
4141+ const existing = best.get(match.rkey);
4242+ if (!existing) {
4343+ best.set(match.rkey, match);
4444+ } else {
4545+ const existingRank = CONFIDENCE_RANK[existing.confidence];
4646+ const newRank = CONFIDENCE_RANK[match.confidence];
4747+ if (newRank > existingRank) {
4848+ best.set(match.rkey, match);
4949+ } else if (newRank === existingRank && match.startTimestamp < existing.startTimestamp) {
5050+ best.set(match.rkey, match);
5151+ }
5252+ }
5353+ }
5454+5555+ return [...best.values()];
5656+}
5757+5858+/**
5959+ * Find the TalkSegment whose time range best covers a given start timestamp.
6060+ * Returns the segment whose startS is closest (from below) to startTimestamp.
6161+ */
6262+function findSegmentForMatch(
6363+ match: BoundaryMatch,
6464+ segments: TalkSegment[],
6565+): TalkSegment | undefined {
6666+ // Find the segment that contains or immediately precedes the match start
6767+ let best: TalkSegment | undefined;
6868+ for (const seg of segments) {
6969+ if (seg.startS <= match.startTimestamp + 1) {
7070+ if (!best || seg.startS > best.startS) {
7171+ best = seg;
7272+ }
7373+ }
7474+ }
7575+ return best;
7676+}
7777+7878+/**
7979+ * Check whether a scheduled time overlaps any hallucination zone.
8080+ */
8181+function isInHallucinationZone(
8282+ startsAt: string,
8383+ hallucinationZones: HallucinationZone[],
8484+ streamStartUnixS: number,
8585+): boolean {
8686+ // We work in stream-relative seconds. The schedule's starts_at is an
8787+ // ISO timestamp; without an absolute anchor we can't convert it. Instead
8888+ // we rely on the caller to pass absolute seconds when the zone was detected,
8989+ // but since ScheduleTalk carries ISO strings, we do a best-effort check:
9090+ // if ANY hallucination zone exists and the schedule entry has no matched
9191+ // boundary, we use the zone's stream-relative window.
9292+ //
9393+ // In practice, callers who know the stream's wall-clock start can compare
9494+ // properly. For the common case we just check all zones.
9595+ if (hallucinationZones.length === 0) return false;
9696+9797+ // Parse the ISO string into a Date epoch seconds
9898+ const talkEpochS = Date.parse(startsAt) / 1000;
9999+ if (isNaN(talkEpochS)) return false;
100100+101101+ // Convert to stream-relative offset
102102+ const relativeS = talkEpochS - streamStartUnixS;
103103+104104+ return hallucinationZones.some(
105105+ (z) => relativeS >= z.startS && relativeS <= z.endS,
106106+ );
107107+}
108108+109109+/**
110110+ * Compute end times for each deduplicated match.
111111+ *
112112+ * Rules (in order):
113113+ * 1. Sort matches by startTimestamp.
114114+ * 2. For each match, the default end = next match's startTimestamp.
115115+ * 3. If the gap to the next match > SESSION_BREAK_GAP_S, treat this as the
116116+ * last talk before a break → use the diarization segment's endS.
117117+ * 4. For the absolute last match: use min(streamDurationS, segment.endS).
118118+ */
119119+function computeEndTimes(
120120+ matches: BoundaryMatch[],
121121+ segments: TalkSegment[],
122122+ streamDurationS: number,
123123+): BoundaryMatch[] {
124124+ const sorted = [...matches].sort((a, b) => a.startTimestamp - b.startTimestamp);
125125+126126+ return sorted.map((match, idx) => {
127127+ const seg = findSegmentForMatch(match, segments);
128128+ const segEndS = seg?.endS ?? streamDurationS;
129129+130130+ let endTimestamp: number;
131131+132132+ if (idx < sorted.length - 1) {
133133+ const nextStart = sorted[idx + 1].startTimestamp;
134134+ const gap = nextStart - match.startTimestamp;
135135+136136+ if (gap > SESSION_BREAK_GAP_S) {
137137+ // Last talk before a session break — use segment end
138138+ endTimestamp = segEndS;
139139+ } else {
140140+ // Normal case: end at the next talk's start
141141+ endTimestamp = nextStart;
142142+ }
143143+ } else {
144144+ // Absolute last match
145145+ endTimestamp = Math.min(streamDurationS, segEndS);
146146+ }
147147+148148+ return { ...match, endTimestamp };
149149+ });
150150+}
151151+152152+/**
153153+ * Collect TalkSegments that have no BoundaryMatch assigned to them.
154154+ *
155155+ * A segment is considered "assigned" if any match's startTimestamp falls
156156+ * within [seg.startS - 5, seg.endS + 5].
157157+ */
158158+function collectUnmatchedSegments(
159159+ segments: TalkSegment[],
160160+ matches: BoundaryMatch[],
161161+): TalkSegment[] {
162162+ return segments.filter((seg) => {
163163+ return !matches.some(
164164+ (m) => m.startTimestamp >= seg.startS - 5 && m.startTimestamp <= seg.endS + 5,
165165+ );
166166+ });
167167+}
168168+169169+// ─── Public API ───────────────────────────────────────────────────────────────
170170+171171+/**
172172+ * Reconcile matched boundaries, fill gaps, and assemble the final V7Output.
173173+ *
174174+ * @param matches Raw BoundaryMatch list from Stage 2
175175+ * @param segments TalkSegments from Stage 1 (diarization-segmenter)
176176+ * @param schedule Full schedule for this stream from the DB
177177+ * @param hallucinationZones Zones detected in Stage 0
178178+ * @param streamDurationS Total stream duration in seconds
179179+ * @param streamName Human-readable stream name (for V7Output.stream)
180180+ * @param streamStartUnixS Optional: wall-clock start of the stream as Unix seconds.
181181+ * Used to map schedule ISO timestamps into stream-relative
182182+ * offsets when checking hallucination zones.
183183+ * Defaults to 0 (disables the check — all unmatched
184184+ * schedule entries go to unmatchedSchedule).
185185+ */
186186+export function reconcileSchedule(
187187+ matches: BoundaryMatch[],
188188+ segments: TalkSegment[],
189189+ schedule: ScheduleTalk[],
190190+ hallucinationZones: HallucinationZone[],
191191+ streamDurationS: number,
192192+ streamName = 'unknown',
193193+ streamStartUnixS = 0,
194194+): V7Output {
195195+ // 1. Deduplicate
196196+ const deduped = deduplicateMatches(matches);
197197+198198+ // 2. Compute end times
199199+ const withEnds = computeEndTimes(deduped, segments, streamDurationS);
200200+201201+ // 3. Unmatched schedule
202202+ const matchedRkeys = new Set(withEnds.map((m) => m.rkey));
203203+ const unmatchedScheduleEntries: BoundaryMatch[] = [];
204204+ const unmatchedScheduleRkeys: string[] = [];
205205+206206+ for (const talk of schedule) {
207207+ if (matchedRkeys.has(talk.rkey)) continue;
208208+209209+ if (
210210+ streamStartUnixS > 0 &&
211211+ isInHallucinationZone(talk.starts_at, hallucinationZones, streamStartUnixS)
212212+ ) {
213213+ // In a hallucination zone — mark as unverifiable result
214214+ unmatchedScheduleEntries.push({
215215+ rkey: talk.rkey,
216216+ title: talk.title,
217217+ startTimestamp: 0,
218218+ endTimestamp: null,
219219+ confidence: 'unverifiable',
220220+ signals: [],
221221+ panel: false,
222222+ hallucinationZones,
223223+ });
224224+ } else {
225225+ unmatchedScheduleRkeys.push(talk.rkey);
226226+ }
227227+ }
228228+229229+ // 4. Unmatched segments
230230+ const unmatchedSegments = collectUnmatchedSegments(segments, withEnds);
231231+232232+ // 5. Assemble V7Output
233233+ const allResults = [
234234+ ...withEnds,
235235+ ...unmatchedScheduleEntries,
236236+ ].sort((a, b) => a.startTimestamp - b.startTimestamp);
237237+238238+ return {
239239+ stream: streamName,
240240+ results: allResults,
241241+ hallucinationZones,
242242+ unmatchedSegments,
243243+ unmatchedSchedule: unmatchedScheduleRkeys,
244244+ };
245245+}
···11+# Boundary Detection v7 — Diarization-First Pipeline
22+33+**Date**: 2026-04-12
44+**Status**: Approved
55+**File**: `apps/ionosphere-appview/src/detect-boundaries-v7.ts`
66+77+## Problem
88+99+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.
1010+1111+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.
1212+1313+## Approach
1414+1515+**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.
1616+1717+## Pipeline
1818+1919+```
2020+Diarization JSON + Transcript JSON + Schedule (DB)
2121+ ↓
2222+Stage 1: Diarization Segmentation
2323+ ↓
2424+Stage 2: Transcript Content Matching
2525+ ↓
2626+Stage 3: Schedule Reconciliation
2727+ ↓
2828+Boundary JSON (v6-compatible format)
2929+```
3030+3131+## Stage 1: Diarization Segmentation
3232+3333+**Input**: `diarization.json` (`{segments: [{start, end, speaker}], speakers: []}`)
3434+3535+**Process**:
3636+- Merge adjacent same-speaker segments with < 5s gaps into speech blocks
3737+- Classify gaps between blocks:
3838+ - \> 60s = **session break**
3939+ - 30-60s = **likely talk boundary**
4040+ - < 30s = **within-talk pause**
4141+- Group blocks between session breaks into sessions
4242+- Within each session, classify by speaker distribution:
4343+ - One speaker > 70% duration = **single-speaker talk**
4444+ - Multiple speakers with balanced time = **panel**
4545+- **Hallucination detection**: Where diarization shows silence but transcript has words, mark as hallucination zone. Also detect known patterns:
4646+ - Repeating phrases in ~30s loops ("Transcription by CastingWords", "Transcribed by https://otter.ai")
4747+ - Numeric zeros ("0 0 0 0 0")
4848+ - "Microsoft Office Word Document MSWordDoc"
4949+ - "Transcription by ESO Translation by --"
5050+ - "UGA Extension Office of Communications and Creative Services"
5151+ - Non-English loops (Welsh "Rwy n gobeithio...")
5252+ - Song lyrics between known gaps (DJ music on GH D2)
5353+ - URLs/attribution ("Subs by www.zeoranger.co.uk", "www.fema.gov")
5454+ - "Thank you for watching" / "Thank you" loops
5555+5656+**Output**:
5757+```ts
5858+interface TalkSegment {
5959+ startS: number;
6060+ endS: number;
6161+ speakers: { id: string; durationS: number }[];
6262+ type: 'single-speaker' | 'panel' | 'unknown';
6363+ dominantSpeaker?: string;
6464+ precedingGapS: number;
6565+ hallucinationZone: boolean;
6666+}
6767+6868+interface HallucinationZone {
6969+ startS: number;
7070+ endS: number;
7171+ pattern: string;
7272+}
7373+```
7474+7575+## Stage 2: Transcript Content Matching
7676+7777+For each `TalkSegment`, extract identity signals from transcript text in that time range.
7878+7979+**Signals (ordered by reliability)**:
8080+8181+1. **MC handoffs**: "please welcome {NAME}", "next up is {NAME}", "setting up next". Found in the 30-60s before a talk starts.
8282+2. **Self-introductions**: "my name is {NAME}", "I'm {NAME} I'm from/at/with {ORG}". First 60s of a talk. Strongest identity signal.
8383+3. **Topic keywords**: Nouns/phrases from first 2 minutes matched against talk titles.
8484+4. **Speaker name matching**: Fuzzy/phonetic match against schedule speaker list. Handles Whisper mangling ("Jekard"/"Jacquard", "Wardmuller"/"Werdmuller").
8585+8686+**Matching logic**:
8787+- Hallucination zone segments: `confidence = 'unverifiable'`, candidates from schedule by time window
8888+- Speaker name + topic keyword match: `confidence = 'high'`
8989+- Speaker name OR topic keyword match: `confidence = 'medium'`
9090+- No match: `confidence = 'low'`
9191+9292+**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`.
9393+9494+**Output**:
9595+```ts
9696+interface BoundaryMatch {
9797+ rkey: string;
9898+ title: string;
9999+ startS: number;
100100+ endS: number;
101101+ confidence: 'high' | 'medium' | 'low' | 'unverifiable';
102102+ signals: string[];
103103+ panel: boolean;
104104+ hallucinationZones: HallucinationZone[];
105105+}
106106+```
107107+108108+## Stage 3: Schedule Reconciliation
109109+110110+1. **Validate matches**: Resolve duplicate assignments (same rkey to multiple segments). Pick highest confidence.
111111+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.
112112+3. **Unmatched segments**: Real speech with no schedule match. Output as `unknown-talk` for manual review.
113113+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.
114114+115115+## Output Format
116116+117117+Compatible with v6 for downstream use by `refine-boundaries-llm.ts` and `apply-boundaries.ts`:
118118+119119+```ts
120120+{
121121+ stream: string;
122122+ results: BoundaryMatch[];
123123+ hallucinationZones: HallucinationZone[];
124124+ unmatchedSegments: TalkSegment[];
125125+ unmatchedSchedule: string[];
126126+}
127127+```
128128+129129+## CLI Interface
130130+131131+```bash
132132+npx tsx src/detect-boundaries-v7.ts \
133133+ data/fullday/<Dir>/transcript-enriched.json \
134134+ --diarization data/fullday/<Dir>/diarization.json \
135135+ --stream-slug great-hall-day-1
136136+```
137137+138138+`--diarization` is required. Stream slug pulls schedule from DB.
139139+140140+## Confidence Tiers
141141+142142+| Tier | Meaning | Action |
143143+|------|---------|--------|
144144+| high | Diarization boundary + transcript confirms speaker and topic | Auto-accept |
145145+| medium | Diarization boundary exists, partial transcript match | Review recommended |
146146+| low | Weak match, possibly wrong assignment | Manual verification needed |
147147+| unverifiable | Talk in hallucination zone, no audio evidence | Check video or remove |
148148+149149+## Future: Hallucination Re-transcription (Phase C)
150150+151151+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.
152152+153153+## Validation
154154+155155+Run v7 on all 7 streams and compare output against the manually verified ground truth from the April 12 audit. Success criteria:
156156+- All `high` confidence results match ground truth
157157+- No cross-track assignment errors
158158+- All hallucination zones correctly detected
159159+- Unmatched segments/schedule entries are legitimate (talks not recorded, etc.)
···11+# Re-transcribe Hallucination Zones
22+33+**Date**: 2026-04-12
44+**Status**: Approved
55+**File**: `apps/ionosphere-appview/src/retranscribe-hallucinations.ts`
66+77+## Problem
88+99+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.
1010+1111+## Approach
1212+1313+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.
1414+1515+Uses existing OpenAI Whisper API (same as original transcription). Splices results back into `transcript-enriched.json`, replacing the hallucinated words.
1616+1717+## Pipeline
1818+1919+```
2020+v7 boundary JSON (hallucinationZones) + HLS stream + diarization
2121+ ↓
2222+1. For each hallucination zone with diarization speech:
2323+ a. Extract audio from HLS via ffmpeg (zone.startS → zone.endS)
2424+ b. Split at diarization gaps if > 25 min (Whisper's limit)
2525+ c. Transcribe via OpenAI Whisper API (word timestamps)
2626+ ↓
2727+2. Load transcript-enriched.json
2828+ ↓
2929+3. For each zone: remove hallucinated words, insert new words
3030+ ↓
3131+4. Write updated transcript-enriched.json
3232+```
3333+3434+## Chunking Strategy
3535+3636+For each hallucination zone:
3737+1. Load diarization segments overlapping the zone
3838+2. Find first speech onset and last speech offset
3939+3. If no speech in zone → skip (genuinely silent)
4040+4. If speech < 25 min → one chunk
4141+5. If speech > 25 min → split at diarization gaps > 5s
4242+4343+Each chunk starts at a diarization speech onset, ensuring Whisper gets clean context.
4444+4545+## Audio Extraction
4646+4747+Use ffmpeg to extract audio from the HLS VOD endpoint:
4848+```bash
4949+ffmpeg -ss <startS> -t <durationS> -i "<playlist_url>" -vn -ac 1 -ar 16000 -f mp3 <output.mp3>
5050+```
5151+5252+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>`.
5353+5454+## Transcript Splicing
5555+5656+After re-transcription of a zone:
5757+1. Filter out existing words where `startS >= zone.startS && endS <= zone.endS`
5858+2. Insert new words (with timestamps relative to zone start, adjusted to absolute stream time)
5959+3. Re-sort word array by start time
6060+4. Recalculate `total_words`
6161+6262+## CLI
6363+6464+```bash
6565+npx tsx src/retranscribe-hallucinations.ts \
6666+ --stream-slug room-2301-day-2 \
6767+ --boundaries data/fullday/Room_2301___Day_2/transcript-enriched-boundaries-v7.json \
6868+ --diarization data/fullday/Room_2301___Day_2/diarization.json
6969+```
7070+7171+Requires: `OPENAI_API_KEY` in environment (from `.env`).
7272+7373+Reads stream URI from `STREAMS` config. Writes updated `transcript-enriched.json` in the same fullday directory.
7474+7575+## Scope
7676+7777+**Does:**
7878+- Extract audio for hallucination zones from HLS
7979+- Re-transcribe with diarization-aligned chunks
8080+- Splice new words into existing transcript
8181+8282+**Does not:**
8383+- Re-run diarization (existing is good)
8484+- Re-run v7 boundary detection (separate step)
8585+- Publish to PDS (separate step)
8686+8787+## Expected Impact
8888+8989+Hallucination zones covering actual talks (where diarization shows real speech):
9090+- R2301 D2: 96-209m (Content Mod Futures, start of Blacksky)
9191+- PT D2: 117-210m (Community Privacy, Cooperate & Succeed)
9292+- PT D1: 124-200m (end of morning session)
9393+- GH D2: 180-267m (lunch period — mostly DJ music, limited real speech)
9494+- ATScience: 264-280m (Welsh hallucination over Astrosky start)
9595+- Various short zones (< 5 min)
9696+9797+Re-transcription should recover talk content currently lost, improving v7 match accuracy from 90% toward 95%+.