480 lines
23 KiB
Python
480 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""Fill in real speaker names in a Fireflies-style transcript by fuzzy-matching
|
|
timestamps against a second transcript of the same meeting that has real
|
|
speaker names but broken/unusable text (e.g. wrong language was detected,
|
|
so the words are garbage but the diarization + speaker labels are fine).
|
|
|
|
Both inputs are expected in Fireflies' Markdown export shape:
|
|
**Speaker Name** *[MM:SS]*: text
|
|
|
|
The two file arguments can be given in either order - whichever one has a
|
|
higher share of generic "Speaker N" labels is auto-detected as the target
|
|
to fill in; the other is treated as the broken source of real names.
|
|
|
|
By default this is a dry run: it only prints the matching report. Nothing
|
|
is written until it is re-run with --apply, so the report can be reviewed
|
|
(and --manual overrides added for any gaps) before anything touches disk.
|
|
|
|
Deterministic, no model reasoning involved - see the accompanying SKILL.md
|
|
for when/how to invoke this.
|
|
"""
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from collections import defaultdict, Counter
|
|
|
|
LINE_RE = re.compile(
|
|
r'^\*\*(?P<speaker>[^*]+)\*\*\s*\*\[(?P<ts>\d{1,2}:\d{2}(?::\d{2})?)\]\*:\s*(?P<text>.*)$'
|
|
)
|
|
GENERIC_RE = re.compile(r'^Speaker\s+\d+$')
|
|
|
|
|
|
def parse_ts(ts):
|
|
parts = [int(p) for p in ts.split(':')]
|
|
if len(parts) == 2:
|
|
m, s = parts
|
|
return m * 60 + s
|
|
h, m, s = parts
|
|
return h * 3600 + m * 60 + s
|
|
|
|
|
|
def normalize_name(name):
|
|
"""Fireflies sometimes labels the same real person with two different
|
|
strings when it isn't sure two segments are the same voice cluster -
|
|
most commonly a trailing digit appended to an otherwise-identical name
|
|
(e.g. "Robert Drazkowski" and "Robert Drazkowski1" are the same person).
|
|
Strip that suffix so both collapse into one candidate name for voting.
|
|
Also strips a trailing " 2", "(2)", "_2" etc. in case Fireflies uses one
|
|
of those variants instead of a bare digit."""
|
|
cleaned = re.sub(r'[\s_]*\(?\d+\)?$', '', name).strip()
|
|
return cleaned if cleaned else name.strip()
|
|
|
|
|
|
def parse_manual_overrides(pairs):
|
|
"""Parse repeated --manual "Speaker N=Real Name" arguments into a dict."""
|
|
overrides = {}
|
|
for pair in pairs or []:
|
|
if '=' not in pair:
|
|
sys.exit(f"--manual expects 'Speaker N=Real Name', got: {pair!r}")
|
|
label, name = pair.split('=', 1)
|
|
overrides[label.strip()] = name.strip()
|
|
return overrides
|
|
|
|
|
|
def resolve_partial_name(name, roster):
|
|
"""If `name` is a partial reference (e.g. just a first name) to someone
|
|
who already appears in `roster` (the broken file's real-name roster),
|
|
suggest/expand to the matching full name instead of taking the partial
|
|
string literally. Returns (final_name, note_or_None).
|
|
|
|
Matching is deliberately conservative: an exact match short-circuits
|
|
immediately; otherwise a candidate qualifies only if `name` shares a
|
|
whole word with it (case-insensitive) or is a substring of it. If more
|
|
than one roster name qualifies, this is ambiguous - the literal name is
|
|
kept as given rather than guessing, with a warning listing the
|
|
candidates so the user can specify which one they meant. If none
|
|
qualify, the name is genuinely new (e.g. a real participant who was
|
|
never captured with a name anywhere in the broken file) and is used
|
|
as-is - that's expected, not an error."""
|
|
if name in roster:
|
|
return name, None
|
|
|
|
name_lower = name.strip().lower()
|
|
name_tokens = set(name_lower.split())
|
|
candidates = []
|
|
for full in roster:
|
|
full_lower = full.lower()
|
|
if name_lower == full_lower:
|
|
return full, f"'{name}' matches roster name '{full}' (case-insensitive) - using '{full}'."
|
|
full_tokens = set(full_lower.split())
|
|
if name_tokens & full_tokens or name_lower in full_lower:
|
|
candidates.append(full)
|
|
|
|
if len(candidates) == 1:
|
|
return candidates[0], f"'{name}' looks like a partial name - matched to the only candidate in the roster, '{candidates[0]}'. Using the full name."
|
|
if len(candidates) > 1:
|
|
return name, (
|
|
f"'{name}' is ambiguous - it could refer to any of: {', '.join(candidates)}. "
|
|
f"Used literally as given since I can't tell which one you meant - re-run with the full "
|
|
f"name to disambiguate if this isn't who you intended."
|
|
)
|
|
return name, None
|
|
|
|
|
|
def parse_file(path):
|
|
entries = []
|
|
for i, line in enumerate(Path(path).read_text(encoding='utf-8').splitlines()):
|
|
m = LINE_RE.match(line.strip())
|
|
if m:
|
|
entries.append({
|
|
'line_no': i,
|
|
'speaker': m.group('speaker').strip(),
|
|
'seconds': parse_ts(m.group('ts')),
|
|
'text': m.group('text'),
|
|
})
|
|
return entries
|
|
|
|
|
|
def classify(entries_a, path_a, entries_b, path_b):
|
|
"""Decide which of the two parsed transcripts is the 'target' (has
|
|
generic Speaker N labels needing real names filled in) and which is
|
|
the 'broken' source (has real names already, used only for its
|
|
timestamps). Whichever file has a higher share of generic-labeled
|
|
lines is the target - the broken source should have few or none,
|
|
since its diarization already resolved real names even though its
|
|
text is garbled. This replaces any assumption about file size or
|
|
argument order."""
|
|
def generic_fraction(entries):
|
|
if not entries:
|
|
return 0.0
|
|
generic = sum(1 for e in entries if GENERIC_RE.match(e['speaker']))
|
|
return generic / len(entries)
|
|
|
|
frac_a = generic_fraction(entries_a)
|
|
frac_b = generic_fraction(entries_b)
|
|
|
|
if frac_a == frac_b:
|
|
sys.exit(
|
|
f"Could not automatically tell which file needs speaker names filled in: "
|
|
f"both '{path_a}' and '{path_b}' have the same share of generic 'Speaker N' "
|
|
f"labels ({frac_a:.0%}). Check the files by eye - the target should read as "
|
|
f"real dialogue with generic labels, the broken source should have real names "
|
|
f"but garbled/wrong-language text."
|
|
)
|
|
|
|
if frac_a > frac_b:
|
|
return (entries_a, path_a), (entries_b, path_b)
|
|
return (entries_b, path_b), (entries_a, path_a)
|
|
|
|
|
|
def proximity_weight(delta, tolerance):
|
|
"""1.0 for an exact match, decaying linearly to just above 0 at the
|
|
tolerance boundary. A match a few seconds off is real signal; a match
|
|
12 seconds off inside a 15s tolerance is mostly noise - weight
|
|
accordingly rather than counting both as one equal 'vote'."""
|
|
return max(0.0, 1.0 - delta / (tolerance + 1))
|
|
|
|
|
|
def find_best_offset(anchor_entries, target_entries, tolerance, offset_range):
|
|
"""Search for a constant clock offset (seconds) between the two
|
|
recordings that maximizes total proximity-weighted overlap between
|
|
target and anchor timestamps. Handles the two Fireflies bots not
|
|
starting at exactly the same instant. Weighted (not a raw count of
|
|
"any match within tolerance") so a wide tolerance can't let a wrong
|
|
offset win just by picking up many loose, low-quality matches."""
|
|
anchor_times = [e['seconds'] for e in anchor_entries]
|
|
best_offset, best_score = 0, -1.0
|
|
for offset in range(-offset_range, offset_range + 1):
|
|
score = 0.0
|
|
for t in target_entries:
|
|
tt = t['seconds'] + offset
|
|
best_delta = min((abs(tt - at) for at in anchor_times), default=tolerance + 1)
|
|
score += proximity_weight(best_delta, tolerance)
|
|
if score > best_score or (score == best_score and abs(offset) < abs(best_offset)):
|
|
best_score, best_offset = score, offset
|
|
return best_offset, best_score
|
|
|
|
|
|
def nearest_match(seconds, anchor_entries, tolerance):
|
|
best, best_delta = None, tolerance + 1
|
|
for a in anchor_entries:
|
|
delta = abs(a['seconds'] - seconds)
|
|
if delta <= tolerance and delta < best_delta:
|
|
best_delta, best = delta, a
|
|
return best, best_delta
|
|
|
|
|
|
def sample_examples(entries, n):
|
|
"""Pick up to n example entries spread across the full span of entries
|
|
(not just the first n) so a spot-check sees variety across the
|
|
meeting's timeline rather than one early cluster."""
|
|
if not entries:
|
|
return []
|
|
if len(entries) <= n:
|
|
return entries
|
|
if n <= 1:
|
|
return [entries[0]]
|
|
idxs = sorted({round(i * (len(entries) - 1) / (n - 1)) for i in range(n)})
|
|
return [entries[i] for i in idxs]
|
|
|
|
|
|
def format_examples(entries, indent=' '):
|
|
lines = []
|
|
for e in entries:
|
|
mm, ss = divmod(e['seconds'], 60)
|
|
snippet = e['text'].strip()
|
|
if len(snippet) > 90:
|
|
snippet = snippet[:90].rstrip() + "..."
|
|
lines.append(f"{indent}[{mm:02d}:{ss:02d}] {snippet}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument('file_a', help='One of the two transcripts (either order - the broken/target roles are auto-detected)')
|
|
ap.add_argument('file_b', help='The other transcript')
|
|
ap.add_argument('--apply', action='store_true',
|
|
help='Write the output file. Without this flag, only the matching report is '
|
|
'printed (dry run) so you can review and confirm before anything is written.')
|
|
ap.add_argument('--tolerance', type=int, default=15,
|
|
help='Max seconds between matched timestamps (default: 15)')
|
|
ap.add_argument('--offset-search', type=int, default=90,
|
|
help='Search +/- this many seconds for a global clock offset between the two recordings (default: 90)')
|
|
ap.add_argument('--min-votes', type=int, default=2,
|
|
help='Minimum matched votes required to resolve a label (default: 2)')
|
|
ap.add_argument('--min-confidence', type=float, default=0.5,
|
|
help='Minimum vote share (0-1) required to resolve a label (default: 0.5)')
|
|
ap.add_argument('--output', help='Output path (default: <target-stem>-speakers-filled.md next to target)')
|
|
ap.add_argument('--no-normalize', action='store_true',
|
|
help='Do not merge duplicate-diarization name variants (e.g. "Name" / "Name1") in the broken file before voting')
|
|
ap.add_argument('--manual', action='append', metavar='"Speaker N=Real Name"',
|
|
help='Force a specific label to a specific name, bypassing matching entirely. '
|
|
'Repeatable. Overrides any automatic result (confident or not) for that label.')
|
|
ap.add_argument('--examples', type=int, default=5,
|
|
help='Example lines (with timestamps) to print per label, spread across its full '
|
|
'timeline, so matches can be spot-checked against the actual recording (default: 5)')
|
|
ap.add_argument('--recording-url',
|
|
help="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 so a reviewer can "
|
|
"jump straight to it and scrub to each example's timestamp. There is no verified "
|
|
"way to encode an exact-timestamp deep link for Fireflies (the parameter their "
|
|
"own UI uses for 'copy link to this moment' isn't publicly documented), so this "
|
|
"links to the recording itself, not a specific moment in it.")
|
|
args = ap.parse_args()
|
|
|
|
manual_overrides = parse_manual_overrides(args.manual)
|
|
|
|
entries_a = parse_file(args.file_a)
|
|
entries_b = parse_file(args.file_b)
|
|
|
|
if not entries_a:
|
|
sys.exit(f"No '**Speaker** *[MM:SS]*: text' lines found in {args.file_a} - check the format.")
|
|
if not entries_b:
|
|
sys.exit(f"No '**Speaker** *[MM:SS]*: text' lines found in {args.file_b} - check the format.")
|
|
|
|
(target_entries, target_path), (broken_entries, broken_path) = classify(
|
|
entries_a, args.file_a, entries_b, args.file_b
|
|
)
|
|
print(f"Auto-detected roles: '{broken_path}' has real names (broken source), "
|
|
f"'{target_path}' has generic labels to fill (target).")
|
|
print()
|
|
|
|
merged_variants = defaultdict(set)
|
|
if not args.no_normalize:
|
|
for b in broken_entries:
|
|
raw = b['speaker']
|
|
normalized = normalize_name(raw)
|
|
if normalized != raw:
|
|
merged_variants[normalized].add(raw)
|
|
b['speaker'] = normalized
|
|
|
|
if merged_variants:
|
|
print("Merged duplicate diarization labels in the broken file (treated as one person):")
|
|
for normalized, raws in sorted(merged_variants.items()):
|
|
variants = sorted(raws | {normalized})
|
|
print(f" {' / '.join(variants)} -> {normalized}")
|
|
print()
|
|
|
|
broken_roster = sorted(set(b['speaker'] for b in broken_entries))
|
|
target_generic_labels = sorted(set(t['speaker'] for t in target_entries if GENERIC_RE.match(t['speaker'])))
|
|
if len(target_generic_labels) > len(broken_roster):
|
|
gap = len(target_generic_labels) - len(broken_roster)
|
|
print(f"NOTE: the target has {len(target_generic_labels)} distinct unnamed speakers but the broken "
|
|
f"file only names {len(broken_roster)} real people ({', '.join(broken_roster)}). At least "
|
|
f"{gap} target speaker(s) are structurally impossible to resolve correctly - they (or their "
|
|
f"voice segments) simply aren't captured with a real name anywhere in the broken file, so "
|
|
f"the closest-timestamp match for them, if any, will be a coincidence, not a correspondence. "
|
|
f"Treat any resolution below with real skepticism, especially ones with few votes.")
|
|
print()
|
|
|
|
offset, offset_score = find_best_offset(broken_entries, target_entries, args.tolerance, args.offset_search)
|
|
|
|
weighted_votes = defaultdict(Counter) # label -> name -> summed proximity weight
|
|
raw_votes = defaultdict(Counter) # label -> name -> raw match count (for the min-votes gate)
|
|
minority_lines = []
|
|
for t in target_entries:
|
|
if not GENERIC_RE.match(t['speaker']):
|
|
continue
|
|
match, delta = nearest_match(t['seconds'] + offset, broken_entries, args.tolerance)
|
|
if match:
|
|
weighted_votes[t['speaker']][match['speaker']] += proximity_weight(delta, args.tolerance)
|
|
raw_votes[t['speaker']][match['speaker']] += 1
|
|
t['_matched_name'] = match['speaker']
|
|
t['_matched_delta'] = delta
|
|
|
|
resolution = {}
|
|
for label, counter in weighted_votes.items():
|
|
total_weight = sum(counter.values())
|
|
ranked = counter.most_common() # all candidates, highest weighted vote share first
|
|
name, top_weight = ranked[0]
|
|
top_count = raw_votes[label][name]
|
|
confidence = top_weight / total_weight if total_weight else 0.0
|
|
resolved = top_count >= args.min_votes and confidence >= args.min_confidence
|
|
candidates = [
|
|
{
|
|
'name': cand_name,
|
|
'raw_count': raw_votes[label][cand_name],
|
|
'share': weight / total_weight if total_weight else 0.0,
|
|
}
|
|
for cand_name, weight in ranked
|
|
]
|
|
resolution[label] = {
|
|
'name': name if resolved else None,
|
|
'top_count': top_count,
|
|
'total_votes': sum(raw_votes[label].values()),
|
|
'confidence': confidence,
|
|
'candidates': candidates,
|
|
}
|
|
if resolved:
|
|
for t in target_entries:
|
|
if t['speaker'] == label and t.get('_matched_name') and t['_matched_name'] != name:
|
|
minority_lines.append((t['line_no'], t['seconds'], label, t['_matched_name'], name))
|
|
|
|
unknown_manual_labels = [label for label in manual_overrides if label not in target_generic_labels]
|
|
if unknown_manual_labels:
|
|
print(f"WARNING: --manual referenced label(s) not found as a generic speaker in the target file: "
|
|
f"{', '.join(unknown_manual_labels)} - ignoring them. Known generic labels: "
|
|
f"{', '.join(target_generic_labels)}")
|
|
print()
|
|
|
|
# Resolve any partial names in --manual against the broken file's real-name
|
|
# roster (e.g. "Dawid" -> "Dawid Cieślicki" if that's the only roster match),
|
|
# rather than taking the literal string when a better match is available.
|
|
manual_final = {}
|
|
for label, raw_name in manual_overrides.items():
|
|
if label not in target_generic_labels:
|
|
continue
|
|
resolved_name, note = resolve_partial_name(raw_name, broken_roster)
|
|
manual_final[label] = resolved_name
|
|
if note:
|
|
print(f"NOTE ({label}): {note}")
|
|
if manual_final:
|
|
print()
|
|
|
|
# Automatic `resolution` is left untouched here (it stays the source of
|
|
# truth for the automatic guess/candidates, printed for every label
|
|
# below regardless of whether a manual override wins); `final_name` is
|
|
# what actually gets written to disk.
|
|
final_name = {}
|
|
for label in target_generic_labels:
|
|
info = resolution.get(label)
|
|
final_name[label] = info['name'] if info else None
|
|
final_name.update(manual_final)
|
|
|
|
lines = Path(target_path).read_text(encoding='utf-8').splitlines()
|
|
resolved_line_count = 0
|
|
generic_totals = Counter(t['speaker'] for t in target_entries if GENERIC_RE.match(t['speaker']))
|
|
for t in target_entries:
|
|
if not GENERIC_RE.match(t['speaker']):
|
|
continue
|
|
name = final_name.get(t['speaker'])
|
|
if name:
|
|
old = f"**{t['speaker']}**"
|
|
new = f"**{name}**"
|
|
lines[t['line_no']] = lines[t['line_no']].replace(old, new, 1)
|
|
resolved_line_count += 1
|
|
|
|
out_path = Path(args.output) if args.output else Path(target_path).with_name(
|
|
Path(target_path).stem + "-speakers-filled.md"
|
|
)
|
|
if args.apply:
|
|
out_path.write_text("\n".join(lines) + "\n", encoding='utf-8')
|
|
|
|
total_generic_lines = sum(generic_totals.values())
|
|
resolved_labels = sum(1 for name in final_name.values() if name)
|
|
|
|
print(f"Global offset applied: {offset:+d}s (best fit: {offset_score}/{len(target_entries)} target lines matched at that offset)")
|
|
if args.apply:
|
|
print(f"Output written to: {out_path}")
|
|
else:
|
|
print(f"DRY RUN - no file written. Would write to: {out_path}")
|
|
if args.recording_url:
|
|
print(f"Recording: {args.recording_url}")
|
|
print("(no verified way to deep-link an exact timestamp - open this and scrub to each example below)")
|
|
print()
|
|
print("Speaker label resolution:")
|
|
unresolved_labels = []
|
|
for label in sorted(generic_totals, key=lambda l: -generic_totals[l]):
|
|
info = resolution.get(label)
|
|
manual_name = manual_final.get(label)
|
|
n_lines = generic_totals[label]
|
|
label_entries = [t for t in target_entries if t['speaker'] == label]
|
|
examples = sample_examples(label_entries, args.examples)
|
|
|
|
# Header line: the FINAL decision for this label (manual wins if given).
|
|
if manual_name:
|
|
agreement = ""
|
|
if info and info.get('name') == manual_name:
|
|
agreement = " - agrees with automatic guess"
|
|
elif info and info.get('name'):
|
|
agreement = f" - OVERRIDES automatic guess of '{info['name']}' ({info['confidence']:.0%} confidence)"
|
|
elif info:
|
|
agreement = " - automatic pass left this unresolved"
|
|
print(f" {label:12s} -> {manual_name:25s} (manually provided{agreement}) - {n_lines} lines")
|
|
elif info is None:
|
|
print(f" {label:12s} -> UNRESOLVED (no timestamp within tolerance found at all) - {n_lines} lines")
|
|
unresolved_labels.append(label)
|
|
elif info['name']:
|
|
print(f" {label:12s} -> {info['name']:25s} ({info['top_count']}/{info['total_votes']} votes, "
|
|
f"{info['confidence']:.0%} confidence) - {n_lines} lines")
|
|
else:
|
|
print(f" {label:12s} -> UNRESOLVED (top guess {info['top_count']}/{info['total_votes']} votes, "
|
|
f"{info['confidence']:.0%} confidence, below threshold) - {n_lines} lines")
|
|
unresolved_labels.append(label)
|
|
|
|
# All candidates with their confidence, for every label regardless of
|
|
# whether the final answer came from automatic matching or --manual -
|
|
# so a manual override's plausibility can still be judged against
|
|
# what the timestamps alone suggested.
|
|
if info and info.get('candidates'):
|
|
winner = info.get('name')
|
|
others = [c for c in info['candidates'] if c['name'] != winner] if winner else info['candidates']
|
|
label_str = "other candidates" if winner else "all candidates"
|
|
if others:
|
|
ranked_str = ", ".join(
|
|
f"{c['name']} ({c['raw_count']}/{info['total_votes']} votes, {c['share']:.0%})"
|
|
for c in others
|
|
)
|
|
print(f" {label_str}, most to least probable: {ranked_str}")
|
|
|
|
if examples:
|
|
print(f" example lines (jump to these timestamps in the recording to verify):")
|
|
print(format_examples(examples))
|
|
if args.recording_url:
|
|
print(f" recording: {args.recording_url}")
|
|
print()
|
|
print(f"Resolved {resolved_labels}/{len(generic_totals)} distinct generic labels, "
|
|
f"covering {resolved_line_count}/{total_generic_lines} generic-labeled lines.")
|
|
|
|
if minority_lines:
|
|
print()
|
|
print(f"{len(minority_lines)} individual line(s) disagreed with their label's majority vote "
|
|
f"(kept the majority name, flagging for manual review):")
|
|
for line_no, seconds, label, minority_name, majority_name in minority_lines[:20]:
|
|
mm, ss = divmod(seconds, 60)
|
|
print(f" line {line_no + 1} [{mm:02d}:{ss:02d}] {label}: nearest match was "
|
|
f"'{minority_name}', used majority '{majority_name}' instead")
|
|
if len(minority_lines) > 20:
|
|
print(f" ... and {len(minority_lines) - 20} more")
|
|
|
|
if unresolved_labels:
|
|
print()
|
|
print("=" * 70)
|
|
print(f"Could not match {len(unresolved_labels)} speaker(s): {', '.join(unresolved_labels)} "
|
|
f"(see their example lines/timestamps above).")
|
|
print("=" * 70)
|
|
print("If you know who any of these are, provide their real names and re-run with, e.g.:")
|
|
example = unresolved_labels[0]
|
|
print(f' --manual "{example}=Real Name"' + (' --manual "..."' if len(unresolved_labels) > 1 else ''))
|
|
|
|
if not args.apply:
|
|
print()
|
|
print("-" * 70)
|
|
print("DRY RUN - nothing was written. Review the resolution table above (and any "
|
|
"unresolved gaps), add --manual \"Speaker N=Real Name\" for anything to correct "
|
|
"or fill in, then re-run with --apply to write the output file.")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|