fix: add summary fallback and stabilize recording sink
This commit is contained in:
+66
-8
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user