daymade/transcript-fixer
>- Corrects speech-to-text transcription errors using dictionary rules and Claude's built-in AI (no external API key required — Native AI Correction is the DEFAULT). Stage 3 API is a backup for automation without Claude Code. Builds personalized correction databases that learn from each fix, auto-loads person-name ASR variants from your people roster, and reads per-domain context files that prime the AI pass for context-dependent homophones. Triggers when working with ASR/STT output containing recognition errors, homophones, garbled technical terms, person-name errors, or Chinese/English mixed content. Also triggers on requests to clean up meeting notes, lecture transcripts, interview recordings, or any text produced by speech recognition. Use this skill even when the user just says "fix this transcript", "clean up these meeting notes", or mentions garbled names without invoking ASR specifically.
npx skills add https://github.com/daymade/claude-code-skills --skill transcript-fixer
默认模式:Claude 内置 AI(Native AI Correction)——无需任何外部 API key。
Stage 1 字典纠错(免费、即时)→ Claude 自己读原文做智能纠错 → compound 进字典。
Stage 3 API 仅用于无 Claude Code 的自动化批处理场景(备选)。
Two-phase correction pipeline: deterministic dictionary rules (instant, free) followed by AI-powered error detection. Corrections accumulate in ~/.transcript-fixer/corrections.db, improving accuracy over time.
What each phase is actually good at (calibration, not a rule): the dictionary shines on *recurring* errors — product names, common homophones, anything you've corrected before — at zero cost and zero latency. But on a fresh database, on high-quality ASR (e.g. transcripts from a strong engine like Whisper, Otter, or Feishu / Tencent-Meeting), or in specialized domains (finance, medical, legal), the dictionary often matches almost nothing — the errors that remain are proper nouns and domain terms it has never seen. There, the AI pass does essentially all the real work. Treat Stage 1 as a cheap pre-filter for known repeats, not as the primary corrector, and don't be alarmed when it changes only a handful of lines on a clean transcript.
All scripts use PEP 723 inline metadata — uv run auto-installs dependencies. Requires uv (install guide).
The commands below use relative script paths (scripts/<name>.py), so they only work from the skill's own directory — and in agent harnesses the shell's working directory resets between calls, which surfaces as Failed to spawn: scripts/fix_transcription.py on the very first command. Take the skill directory from the "Base directory for this skill" line printed when this skill was invoked, and either cd there in the same command or prefix every script path with it. Do not rely on $CLAUDE_SKILL_DIR — it is unset in at least some harnesses (verified 2026-08), so a command built on it fails with the same error it was meant to prevent. If you no longer have the invocation line, find -L ~/.claude ~/.codex -name SKILL.md -path '*transcript-fixer*' locates the bundle — but it returns dozens of hits — every installed *version*, plus backups, staging copies and pre-edit snapshots — and the first is not the newest. Skip any path containing skill-before, -workspace, source-sync-backups, .tmp or .staging. Among what remains, prefer the highest version directory; some installs (a marketplace checkout, another agent's skills dir) carry no version at all, so if you end up choosing between those, take the one with the newest mtime and sanity-check it against this file's content before trusting it.
# First time: Initialize database
uv run scripts/fix_transcription.py --init
# Single file — Stage 1 runs in SAFE MODE by default: only low-risk
# (non-word, high-confidence) corrections auto-apply. Medium/high-risk ones
# (common words, <=2-char, real-word fragments) are written to
# *_needs_review.md for you / the AI pass to judge, not applied silently.
uv run scripts/fix_transcription.py --input meeting.md --stage 1
# Trust ONE project domain's rules (recommended for batches): rules of the
# domain you explicitly pass via --domain apply at every risk level — they were
# hand-confirmed for this project's vocabulary, so domain match = trust. The
# roster and everything else keep safe-mode deferral. One pass instead of three
# (safe run -> review sidecar -> --apply-all rerun).
uv run scripts/fix_transcription.py --input meeting.md --stage 1 --domain myproject --apply-domain
# Apply EVERY risk level regardless of origin (the pre-safe-mode behavior).
# Higher false-positive risk — only when you've reviewed ALL loaded rules.
uv run scripts/fix_transcription.py --input meeting.md --stage 1 --apply-all
# Dry run: preview all Stage 1 changes (with risk levels) without writing *_stage1.md
uv run scripts/fix_transcription.py --input meeting.md --stage 1 --dry-run
# Extract likely ASR errors without applying any corrections
uv run scripts/fix_transcription.py --extract-uncertain -i meeting.md -o ./review
# Batch: multiple files in parallel (use shell loop)
for f in /path/to/*.txt; do
uv run scripts/fix_transcription.py --input "$f" --stage 1
done
After Stage 1, Claude reads the output and fixes remaining ASR errors natively (no API key needed). The full method — triage by confidence, verify-don't-guess, second pass, needs-checking list — is in Native AI Correction below; read that section as the source of truth. For a quick, clean transcript it collapses to: read the domain's context file if one exists (~/.transcript-fixer/contexts/<domain>.md) → read the whole thing → fix the obvious one-off errors inline → --add any recurring or project-specific ones (especially names) to a --domain dictionary so they auto-fix next time (see "Project-Specific & Person-Name Corrections").
See references/example_session.md for a concrete input/output walkthrough.
如果你正在 Claude Code 里运行此 skill,跳过本节——直接用上面的 Stage 1 + Native AI Correction,不要跑 --stage 3。
Stage 3 是给 CI/脚本/无 Claude 环境的批量自动化用的,需要额外配置 GLM API key。
# 备选: 仅限无 Claude Code 的批处理
export GLM_API_KEY="<api-key>" # From https://open.bigmodel.cn/
uv run scripts/fix_transcript_enhanced.py input.md --output ./corrected
See references/installation_setup.md for the full config-file format and references/glm_api_setup.md for GLM endpoint details.
Two-phase pipeline with persistent learning:
uv run scripts/fix_transcription.py --init--add "错误词" "正确词" --domain <domain>--input file.md --stage 1 (instant, free)--stage 3 API 模式仅限无 Claude Code 的自动化批处理(需额外配置 GLM API key——见上方 §⚠️ Stage 3 API)。在 Claude Code 内不要跑 --stage 3。--add "错误词" "正确词" after each session--review-learned and --approve high-confidence suggestionsDomains: general, embodied_ai, finance, medical, tech, or custom (e.g., legal, gaming)
Learning: Repeated AI corrections are written to SQLite history; --review-learned turns high-confidence repeated patterns into pending suggestions, and --approve FROM TO promotes the exact suggestion into the dictionary.
*_needs_review.md instead of being applied silently. So Applied: 0 on a clean transcript is correct, not a bug — the risky rules are waiting in *_needs_review.md for you or the AI pass to judge. Pass --apply-all to apply every risk level (the old behavior); --review is kept as a deprecated no-op. This reconnects the risk classifier that was being computed and then ignored — but it does NOT eliminate every false positive: rules whose from_text is a 4+ char valid phrase are still graded low and auto-apply (see references/false_positive_guide.md → "The 4+ char real-word blind spot").--dry-run writes *_dryrun.md with every planned Stage 1 change and its risk level.--changes-file writes *_changes.md with before/after/risk for every correction (on by default in safe mode).--json): prints ONE line of {applied, deferred, output_path, needs_review_path, input_unchanged, review_enqueued} on stdout (the human-readable log is routed to stderr for that run). Consumers read this instead of inferring a no-op from whether *_stage1.md exists on disk — input_unchanged: true (or output_path: null) is the authoritative no-op signal for a domain. This is a cross-skill contract (a caller's pre-classify chain consumes it); keep the field names and semantics stable (review_enqueued was added additively: how many safe-mode deferrals landed in the persistent review queue — see "Review Queue & Dashboard"). Without --json the human-readable output is unchanged.--extract-uncertain -i file.md writes *_uncertain.md with likely errors (short all-caps tokens, transliteration fragments, repeated words) without changing the file.--load-presets tech imports a curated set of tech/Claude Code ASR corrections.--report-false-positive "错误词" "正确词" -d domain disables a bad dictionary rule and lowers its confidence.--audit flags existing rules that look like false-positive sources (common words, ≤2-char, substring collisions, and — with jieba — 4+ char real-word phrases). It is advisory: it surfaces candidates, it does NOT disable anything. Disabling is a human decision — review each hit by hand and back up the DB first, because the audit cannot know your context and mislabels a large fraction of good rules (e.g. GDP 5.5→GPT 5.5 looks wrong generically but is a correct fix for an AI-heavy user). See references/false_positive_guide.md.This skill is often wired into another skill's ingest pipeline — e.g. a meeting-sync skill runs Stage 1 as a pre-classify hook before filing the transcript. That caller pipeline changes one assumption that bites silently, so a caller MUST follow this contract or it will run Stage 1, apply almost nothing, and report success.
The failure mode (verified, reproducible). Safe mode defers medium/high-risk corrections to *_needs_review.md rather than applying them. On a single file you edit by hand, that's fine — you read the sidecar next. But a caller pipeline typically runs transcript-fixer inside a TemporaryDirectory and reads only the corrected transcript.txt back out. The *_needs_review.md sidecar lives in that temp dir and is deleted with it — so 95%+ of the dictionary's corrections silently vanish while the run reports "complete." Real measurement on a 95-minute transcript with a 108-rule domain: safe mode applied 2/108, deferred 106 to a sidecar that was immediately discarded. The run looked clean; only ~2% of known corrections landed. The user then had to run transcript-fixer a second time by hand to get the other 98%.
Caller rule — pass --apply-domain for hand-confirmed project domains. The domains a pipeline wires in (its config domains: list) are exactly the domains whose rules a human already curated for that project's vocabulary. A domain match there is not a guess — it's a confirmed fix — so the pipeline should trust it the same way a batch run does:
# CORRECT for a caller pipeline — trust the configured project domains
uv run scripts/fix_transcription.py --input "$staged" --stage 1 \
--domain "$domain" --apply-domain --json
With --apply-domain, the same 108-rule run applies 97/97 at low risk instead of 2/108. The general domain (catch-all, lower curation) can stay in safe mode — only the project-specific domains earned full trust. If a caller cannot pass --apply-domain, it MUST instead read deferred from the --json status object and either persist the *_needs_review.md sidecar to a non-temp location for a downstream pass, or surface a non-zero deferred count to the user as a failure. Silently dropping deferred corrections and reporting success is the bug.
The --json status line is the contract surface. It prints {applied, deferred, output_path, needs_review_path, input_unchanged} on one stdout line. deferred is the number that must not be silently lost. input_unchanged: true / output_path: null is the authoritative "0 corrections this domain" signal — do NOT infer no-op from whether *_stage1.md exists on disk (the file-presence check is what once aborted the whole chain and dropped corrections). Keep these field names and semantics stable; a caller's pre-classify chain depends on them.
The complementary side: keep the dictionary warm. A caller pipeline that trusts --apply-domain only delivers value to the degree its project domain is populated. Every confirmed correction the downstream native pass makes should be --added back to that domain (--add "ASR-variant" "correct" --domain <project>), so the next ingest auto-fixes it and the native pass keeps getting lighter. A cold domain + --apply-domain still applies almost nothing — the fix is --apply-domain *and* ongoing --add discipline together.
After fixing, always save reusable corrections to dictionary. The skill's core value — see references/iteration_workflow.md for the complete checklist.
After native AI correction, review all applied fixes and decide which to save. Use this decision matrix:
| Pattern type | Example | Action |
|-------------|---------|--------|
| Non-word → correct term | 克劳锐→Claude, cloucode→Claude Code | ✅ Add (zero false positive risk) |
| Rare word → correct term | 拉行链→LangChain, 哈金费斯→Hugging Face | ✅ Add (verify it's not a real word first) |
| Person/company name ASR error | 卡帕西→Karpathy, Anthropics→Anthropic | For important recurring people, add to your people roster instead (see "People Roster" below) — it carries relationship context and survives DB resets. For one-off names: ✅ --add --domain (stable, unique) |
| Common word → context word | 争→蒸, 减→剪, affect→effect | ❌ Never add as a rule — record the trap + its disambiguating cue in the domain's context file instead (see "Domain Correction Contexts") |
| Real brand → different brand | Xcode→Claude Code, Clover→Claude | ❌ Skip (real words in other contexts) |
| Real name → different real name | 李明→黎明 (two real people in different projects) | ❌ Never a rule — same hazard as real brand → brand, but it corrupts a real person's name. Domain context trap with a disambiguating cue instead (see the user-verdict refinements in Native AI Correction step 4) |
The middle path, and it applies to exactly one of the ❌ rows. The
*common word → context word* row (争→蒸) forbids a bare common word as a
rule, because it fires everywhere the word is legitimately used. It does not
forbid the same fix carried by enough surrounding text that the phrase only
occurs in the mishearing — 村里商量 → <name>商量 is defensible where bare
村里 would be reckless. **The *real name → different real name* row is not
relaxed by this and never anchored into the dictionary**: keep it in the domain
context file as the row itself says.
That exclusion holds because **the validator cannot be trusted either way on a
person's name.** --add runs a jieba check that warns when the FROM side
decomposes into all-known words, and whether a name counts as "known" is an
accident of jieba's dictionary: measured, 李娜商量 warns (李娜 has frequency
438) while 张伟商量 is silent (张伟 is out-of-vocabulary, frequency 0). So a
name-anchored rule that passes quietly tells you nothing, and one that warns
tells you nothing either. With no reliable signal on the class whose blast radius
is a real person's name in every future transcript, the row stays out. (The same
reasoning excludes the *real brand → different brand* row: Xcode→Claude Code
is right in one project and destroys a build log in the next, and no validator
knows which one you are in.)
Warning versus error, because they end differently. A valid_phrase warning
means *review this by hand*, not *it was rejected* — the rule is added and
--add exits 0. common_word and both_common are errors: --add exits 1
and writes nothing, and --force is the only way past. substring_collision
is *both*, depending on which branch fires — a hit against the curated collision
map is an error, while the broader dynamic check is only a warning and the rule
lands. So read the exit status rather than the noise: a loud add may have
succeeded, and a rule you believe you saved may not be in the database at all.
Reach for --force only after reading *which* check objected, since it silences
the blocking ones too.
One caveat decides whether an anchored rule is worth adding: anchor to a
recurring collocation, not to a one-off sentence fragment. A snippet of one
particular sentence never matches again — it costs a dictionary row, compounds
nothing, and dead rows are what make a domain slow to load and hard to audit.
When even a collocation would be too narrow, the trap belongs in the domain
context file with its disambiguating cue.
Batch add multiple corrections in one session:
uv run scripts/fix_transcription.py --add "错误1" "正确1" --domain tech
uv run scripts/fix_transcription.py --add "错误2" "正确2" --domain business
# Chain with && for efficiency
Confirmed corrections compound through the dictionary; uncertain ones used to
evaporate — the native pass listed them in chat (gone when the session ends),
safe-mode deferrals sat in a *_needs_review.md sidecar (discarded by temp-dir
callers), and learned suggestions waited behind a CLI nobody ran. The review
queue gives all three one persistent home in corrections.db (review_items),
and the dashboard makes deciding them nearly free — that friction is what stood
between "AI suspects an error" and "the dictionary learns the answer."
Queue CLI (all support --json):
# Enqueue uncertain items (native pass step 7 does this; '-' reads stdin)
uv run scripts/fix_transcription.py --enqueue-review items.json
# Inspect
uv run scripts/fix_transcription.py --list-review # pending, priority-sorted
uv run scripts/fix_transcription.py --show-review 12 # full evidence + action pack
# Decide (agent path — humans use the dashboard)
uv run scripts/fix_transcription.py --resolve-review 12 --decision accepted --by reviewer
uv run scripts/fix_transcription.py --resolve-review 12 --decision overridden --override-to "正确词" --note "<evidence>"
uv run scripts/fix_transcription.py --resolve-review 12 --decision kept_original # transcript was right
uv run scripts/fix_transcription.py --resolve-review 12 --decision reopen # undo (reverts applied edits)
Each item carries: the original text (left untouched in the file), a pre-filled
suggestion, kind (entity/unknown lead the queue — they compound into
dictionary+roster; homophone/wording trail), the evidence your search ladder
produced, and an optional action pack executed on accept: file_edit
(replace in the transcript), dict_add (add to a --domain dictionary),
append_note (add a trap line to a domain context file). No action pack + a
file anchor = the default single file_edit.
Fail-closed anchor guard: the whole action pack is planned in memory
against the CURRENT file state (each edit validated against the content as the
pack's previous actions left it), and only when every action plans successfully
does anything reach disk — original text missing (file edited since enqueue),
ambiguous (multiple occurrences with no unique winner near the line hint), or a
drifted context (no nearby line matches the snippet recorded at enqueue) →
nothing is written, the CLI exits 2 with a {"error": "re_anchor_needed"}
status object, and the item stays pending. A wrong auto-edit is worse than a
missed one. Machine callers should parse the stdout error field rather than
the bare return code (argparse usage errors also exit 2). On overridden, only
retargeted file_edits run — suggestion-specific dict_add/append_note
actions are dropped (they were planned for a suggestion the human rejected).
(One scope note: the context check only runs when the original occurs MORE
THAN ONCE — a unique occurrence has no look-alike to refuse, so a
single-occurrence edit applies without consulting the snippet.)
When the guard refuses: --reanchor-review repairs the item. A refusal is
not a dead end and NOT a cue to hand-edit the file around the queue — that
leaves the item pending forever and the edit unaudited. Run the re-anchor and
then verdict again:
uv run scripts/fix_transcription.py --reanchor-review <id> [<id>...]
# file itself is gone (moved/renamed/cleaned)? add search root(s):
uv run scripts/fix_transcription.py --reanchor-review <id> --reanchor-root <dir-with-transcripts>
Two drift shapes are repaired against current disk state, both fail-closed:
context/line drift (file edited since enqueue — re-locates original in
the file, preferring lines that still match the RECORDED context snippet over
mere distance, refreshes line + verbatim context) and file gone (searches
the recorded parent dir plus every --reanchor-root for *.md containing
original; exactly one candidate re-points the anchor, zero changes nothing,
and multiple asks for --reanchor-to FILE — the explicit-target form, which
is itself refused if original is not in it). After a successful re-anchor,
the guard's context check passes and A/W/CLI resolve proceed normally
(explicit action packs get their file_edit path rewritten to the new file).
The refusal messages themselves name this command. (Root-caused 2026-08-03: an item
enqueued with a PARAPHRASED context could never be verdicted — the human's
override died at the guard and the file got hand-edited around the queue
before this command existed.)
**Enqueue validates anchors verbatim — authoring errors die at enqueue, not
at verdict.** When an item declares a readable file, --enqueue-review
checks that original (and context, if given) literally appears in it, and
repairs a line hint that points beyond the resolve window (±3 lines) of a
UNIQUE match (a hint inside the window works as-is and is left alone; repairs
are printed to stderr). Anything else is REJECTED on the spot with the
reason, and the run exits 3 — the JSON carries the rejects under
rejected_unanchored (items under added WERE enqueued; fix the rejects and
re-enqueue them). context must be copied verbatim from the file; a
paraphrase drifts the anchor at the first surrounding edit. (Files that don't
exist yet are not validated — e.g. items enqueued for a file on another
machine; the resolve-time guard owns that case. stage1_deferred items are
also exempt — their from_text is the engine's evolving text after earlier
rules applied in-memory, legitimately not in the input file yet.)
One verdict fixes one occurrence — sweep the siblings yourself. A resolved
item edits exactly one span. When the original text occurs several times the
guard does not edit them all: it picks the occurrence nearest the recorded line
hint whose context matches, and refuses (re_anchor_needed) when it cannot
choose — no line hint at all, nothing matching near the hint, or two occurrences
equally near it. Either way the other occurrences are left standing,
including on the very line the verdict just edited, which is
where a repeated name is most likely. Measured on one real batch: ten items
resolved, four of them left six more occurrences behind, two of those on a line a
verdict had already touched. So a verdict batch has a second half:
# 1. See what was actually decided. The default listing shows PENDING only —
# the items you just resolved are precisely the ones it hides.
uv run scripts/fix_transcription.py --list-review --review-status accepted
uv run scripts/fix_transcription.py --list-review --review-status overridden
# 2. Read the verdict that was recorded, per item.
uv run scripts/fix_transcription.py --show-review <id> --json
Take the replacement from resolved_text, never from the listing line. On
an override the human's typed text lands in resolved_text while
suggested_text still holds the suggestion they *rejected* — and the
human-readable listing prints the suggestion. Propagating from that line pushes
the rejected answer into every remaining occurrence, which is worse than leaving
them alone. An override is free text, so read it before propagating: a typo
typed once otherwise becomes a typo in five places.
Fix the remaining occurrences with Edit, or a sed scoped to that one file
— this is within-file propagation of a decision a human already made, not the
cross-file find-and-replace the batch rules forbid — then re-grep to confirm.
Sweep entity-kind items only. A homophone or wording verdict is a
judgement about *that sentence* — those are the context-dependent class step 5
says to anchor to surrounding text, and the class the 争→蒸 row keeps out of
blanket rules. Propagating one across a file is the mistake the dictionary matrix
exists to prevent.
**And within entity, a verdict settles the entity, not every token that sounds
like it** — this is step 4's carve-out, unchanged. An occurrence that is a
*referred-to* third party rather than the person being addressed ("I'll ask
<token> from the bank") can legitimately need the opposite answer: leave it and
enqueue it on its own. A verdict the human reached by listening to one clip
deserves the same caution — those seconds of audio settle that utterance, and a
second occurrence is a second utterance. Sweep the occurrences that are plainly
the same entity in the same sense; that is the ordinary case, and the one the
measurement above counted.
Sweep after the whole batch is resolved, not between verdicts. A swept
occurrence that a still-pending item is anchored to will fail that item's guard
(re_anchor_needed, exit 2) and have to be re-enqueued.
An override does not compound on its own — finish it with --add. On
overridden the queue drops the dict_add / append_note actions (they were
planned for the suggestion the human rejected), so the strongest signal in the
whole loop — a human personally correcting the AI — is the one case that never
reaches the dictionary unless you put it there:
--add "<original>" "<resolved_text>" --domain <project>, subject to the
real-word rules above.
Dashboard (single reviewer, local):
uv run scripts/review-dashboard/server.py # opens http://127.0.0.1:8767
Prodigy-style single-focus card: live file context with the anchor line
highlighted, suggestion pre-filled, evidence shown, keyboard-first —
Q play the utterance · A accept · R original-is-correct · W override
(type the right text) · S skip/can't judge · Z undo · ↑↓/J K navigate
(verdict keys deliberately cluster on the left hand; the right hand stays on
the mouse). Env knobs: REVIEW_DASHBOARD_PORT (default 8767),
REVIEW_DASHBOARD_NO_BROWSER=1 to skip auto-opening a browser tab.
Reads go straight to the DB (read-only); every write shells out to the CLI,
so the state machine, anchor guards, and audit log stay the single source of
truth, and agent (CLI) and human (page) are equal writers.
Audio playback (Q) — often the reviewer can't judge a garbled utterance
from text alone; hearing the original second settles it. A transcript opts in
by declaring its recording EXPLICITLY in frontmatter (no implicit directory
scanning — if the field is absent, the card simply has no play button):
---
date: 2026-08-02
minute_token: abc123
audio: /absolute/path/to/recording.m4a
---
The audio: line is the one you add; the others stand for whatever the
transcript already carries. It is written bare on purpose — see below, and
note that this example is copied verbatim often enough that a trailing #
annotation on that line has shipped as a real bug more than once.
**Add the line to the block the transcript already has — do not append a second
one.** A synced transcript normally arrives with frontmatter (date,
minute_token, participants…), and the parser stops at the first ---
terminator it meets, so a second block below it is never read.
Write the value bare — no trailing comment. The parser takes everything after
the first colon (line.split(":", 1)[1].strip()) and does not strip #, so
audio: /path/x.m4a # same timeline becomes a path ending in # same timeline,
which does not exist. Same for the block's shape: it must open at line 1, be
closed by its ---, and the key must sit unindented.
Every one of those mistakes fails the same way — the card shows **no play button
and no error**, which reads exactly like "this transcript has no audio." If a
card you expected to have audio doesn't, suspect the frontmatter before you
suspect the recording.
The file must be on the same timeline the transcript's timestamps refer to —
the exact file fed to the ASR. A transcript produced from a 1.3x-speed input
pairs only with the 1.3x file; pairing it with the original makes every clip play
the wrong seconds.
The dashboard derives the clip window from the speaker-timestamp lines
(<speaker> HH:MM:SS.mmm) around the anchor, streams the file with HTTP Range
(instant seek, no full download), and plays just that utterance; ± 3s widens
the window when the cut lands mid-sentence. Verify the timeline pairing once
per recording source (ffprobe duration ≈ the transcript's last timestamp) —
a mismatched speed rate plays the wrong seconds everywhere.
Wiring audio for a Feishu-minute transcript (the common case when the
transcript came from a minutes-sync pipeline) — use the bundled script, which
does the download, the timeline check, and prints the frontmatter line:
uv run scripts/fetch_minute_audio.py \
--token <minute-token> --profile <lark-cli-profile> \
--output ~/.transcript-fixer/cache/audio/<name>.m4a \
--transcript <path/to/transcript.md>
Both arguments come from outside the transcript's body. --token is the
minute_token: field in the transcript's own frontmatter (a minutes-sync
pipeline writes it there; if it is absent, the minute URL's last path segment is
the same value). --profile is a lark-cli profile name — list them with
lark-cli profile list and pick the one belonging to the account that owns the
recording; the transcript does not record it, so if the owner is not obvious,
ask rather than guess (a wrong profile fails in the silent way described below).
Keep the audio outside the docs repo — a media blob should not ride into its git.
Exit codes — check the status, not the output: diagnostics go to stderr while
the audio: line goes to stdout, so a run that verified nothing still prints a
usable-looking line.
| code | meaning |
|---|---|
| 0 | verified — audio and transcript share a timeline |
| 1 | timeline mismatch: a file downloaded, but do not wire it |
| 2 | downloaded, pairing unverified — ffprobe absent or its output unusable, no --transcript, the transcript has no <speaker> HH:MM:SS.mmm lines, or every one of them is 00:00:00 (argparse also exits 2 on a malformed invocation; its message says so) |
| 3 | nothing usable produced — bad --transcript path (checked before any network work), or the fetch failed: lark-cli errored, curl failed, the download was too small, or the --profile cannot read this minute, which is the most common cause and is not a bad token |
A 2 caused by missing speaker-timestamp lines is worth stopping for rather than
working around: the dashboard builds its clip windows from those same lines, so
audio wired to such a transcript has nothing to play.
The by-hand route, for when lark-cli is unavailable or the script fails:
mkdir -p ~/.transcript-fixer/cache/audio && cd $_ # --output below accepts only
# a relative path inside the
# CURRENT dir ("../" refused)
LARK_CLI_NO_PROXY=1 lark-cli minutes +download \
--minute-tokens <token> --profile <profile> --output ./audio.m4a
# If that trips the SSRF guard, take the signed URL and fetch it yourself.
# Parse the envelope as JSON — a regex scrape leaves escapes literal and
# truncates the URL at its first parameter:
URL=$(LARK_CLI_NO_PROXY=1 lark-cli minutes +download \
--minute-tokens <token> --profile <profile> --url-only \
| python3 -c 'import sys,json
raw = sys.stdin.read() # the CLI may print prose around the
s, e = raw.find("{"), raw.rfind("}") # JSON, so isolate the object first
print(json.loads(raw[s:e+1])["data"]["download_url"])')
[ -n "$URL" ] || { echo "no download_url — check the profile"; exit 3; }
curl -sSL --noproxy '*' -o audio.m4a "$URL"
# Verify the pairing yourself: compare the duration against the transcript's
# LAST speaker timestamp. Treat a gap over max(60s, 5% of that timestamp) as a
# mismatch — recordings usually run a minute or two past the last utterance,
# but a speed-rate mismatch shows up as a large proportional gap.
ffprobe -v quiet -show_entries format=duration -of csv=p=0 audio.m4a
Three things the script encodes, each of which is a real failure by hand:
blocked download URL: local/internal host is not allowed — Feishu's
signed-download domain is literally named internal-api-drive-stream.… and
the internal- prefix trips the guard. The fallback is --url-only plus your
own curl -L, which is what the script runs.
--url-only envelope is real JSON — parse it, don't pattern-match it.The URL lives at data.download_url (nested, not top level), and a regex
scrape leaves JSON escapes such as & literal, producing a URL that
truncates at its first parameter and downloads a redirect stub instead of
audio. json.loads handles this natively and a hand-rolled extraction is
where the escaping bug comes from.
--profile is the partthat usually fails, not the token.** A profile from another tenant — or one
the minute was never shared with — authenticates fine and still returns no
download_url. Pass the profile belonging to the account that owns the
recording.
Wire the audio before enqueueing items you intend to have judged by ear
(step 4 routes cross-language proper nouns there) — otherwise the reviewer opens
a card with no play button and no way to answer the question you asked.
Stage 1 integration: safe-mode deferrals are auto-enqueued
(source: stage1_deferred) at run time, so a caller discarding the sidecar no
longer loses them. Exception: an input under the OS temp dir is NOT enqueued
(the anchor would be a dead pointer once the staging copy vanishes) — the
--json deferred count still reports those to the caller, and the additive
review_enqueued field says how many landed in the queue.
Adding wrong dictionary rules silently corrupts future transcripts. Read references/false_positive_guide.md before adding any correction rule, especially for short words (≤2 chars) or common Chinese words that appear correctly in normal text.
--domain isolation)The most important pattern for recurring, project-specific errors — person names, project jargon, product codenames — is the --domain flag. It is also the *answer* to the false-positive worry above: a person-name fix that's right in your project (a teammate's name the ASR keeps garbling) might collide with a real, differently-spelled person in someone else's transcript — so it must NOT go into the global (general) dictionary.
--domain makes such rules safe by isolating them:
# Add the rule under an isolated, project-named domain (not 'general')
uv run scripts/fix_transcription.py --add "<ASR-garbled-name>" "<correct-name>" --domain <project>
# Apply ONLY that domain's rules to this project's transcripts
uv run scripts/fix_transcription.py --input meeting.md --stage 1 --domain <project>
A rule added under --domain <project> only fires when you pass --domain <project> at correction time. Other projects (their own domain, or default all) are unaffected — so even a risky short-word / common-word person-name rule is safe, because it only fires inside the project where it's correct.
Facing a transcript — or a whole batch — full of the same ASR-garbled names, the tempting move is a quick sed / python find-and-replace. Don't. That is the single biggest anti-pattern with this skill:
--add once, and every future transcript auto-corrects via --stage 1 --domain <project>. Wire that one command into the project's ingest step and the names are fixed forever, for free.audit command, --report-false-positive); a raw sed has none and will silently corrupt look-alike words.Rule of thumb: recurring or project-specific error → --add ... --domain <project> (it compounds). Never a throwaway sed/python replace. A one-off script is acceptable only for a genuinely one-time, never-recurring fix — and even then the dictionary is usually less effort.
ASR is especially unstable on Chinese names: one person can shatter into a dozen homophone variants (in one real project a single surname+given-name was seen as 13+ [姓变体]×[名变体] combinations). Capture every confirmed variant with --add --domain <project> so they all collapse to the canonical name on every future run.
For important recurring people whose names ASR consistently garbles
(coworkers, clients, family, workshop attendees), maintain a people roster
markdown file — the SSOT for person names — rather than adding them one-by-one
to the DB. Transcript-fixer auto-loads person-name corrections from this roster
at Stage 1 time when people_roster_path is set in
~/.transcript-fixer/config.json.
Roster format (canonical: ### Name + - ASR 变体: variant1, variant2):
### Nina Zhao
- **ASR 变体**: Nena, 妮娜
### 小雨
- **ASR 变体**: 晓雨, 小宇老师
Both example shapes are worth copying. An English given name spoken inside
Chinese speech produces *two* kinds of variant — a misspelling (Nena) and a
Chinese transliteration (妮娜) — and a Chinese nickname produces homophone
variants plus honorific forms (小宇老师). List every form you have actually
seen; each one is a rule that fires for free.
Setup (once):
# Edit ~/.transcript-fixer/config.json and add:
# "paths": { "people_roster_path": "/path/to/people.md" }
After this, every --stage 1 run automatically merges roster corrections
(in-memory only — never written to DB). The DB always wins on conflicts, so the
roster fills gaps without overriding hand-tuned entries. See
scripts/core/people_roster.py for the parser.
When to use the roster vs --add to DB:
| Person | Go to | Why |
|--------|-------|-----|
| Long-term recurring (coworker, client, family, workshop attendee) | people.md | SSOT with relationship context; survives DB resets |
| One-off / minor name | DB (--add --domain) | Quick, no context needed |
The dictionary handles deterministic replacements; the people roster handles names. A third class of error can't safely live in either: context-dependent homophones — words that are only wrong in a particular discussion context. Think 减→剪 in a meeting about producing N video clips per day, or a finance call where a common word collides with a ticker nickname. A dictionary rule on a common word silently corrupts every other transcript, and a generic AI pass lacks the domain prior to fix it confidently — it either guesses wrong or leaves it for the human. (Real case: a transcript had four 减到 N 条 occurrences that all meant 剪到; the AI pass suspected but wouldn't touch them without a domain prior, and the user had to fix them by hand.)
Domain context files close this gap. One markdown file per domain, in user space next to your corrections.db and people.md (never inside the skill bundle — it survives skill updates and keeps project knowledge private):
~/.transcript-fixer/contexts/<domain>.md
(If you relocated the config dir via TRANSCRIPT_FIXER_CONFIG_DIR, contexts live under that dir's contexts/.)
During native correction (see workflow below), read the transcript's domain context file before triaging. It should contain three things:
剪 is intended"), optionally with a dated real exampleWhat must NOT go in a context file: hard replacement rules. 减→剪 as a rule belongs in NEITHER the context file NOR the dictionary — the file primes your judgment with priors and cues; it never authorizes blind replacement. Every fix still goes through the confidence triage below.
Maintenance loop (mirrors the dictionary's --add habit): when a native session surfaces a context-dependent recurring error — you fixed it here, and it'll recur in this domain's future transcripts — append it to the domain's context file with its disambiguating cue. Deterministic non-word/name fixes keep going to --add --domain / the roster as before.
Format and a worked template: references/domain_context_guide.md.
Note: contexts are consumed by the native workflow (the agent reads the file — no code involved). API mode (--stage 2/3, the backup channel) does not inject them yet; if that channel gets completed, the same files should feed its prompt.
When running inside Claude Code, use Claude's own language understanding for Phase 2 — on high-quality ASR this is where almost all the real correction happens. Scale the effort to the transcript. Don't turn a 10-second memo into a research project, but don't starve a 90-minute strategy call either. Pick the tier from the recording's shape, not your mood:
| Signal | Fast tier (minutes, not hours) | Full tier (the whole ladder earns its keep) |
|---|---|---|
| Length | short (≤ ~15 min / a few hundred lines) | long (30+ min / 1000+ lines) |
| Speakers | one or two, names you already know | 3+ speakers, or unfamiliar names |
| Vocabulary | plain language, no domain jargon | domain-heavy (finance/medical/legal/project codenames) or many proper nouns |
| Stakes | internal memo, throwaway | client-facing, committed to a shared repo, drives a decision |
--apply-domain), read the domain context file if one exists, read the whole thing once, fix the obvious one-off errors inline, --add any recurring/project-specific term to a --domain. Skip: the cross-domain name ladder, the second-pass subagent, the needs-checking ceremony. One linear pass, done.A recording can be long but still fast-tier (two known speakers, plain language) or short but full-tier (a 5-minute call full of unfamiliar drug names that feed a report). Let the *vocabulary and stakes* call the tier, with length as a tiebreaker — that's where the real work is.
Correction scope includes the metadata lines, not just the body. A filed transcript usually carries ASR-derived metadata — a Keywords: line, frontmatter, a title — and those lines contain the *same* recognition errors as the spoken body (e.g. a Keywords: line still listing 克劳锐 when every body mention was already corrected to Claude). Fix them with the same rules. There is no "metadata is sacred, leave it" exception: the metadata is a search/grep surface too, and a keyword left in its ASR-garbled form will silently fail every future grep Claude while the body looks clean. When you re-grep the final file to confirm a correction landed, include the metadata lines in that check.
And when the input already passed through an automated corrector (a sync pipeline's pre-classify stage, a previous Stage 3 API run), your input is NOT raw ASR — upstream corrections are baked in with no evidence trail. Before triaging, diff against the raw source (the caller's raw transcript — sync engines typically keep one alongside the corrected copy, e.g. transcript_raw.txt — or re-pull from the source API). Two things fall out of that diff, in opposite directions: (a) every upstream entity swap is itself a suspect in step 4's triage, because an upstream AI "correction" can be a fluent wrong guess — real case: raw ASR 「新的车辆」 was "smoothed" by a pipeline AI into 「新出来的反馈」 (grammatical, plausible, wrong: the speaker said a near-homophone name), and only the raw diff caught it; (b) what upstream already fixed correctly is settled — check the diff *before* proposing a fix that's already applied, or you redo work and risk "fixing" a correct form back to a wrong one
~/.transcript-fixer/contexts/<domain>.md exists for this transcript's domain, read it first — it primes which homophone traps to suspect and names the authoritative sources for step 4's ladder (see "Domain Correction Contexts" above). Then read the entire transcript before proposing corrections — later context disambiguates earlier errors (a name garbled near the start often becomes obvious later). For large files, read in chunks but finish the whole thing before deciding anythingBefore searching anywhere, collect the set of speaker labels in the file;
if a garbled token matches one by SOUND, it is almost certainly that same
person, and the label carries the spelling — a label is copied from a name
*registry* (a human annotating the recording, or the attendee list /
voiceprint enrollment a diarizer matched against), while the body is raw
ASR of a spoken sound. Four qualifiers, and the first one is not optional.
**(a) Apply it only to a name being ADDRESSED or SELF-INTRODUCED, never one
being REFERRED TO.** "hi, <token>" and "my name is <token>" identify a
speaker; "I'll ask <token> from the bank" identifies a third party who may
merely *sound* like one — and rewriting them to a speaker's name corrupts a
real person, the exact hazard the dictionary table calls "Real name →
different real name ❌ Never a rule". Two acoustically identical tokens in
one file can need opposite answers for this reason alone, so a referred-to
name keeps walking the ladder rather than resolving here.
**(b) Match against ALL labels — including, but not limited to, the one
above the block** — a garbled name usually sits in a block spoken by
someone else (A greeting B), and sometimes in that speaker's own block
(a self-introduction). **(c) The label settles WHO; the roster still
settles the canonical spelling** — a hand-typed Joe normalizes to the
roster's Jo, spelling only, never length. **(d) Labels annotated by a
human are a human identification: apply them, and do NOT put that name on
the needs-checking list or ask the user to confirm it — they answered it by
labeling it.** (Real case: walked the whole ladder on a name printed above
every one of that speaker's blocks, found nothing, then asked the user to
confirm it. They had labeled it themselves.)
When you cannot tell whether labels were hand-annotated or auto-assigned,
assume auto-assigned: voiceprint matching can attach a perfectly
spelled name to the wrong speaker, and that never looks garbled — so treat
the label as a strong candidate to confirm through the ladder, not as a
stop condition. Fall through to the ladder for 说话人 N / Speaker N,
role labels (主持人 / Interviewer), a third party who is not one of the
speakers, or a label that is itself visibly garbled. Fix the body only
— never edit a label or reassign who said what — keep the edit minimal
(do not expand a given name into a full name nobody said), and --add the
confirmed variant to a --domain so the next transcript fixes it free.
X小Y when the roster or dictionary already holds X晓Y (小/晓 are the same sound) is the same person → Confident fix — do NOT downgrade it to Uncertain just because 小≠晓 on the page. Same logic for a foreign name whose syllables all map by sound to a near-homophone transliteration. The dictionary having a sound-alike canonical is *evidence for* the fix, not a mismatch to be dismissed.This does not reopen the (a) bullet above. That rule forbids resolving a *referred-to* token into one of the speakers' names, using speaker labels as the source — the failure mode where a third party gets overwritten with whoever is in the room. This class is the opposite direction: the token resolves to a known non-speaker whose canonical form comes from the roster or the project's ledger, and it is settled by the human's ear rather than by a label. Where the two are hard to tell apart, the (a) bullet wins and the token keeps walking the ladder.
So a candidate that fails the sound test but sits in a known person's slot is neither dismissed nor rewritten: enqueue it as kind: entity for audio verification (the dashboard's Q is the instrument for exactly this). Put your best candidate in suggested even when you doubt it — an item with no suggestion cannot be accepted at all (--decision accepted errors on it), so the reviewer would be forced to retype the answer for every card. Wire the transcript's audio *before* enqueueing (see the dashboard's audio section): the frontmatter is read live at view time, so adding it later does light up the play button — but editing the transcript shifts line numbers against the ±3-line window each item's anchor was recorded with, which is the expensive half to undo.
--stage 1, which may be brand-new and empty. Querying only that one and giving up looks exactly like "I checked" while finding nothing that was right there.their→there where context forces it; 彭波→彭博 when every other mention already reads 彭博). Apply directly (step 5).people.md (or wherever people_roster_path in~/.transcript-fixer/config.json points). This is your curated SSOT
of long-term recurring people with their ASR variants annotated under
- ASR 变体:. A garbled name that already maps to a canonical
person here — e.g. Nena→Nina Zhao, 小宇老师→小雨 — is a
Confident fix: apply immediately. **This one step replaces asking the
user for every name they've already documented.** Skip only for
transcripts whose speakers are confirmed NOT in the roster.
corrections.db, not just the current --domain. The same entity shatters into different ASR variants across projects, and every prior fix already collapsed them to the canonical name — so the answer is often sitting in another domain you didn't pass to --stage 1. Checking only the current domain and giving up is the recurring failure mode.sqlite3 ~/.transcript-fixer/corrections.db "SELECT from_text, to_text, domain FROM active_corrections WHERE to_text LIKE '%<fragment>%' OR from_text LIKE '%<fragment>%';"
grep -rl "<fragment>" <project-dir> then read the hits. (The domain context file you loaded before triage usually names the project's alias ledger explicitly — start there.) Read every name table the ledger holds, not just the one that looks like "the speaker list." A project's people are almost always split across role-based tables — internal speakers, external collaborators, client-side, vendor/dealer-side, attendees — and the person you're chasing often lives in a sibling table you didn't open. If a name you end up confirming wasn't reachable from the context file's name-source manifest, that manifest is incomplete: add the missing table to it so the next run can't miss it. (See domain_context_guide.md Rule 6 for the failure case this prevents.)~/.claude/.../memory/) — project relationship maps and person profiles often record canonical names explicitly.Only after all of these strike out do you ask the user — and by then you've shown the entity isn't already recorded on this machine, which makes the ask legitimate. A confirmed result becomes a Confident fix; if the search *can't* confirm it, it drops to Uncertain. Batch these: collect the unique unknowns and run the ladder once per unique entity, not once per occurrence.
And when the user answers, their verdict is ✅ authoritative — the strongest source in this whole loop — and it compounds three ways in the same session. A user who says "X is actually Y (my colleague on team Z)" has handed you a source stronger than any local document. Cash it in immediately: ① apply the fix; ② persist the variant where it compounds — an important recurring person goes to the people roster (per the roster-vs-DB table above), a project term or one-off name goes to --add ... --domain <project> (the same ASR will mishear the same name again next week); ③ record it in the ledger / roster / domain context with the user's words, the date, and a ✅ "user-confirmed" marker — no later session should re-ask. Two refinements learned the hard way:
李明), a 李明→黎明 dictionary rule will corrupt that person's future transcripts. That fix belongs in the domain context file as a trap with its disambiguating cue ("in editing-team context, 李明 = 黎明"), never in the dictionary.--add it to a --domain so it compounds to every future run; for a genuinely one-off term, one sed -i '' with multiple -e flagsTake daymade/transcript-fixer from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.