fix: add summary fallback and stabilize recording sink

This commit is contained in:
2026-06-10 12:01:13 +00:00
parent 53889da8f6
commit 5e51535693
4 changed files with 153 additions and 10 deletions
+66 -8
View File
@@ -31,6 +31,7 @@ DIRECT_OPENAI_TRANSCRIPTION_MODELS = [
TRANSCRIPTION_CHUNK_SECONDS = 600 TRANSCRIPTION_CHUNK_SECONDS = 600
MAX_TRANSCRIPTION_FILE_BYTES = 25 * 1024 * 1024 MAX_TRANSCRIPTION_FILE_BYTES = 25 * 1024 * 1024
SUMMARY_TRANSCRIPT_CHAR_LIMIT = 12000 SUMMARY_TRANSCRIPT_CHAR_LIMIT = 12000
DIRECT_OPENAI_SUMMARY_MODEL = "gpt-4o-mini"
def _api_key() -> str: def _api_key() -> str:
@@ -362,7 +363,18 @@ async def transcribe(audio_path: str) -> str:
pass 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 = { body = {
"model": "@preset/cheap-deepseek-v4-flash", "model": "@preset/cheap-deepseek-v4-flash",
"messages": [{"role": "user", "content": prompt}], "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 raise RuntimeError(f"OpenRouter summarization failed: {detail}") from exc
data = resp.json() data = resp.json()
try: return _extract_chat_content(data, provider="OpenRouter")
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise RuntimeError("Unexpected OpenRouter summarization response shape") from exc
if not isinstance(content, str) or not content.strip():
raise RuntimeError("OpenRouter summarization returned empty content") async def _chat_completion_openai(prompt: str, *, timeout: int = 180) -> str:
return content.strip() 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: def _summary_prompt(transcript: str) -> str:
+40
View File
@@ -9,6 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import discord import discord
import bot import bot
import voice
class FakeChannel: class FakeChannel:
@@ -66,3 +67,42 @@ def test_build_retry_notice_mentions_reason_and_path():
assert "OpenRouter 502" in message assert "OpenRouter 502" in message
assert "meeting.wav" in message assert "meeting.wav" in message
assert "`/retry`" 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
+44
View File
@@ -13,6 +13,7 @@ from openrouter_client import (
TRANSCRIPTION_MODELS, TRANSCRIPTION_MODELS,
_audio_format, _audio_format,
_build_transcription_payload, _build_transcription_payload,
_chat_completion,
_chunk_text, _chunk_text,
_combined_summary_prompt, _combined_summary_prompt,
_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 "Transcription failed across providers" in message
assert "OpenRouter transcription failed" in message assert "OpenRouter transcription failed" in message
assert "direct OpenAI fallback skipped" 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
+3 -2
View File
@@ -16,14 +16,15 @@ class MeetingRecorder:
self.vc = voice_client self.vc = voice_client
self.output_path = output_path self.output_path = output_path
self.recording = False 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: async def start(self, after_callback: Callable[[Exception | None], None]) -> None:
if self.vc.is_listening(): if self.vc.is_listening():
raise RuntimeError("Voice client is already listening") raise RuntimeError("Voice client is already listening")
Path(self.output_path).parent.mkdir(parents=True, exist_ok=True) 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.vc.listen(self.sink, after=after_callback)
self.recording = True self.recording = True
logger.info("Voice receive started: output_path=%s", self.output_path) logger.info("Voice receive started: output_path=%s", self.output_path)