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

302 lines
17 KiB
Markdown

---
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)
```bash
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:
```bash
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.