ckb/.agents/skills/transcript-speaker-fill/SKILL.md
2026-07-15 09:54:12 +02:00

17 KiB


name: transcript-speaker-fill description: Recover real speaker names in a Fireflies transcript that only has generic "Speaker N" labels, by fuzzy-matching timestamps against a second transcript of the same meeting that has real names but broken/unusable text (commonly caused by wrong-language detection). Use when the user has two Fireflies exports of one meeting - one with correct text but no speaker names, one with real names but garbled text - and wants the names filled into the good transcript. Trigger phrases: "fill in the speakers", "match speakers by timestamp", "recover speaker names", "one transcript is missing speakers and the other has the wrong language".

Transcript speaker fill skill

Purpose

Fireflies occasionally produces two broken outcomes for the same meeting: one export has correct transcript text but generic Speaker 1, Speaker 2, ... labels (diarization worked, but names were never resolved or the Fireflies bot lost the participant roster); another export - often from a retry after fixing the language setting - has real speaker names but garbage text (wrong language was detected, so the words are nonsense, but the underlying speaker diarization and timestamps are still meaningful).

This skill cross-references the two: it can't read the garbled text, but it can compare when each speaker was talking, and use that timing to guess which generic label corresponds to which real name in the good transcript.

This is entirely mechanical - a bundled Python script does the parsing, offset detection, and voting. Nothing here needs model judgment except interpreting the final report and deciding whether the result is trustworthy enough to use.

Important: this is best-effort, not a solved match

Be direct with the user about this before and after running it:

  • Some speakers may be structurally unresolvable. If the target transcript has more distinct unnamed speakers than the broken transcript has distinct real names, some target speakers simply aren't captured with a real name anywhere in the broken file (they may have joined late, or their voice wasn't separated out in that broken run). No amount of tuning fixes this - the script detects and reports this gap explicitly, but can still produce a confident-looking wrong answer for an affected label, because it has no way to know a name is entirely absent from the candidate pool.
  • Timestamps are start-of-utterance markers from two independent diarization runs, not a shared clock - they can disagree by several seconds even for a genuine match, and the two files may have a constant offset if the bots didn't start recording at exactly the same instant. The script searches for that offset automatically; it can still get it slightly wrong in a noisy recording.
  • Always spot-check the result - especially any label resolved with fewer than ~4-5 votes or under ~65% confidence, and especially any label affected by the roster-gap warning. Read a couple of the actual lines attributed to a resolved name and sanity-check against tone/content (a name attributed to a monologue about hairdressers when the person is known to talk mostly about delivery process, say, is a red flag).

How to run this skill

This is a two-pass flow: a dry-run preview first, a write-to-disk second, only after the matches have been confirmed with the user.

Pass 1 - preview (dry run, default)

python3 "<skill-dir>/scripts/fill_speakers.py" "<file-1>" "<file-2>"
  • <file-1> / <file-2> - the two transcripts, in either order. The script auto-detects which one is the broken source (real speaker names, garbled/wrong-language text) and which is the target (correct text, generic Speaker N labels) by comparing how much of each file is still labeled with generic Speaker N names - it does not rely on file size or on which argument came first. It prints which role it assigned to each file near the top of the output - check that this matches what you'd expect from the filenames/content before trusting the rest of the report. If the two files are too similar to tell apart (rare), it exits with an error instead of guessing - open both and check by eye: the broken one reads as nonsense/wrong language but has real names; the target reads correctly but has Speaker N labels.

Resolve <skill-dir> to this skill's own directory.

With no --apply flag, the script only prints the matching report - merges, roster-gap warning, offset, the per-label resolution table, and any unresolved labels with text snippets. It does not touch disk yet. Present this report to the user (see "After the preview" below) and get their explicit confirmation - or their corrections via --manual - before moving to pass 2.

Pass 2 - apply (only after confirmation)

Once the user has confirmed the matches (and supplied any --manual overrides for gaps or corrections), re-run the exact same command with --apply added:

python3 "<skill-dir>/scripts/fill_speakers.py" "<file-1>" "<file-2>" --apply [--manual "Speaker N=Real Name" ...]

Only this run writes the output file. It never modifies either input file - it writes a new file next to the target, named <target-stem>-speakers-filled.md by default (override with --output <path>), with every generic label it could confidently resolve (or that was given via --manual) replaced by the real name throughout the target - one resolution per label, applied consistently everywhere that label appears, not line-by-line guessing.

Do not pass --apply on the first run, and do not treat pass 1's report as final - it is a proposal for the user to react to, not a completed action.

