diff --git a/openrouter_client.py b/openrouter_client.py index 5ac631b..c450f70 100644 --- a/openrouter_client.py +++ b/openrouter_client.py @@ -31,6 +31,7 @@ DIRECT_OPENAI_TRANSCRIPTION_MODELS = [ TRANSCRIPTION_CHUNK_SECONDS = 600 MAX_TRANSCRIPTION_FILE_BYTES = 25 * 1024 * 1024 SUMMARY_TRANSCRIPT_CHAR_LIMIT = 12000 +DIRECT_OPENAI_SUMMARY_MODEL = "gpt-4o-mini" def _api_key() -> str: @@ -362,7 +363,18 @@ async def transcribe(audio_path: str) -> str: pass -async def _chat_completion(prompt: str, *, timeout: int = 180) -> str: +def _extract_chat_content(data: Any, *, provider: str) -> str: + try: + content = data["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeError(f"Unexpected {provider} summarization response shape") from exc + + if not isinstance(content, str) or not content.strip(): + raise RuntimeError(f"{provider} summarization returned empty content") + return content.strip() + + +async def _chat_completion_openrouter(prompt: str, *, timeout: int = 180) -> str: body = { "model": "@preset/cheap-deepseek-v4-flash", "messages": [{"role": "user", "content": prompt}], @@ -386,14 +398,60 @@ async def _chat_completion(prompt: str, *, timeout: int = 180) -> str: raise RuntimeError(f"OpenRouter summarization failed: {detail}") from exc data = resp.json() - try: - content = data["choices"][0]["message"]["content"] - except (KeyError, IndexError, TypeError) as exc: - raise RuntimeError("Unexpected OpenRouter summarization response shape") from exc + return _extract_chat_content(data, provider="OpenRouter") - if not isinstance(content, str) or not content.strip(): - raise RuntimeError("OpenRouter summarization returned empty content") - return content.strip() + +async def _chat_completion_openai(prompt: str, *, timeout: int = 180) -> str: + api_key = _openai_api_key() + if not api_key: + raise RuntimeError("OPENAI_API_KEY not set") + + body = { + "model": DIRECT_OPENAI_SUMMARY_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 1500, + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + f"{OPENAI_BASE}/chat/completions", + headers=headers, + json=body, + ) + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = summarize_error(_safe_json(resp), fallback=resp.text) + request_id = resp.headers.get("x-request-id") + suffix = f"; request_id={request_id}" if request_id else "" + logger.error("Direct OpenAI summarization failed: %s%s", detail, suffix) + raise RuntimeError(f"Direct OpenAI summarization failed: {detail}{suffix}") from exc + + data = resp.json() + return _extract_chat_content(data, provider="Direct OpenAI") + + +async def _chat_completion(prompt: str, *, timeout: int = 180) -> str: + failures: list[str] = [] + try: + return await _chat_completion_openrouter(prompt, timeout=timeout) + except RuntimeError as exc: + failures.append(str(exc)) + logger.warning("OpenRouter summarization failed; checking direct OpenAI fallback: %s", exc) + + if _openai_api_key(): + try: + return await _chat_completion_openai(prompt, timeout=timeout) + except RuntimeError as exc: + failures.append(str(exc)) + else: + failures.append("direct OpenAI summarization fallback skipped: OPENAI_API_KEY not set") + + raise RuntimeError("Summarization failed across providers: " + " | ".join(failures)) def _summary_prompt(transcript: str) -> str: diff --git a/tests/test_bot.py b/tests/test_bot.py index 4b7f392..cf4f532 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -9,6 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) import discord import bot +import voice class FakeChannel: @@ -66,3 +67,42 @@ def test_build_retry_notice_mentions_reason_and_path(): assert "OpenRouter 502" in message assert "meeting.wav" in message assert "`/retry`" in message + + +def test_meeting_recorder_wraps_wave_sink_with_silence_generator(tmp_path, monkeypatch): + created = {} + + class FakeWaveSink: + def __init__(self, output_path): + self.output_path = output_path + created["wave"] = self + + class FakeSilenceGeneratorSink: + def __init__(self, destination): + self.destination = destination + created["silence"] = self + + class FakeVoiceClient: + def __init__(self): + self.listened_sink = None + + def is_listening(self): + return False + + def listen(self, sink, after): + self.listened_sink = sink + created["after"] = after + + monkeypatch.setattr(voice.voice_recv, "WaveSink", FakeWaveSink) + monkeypatch.setattr(voice.voice_recv, "SilenceGeneratorSink", FakeSilenceGeneratorSink) + + vc = FakeVoiceClient() + output_path = tmp_path / "meeting.wav" + recorder = voice.MeetingRecorder(vc, str(output_path)) + + asyncio.run(recorder.start(lambda error: None)) + + assert created["wave"].output_path == str(output_path) + assert created["silence"].destination is created["wave"] + assert vc.listened_sink is created["silence"] + assert recorder.recording is True diff --git a/tests/test_openrouter_client.py b/tests/test_openrouter_client.py index 8ab081f..8acfc16 100644 --- a/tests/test_openrouter_client.py +++ b/tests/test_openrouter_client.py @@ -13,6 +13,7 @@ from openrouter_client import ( TRANSCRIPTION_MODELS, _audio_format, _build_transcription_payload, + _chat_completion, _chunk_text, _combined_summary_prompt, _summary_prompt, @@ -182,3 +183,46 @@ def test_transcribe_chunk_reports_provider_fallback_when_all_providers_fail(tmp_ 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 diff --git a/voice.py b/voice.py index 0c1d80e..2ba5c8d 100644 --- a/voice.py +++ b/voice.py @@ -16,14 +16,15 @@ class MeetingRecorder: self.vc = voice_client self.output_path = output_path self.recording = False - self.sink: voice_recv.WaveSink | None = None + self.sink: voice_recv.AudioSink | None = None async def start(self, after_callback: Callable[[Exception | None], None]) -> None: if self.vc.is_listening(): raise RuntimeError("Voice client is already listening") Path(self.output_path).parent.mkdir(parents=True, exist_ok=True) - self.sink = voice_recv.WaveSink(self.output_path) + wave_sink = voice_recv.WaveSink(self.output_path) + self.sink = voice_recv.SilenceGeneratorSink(wave_sink) self.vc.listen(self.sink, after=after_callback) self.recording = True logger.info("Voice receive started: output_path=%s", self.output_path)