302 lines
17 KiB
Markdown
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.
|