303 lines
11 KiB
Python
303 lines
11 KiB
Python
import asyncio
|
|
import base64
|
|
import logging
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
import openrouter_client
|
|
from openrouter_client import (
|
|
SUMMARY_TRANSCRIPT_CHAR_LIMIT,
|
|
TRANSCRIPTION_MODELS,
|
|
_audio_format,
|
|
_build_transcription_payload,
|
|
_chat_completion,
|
|
_chunk_text,
|
|
_combined_summary_prompt,
|
|
_summary_prompt,
|
|
_transcribe_chunk,
|
|
summarize,
|
|
transcribe,
|
|
transcribe_tracks,
|
|
)
|
|
|
|
|
|
def test_audio_format_defaults_to_wav_when_missing_suffix(tmp_path: Path):
|
|
path = tmp_path / "recording"
|
|
path.write_bytes(b"abc")
|
|
assert _audio_format(str(path)) == "wav"
|
|
|
|
|
|
def test_build_transcription_payload_uses_base64_json_shape(tmp_path: Path):
|
|
path = tmp_path / "meeting.wav"
|
|
path.write_bytes(b"RIFFdemo")
|
|
|
|
payload = _build_transcription_payload(str(path), TRANSCRIPTION_MODELS[0])
|
|
|
|
assert payload["model"] == "openai/gpt-4o-transcribe"
|
|
assert payload["input_audio"]["format"] == "wav"
|
|
assert payload["input_audio"]["data"] == base64.b64encode(b"RIFFdemo").decode("ascii")
|
|
|
|
|
|
def test_chunk_text_splits_long_text_under_limit():
|
|
text = ("alpha beta gamma\n" * 1500).strip()
|
|
chunks = _chunk_text(text, limit=SUMMARY_TRANSCRIPT_CHAR_LIMIT)
|
|
|
|
assert len(chunks) > 1
|
|
assert all(len(chunk) <= SUMMARY_TRANSCRIPT_CHAR_LIMIT for chunk in chunks)
|
|
assert "\n".join(chunks) == text
|
|
|
|
|
|
def test_transcribe_combines_chunk_transcripts_and_logs_chunking(tmp_path: Path, monkeypatch, caplog):
|
|
source = tmp_path / "meeting.wav"
|
|
normalized = tmp_path / "normalized.wav"
|
|
chunk1 = tmp_path / "chunk-000.wav"
|
|
chunk2 = tmp_path / "chunk-001.wav"
|
|
source.write_bytes(b"source")
|
|
normalized.write_bytes(b"normalized")
|
|
chunk1.write_bytes(b"c1")
|
|
chunk2.write_bytes(b"c2")
|
|
|
|
async def fake_normalize(audio_path: str) -> str:
|
|
assert audio_path == str(source)
|
|
return str(normalized)
|
|
|
|
async def fake_split(audio_path: str) -> list[str]:
|
|
assert audio_path == str(normalized)
|
|
return [str(chunk1), str(chunk2)]
|
|
|
|
seen = []
|
|
|
|
async def fake_transcribe_chunk(_client, audio_path: str, _headers: dict[str, str]) -> str:
|
|
seen.append(Path(audio_path).name)
|
|
return {
|
|
"chunk-000.wav": "hello there",
|
|
"chunk-001.wav": "general kenobi",
|
|
}[Path(audio_path).name]
|
|
|
|
monkeypatch.setattr(openrouter_client, "_normalize_audio_for_transcription", fake_normalize)
|
|
monkeypatch.setattr(openrouter_client, "_split_audio_for_transcription", fake_split)
|
|
monkeypatch.setattr(openrouter_client, "_transcribe_chunk", fake_transcribe_chunk)
|
|
monkeypatch.setattr(openrouter_client, "_auth_headers", lambda: {"Authorization": "Bearer test"})
|
|
|
|
class DummyClient:
|
|
async def __aenter__(self):
|
|
return object()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
monkeypatch.setattr(openrouter_client.httpx, "AsyncClient", lambda timeout: DummyClient())
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
result = asyncio.run(transcribe(str(source)))
|
|
|
|
assert result == "hello there\n\ngeneral kenobi"
|
|
assert seen == ["chunk-000.wav", "chunk-001.wav"]
|
|
assert "Split normalized audio into 2 chunk(s)" in caplog.text
|
|
|
|
|
|
def test_summarize_uses_multi_stage_pipeline_for_long_transcript(monkeypatch, caplog):
|
|
long_transcript = ("decision item follow-up\n" * 4000).strip()
|
|
prompts = []
|
|
|
|
async def fake_chat_completion(prompt: str, *, timeout: int = 180) -> str:
|
|
prompts.append(prompt)
|
|
if "Partial meeting transcript chunk" in prompt:
|
|
return f"INTERMEDIATE-{len(prompts)}"
|
|
return "FINAL SUMMARY"
|
|
|
|
monkeypatch.setattr(openrouter_client, "_chat_completion", fake_chat_completion)
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
result = asyncio.run(summarize(long_transcript))
|
|
|
|
assert result == "FINAL SUMMARY"
|
|
assert len(prompts) >= 2
|
|
assert any("Partial meeting transcript chunk" in prompt for prompt in prompts[:-1])
|
|
assert "Combined chunk summaries" in prompts[-1]
|
|
assert "Transcript exceeds single-pass summary limit" in caplog.text
|
|
|
|
|
|
def test_summary_prompt_requires_requested_sections():
|
|
prompt = _summary_prompt("Alice: finished API work")
|
|
|
|
assert "## What Was Done" in prompt
|
|
assert "## What Will Be Done Next" in prompt
|
|
assert "## Action Items" in prompt
|
|
assert "## Decisions" in prompt
|
|
assert "all dev team members' reports" in prompt
|
|
|
|
|
|
def test_combined_summary_prompt_requires_requested_sections():
|
|
prompt = _combined_summary_prompt(["Chunk 1 summary", "Chunk 2 summary"])
|
|
|
|
assert "## What Was Done" in prompt
|
|
assert "## What Will Be Done Next" in prompt
|
|
assert "## Action Items" in prompt
|
|
assert "## Decisions" in prompt
|
|
assert "all dev team members' reports" in prompt
|
|
|
|
|
|
def test_transcribe_chunk_falls_back_to_direct_openai_when_openrouter_fails(tmp_path: Path, monkeypatch):
|
|
audio = tmp_path / "chunk-000.wav"
|
|
audio.write_bytes(b"audio")
|
|
calls = []
|
|
|
|
async def fake_openrouter(_client, audio_path: str, _headers: dict[str, str]) -> str:
|
|
calls.append(("openrouter", Path(audio_path).name))
|
|
raise RuntimeError("OpenRouter transcription failed across all models: Cloudflare 502")
|
|
|
|
async def fake_openai(_client, audio_path: str) -> str:
|
|
calls.append(("openai", Path(audio_path).name))
|
|
return "direct provider transcript"
|
|
|
|
monkeypatch.setattr(openrouter_client, "_transcribe_chunk_with_openrouter", fake_openrouter)
|
|
monkeypatch.setattr(openrouter_client, "_transcribe_chunk_with_openai", fake_openai)
|
|
monkeypatch.setattr(openrouter_client, "_openai_api_key", lambda: "sk-test")
|
|
|
|
result = asyncio.run(_transcribe_chunk(object(), str(audio), {"Authorization": "Bearer test"}))
|
|
|
|
assert result == "direct provider transcript"
|
|
assert calls == [("openrouter", "chunk-000.wav"), ("openai", "chunk-000.wav")]
|
|
|
|
|
|
def test_transcribe_chunk_reports_provider_fallback_when_all_providers_fail(tmp_path: Path, monkeypatch):
|
|
audio = tmp_path / "chunk-000.wav"
|
|
audio.write_bytes(b"audio")
|
|
|
|
async def fake_openrouter(_client, _audio_path: str, _headers: dict[str, str]) -> str:
|
|
raise RuntimeError("OpenRouter transcription failed across all models: Cloudflare 502")
|
|
|
|
monkeypatch.setattr(openrouter_client, "_transcribe_chunk_with_openrouter", fake_openrouter)
|
|
monkeypatch.setattr(openrouter_client, "_openai_api_key", lambda: "")
|
|
|
|
try:
|
|
asyncio.run(_transcribe_chunk(object(), str(audio), {"Authorization": "Bearer test"}))
|
|
except RuntimeError as exc:
|
|
message = str(exc)
|
|
else:
|
|
raise AssertionError("Expected transcription failure")
|
|
|
|
assert "Transcription failed across providers" in message
|
|
assert "OpenRouter transcription failed" in message
|
|
assert "direct OpenAI fallback skipped" in message
|
|
|
|
|
|
def test_chat_completion_falls_back_to_direct_openai_when_openrouter_returns_empty(monkeypatch):
|
|
calls = []
|
|
|
|
async def fake_openrouter(prompt: str, *, timeout: int = 180) -> str:
|
|
calls.append(("openrouter", prompt, timeout))
|
|
raise RuntimeError("OpenRouter summarization returned empty content")
|
|
|
|
async def fake_openai(prompt: str, *, timeout: int = 180) -> str:
|
|
calls.append(("openai", prompt, timeout))
|
|
return "direct summary"
|
|
|
|
monkeypatch.setattr(openrouter_client, "_chat_completion_openrouter", fake_openrouter)
|
|
monkeypatch.setattr(openrouter_client, "_chat_completion_openai", fake_openai)
|
|
monkeypatch.setattr(openrouter_client, "_openai_api_key", lambda: "sk-test")
|
|
|
|
result = asyncio.run(_chat_completion("summarize this", timeout=42))
|
|
|
|
assert result == "direct summary"
|
|
assert calls == [
|
|
("openrouter", "summarize this", 42),
|
|
("openai", "summarize this", 42),
|
|
]
|
|
|
|
|
|
def test_chat_completion_reports_provider_fallback_when_all_summarizers_fail(monkeypatch):
|
|
async def fake_openrouter(_prompt: str, *, timeout: int = 180) -> str:
|
|
raise RuntimeError("OpenRouter summarization returned empty content")
|
|
|
|
monkeypatch.setattr(openrouter_client, "_chat_completion_openrouter", fake_openrouter)
|
|
monkeypatch.setattr(openrouter_client, "_openai_api_key", lambda: "")
|
|
|
|
try:
|
|
asyncio.run(_chat_completion("summarize this"))
|
|
except RuntimeError as exc:
|
|
message = str(exc)
|
|
else:
|
|
raise AssertionError("Expected summarization failure")
|
|
|
|
assert "Summarization failed across providers" in message
|
|
assert "OpenRouter summarization returned empty content" in message
|
|
assert "direct OpenAI summarization fallback skipped" in message
|
|
|
|
|
|
def test_transcribe_tracks_labels_each_speaker_block(tmp_path: Path, monkeypatch):
|
|
alice = tmp_path / "alice.wav"
|
|
bob = tmp_path / "bob.wav"
|
|
alice.write_bytes(b"alice audio")
|
|
bob.write_bytes(b"bob audio")
|
|
calls = []
|
|
|
|
async def fake_transcribe(path: str) -> str:
|
|
calls.append(Path(path).name)
|
|
return {
|
|
"alice.wav": "I finished the API work.",
|
|
"bob.wav": "I will test it tomorrow.",
|
|
}[Path(path).name]
|
|
|
|
monkeypatch.setattr(openrouter_client, "transcribe", fake_transcribe)
|
|
|
|
result = asyncio.run(
|
|
transcribe_tracks(
|
|
[
|
|
{"display_name": "Alice", "path": str(alice)},
|
|
{"display_name": "Bob", "path": str(bob)},
|
|
]
|
|
)
|
|
)
|
|
|
|
assert result == "[Alice]\nI finished the API work.\n\n[Bob]\nI will test it tomorrow."
|
|
assert calls == ["alice.wav", "bob.wav"]
|
|
|
|
|
|
def test_transcribe_tracks_skips_failed_tracks_when_at_least_one_succeeds(tmp_path: Path, monkeypatch):
|
|
alice = tmp_path / "alice.wav"
|
|
bob = tmp_path / "bob.wav"
|
|
alice.write_bytes(b"alice audio")
|
|
bob.write_bytes(b"bob audio")
|
|
|
|
async def fake_transcribe(path: str) -> str:
|
|
if Path(path).name == "bob.wav":
|
|
raise RuntimeError("provider down")
|
|
return "Alice spoke."
|
|
|
|
monkeypatch.setattr(openrouter_client, "transcribe", fake_transcribe)
|
|
|
|
result = asyncio.run(
|
|
transcribe_tracks(
|
|
[
|
|
{"display_name": "Alice", "path": str(alice)},
|
|
{"display_name": "Bob", "path": str(bob)},
|
|
]
|
|
)
|
|
)
|
|
|
|
assert result == "[Alice]\nAlice spoke."
|
|
|
|
|
|
def test_transcribe_tracks_raises_when_no_tracks_produce_text(tmp_path: Path, monkeypatch):
|
|
alice = tmp_path / "alice.wav"
|
|
alice.write_bytes(b"alice audio")
|
|
|
|
async def fake_transcribe(_path: str) -> str:
|
|
return ""
|
|
|
|
monkeypatch.setattr(openrouter_client, "transcribe", fake_transcribe)
|
|
|
|
try:
|
|
asyncio.run(transcribe_tracks([{"display_name": "Alice", "path": str(alice)}]))
|
|
except RuntimeError as exc:
|
|
message = str(exc)
|
|
else:
|
|
raise AssertionError("Expected transcribe_tracks failure")
|
|
|
|
assert "Speaker-track transcription produced no text" in message
|