feat(transcribe): Whisper transcription module with Groq→OpenAI fallback (#277)
Adds agent_reach/transcribe.py (download → compress → chunk → transcribe with provider fallback, fully mocked tests). Maintainer follow-up on the branch: wired an agent-reach transcribe CLI subcommand + skill docs so agents can actually invoke it, added the missing configure openai-key branch the error hint referenced, removed two dead static methods. 103 tests pass; wheel-gate clean.
This commit is contained in:
@@ -13,3 +13,6 @@
|
|||||||
|
|
||||||
# Groq Whisper (optional, for video transcription) — https://console.groq.com
|
# Groq Whisper (optional, for video transcription) — https://console.groq.com
|
||||||
# GROQ_API_KEY=gsk_your_key_here
|
# GROQ_API_KEY=gsk_your_key_here
|
||||||
|
|
||||||
|
# OpenAI Whisper (optional fallback when Groq is rate-limited) — https://platform.openai.com
|
||||||
|
# OPENAI_API_KEY=sk-your_key_here
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class YouTubeChannel(Channel):
|
|||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
d = urlparse(url).netloc.lower()
|
d = urlparse(url).netloc.lower()
|
||||||
return "youtube.com" in d or "youtu.be" in d
|
return "youtube.com" in d or "youtu.be" in d
|
||||||
|
|
||||||
@@ -40,7 +41,31 @@ class YouTubeChannel(Channel):
|
|||||||
has_js_config = "--js-runtimes" in read_utf8_text(ytdlp_config)
|
has_js_config = "--js-runtimes" in read_utf8_text(ytdlp_config)
|
||||||
if not has_js_config:
|
if not has_js_config:
|
||||||
return "warn", (
|
return "warn", (
|
||||||
"yt-dlp 已安装但未配置 JS runtime。运行:\n"
|
f"yt-dlp 已安装但未配置 JS runtime。运行:\n {render_ytdlp_fix_command()}"
|
||||||
f" {render_ytdlp_fix_command()}"
|
|
||||||
)
|
)
|
||||||
return "ok", "可提取视频信息和字幕"
|
# Surface transcription readiness so `doctor` reports it.
|
||||||
|
msg = "可提取视频信息和字幕"
|
||||||
|
if config is not None:
|
||||||
|
providers = []
|
||||||
|
if config.is_configured("groq_whisper"):
|
||||||
|
providers.append("groq")
|
||||||
|
if config.is_configured("openai_whisper"):
|
||||||
|
providers.append("openai")
|
||||||
|
if providers:
|
||||||
|
if not shutil.which("ffmpeg"):
|
||||||
|
msg += "(音频转写需安装 ffmpeg)"
|
||||||
|
else:
|
||||||
|
msg += f",可转写音频({'→'.join(providers)})"
|
||||||
|
return "ok", msg
|
||||||
|
|
||||||
|
def transcribe(self, url: str, *, provider: str = "auto", config=None) -> str:
|
||||||
|
"""Download a YouTube video's audio and return its transcript.
|
||||||
|
|
||||||
|
Delegates to :func:`agent_reach.transcribe.transcribe`. Imported lazily
|
||||||
|
so the channel module stays cheap to import for users who never
|
||||||
|
transcribe.
|
||||||
|
"""
|
||||||
|
from agent_reach.transcribe import transcribe as _transcribe
|
||||||
|
|
||||||
|
return _transcribe(url, provider=provider, config=config)
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -79,7 +79,7 @@ def main():
|
|||||||
# ── configure ──
|
# ── configure ──
|
||||||
p_conf = sub.add_parser("configure", help="Set a config value or auto-extract from browser")
|
p_conf = sub.add_parser("configure", help="Set a config value or auto-extract from browser")
|
||||||
p_conf.add_argument("key", nargs="?", default=None,
|
p_conf.add_argument("key", nargs="?", default=None,
|
||||||
choices=["proxy", "github-token", "groq-key",
|
choices=["proxy", "github-token", "groq-key", "openai-key",
|
||||||
"twitter-cookies", "youtube-cookies",
|
"twitter-cookies", "youtube-cookies",
|
||||||
"xhs-cookies"],
|
"xhs-cookies"],
|
||||||
help="What to configure (omit if using --from-browser)")
|
help="What to configure (omit if using --from-browser)")
|
||||||
@@ -111,6 +111,14 @@ def main():
|
|||||||
p_format.add_argument("platform", choices=["xhs"], help="Platform to format (xhs)")
|
p_format.add_argument("platform", choices=["xhs"], help="Platform to format (xhs)")
|
||||||
|
|
||||||
# ── check-update ──
|
# ── check-update ──
|
||||||
|
# ── transcribe ──
|
||||||
|
p_tr = sub.add_parser("transcribe", help="Transcribe a URL or local audio file (Whisper via Groq/OpenAI)")
|
||||||
|
p_tr.add_argument("source", help="Audio/video URL or local file path")
|
||||||
|
p_tr.add_argument("--provider", choices=["auto", "groq", "openai"], default="auto",
|
||||||
|
help="Transcription provider (default: auto = groq → openai fallback)")
|
||||||
|
p_tr.add_argument("-o", "--output", default=None,
|
||||||
|
help="Write transcript to a file instead of stdout")
|
||||||
|
|
||||||
sub.add_parser("check-update", help="Check for new versions and changes")
|
sub.add_parser("check-update", help="Check for new versions and changes")
|
||||||
|
|
||||||
# ── watch ──
|
# ── watch ──
|
||||||
@@ -150,6 +158,8 @@ def main():
|
|||||||
_cmd_skill(args)
|
_cmd_skill(args)
|
||||||
elif args.command == "format":
|
elif args.command == "format":
|
||||||
_cmd_format(args)
|
_cmd_format(args)
|
||||||
|
elif args.command == "transcribe":
|
||||||
|
_cmd_transcribe(args)
|
||||||
|
|
||||||
|
|
||||||
# ── Command handlers ────────────────────────────────
|
# ── Command handlers ────────────────────────────────
|
||||||
@@ -1125,6 +1135,29 @@ def _cmd_configure(args):
|
|||||||
config.set("groq_api_key", value)
|
config.set("groq_api_key", value)
|
||||||
print(f"✅ Groq key configured!")
|
print(f"✅ Groq key configured!")
|
||||||
|
|
||||||
|
elif args.key == "openai-key":
|
||||||
|
config.set("openai_api_key", value)
|
||||||
|
print(f"✅ OpenAI key configured!")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_transcribe(args):
|
||||||
|
"""Transcribe a URL or local audio file via Whisper (Groq → OpenAI fallback)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agent_reach.transcribe import TranscribeError, transcribe
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = transcribe(args.source, provider=args.provider)
|
||||||
|
except TranscribeError as e:
|
||||||
|
print(f"❌ {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
||||||
|
print(f"✅ Transcript written to {args.output}")
|
||||||
|
else:
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
|
||||||
def _parse_twitter_cookie_input(value: str):
|
def _parse_twitter_cookie_input(value: str):
|
||||||
"""Parse Twitter cookie input from either separate values or a cookie header."""
|
"""Parse Twitter cookie input from either separate values or a cookie header."""
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Config:
|
|||||||
"exa_search": ["exa_api_key"],
|
"exa_search": ["exa_api_key"],
|
||||||
"twitter_xreach": ["twitter_auth_token", "twitter_ct0"], # legacy key name; used by twitter-cli
|
"twitter_xreach": ["twitter_auth_token", "twitter_ct0"], # legacy key name; used by twitter-cli
|
||||||
"groq_whisper": ["groq_api_key"],
|
"groq_whisper": ["groq_api_key"],
|
||||||
|
"openai_whisper": ["openai_api_key"],
|
||||||
"github_token": ["github_token"],
|
"github_token": ["github_token"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ yt-dlp --dump-json "ytsearch5:query"
|
|||||||
> **字幕注意**: 手动上传的字幕提取可靠;自动生成字幕可能存在行间重复,需后处理。
|
> **字幕注意**: 手动上传的字幕提取可靠;自动生成字幕可能存在行间重复,需后处理。
|
||||||
> **评论注意**: `--write-comments` 基于网页抓取(非 YouTube Data API),部分评论可能丢失。
|
> **评论注意**: `--write-comments` 基于网页抓取(非 YouTube Data API),部分评论可能丢失。
|
||||||
|
|
||||||
|
### 无字幕兜底:Whisper 音频转写
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 视频没有字幕时的兜底:下载音频并用 Whisper 转写(Groq 免费 key 即可)
|
||||||
|
agent-reach transcribe "https://www.youtube.com/watch?v=VIDEO_ID"
|
||||||
|
agent-reach transcribe ./local_audio.mp3 -o /tmp/transcript.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
> 需要先配置 key:`agent-reach configure groq-key gsk_xxx`(免费,console.groq.com)
|
||||||
|
> 或 `agent-reach configure openai-key sk-xxx`。默认 auto 模式:groq 失败自动降级 openai。
|
||||||
|
|
||||||
## B站 / Bilibili (yt-dlp + bili-cli)
|
## B站 / Bilibili (yt-dlp + bili-cli)
|
||||||
|
|
||||||
### 视频元数据 (yt-dlp)
|
### 视频元数据 (yt-dlp)
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Whisper audio transcription with Groq → OpenAI fallback.
|
||||||
|
|
||||||
|
Downloads audio (yt-dlp), compresses + chunks (ffmpeg), and posts to a
|
||||||
|
Whisper-compatible API. Defaults to Groq's free `whisper-large-v3` and falls
|
||||||
|
back to OpenAI's `whisper-1` on HTTP error.
|
||||||
|
|
||||||
|
Public entry point:
|
||||||
|
transcribe(source, *, provider="auto", out_dir=None, config=None) -> str
|
||||||
|
|
||||||
|
Designed to be importable from channels (e.g. YouTubeChannel.transcribe).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from agent_reach.config import Config
|
||||||
|
|
||||||
|
# Whisper API limit is 25MB; leave headroom for multipart overhead.
|
||||||
|
SIZE_LIMIT_BYTES = 24 * 1024 * 1024
|
||||||
|
CHUNK_SECONDS = 600 # 10 min — small enough that boundary cuts rarely lose meaning
|
||||||
|
|
||||||
|
PROVIDERS = {
|
||||||
|
"groq": {
|
||||||
|
"endpoint": "https://api.groq.com/openai/v1/audio/transcriptions",
|
||||||
|
"model": "whisper-large-v3",
|
||||||
|
"key_field": "groq_api_key",
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"endpoint": "https://api.openai.com/v1/audio/transcriptions",
|
||||||
|
"model": "whisper-1",
|
||||||
|
"key_field": "openai_api_key",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeError(RuntimeError):
|
||||||
|
"""Raised when transcription cannot complete."""
|
||||||
|
|
||||||
|
|
||||||
|
class MissingDependency(TranscribeError):
|
||||||
|
"""Raised when a required external binary is missing."""
|
||||||
|
|
||||||
|
|
||||||
|
class NoProviderConfigured(TranscribeError):
|
||||||
|
"""Raised when no provider has an API key configured."""
|
||||||
|
|
||||||
|
|
||||||
|
def _require(binary: str) -> None:
|
||||||
|
if not shutil.which(binary):
|
||||||
|
raise MissingDependency(f"{binary} not found in PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: List[str]) -> None:
|
||||||
|
"""Run a subprocess, raising TranscribeError on nonzero exit."""
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise TranscribeError(
|
||||||
|
f"{cmd[0]} failed (exit {proc.returncode}): {proc.stderr.strip()[:300]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_audio(url: str, out_dir: Path) -> Path:
|
||||||
|
"""Download audio with yt-dlp into out_dir; return the resulting file path."""
|
||||||
|
_require("yt-dlp")
|
||||||
|
template = out_dir / "source.%(ext)s"
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"yt-dlp",
|
||||||
|
"-x",
|
||||||
|
"--audio-format",
|
||||||
|
"m4a",
|
||||||
|
"--audio-quality",
|
||||||
|
"0",
|
||||||
|
"-o",
|
||||||
|
str(template),
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
files = sorted(out_dir.glob("source.*"))
|
||||||
|
if not files:
|
||||||
|
raise TranscribeError("yt-dlp produced no output file")
|
||||||
|
return files[0]
|
||||||
|
|
||||||
|
|
||||||
|
def compress_audio(src: Path, out_dir: Path) -> Path:
|
||||||
|
"""Re-encode to mono / 16kHz / 32kbps m4a — keeps most content under 25MB."""
|
||||||
|
_require("ffmpeg")
|
||||||
|
dst = out_dir / "compressed.m4a"
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-vn",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-b:a",
|
||||||
|
"32k",
|
||||||
|
str(dst),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_audio(src: Path, out_dir: Path, segment_seconds: int = CHUNK_SECONDS) -> List[Path]:
|
||||||
|
"""Split src into segments. Re-encodes each segment so cuts align to keyframes."""
|
||||||
|
_require("ffmpeg")
|
||||||
|
pattern = out_dir / "chunk_%03d.m4a"
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-f",
|
||||||
|
"segment",
|
||||||
|
"-segment_time",
|
||||||
|
str(segment_seconds),
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-b:a",
|
||||||
|
"32k",
|
||||||
|
str(pattern),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
chunks = sorted(out_dir.glob("chunk_*.m4a"))
|
||||||
|
if not chunks:
|
||||||
|
raise TranscribeError("ffmpeg produced no chunks")
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_key(provider: str, config: Config) -> Optional[str]:
|
||||||
|
field = PROVIDERS[provider]["key_field"]
|
||||||
|
val = config.get(field)
|
||||||
|
return val or None
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_chunk(
|
||||||
|
chunk: Path,
|
||||||
|
provider: str,
|
||||||
|
*,
|
||||||
|
config: Optional[Config] = None,
|
||||||
|
timeout: int = 120,
|
||||||
|
) -> str:
|
||||||
|
"""Transcribe one chunk via the named provider. Raises TranscribeError on failure."""
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
raise TranscribeError(f"unknown provider: {provider}")
|
||||||
|
cfg = config or Config()
|
||||||
|
key = _provider_key(provider, cfg)
|
||||||
|
if not key:
|
||||||
|
raise NoProviderConfigured(
|
||||||
|
f"{provider}: missing {PROVIDERS[provider]['key_field']} "
|
||||||
|
f"(configure with `agent-reach configure {provider}-key ...`)"
|
||||||
|
)
|
||||||
|
|
||||||
|
info = PROVIDERS[provider]
|
||||||
|
with chunk.open("rb") as fh:
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
info["endpoint"],
|
||||||
|
headers={"Authorization": f"Bearer {key}"},
|
||||||
|
files={"file": (chunk.name, fh, "audio/m4a")},
|
||||||
|
data={"model": info["model"], "response_format": "text"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise TranscribeError(f"{provider}: network error: {e}") from e
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
raise TranscribeError(f"{provider}: HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_order(provider: str) -> List[str]:
|
||||||
|
if provider == "auto":
|
||||||
|
return ["groq", "openai"]
|
||||||
|
if provider in PROVIDERS:
|
||||||
|
return [provider]
|
||||||
|
raise TranscribeError(f"unknown provider: {provider} (use groq|openai|auto)")
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe(
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
provider: str = "auto",
|
||||||
|
out_dir: Optional[Path] = None,
|
||||||
|
config: Optional[Config] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Transcribe a URL or local file path. Returns the joined transcript text.
|
||||||
|
|
||||||
|
`provider` is one of `auto` (groq → openai), `groq`, or `openai`.
|
||||||
|
`out_dir` defaults to a fresh temp directory; intermediate files stay there.
|
||||||
|
"""
|
||||||
|
cfg = config or Config()
|
||||||
|
order = _provider_order(provider)
|
||||||
|
|
||||||
|
# Validate at least one provider is configured before doing expensive work.
|
||||||
|
if not any(_provider_key(p, cfg) for p in order):
|
||||||
|
names = ", ".join(PROVIDERS[p]["key_field"] for p in order)
|
||||||
|
raise NoProviderConfigured(f"no provider key configured (need one of: {names})")
|
||||||
|
|
||||||
|
work_dir = Path(out_dir) if out_dir else Path(tempfile.mkdtemp(prefix="transcribe-"))
|
||||||
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
src_path = Path(source)
|
||||||
|
if src_path.is_file():
|
||||||
|
audio = src_path
|
||||||
|
else:
|
||||||
|
audio = download_audio(source, work_dir)
|
||||||
|
|
||||||
|
compressed = compress_audio(audio, work_dir)
|
||||||
|
if compressed.stat().st_size <= SIZE_LIMIT_BYTES:
|
||||||
|
chunks = [compressed]
|
||||||
|
else:
|
||||||
|
chunks = chunk_audio(compressed, work_dir)
|
||||||
|
|
||||||
|
pieces: List[str] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
text = _transcribe_with_fallback(chunk, order, cfg)
|
||||||
|
pieces.append(text.strip())
|
||||||
|
return "\n".join(p for p in pieces if p)
|
||||||
|
|
||||||
|
|
||||||
|
def _transcribe_with_fallback(chunk: Path, order: List[str], config: Config) -> str:
|
||||||
|
"""Try each provider in order; return first success or raise the last error."""
|
||||||
|
last_err: Optional[Exception] = None
|
||||||
|
for p in order:
|
||||||
|
if not _provider_key(p, config):
|
||||||
|
# Skip silently — caller already validated at least one is configured.
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return transcribe_chunk(chunk, p, config=config)
|
||||||
|
except TranscribeError as e:
|
||||||
|
last_err = e
|
||||||
|
continue
|
||||||
|
raise TranscribeError(f"all providers failed for {chunk.name}: {last_err}")
|
||||||
@@ -33,6 +33,21 @@ class TestCLI:
|
|||||||
assert "Agent Reach" in captured.out
|
assert "Agent Reach" in captured.out
|
||||||
assert "✅" in captured.out
|
assert "✅" in captured.out
|
||||||
|
|
||||||
|
def test_transcribe_command_prints_text(self, capsys):
|
||||||
|
with patch("agent_reach.transcribe.transcribe", return_value="hello transcript"):
|
||||||
|
with patch("sys.argv", ["agent-reach", "transcribe", "audio.mp3"]):
|
||||||
|
main()
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "hello transcript" in captured.out
|
||||||
|
|
||||||
|
def test_transcribe_command_writes_output_file(self, capsys, tmp_path):
|
||||||
|
out_file = tmp_path / "t.txt"
|
||||||
|
with patch("agent_reach.transcribe.transcribe", return_value="saved text"):
|
||||||
|
with patch("sys.argv", ["agent-reach", "transcribe", "audio.mp3", "-o", str(out_file)]):
|
||||||
|
main()
|
||||||
|
assert out_file.read_text(encoding="utf-8").strip() == "saved text"
|
||||||
|
assert "Transcript written" in capsys.readouterr().out
|
||||||
|
|
||||||
def test_parse_twitter_cookie_input_separate_values(self):
|
def test_parse_twitter_cookie_input_separate_values(self):
|
||||||
auth_token, ct0 = cli._parse_twitter_cookie_input("token123 ct0abc")
|
auth_token, ct0 = cli._parse_twitter_cookie_input("token123 ct0abc")
|
||||||
assert auth_token == "token123"
|
assert auth_token == "token123"
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for agent_reach.transcribe — provider routing, fallback, and errors."""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent_reach import transcribe as tr
|
||||||
|
from agent_reach.config import Config
|
||||||
|
|
||||||
|
# --- Fixtures ----------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_config(tmp_path, monkeypatch):
|
||||||
|
"""A Config that writes to a temp dir and never touches the user's HOME."""
|
||||||
|
cfg_path = tmp_path / "config.yaml"
|
||||||
|
monkeypatch.setattr(Config, "CONFIG_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(Config, "CONFIG_FILE", cfg_path)
|
||||||
|
cfg = Config(config_path=cfg_path)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chunk_file(tmp_path):
|
||||||
|
p = tmp_path / "chunk.m4a"
|
||||||
|
p.write_bytes(b"\x00fake-m4a-bytes")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, status_code: int, text: str = ""):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return 200 <= self.status_code < 300
|
||||||
|
|
||||||
|
|
||||||
|
# --- transcribe_chunk: provider routing -------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranscribeChunk:
|
||||||
|
def test_routes_to_groq_endpoint(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["headers"] = headers
|
||||||
|
captured["model"] = data["model"]
|
||||||
|
return FakeResponse(200, "hello world")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
|
||||||
|
assert text == "hello world"
|
||||||
|
assert captured["url"] == tr.PROVIDERS["groq"]["endpoint"]
|
||||||
|
assert captured["model"] == "whisper-large-v3"
|
||||||
|
assert captured["headers"]["Authorization"] == "Bearer gsk_test"
|
||||||
|
|
||||||
|
def test_routes_to_openai_endpoint(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["model"] = data["model"]
|
||||||
|
return FakeResponse(200, "openai output")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr.transcribe_chunk(chunk_file, "openai", config=fake_config)
|
||||||
|
assert text == "openai output"
|
||||||
|
assert captured["url"] == tr.PROVIDERS["openai"]["endpoint"]
|
||||||
|
assert captured["model"] == "whisper-1"
|
||||||
|
|
||||||
|
def test_raises_when_key_missing(self, fake_config, chunk_file):
|
||||||
|
with pytest.raises(tr.NoProviderConfigured):
|
||||||
|
tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
|
||||||
|
|
||||||
|
def test_raises_on_http_error(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tr.requests,
|
||||||
|
"post",
|
||||||
|
lambda *a, **k: FakeResponse(429, "rate limited"),
|
||||||
|
)
|
||||||
|
with pytest.raises(tr.TranscribeError, match="HTTP 429"):
|
||||||
|
tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
|
||||||
|
|
||||||
|
def test_unknown_provider(self, fake_config, chunk_file):
|
||||||
|
with pytest.raises(tr.TranscribeError, match="unknown provider"):
|
||||||
|
tr.transcribe_chunk(chunk_file, "azure", config=fake_config)
|
||||||
|
|
||||||
|
|
||||||
|
# --- _transcribe_with_fallback ----------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestFallback:
|
||||||
|
def test_groq_succeeds_no_openai_call(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
calls: List[str] = []
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
calls.append(url)
|
||||||
|
return FakeResponse(200, "from-groq")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||||
|
assert text == "from-groq"
|
||||||
|
assert calls == [tr.PROVIDERS["groq"]["endpoint"]]
|
||||||
|
|
||||||
|
def test_groq_429_falls_back_to_openai(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
calls: List[str] = []
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
calls.append(url)
|
||||||
|
if url == tr.PROVIDERS["groq"]["endpoint"]:
|
||||||
|
return FakeResponse(429, "rate limited")
|
||||||
|
return FakeResponse(200, "from-openai")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||||
|
assert text == "from-openai"
|
||||||
|
assert calls == [
|
||||||
|
tr.PROVIDERS["groq"]["endpoint"],
|
||||||
|
tr.PROVIDERS["openai"]["endpoint"],
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_skip_unconfigured_provider(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
# Only openai key configured — fallback should skip groq silently.
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
calls: List[str] = []
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||||
|
calls.append(url)
|
||||||
|
return FakeResponse(200, "via-openai")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||||
|
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||||
|
assert text == "via-openai"
|
||||||
|
assert calls == [tr.PROVIDERS["openai"]["endpoint"]]
|
||||||
|
|
||||||
|
def test_all_fail_raises_with_last_error(self, monkeypatch, fake_config, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tr.requests,
|
||||||
|
"post",
|
||||||
|
lambda *a, **k: FakeResponse(500, "boom"),
|
||||||
|
)
|
||||||
|
with pytest.raises(tr.TranscribeError, match="all providers failed"):
|
||||||
|
tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||||
|
|
||||||
|
|
||||||
|
# --- transcribe (orchestrator) ---------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrchestrator:
|
||||||
|
def test_local_file_skips_yt_dlp(self, monkeypatch, fake_config, tmp_path, chunk_file):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
|
||||||
|
def boom_download(*a, **k):
|
||||||
|
raise AssertionError("yt-dlp must not be called for local files")
|
||||||
|
|
||||||
|
# Stub heavy external steps to no-ops that keep file paths valid.
|
||||||
|
compressed = tmp_path / "compressed.m4a"
|
||||||
|
compressed.write_bytes(b"x" * 1024)
|
||||||
|
|
||||||
|
def fake_compress(src, out_dir):
|
||||||
|
return compressed
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr, "download_audio", boom_download)
|
||||||
|
monkeypatch.setattr(tr, "compress_audio", fake_compress)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tr.requests,
|
||||||
|
"post",
|
||||||
|
lambda *a, **k: FakeResponse(200, "transcript text"),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = tr.transcribe(
|
||||||
|
str(chunk_file),
|
||||||
|
out_dir=tmp_path / "work",
|
||||||
|
config=fake_config,
|
||||||
|
)
|
||||||
|
assert text == "transcript text"
|
||||||
|
|
||||||
|
def test_chunks_concatenated_with_newlines(
|
||||||
|
self, monkeypatch, fake_config, tmp_path, chunk_file
|
||||||
|
):
|
||||||
|
fake_config.set("groq_api_key", "gsk_test")
|
||||||
|
# Force the "needs chunking" path by writing a file above the size limit.
|
||||||
|
big = tmp_path / "compressed.m4a"
|
||||||
|
big.write_bytes(b"x" * (tr.SIZE_LIMIT_BYTES + 1))
|
||||||
|
monkeypatch.setattr(tr, "compress_audio", lambda src, out_dir: big)
|
||||||
|
c1 = tmp_path / "chunk_001.m4a"
|
||||||
|
c2 = tmp_path / "chunk_002.m4a"
|
||||||
|
c1.write_bytes(b"a")
|
||||||
|
c2.write_bytes(b"b")
|
||||||
|
monkeypatch.setattr(tr, "chunk_audio", lambda src, out_dir: [c1, c2])
|
||||||
|
|
||||||
|
responses = iter(["part one ", "part two "])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tr.requests,
|
||||||
|
"post",
|
||||||
|
lambda *a, **k: FakeResponse(200, next(responses)),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = tr.transcribe(
|
||||||
|
str(chunk_file),
|
||||||
|
out_dir=tmp_path / "work",
|
||||||
|
config=fake_config,
|
||||||
|
)
|
||||||
|
assert text == "part one\npart two"
|
||||||
|
|
||||||
|
def test_no_provider_configured_fails_fast(self, fake_config, chunk_file):
|
||||||
|
with pytest.raises(tr.NoProviderConfigured):
|
||||||
|
tr.transcribe(str(chunk_file), config=fake_config)
|
||||||
|
|
||||||
|
def test_invalid_provider_string(self, fake_config, chunk_file):
|
||||||
|
with pytest.raises(tr.TranscribeError, match="unknown provider"):
|
||||||
|
tr.transcribe(str(chunk_file), provider="azure", config=fake_config)
|
||||||
|
|
||||||
|
|
||||||
|
# --- YouTubeChannel integration --------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestYouTubeChannelTranscribe:
|
||||||
|
def test_delegates_to_transcribe(self, monkeypatch, fake_config):
|
||||||
|
from agent_reach.channels.youtube import YouTubeChannel
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_transcribe(source, *, provider="auto", out_dir=None, config=None):
|
||||||
|
captured["source"] = source
|
||||||
|
captured["provider"] = provider
|
||||||
|
captured["config"] = config
|
||||||
|
return "delegated text"
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr, "transcribe", fake_transcribe)
|
||||||
|
out = YouTubeChannel().transcribe(
|
||||||
|
"https://youtu.be/abc", provider="groq", config=fake_config
|
||||||
|
)
|
||||||
|
assert out == "delegated text"
|
||||||
|
assert captured["source"] == "https://youtu.be/abc"
|
||||||
|
assert captured["provider"] == "groq"
|
||||||
|
assert captured["config"] is fake_config
|
||||||
|
|
||||||
|
|
||||||
|
# --- Config feature requirement --------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigOpenAIWhisper:
|
||||||
|
def test_openai_whisper_feature_registered(self, fake_config):
|
||||||
|
assert "openai_whisper" in Config.FEATURE_REQUIREMENTS
|
||||||
|
assert Config.FEATURE_REQUIREMENTS["openai_whisper"] == ["openai_api_key"]
|
||||||
|
assert not fake_config.is_configured("openai_whisper")
|
||||||
|
fake_config.set("openai_api_key", "sk-test")
|
||||||
|
assert fake_config.is_configured("openai_whisper")
|
||||||
Reference in New Issue
Block a user