#!/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[^*]+)\*\*\s*\*\[(?P\d{1,2}:\d{2}(?::\d{2})?)\]\*:\s*(?P.*)$' ) 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: -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()