diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml new file mode 100644 index 0000000..8c4f5e6 --- /dev/null +++ b/.github/workflows/python-quality.yml @@ -0,0 +1,35 @@ +name: python-quality + +on: + pull_request: + push: + branches: + - main + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Check formatting (ruff) + run: uvx ruff format --check skills/distill-knowledge/scripts + + - name: Lint (ruff) + run: uvx ruff check skills/distill-knowledge/scripts + + - name: Syntax check (compileall) + run: python -m compileall -q skills/distill-knowledge/scripts + + - name: Run tests + run: uvx pytest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c0b419d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.13 + hooks: + - id: ruff-format + files: ^skills/distill-knowledge/scripts/.*\.py$ + - id: ruff-check + files: ^skills/distill-knowledge/scripts/.*\.py$ + + - repo: local + hooks: + - id: python-compileall + name: python compileall (syntax check) + entry: python3 -m compileall -q skills/distill-knowledge/scripts + language: system + pass_filenames: false diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e661641 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: format lint syntax test quality install-hooks + +PY_SCRIPTS=skills/distill-knowledge/scripts + +format: + uvx ruff format $(PY_SCRIPTS) + +lint: + uvx ruff check $(PY_SCRIPTS) + +syntax: + python3 -m compileall -q $(PY_SCRIPTS) + +test: + uvx pytest + +quality: + uvx ruff format --check $(PY_SCRIPTS) + uvx ruff check $(PY_SCRIPTS) + python3 -m compileall -q $(PY_SCRIPTS) + uvx pytest + +install-hooks: + uv tool install pre-commit + pre-commit install diff --git a/README.md b/README.md index c2cdc00..2a7569e 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,16 @@ The published GitHub repo is named `distill-knowledge` to match the skill (skill The skill picks the path at Gate 1 of the workflow and asks you to confirm before spending API budget. +## Development checks + +```bash +make quality # format check + lint + syntax + tests +make format # apply formatter +make install-hooks # enable commit-time checks +``` + +CI runs the same quality checks in `.github/workflows/python-quality.yml`. + ## License MIT — see [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..496e252 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.ruff] +target-version = "py310" +line-length = 100 +extend-exclude = [ + "tmp", + "inbox", + "outbox", +] + +[tool.ruff.lint] +select = ["E", "F"] + +[tool.pytest.ini_options] +testpaths = ["skills/distill-knowledge/scripts/tests"] +pythonpath = ["skills/distill-knowledge/scripts"] +addopts = "-q" diff --git a/skills/distill-knowledge/SKILL.md b/skills/distill-knowledge/SKILL.md index 1505123..e86c592 100755 --- a/skills/distill-knowledge/SKILL.md +++ b/skills/distill-knowledge/SKILL.md @@ -11,7 +11,7 @@ compatibility: Requires uv, ffmpeg, ffprobe, Python ≥3.10, and OPENAI_API_KEY license: MIT metadata: author: Dim Kharitonov (https://github.com/dimdasci) - version: "1.0.0" + version: "1.1.0" --- # Convert Recording → Knowledge Markdown diff --git a/skills/distill-knowledge/scripts/extract_clip.py b/skills/distill-knowledge/scripts/extract_clip.py index 5e08480..757db3e 100755 --- a/skills/distill-knowledge/scripts/extract_clip.py +++ b/skills/distill-knowledge/scripts/extract_clip.py @@ -24,11 +24,11 @@ --duration 6 --out tmp/clips/clip_.ogg """ + from __future__ import annotations import argparse import json -import re import shutil import subprocess import sys @@ -50,15 +50,22 @@ def parse_timestamp(value: str) -> float: def main() -> int: - p = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) p.add_argument("audio", type=Path, help="Source audio or video file") - p.add_argument("--at", type=parse_timestamp, required=True, - help="Start timestamp (seconds, MM:SS, or H:MM:SS)") - p.add_argument("--duration", type=float, default=6.0, - help="Clip duration in seconds (default 6)") - p.add_argument("--out", type=Path, default=None, - help="Output path (default tmp/clips/clip_.ogg)") + p.add_argument( + "--at", + type=parse_timestamp, + required=True, + help="Start timestamp (seconds, MM:SS, or H:MM:SS)", + ) + p.add_argument( + "--duration", type=float, default=6.0, help="Clip duration in seconds (default 6)" + ) + p.add_argument( + "--out", type=Path, default=None, help="Output path (default tmp/clips/clip_.ogg)" + ) args = p.parse_args() if not shutil.which("ffmpeg"): @@ -75,12 +82,26 @@ def main() -> int: out.parent.mkdir(parents=True, exist_ok=True) cmd = [ - "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", - "-ss", f"{args.at}", - "-t", f"{args.duration}", - "-i", str(args.audio), + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-ss", + f"{args.at}", + "-t", + f"{args.duration}", + "-i", + str(args.audio), "-vn", - "-c:a", "libopus", "-b:a", "32k", "-ac", "1", "-ar", "16000", + "-c:a", + "libopus", + "-b:a", + "32k", + "-ac", + "1", + "-ar", + "16000", str(out), ] res = subprocess.run(cmd, capture_output=True, text=True) diff --git a/skills/distill-knowledge/scripts/merge_chunks.py b/skills/distill-knowledge/scripts/merge_chunks.py index 15edf00..2c2eeb5 100644 --- a/skills/distill-knowledge/scripts/merge_chunks.py +++ b/skills/distill-knowledge/scripts/merge_chunks.py @@ -110,13 +110,15 @@ def _annotate_segments( elif abs_end > core_end and chunk_end > core_end: in_overlap = "next" - annotated.append({ - **seg, - "start": abs_start, - "end": abs_end, - "source_chunk": chunk_index, - "in_overlap": in_overlap, - }) + annotated.append( + { + **seg, + "start": abs_start, + "end": abs_end, + "source_chunk": chunk_index, + "in_overlap": in_overlap, + } + ) return annotated @@ -131,7 +133,6 @@ def _build_overlap_windows( for i in range(len(chunks) - 1): left_chunk = chunks[i] - right_chunk = chunks[i + 1] # Shared region: from core_end - overlap to core_end + overlap # More precisely: the region where both chunks have data @@ -141,22 +142,26 @@ def _build_overlap_windows( # Left segments in shared region (timestamps are now absolute) left_segs = [ - seg for seg in all_annotated[i] + seg + for seg in all_annotated[i] if seg["end"] > shared_start and seg["start"] < shared_end ] # Right segments in shared region right_segs = [ - seg for seg in all_annotated[i + 1] + seg + for seg in all_annotated[i + 1] if seg["end"] > shared_start and seg["start"] < shared_end ] - windows.append({ - "boundary_idx": i, - "shared_region": [round(shared_start, 1), round(shared_end, 1)], - "left": left_segs, - "right": right_segs, - }) + windows.append( + { + "boundary_idx": i, + "shared_region": [round(shared_start, 1), round(shared_end, 1)], + "left": left_segs, + "right": right_segs, + } + ) return windows @@ -169,14 +174,16 @@ def _build_chunk_boundaries(manifest: dict) -> list[dict]: for i in range(len(chunks) - 1): core_end = chunks[i]["core_end_s"] - boundaries.append({ - "index": i, - "core_end_s": core_end, - "shared_region": [ - round(core_end - overlap_s, 1), - round(core_end + overlap_s, 1), - ], - }) + boundaries.append( + { + "index": i, + "core_end_s": core_end, + "shared_region": [ + round(core_end - overlap_s, 1), + round(core_end + overlap_s, 1), + ], + } + ) return boundaries @@ -199,19 +206,23 @@ def main() -> None: description="Merge per-chunk transcription JSONs into unified timeline." ) parser.add_argument( - "--manifest", required=True, + "--manifest", + required=True, help="Path to manifest.json from prep_audio.py", ) parser.add_argument( - "--intake", required=True, + "--intake", + required=True, help="JSON string with intake context (speaker_count, speaker_names, topic, terms)", ) parser.add_argument( - "--vtt", default=None, + "--vtt", + default=None, help="Path to VTT file for cross-check cues (optional)", ) parser.add_argument( - "--out", required=True, + "--out", + required=True, help="Output path for merged.json", ) diff --git a/skills/distill-knowledge/scripts/parse_vtt.py b/skills/distill-knowledge/scripts/parse_vtt.py index 8de6dc1..a1007a1 100755 --- a/skills/distill-knowledge/scripts/parse_vtt.py +++ b/skills/distill-knowledge/scripts/parse_vtt.py @@ -17,28 +17,40 @@ from pathlib import Path from typing import NoReturn -TIMESTAMP_RE = re.compile( - r"(\d{2}:\d{2}:\d{2}\.\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}\.\d{3})" -) +TIMESTAMP_RE = re.compile(r"(\d{2}:\d{2}:\d{2}\.\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}\.\d{3})") SPEAKER_RE = re.compile(r"^(.+?):\s+(.+)$") class VTTParseError(Exception): """Raised when a VTT file cannot be read or parsed.""" + pass SCREEN_PATTERNS = { - "explicit": ["see my screen", "screen share", "showing you", "let me show", - "look at this", "you see this", "can you see"], - "deictic": ["this table", "this page", "this column", "this field", - "this button", "right here", "over here", "this one"], - "navigation": ["scroll down", "click here", "go to", "open this", - "switch to", "zoom in"], + "explicit": [ + "see my screen", + "screen share", + "showing you", + "let me show", + "look at this", + "you see this", + "can you see", + ], + "deictic": [ + "this table", + "this page", + "this column", + "this field", + "this button", + "right here", + "over here", + "this one", + ], + "navigation": ["scroll down", "click here", "go to", "open this", "switch to", "zoom in"], } SCREEN_COMPILED = { - cat: [re.compile(p, re.IGNORECASE) for p in pats] - for cat, pats in SCREEN_PATTERNS.items() + cat: [re.compile(p, re.IGNORECASE) for p in pats] for cat, pats in SCREEN_PATTERNS.items() } @@ -140,11 +152,7 @@ def parse_vtt(filepath: str | Path) -> dict: break if TIMESTAMP_RE.match(tl): break - if ( - tl.isdigit() - and i + 1 < len(lines) - and TIMESTAMP_RE.match(lines[i + 1].strip()) - ): + if tl.isdigit() and i + 1 < len(lines) and TIMESTAMP_RE.match(lines[i + 1].strip()): break text_lines.append(tl) i += 1 @@ -161,18 +169,29 @@ def parse_vtt(filepath: str | Path) -> dict: cue_index += 1 ref_type = detect_screen_ref(cleaned) - cues.append({ - "index": cue_index, "start": start_ts, "end": end_ts, - "start_seconds": round(start_sec, 3), "end_seconds": round(end_sec, 3), - "speaker": speaker, "text": cleaned, - "screen_reference": ref_type is not None, - }) + cues.append( + { + "index": cue_index, + "start": start_ts, + "end": end_ts, + "start_seconds": round(start_sec, 3), + "end_seconds": round(end_sec, 3), + "speaker": speaker, + "text": cleaned, + "screen_reference": ref_type is not None, + } + ) if ref_type: - screen_refs.append({ - "cue_index": cue_index, "timestamp": start_ts, - "seconds": round(start_sec, 3), "context": cleaned, "type": ref_type, - }) + screen_refs.append( + { + "cue_index": cue_index, + "timestamp": start_ts, + "seconds": round(start_sec, 3), + "context": cleaned, + "type": ref_type, + } + ) sorted_speakers = sorted(speakers) return { @@ -189,12 +208,15 @@ def parse_vtt(filepath: str | Path) -> dict: def main(): parser = argparse.ArgumentParser( - description="Parse WebVTT transcript files into structured JSON.") + description="Parse WebVTT transcript files into structured JSON." + ) parser.add_argument("vtt_file", help="Path to the VTT file to parse") - parser.add_argument("--pretty", action="store_true", - help="Pretty-print JSON output with indentation") - parser.add_argument("--output", "-o", default=None, - help="Write JSON to this file instead of stdout") + parser.add_argument( + "--pretty", action="store_true", help="Pretty-print JSON output with indentation" + ) + parser.add_argument( + "--output", "-o", default=None, help="Write JSON to this file instead of stdout" + ) args = parser.parse_args() vtt_path = _check_input(args.vtt_file) diff --git a/skills/distill-knowledge/scripts/prep_audio.py b/skills/distill-knowledge/scripts/prep_audio.py index fa79a19..af82989 100644 --- a/skills/distill-knowledge/scripts/prep_audio.py +++ b/skills/distill-knowledge/scripts/prep_audio.py @@ -31,9 +31,13 @@ def _die(message: str, code: int = 1) -> NoReturn: def _probe_duration(path: Path) -> float: """Get duration in seconds via ffprobe.""" cmd = [ - "ffprobe", "-v", "quiet", - "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", + "ffprobe", + "-v", + "quiet", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", str(path), ] try: @@ -55,15 +59,23 @@ def _reencode_stripped( duration = source_duration - leading_s - trailing_s cmd = [ - "ffmpeg", "-y", - "-ss", f"{start:.3f}", - "-i", str(input_path), - "-t", f"{duration:.3f}", + "ffmpeg", + "-y", + "-ss", + f"{start:.3f}", + "-i", + str(input_path), + "-t", + f"{duration:.3f}", "-vn", - "-ac", "1", - "-ar", "16000", - "-c:a", "libopus", - "-b:a", "32k", + "-ac", + "1", + "-ar", + "16000", + "-c:a", + "libopus", + "-b:a", + "32k", str(output_path), ] result = subprocess.run(cmd, capture_output=True, text=True) @@ -79,11 +91,16 @@ def _cut_chunk( ) -> None: """Cut a chunk from stripped.ogg using stream copy (no re-encode).""" cmd = [ - "ffmpeg", "-y", - "-ss", f"{start_s:.3f}", - "-i", str(source), - "-t", f"{duration_s:.3f}", - "-c", "copy", + "ffmpeg", + "-y", + "-ss", + f"{start_s:.3f}", + "-i", + str(source), + "-t", + f"{duration_s:.3f}", + "-c", + "copy", str(output), ] result = subprocess.run(cmd, capture_output=True, text=True) @@ -109,21 +126,24 @@ def _find_nearest_silence( def main() -> None: - parser = argparse.ArgumentParser( - description="Preprocess audio/video for transcription." - ) + parser = argparse.ArgumentParser(description="Preprocess audio/video for transcription.") parser.add_argument("input", help="Input audio or video file") parser.add_argument("--out-dir", required=True, help="Output directory") parser.add_argument( - "--max-chunk", type=float, default=480.0, - help="Max chunk duration in seconds (default: 480 = 8 min), + "--max-chunk", + type=float, + default=480.0, + help="Max chunk duration in seconds (default: 480 = 8 min)", ) parser.add_argument( - "--overlap", type=float, default=30.0, + "--overlap", + type=float, + default=30.0, help="Overlap between chunks in seconds (default: 30)", ) parser.add_argument( - "--no-split", action="store_true", + "--no-split", + action="store_true", help="Force single output even if >8 min", ) @@ -211,18 +231,20 @@ def main() -> None: if not needs_split: # Single chunk = the whole file manifest["n_chunks"] = 1 - manifest["chunks"] = [{ - "index": 0, - "file": "stripped.ogg", - "core_start_s": 0.0, - "core_end_s": round(stripped_duration, 1), - "chunk_start_s": 0.0, - "chunk_end_s": round(stripped_duration, 1), - "split_silence": None, - "status": "pending", - "transcript_file": None, - "request_id": None, - }] + manifest["chunks"] = [ + { + "index": 0, + "file": "stripped.ogg", + "core_start_s": 0.0, + "core_end_s": round(stripped_duration, 1), + "chunk_start_s": 0.0, + "chunk_end_s": round(stripped_duration, 1), + "split_silence": None, + "status": "pending", + "transcript_file": None, + "request_id": None, + } + ] else: # Split into balanced chunks n = math.ceil(stripped_duration / args.max_chunk) @@ -273,22 +295,27 @@ def main() -> None: _cut_chunk(stripped_path, chunk_path, chunk_start, chunk_duration) - chunks.append({ - "index": idx, - "file": chunk_file, - "core_start_s": round(core_start, 1), - "core_end_s": round(core_end, 1), - "chunk_start_s": round(chunk_start, 1), - "chunk_end_s": round(chunk_end, 1), - "split_silence": ( - {"start": round(split_silence["start"], 1), - "end": round(split_silence["end"], 1)} - if split_silence else None - ), - "status": "pending", - "transcript_file": None, - "request_id": None, - }) + chunks.append( + { + "index": idx, + "file": chunk_file, + "core_start_s": round(core_start, 1), + "core_end_s": round(core_end, 1), + "chunk_start_s": round(chunk_start, 1), + "chunk_end_s": round(chunk_end, 1), + "split_silence": ( + { + "start": round(split_silence["start"], 1), + "end": round(split_silence["end"], 1), + } + if split_silence + else None + ), + "status": "pending", + "transcript_file": None, + "request_id": None, + } + ) prev_core_end = core_end manifest["n_chunks"] = n diff --git a/skills/distill-knowledge/scripts/render_transcript.py b/skills/distill-knowledge/scripts/render_transcript.py index a609b68..2746b15 100755 --- a/skills/distill-knowledge/scripts/render_transcript.py +++ b/skills/distill-knowledge/scripts/render_transcript.py @@ -29,7 +29,6 @@ import argparse import json -import re import sys from datetime import datetime from pathlib import Path @@ -187,9 +186,7 @@ def _reconstruct_overlaps( ): cluster: list[dict[str, Any]] = [segments[i], segments[i + 1]] cluster_start = segments[i].get("start", 0) - cluster_max_end = max( - segments[i].get("end", 0), segments[i + 1].get("end", 0) - ) + cluster_max_end = max(segments[i].get("end", 0), segments[i + 1].get("end", 0)) j = i + 2 while j < n: prev = cluster[-1] @@ -253,10 +250,7 @@ def _smooth_crosstalk(segments: list[dict[str, Any]]) -> list[dict[str, Any]]: # Absorb the interjection into the surrounding speaker's turn merged = dict(segments[i]) merged["end"] = segments[i + 2]["end"] - merged["text"] = ( - segments[i].get("text", "") - + segments[i + 2].get("text", "") - ) + merged["text"] = segments[i].get("text", "") + segments[i + 2].get("text", "") result.append(merged) i += 3 else: diff --git a/skills/distill-knowledge/scripts/transcribe_diarize.py b/skills/distill-knowledge/scripts/transcribe_diarize.py index 8d46afd..e93389e 100755 --- a/skills/distill-knowledge/scripts/transcribe_diarize.py +++ b/skills/distill-knowledge/scripts/transcribe_diarize.py @@ -111,10 +111,7 @@ def _normalize_response_format(value: str | None) -> str: return DEFAULT_RESPONSE_FORMAT fmt = value.strip().lower() if fmt not in ALLOWED_RESPONSE_FORMATS: - _die( - "response-format must be one of: " - + ", ".join(sorted(ALLOWED_RESPONSE_FORMATS)) - ) + _die("response-format must be one of: " + ", ".join(sorted(ALLOWED_RESPONSE_FORMATS))) return fmt @@ -268,14 +265,16 @@ def _preflight(client: Any, model: str, cache_dir: Path | None) -> None: client.models.retrieve(model) except AuthenticationError as exc: _die_api( - "auth", 10, + "auth", + 10, "OpenAI rejected the API key (HTTP 401). The key may be revoked, " "expired, or malformed. Replace OPENAI_API_KEY and retry.\n" f" Details: {exc}", ) except PermissionDeniedError as exc: _die_api( - "permission", 11, + "permission", + 11, f"API key does not have access to model {model!r} (HTTP 403). " "Grant model access on the project at " "https://platform.openai.com/settings, or choose a model the key can use.\n" @@ -283,7 +282,8 @@ def _preflight(client: Any, model: str, cache_dir: Path | None) -> None: ) except NotFoundError as exc: _die_api( - "permission", 11, + "permission", + 11, f"Model {model!r} was not found (HTTP 404). " "Check the model name; it may be misspelled or " "unavailable on this account.\n" @@ -291,7 +291,8 @@ def _preflight(client: Any, model: str, cache_dir: Path | None) -> None: ) except APIConnectionError as exc: _die_api( - "service", 20, + "service", + 20, "Could not reach OpenAI during pre-flight check. " "Check connectivity and retry.\n" f" Details: {exc}", @@ -345,6 +346,7 @@ def _run_one( PermissionDeniedError, RateLimitError, ) + model = payload.get("model") req_id = request_id or str(uuid.uuid4()) effective_wall = max_wall if max_wall is not None else DEFAULT_MAX_WALL @@ -379,7 +381,8 @@ def _do_call() -> Any: except FuturesTimeoutError: elapsed = time.monotonic() - t0 _die_api( - "timeout", 21, + "timeout", + 21, f"Wall-clock limit exceeded: {effective_wall:.0f}s " f"(elapsed {elapsed:.1f}s). request_id={req_id}. " "The API accepted the request but never completed. " @@ -389,14 +392,16 @@ def _do_call() -> Any: ) except AuthenticationError as exc: _die_api( - "auth", 10, + "auth", + 10, "OpenAI rejected the API key (HTTP 401). The key may be revoked, " "expired, or malformed. Replace OPENAI_API_KEY and retry.\n" f" Details: {exc}", ) except PermissionDeniedError as exc: _die_api( - "permission", 11, + "permission", + 11, f"API key does not have access to model {model!r} (HTTP 403). " "Grant model access on the project at " "https://platform.openai.com/settings, or choose a model the key can use.\n" @@ -404,7 +409,8 @@ def _do_call() -> Any: ) except NotFoundError as exc: _die_api( - "permission", 11, + "permission", + 11, f"Model {model!r} was not found (HTTP 404). " "Check the model name; it may be misspelled or " "unavailable on this account.\n" @@ -412,7 +418,8 @@ def _do_call() -> Any: ) except RateLimitError as exc: _die_api( - "rate-limit", 12, + "rate-limit", + 12, "Rate limit or quota exceeded (HTTP 429). " "Inspect usage at https://platform.openai.com/usage. " "Wait and retry, or switch to a different model/tier.\n" @@ -420,7 +427,8 @@ def _do_call() -> Any: ) except BadRequestError as exc: _die_api( - "bad-request", 30, + "bad-request", + 30, f"Request rejected (HTTP 400) for {audio_path}. Likely an unsupported " "audio format, malformed file, or invalid parameter combination.\n" f" Details: {exc}", @@ -429,7 +437,8 @@ def _do_call() -> Any: elapsed = time.monotonic() - t0 effective_timeout = read_timeout if read_timeout is not None else 450.0 _die_api( - "timeout", 21, + "timeout", + 21, f"Request accepted but no response within {effective_timeout:.0f}s " f"(elapsed {elapsed:.1f}s). request_id={req_id}. " "Retry with --timeout or cancel.\n" @@ -437,21 +446,24 @@ def _do_call() -> Any: ) except APIConnectionError as exc: _die_api( - "service", 20, + "service", + 20, "Could not reach OpenAI (network error). The SDK already retried; " "this is usually transient. Check connectivity and retry.\n" f" Details: {exc}", ) except InternalServerError as exc: _die_api( - "service", 20, + "service", + 20, "OpenAI service error (HTTP 5xx). The SDK already retried. " "Check https://status.openai.com and retry.\n" f" Details: {exc}", ) except Exception as exc: _die_api( - "unknown", 1, + "unknown", + 1, f"Unexpected API error for {audio_path}: {type(exc).__name__}: {exc}", ) @@ -463,9 +475,7 @@ def _do_call() -> Any: def main() -> None: parser = argparse.ArgumentParser( - description=( - "Transcribe audio (optionally with speaker diarization) using OpenAI." - ) + description=("Transcribe audio (optionally with speaker diarization) using OpenAI.") ) parser.add_argument("audio", nargs="+", help="Audio file(s) to transcribe") parser.add_argument( @@ -514,7 +524,7 @@ def main() -> None: type=float, default=None, help="Hard wall-clock limit per API call in seconds (default: 600). " - "Kills the request if the server streams bytes without completing.", + "Kills the request if the server streams bytes without completing.", ) parser.add_argument( "--skip-preflight", @@ -546,10 +556,7 @@ def main() -> None: if args.prompt and "transcribe-diarize" in args.model: _die("prompt is not supported with gpt-4o-transcribe-diarize") - if ( - args.response_format == "diarized_json" - and "transcribe-diarize" not in args.model - ): + if args.response_format == "diarized_json" and "transcribe-diarize" not in args.model: _die("diarized_json requires gpt-4o-transcribe-diarize") if args.manifest and args.chunk_index is None: _die("--chunk-index is required when using --manifest") @@ -565,10 +572,7 @@ def main() -> None: known_names, known_refs = _parse_known_speakers(args.known_speaker) if known_names and "transcribe-diarize" not in args.model: - _warn( - "known-speaker references are only supported for " - "gpt-4o-transcribe-diarize" - ) + _warn("known-speaker references are only supported for gpt-4o-transcribe-diarize") payload = _build_payload(args, known_names, known_refs) if args.dry_run: @@ -596,12 +600,16 @@ def main() -> None: # Update manifest: in-progress if manifest_path and args.chunk_index is not None: _update_manifest( - manifest_path, args.chunk_index, - status="in_progress", request_id=req_id, + manifest_path, + args.chunk_index, + status="in_progress", + request_id=req_id, ) result, req_id, elapsed = _run_one( - client, path, payload, + client, + path, + payload, read_timeout=args.timeout, max_wall=args.max_wall, request_id=req_id, @@ -622,9 +630,7 @@ def main() -> None: if args.stdout: print(output) continue - out_path = _build_output_path( - path, args.response_format, args.out, args.out_dir - ) + out_path = _build_output_path(path, args.response_format, args.out, args.out_dir) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(output, encoding="utf-8") print(f"Wrote {out_path}") @@ -639,7 +645,8 @@ def main() -> None: except ValueError: transcript_file_value = str(out_path.resolve()) _update_manifest( - manifest_path, args.chunk_index, + manifest_path, + args.chunk_index, status="done", transcript_file=transcript_file_value, )