Useful tuning flags if the default report looks too conservative or too noisy:

  • --tolerance <seconds> (default 15) - how close two timestamps must be to count as a candidate match.
  • --min-votes <n> (default 2) / --min-confidence <0-1> (default 0.5) - how many matches, and what vote share, a label needs before it gets resolved instead of left as Speaker N. Raise both for a more conservative (fewer, more trustworthy) result.
  • --offset-search <seconds> (default 90) - how wide a window to search for a constant clock offset between the two recordings.
  • --no-normalize - by default, the broken file's own duplicate-diarization name variants are merged before voting (see next section) - most commonly a trailing digit Fireflies adds when it's unsure two segments are the same voice cluster (e.g. Robert Drazkowski and Robert Drazkowski1 collapse into one candidate name, Robert Drazkowski). Disable this only if that assumption is wrong for a given pair of files - e.g. the trailing digit is genuinely disambiguating two different real people who happen to share a name, which would be unusual but isn't impossible.
  • --manual "Speaker N=Real Name" (repeatable) - force a specific label to a specific name directly, bypassing matching entirely. This is how you apply names the user supplies for labels the automatic pass couldn't resolve (see "After the preview" below) - it overrides any automatic result for that label, confident or not, and works even for a label that had zero automatic matches at all.
    • Partial names are matched against the broken file's roster automatically. If the user only gives a first name (or any partial string) and it matches exactly one real name already appearing in the broken file - e.g. --manual "Speaker 3=Dawid" when the roster contains Dawid Cieślicki and no one else called Dawid - the script expands it to the full roster name and prints a NOTE (Speaker 3): ... line saying so. Relay that note to the user so they know which full name actually got applied. If the partial name matches more than one roster name, it's genuinely ambiguous - the script keeps the literal string as given (does not guess) and prints a warning listing every candidate it could have meant; relay that to the user and ask them to supply the full name instead. If it matches no one in the roster, the literal name is used as-is with no note - that's the expected, normal case for a real participant who was never captured with a name anywhere in the broken file either (the roster-gap scenario), not an error.
  • --examples <n> (default 5) - how many timestamped example lines to print per label (spread across its timeline) for the user to spot-check against the actual recording. Raise it if a label needs more coverage before the user is comfortable confirming it.
  • --recording-url <url> - a link to the target meeting's recording (e.g. a Fireflies share URL). If given, it's printed once at the top and repeated under every label next to its example lines, so a reviewer has one click to the recording right where they need it. There's no verified way to encode an exact-timestamp deep link for Fireflies (their own UI has a "copy link to this moment" feature, but the query parameter it produces isn't publicly documented), so this links to the recording itself - the user still scrubs to each example's timestamp manually.

Same speaker, two labels in the broken file

Fireflies sometimes emits two different name strings for what is actually one person, when its diarization isn't confident two speech segments are the same voice cluster - most visibly a trailing digit appended to an otherwise-identical name (Robert Drazkowski / Robert Drazkowski1). Left unhandled, this would split one real person's votes across two candidate names and could prevent either from reaching the resolution threshold, or worse, cause the script to treat them as two different (wrong) people.

The script merges these automatically before voting (normalize_name, default on) and prints exactly what it merged near the top of its output - always check that block. If it merged something that was actually two different people, or missed a variant that isn't a bare trailing digit (e.g. Name (2) or Name_2 - the normalizer also handles these, but a truly unusual format might slip through), re-run with --no-normalize and handle that pair of names manually via --manual instead.

After the preview (pass 1)

Report to the user, based on the script's own printed output, before ever running pass 2:

  • Which file it auto-detected as broken vs target - flag it if this looks wrong given the filenames/content.
  • Any merged duplicate-diarization labels (so they can sanity-check the merge was correct, not two different people collapsed into one).
  • The roster-gap warning, if any (how many target speakers can't structurally be resolved).
  • The offset it settled on.
  • The per-label resolution table (name/UNRESOLVED, vote count, confidence)
    • present this as proposed matches for the user to accept, not as a done deal.
  • Any other candidates for a label, most to least probable. Whenever a label had votes for more than one real name, the script prints a second line under it - other candidates, most to least probable: ... for a resolved label, all candidates, most to least probable: ... for an unresolved one - each with its own vote count and vote share. Always relay this ranked list, not just the winning name, especially when the top two candidates are close in vote share (e.g. 45% vs 40%): that's a near-tie, not a confident resolution, and the user may recognize the second-place name as the right one from the snippets.
  • The example lines printed under every label - the script picks up to --examples (default 5) lines per label, spread across that label's full timeline rather than clustered at the start, and prints each with its timestamp in the target recording's own timeline (not offset-adjusted - these are the timestamps to scrub to in the actual recording/video, since that's what the user has playback access to). This applies to every label, not just unresolved ones - relay them for the resolved labels too, and explicitly suggest the user jump to a couple of these timestamps in the recording and confirm by ear who's actually speaking, especially for anything under ~70% confidence. This is the concrete way to turn "the vote count says X" into "I checked and it's actually X" - don't skip offering it just because a label came back resolved. Raise --examples if the user wants more per label to check. If you have a link to the recording, pass it via --recording-url so it's printed alongside every label's examples - don't make the user go find the meeting themselves.
  • When a --manual override is given, the automatic guess is still shown underneath it, not discarded - the header line says whether the manual name agrees with the automatic guess, overrides it (naming what the automatic pass would have picked instead, and at what confidence), or fills a gap the automatic pass left unresolved. Relay this distinction - an override that contradicts a high-confidence automatic guess is worth flagging back to the user as a "you sure?" before applying, whereas one that just fills an unresolved gap or agrees with the automatic guess needs no extra scrutiny.
  • The minority-vote lines flagged for manual review, if any.
  • If the roster-gap warning fired, look for suspicious patterns across multiple labels before taking individual resolutions at face value - e.g. two different labels both resolving to the same real name, each at middling confidence with the same runner-up(s), is a sign that one of them is actually an unnamed real participant being misattributed, not genuinely two clusters of the same person. Flag this pattern explicitly to the user rather than reporting each label's line as independently fine.

Then explicitly recommend spot-checking the lowest-confidence resolutions against actual dialogue content - and, now that timestamps are available for every label, against the actual recording audio - before accepting them. Do not present the preview as a finished, verified transcript.

Always relay the script's "Could not match" section and actually ask the user about it - don't just print it and move on. For each unresolved label, show the line count and its example timestamps, then ask something like: "I couldn't match Speaker 2, 4, and 9 - here's what each said, with timestamps to check in the recording [examples]. Do you know who any of these are?"

Wait for explicit confirmation before running pass 2 (--apply). The user needs to either:

  • confirm the proposed matches look right, and/or
  • supply real names for any unresolved gaps, and/or
  • correct any match that looks wrong (even a "resolved" one they don't trust).

Fold whatever they give you into --manual "Speaker N=Name" flags (one per label) on the pass-2 run - this overrides the automatic result for that label, confident or not. Then re-run with --apply and show the updated report. Don't hand-edit the output file directly, since a re-run keeps the automatic resolutions and the merge/roster reporting consistent with the final file.

If the user wants a stricter or looser automatic pass instead, re-run pass 1 (still without --apply) with adjusted --tolerance/--min-votes/--min-confidence and present the new preview before applying anything.

Edge cases

  • Neither file matches the expected **Speaker** *[MM:SS]*: text format (e.g. it's an .mhtml capture, not an extracted .md) - point the user at the extract-transcript skill first to get a proper Markdown export.
  • A generic label appears in a burst with no broken-file activity nearby at all (e.g. everyone else was silent while this person spoke for a while) - it will correctly come back UNRESOLVED (no timestamp within tolerance found at all) rather than a forced guess.
  • The target file already has some real names mixed with Speaker N labels (partial resolution done elsewhere) - only the Speaker N entries are touched; already-named lines are left exactly as they are. This doesn't confuse auto-detection either, since it compares the share of generic labels between the two files, not just their presence.
  • Auto-detection can't tell the files apart (exactly equal share of generic labels in both, e.g. both 0% or both 100%) - the script exits with an error rather than guessing, since swapping argument order wouldn't change the outcome either. Inspect both files by eye instead - it likely means one file isn't in the format expected, or this isn't actually a broken/target pair.
  • Re-running after tuning flags or --manual overrides - always safe in either pass. Without --apply nothing is ever written, and with --apply each run's output filename defaults to the same path (overwritten on re-run, not accumulated).
  • --manual references a label that doesn't exist in the target (typo, or a label that already has a real name) - the script warns and ignores it rather than silently doing nothing; check the warning if a manual override doesn't seem to have taken effect.
  • A --manual partial name matches more than one roster name (e.g. two different real people in the broken file share a first name) - the script refuses to guess, uses the literal string as given, and prints a warning listing every candidate it could have meant. Relay this to the user and get the full name before applying, rather than letting the ambiguous literal string silently become the final label.