diff --git a/bot.py b/bot.py index 83a06f4..38ef7e2 100644 --- a/bot.py +++ b/bot.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import logging import os -import shutil import uuid from pathlib import Path @@ -17,8 +16,13 @@ from helpers import chunk_message, command_channel_error from openrouter_client import summarize, transcribe from voice import MeetingRecorder +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", +) logging.getLogger("discord.ext.voice_recv.reader").setLevel(logging.WARNING) logging.getLogger("discord.ext.voice_recv.gateway").setLevel(logging.WARNING) +logger = logging.getLogger(__name__) load_dotenv() @@ -38,14 +42,14 @@ commands_synced = False @bot.event async def on_ready(): global commands_synced - print(f"Logged in as {bot.user}") + logger.info("Logged in as %s", bot.user) if not commands_synced: try: synced = await bot.tree.sync() commands_synced = True - print(f"Synced {len(synced)} commands") - except Exception as exc: - print(f"Sync error: {exc}") + logger.info("Synced %s commands", len(synced)) + except Exception: + logger.exception("Sync error while registering slash commands") async def resolve_text_channel(channel_id: int | None): @@ -57,6 +61,7 @@ async def resolve_text_channel(channel_id: int | None): try: channel = await bot.fetch_channel(channel_id) except discord.DiscordException: + logger.warning("Failed to resolve text channel: channel_id=%s", channel_id) return None return channel @@ -80,19 +85,35 @@ async def wait_for_file_ready(file_path: str, attempts: int = 20, delay: float = last_size = -1 stable_count = 0 - for _ in range(attempts): + for attempt in range(1, attempts + 1): if os.path.exists(file_path): size = os.path.getsize(file_path) + logger.info( + "Checking recording file readiness: path=%s attempt=%s/%s size_bytes=%s", + file_path, + attempt, + attempts, + size, + ) if size > 0 and size == last_size: stable_count += 1 if stable_count >= 2: + logger.info("Recording file finalized: path=%s size_bytes=%s", file_path, size) return True else: stable_count = 0 last_size = size + else: + logger.info( + "Recording file not present yet: path=%s attempt=%s/%s", + file_path, + attempt, + attempts, + ) await asyncio.sleep(delay) + logger.warning("Recording file did not stabilize before timeout: path=%s", file_path) return False @@ -107,44 +128,72 @@ async def process_recording( await config.get_output_channel(guild_id) or fallback_channel_id ) + logger.info( + "Starting post-recording processing: guild_id=%s path=%s fallback_channel_id=%s", + guild_id, + file_path, + fallback_channel_id, + ) + if error is not None: + logger.exception("Recording callback reported an error for guild_id=%s", guild_id, exc_info=error) if target_channel: - await send_chunked(target_channel, f"❌ Recording failed: {error}") + await send_chunked( + target_channel, + "❌ Recording failed. Check the bot logs for details. The original recording was kept if it was written to disk.", + ) return if not await wait_for_file_ready(file_path): if target_channel: await target_channel.send( - "⚠️ Recording finished, but the audio file was not finalized in time." + "⚠️ Recording finished, but the audio file was not finalized in time. Check the bot logs. The original recording path was kept for retry." ) return + if os.path.exists(file_path): + logger.info( + "Original recording preserved for guild_id=%s at path=%s size_mib=%.2f", + guild_id, + str(Path(file_path).resolve()), + os.path.getsize(file_path) / 1024 / 1024, + ) + transcript = await transcribe(file_path) + logger.info("Transcription complete for guild_id=%s transcript_chars=%s", guild_id, len(transcript)) summary = await summarize(transcript) + logger.info("Summarization complete for guild_id=%s summary_chars=%s", guild_id, len(summary)) if target_channel: await send_chunked(target_channel, f"📋 **Meeting Summary**\n\n{summary}") - except Exception as exc: + except Exception: + logger.exception( + "Meeting summary processing failed: guild_id=%s path=%s", + guild_id, + file_path, + ) error_channel = await resolve_text_channel(fallback_channel_id) if error_channel: - await send_chunked(error_channel, f"❌ Meeting summary failed: {exc}") + await send_chunked( + error_channel, + "❌ Meeting summary failed. Check the bot logs for details. The original recording was kept on disk for manual retry.", + ) finally: processing.discard(guild_id) recorders.pop(guild_id, None) - try: - os.remove(file_path) - session_dir = Path(file_path).parent - if session_dir.exists() and not any(session_dir.iterdir()): - session_dir.rmdir() - guild_dir = session_dir.parent - if guild_dir.exists() and not any(guild_dir.iterdir()): - guild_dir.rmdir() - except OSError: - pass + if os.path.exists(file_path): + logger.info("Finished processing guild_id=%s; original recording retained at %s", guild_id, str(Path(file_path).resolve())) + def make_after_callback(file_path: str, guild_id: int, fallback_channel_id: int | None): def _after(error: Exception | None): + logger.info( + "Record callback fired: guild_id=%s path=%s had_error=%s", + guild_id, + file_path, + error is not None, + ) future = asyncio.run_coroutine_threadsafe( process_recording(file_path, guild_id, fallback_channel_id, error), bot.loop, @@ -153,8 +202,8 @@ def make_after_callback(file_path: str, guild_id: int, fallback_channel_id: int def _consume_future(fut): try: fut.result() - except Exception as exc: - print(f"Post-recording processing failed: {exc}") + except Exception: + logger.exception("Post-recording processing future failed: guild_id=%s", guild_id) future.add_done_callback(_consume_future) @@ -171,6 +220,7 @@ async def set_output(interaction: discord.Interaction, channel: discord.TextChan return await config.set_output_channel(guild_id, channel.id) + logger.info("Configured output channel: guild_id=%s channel_id=%s", guild_id, channel.id) await interaction.response.send_message( f"✅ Output channel set to {channel.mention}", ephemeral=True, @@ -221,6 +271,12 @@ async def join(interaction: discord.Interaction): audio_path = session_dir / "meeting.wav" after_callback = make_after_callback(str(audio_path), guild_id, interaction.channel_id) + logger.info( + "Starting recording session: guild_id=%s voice_channel=%s output_path=%s", + guild_id, + user_voice.channel.id, + str(audio_path.resolve()), + ) voice_client = await user_voice.channel.connect(cls=voice_recv.VoiceRecvClient) recorder = MeetingRecorder(voice_client, str(audio_path)) await recorder.start(after_callback) @@ -230,13 +286,14 @@ async def join(interaction: discord.Interaction): f"🎙️ Joined **{user_voice.channel.name}** and started recording. Use `/leave` to stop.", ephemeral=True, ) - except Exception as exc: + except Exception: + logger.exception("Failed to start recording: guild_id=%s", guild_id) if voice_client and voice_client.is_connected(): try: await voice_client.disconnect(force=True) except Exception: - pass - await interaction.followup.send(f"❌ Failed to start recording: {exc}", ephemeral=True) + logger.exception("Failed to disconnect voice client after startup error: guild_id=%s", guild_id) + await interaction.followup.send("❌ Failed to start recording. Check the bot logs for details.", ephemeral=True) @app_commands.guild_only() @@ -264,16 +321,18 @@ async def leave(interaction: discord.Interaction): try: processing.add(guild_id) + logger.info("Stopping recording session: guild_id=%s", guild_id) await recorder.stop() recorders.pop(guild_id, None) await voice_client.disconnect(force=True) await interaction.followup.send( - "🎧 Stopped recording. I'm transcribing and summarizing now — I'll post the result in the configured output channel.", + "🎧 Stopped recording. I'm transcribing and summarizing now — I'll post the result in the configured output channel. The original recording will be kept on disk.", ephemeral=True, ) - except Exception as exc: + except Exception: processing.discard(guild_id) - await interaction.followup.send(f"❌ Failed to stop recording: {exc}", ephemeral=True) + logger.exception("Failed to stop recording cleanly: guild_id=%s", guild_id) + await interaction.followup.send("❌ Failed to stop recording. Check the bot logs for details.", ephemeral=True) @app_commands.guild_only() @@ -302,9 +361,9 @@ async def status(interaction: discord.Interaction): if __name__ == "__main__": if not TOKEN: - print("ERROR: DISCORD_BOT_TOKEN not set. Create a .env file.") + logger.error("DISCORD_BOT_TOKEN not set. Create a .env file.") raise SystemExit(1) if not OPENROUTER_KEY: - print("ERROR: OPENROUTER_API_KEY not set. Create a .env file.") + logger.error("OPENROUTER_API_KEY not set. Create a .env file.") raise SystemExit(1) bot.run(TOKEN) diff --git a/openrouter_client.py b/openrouter_client.py index 4d56b50..cf38a8a 100644 --- a/openrouter_client.py +++ b/openrouter_client.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio import base64 -import json +import logging import os +import shutil import tempfile from pathlib import Path from typing import Any @@ -15,7 +16,16 @@ from helpers import summarize_error load_dotenv() +logger = logging.getLogger(__name__) + OPENROUTER_BASE = "https://openrouter.ai/api/v1" +TRANSCRIPTION_MODELS = [ + "openai/gpt-4o-mini-transcribe", + "openai/whisper-large-v3", +] +TRANSCRIPTION_CHUNK_SECONDS = 600 +MAX_TRANSCRIPTION_FILE_BYTES = 25 * 1024 * 1024 +SUMMARY_TRANSCRIPT_CHAR_LIMIT = 12000 def _api_key() -> str: @@ -36,12 +46,6 @@ def _audio_format(audio_path: str) -> str: return suffix or "wav" -TRANSCRIPTION_MODELS = [ - "openai/gpt-4o-mini-transcribe", - "openai/whisper-large-v3", -] - - def _build_transcription_payload(audio_path: str, model: str) -> dict[str, Any]: with open(audio_path, "rb") as audio_file: encoded = base64.b64encode(audio_file.read()).decode("ascii") @@ -55,6 +59,39 @@ def _build_transcription_payload(audio_path: str, model: str) -> dict[str, Any]: } +def _chunk_text(text: str, limit: int = SUMMARY_TRANSCRIPT_CHAR_LIMIT) -> list[str]: + clean = text.strip() + if len(clean) <= limit: + return [clean] + + chunks: list[str] = [] + remaining = clean + while remaining: + if len(remaining) <= limit: + chunks.append(remaining) + break + + split_at = remaining.rfind("\n", 0, limit) + separator_length = 1 + if split_at <= 0: + split_at = remaining.rfind(" ", 0, limit) + separator_length = 1 + if split_at <= 0: + split_at = limit + separator_length = 0 + + chunk = remaining[:split_at].strip() + if chunk: + chunks.append(chunk) + remaining = remaining[split_at + separator_length :].lstrip() + + return chunks + + +def _file_size_mb(path: str) -> float: + return os.path.getsize(path) / 1024 / 1024 + + async def _normalize_audio_for_transcription(audio_path: str) -> str: source = Path(audio_path) fd, normalized_path = tempfile.mkstemp(prefix="meeting-normalized-", suffix=".wav") @@ -85,64 +122,161 @@ async def _normalize_audio_for_transcription(audio_path: str) -> str: "Audio normalization failed: " + (stderr.decode("utf-8", errors="replace").strip() or f"ffmpeg exited {proc.returncode}") ) + logger.info( + "Normalized audio for transcription: path=%s size_mib=%.2f", + normalized_path, + _file_size_mb(normalized_path), + ) return normalized_path +async def _split_audio_for_transcription(audio_path: str) -> list[str]: + chunk_dir = tempfile.mkdtemp(prefix="meeting-chunks-") + chunk_pattern = str(Path(chunk_dir) / "chunk-%03d.wav") + + proc = await asyncio.create_subprocess_exec( + "ffmpeg", + "-y", + "-i", + audio_path, + "-f", + "segment", + "-segment_time", + str(TRANSCRIPTION_CHUNK_SECONDS), + "-c:a", + "pcm_s16le", + chunk_pattern, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + shutil.rmtree(chunk_dir, ignore_errors=True) + raise RuntimeError( + "Audio chunking failed: " + (stderr.decode("utf-8", errors="replace").strip() or f"ffmpeg exited {proc.returncode}") + ) + + chunk_paths = sorted(str(path) for path in Path(chunk_dir).glob("chunk-*.wav")) + if not chunk_paths: + shutil.rmtree(chunk_dir, ignore_errors=True) + raise RuntimeError("Audio chunking produced no output files") + + for chunk_path in chunk_paths: + size_bytes = os.path.getsize(chunk_path) + if size_bytes > MAX_TRANSCRIPTION_FILE_BYTES: + shutil.rmtree(chunk_dir, ignore_errors=True) + raise RuntimeError( + f"Chunk {Path(chunk_path).name} is too large for transcription: {size_bytes} bytes" + ) + + logger.info( + "Split normalized audio into %s chunk(s): %s", + len(chunk_paths), + ", ".join(f"{Path(path).name} ({_file_size_mb(path):.2f} MiB)" for path in chunk_paths), + ) + return chunk_paths + + +async def _transcribe_chunk(client: httpx.AsyncClient, audio_path: str, headers: dict[str, str]) -> str: + failures: list[str] = [] + for model in TRANSCRIPTION_MODELS: + resp = await client.post( + f"{OPENROUTER_BASE}/audio/transcriptions", + headers=headers, + json=_build_transcription_payload(audio_path, model), + ) + try: + resp.raise_for_status() + except httpx.HTTPStatusError: + detail = summarize_error(_safe_json(resp), fallback=resp.text) + generation_id = resp.headers.get("x-generation-id") + suffix = f"; generation_id={generation_id}" if generation_id else "" + failures.append(f"{model}: HTTP {resp.status_code}: {detail}{suffix}") + logger.warning( + "Transcription model failed: model=%s chunk=%s status=%s detail=%s generation_id=%s", + model, + Path(audio_path).name, + resp.status_code, + detail, + generation_id, + ) + continue + + data = resp.json() + text = data.get("text", "") + if text.strip(): + logger.info( + "Transcription chunk completed: chunk=%s model=%s transcript_chars=%s", + Path(audio_path).name, + model, + len(text.strip()), + ) + return text.strip() + + failures.append(f"{model}: returned empty text") + logger.warning( + "Transcription model returned empty text: model=%s chunk=%s", + model, + Path(audio_path).name, + ) + + raise RuntimeError("OpenRouter transcription failed across all models: " + " | ".join(failures)) + + async def transcribe(audio_path: str) -> str: """Send audio to OpenRouter STT models and return transcript text.""" + logger.info( + "Starting transcription pipeline: source=%s size_mib=%.2f", + audio_path, + _file_size_mb(audio_path), + ) headers = _auth_headers() headers["Content-Type"] = "application/json" headers["X-OpenRouter-Title"] = "discord-meeting-bot" normalized_path = await _normalize_audio_for_transcription(audio_path) - failures: list[str] = [] + chunk_paths: list[str] = [] try: + chunk_paths = await _split_audio_for_transcription(normalized_path) + logger.info("Split normalized audio into %s chunk(s)", len(chunk_paths)) + transcripts: list[str] = [] async with httpx.AsyncClient(timeout=300) as client: - for model in TRANSCRIPTION_MODELS: - resp = await client.post( - f"{OPENROUTER_BASE}/audio/transcriptions", - headers=headers, - json=_build_transcription_payload(normalized_path, model), + for index, chunk_path in enumerate(chunk_paths, start=1): + logger.info( + "Submitting chunk for transcription: index=%s/%s chunk=%s size_mib=%.2f", + index, + len(chunk_paths), + Path(chunk_path).name, + _file_size_mb(chunk_path), ) - try: - resp.raise_for_status() - except httpx.HTTPStatusError: - detail = summarize_error(_safe_json(resp), fallback=resp.text) - generation_id = resp.headers.get("x-generation-id") - suffix = f"; generation_id={generation_id}" if generation_id else "" - failures.append(f"{model}: HTTP {resp.status_code}: {detail}{suffix}") - continue + transcripts.append(await _transcribe_chunk(client, chunk_path, headers)) - data = resp.json() - text = data.get("text", "") - if text.strip(): - return text.strip() - failures.append(f"{model}: returned empty text") + combined = "\n\n".join(part for part in transcripts if part.strip()).strip() + if not combined: + raise RuntimeError("Transcription completed but produced no text") - raise RuntimeError( - "OpenRouter transcription failed across all models: " + " | ".join(failures) + logger.info( + "Completed transcription pipeline: chunks=%s transcript_chars=%s", + len(chunk_paths), + len(combined), ) + return combined finally: + for chunk_path in chunk_paths: + try: + os.remove(chunk_path) + except OSError: + pass + chunk_dirs = {str(Path(chunk_path).parent) for chunk_path in chunk_paths} + for chunk_dir in chunk_dirs: + shutil.rmtree(chunk_dir, ignore_errors=True) try: os.remove(normalized_path) except OSError: pass -async def summarize(transcript: str) -> str: - """Send transcript to DeepSeek via OpenRouter and return structured meeting summary.""" - prompt = f"""You are a meeting summarizer. Given the following meeting transcript, produce a concise, well-structured summary with: - -1. **Overview** — 2-3 sentences covering what was discussed -2. **Key Decisions** — decisions that were made -3. **Action Items** — who needs to do what (use bullet points) -4. **Next Steps / Deadlines** — any dates, timelines, or follow-ups mentioned - -If speaker labels are present, preserve them where helpful in action items. - -Transcript: -{transcript[:16000]}""" - +async def _chat_completion(prompt: str, *, timeout: int = 180) -> str: body = { "model": "@preset/cheap-deepseek-v4-flash", "messages": [{"role": "user", "content": prompt}], @@ -152,7 +286,7 @@ Transcript: headers = _auth_headers() headers["Content-Type"] = "application/json" - async with httpx.AsyncClient(timeout=180) as client: + async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post( f"{OPENROUTER_BASE}/chat/completions", headers=headers, @@ -162,6 +296,7 @@ Transcript: resp.raise_for_status() except httpx.HTTPStatusError as exc: detail = summarize_error(_safe_json(resp), fallback=resp.text) + logger.error("OpenRouter summarization failed: %s", detail) raise RuntimeError(f"OpenRouter summarization failed: {detail}") from exc data = resp.json() @@ -175,6 +310,80 @@ Transcript: return content.strip() +def _summary_prompt(transcript: str) -> str: + return f"""You are a meeting summarizer. Given the following meeting transcript, produce a concise, well-structured summary with: + +1. **Overview** — 2-3 sentences covering what was discussed +2. **Key Decisions** — decisions that were made +3. **Action Items** — who needs to do what (use bullet points) +4. **Next Steps / Deadlines** — any dates, timelines, or follow-ups mentioned + +If speaker labels are present, preserve them where helpful in action items. + +Transcript: +{transcript}""" + + +def _intermediate_summary_prompt(chunk: str, index: int, total: int) -> str: + return f"""You are preparing notes for a longer meeting summary. Summarize this partial transcript chunk clearly and compactly. + +Focus on: +- important discussion points +- decisions +- action items +- deadlines or follow-ups + +Partial meeting transcript chunk {index}/{total}: +{chunk}""" + + +def _combined_summary_prompt(chunk_summaries: list[str]) -> str: + merged = "\n\n".join( + f"Chunk {index}:\n{summary}" for index, summary in enumerate(chunk_summaries, start=1) + ) + return f"""You are a meeting summarizer. The transcript was summarized in chunks first. Combine them into one final meeting summary with: + +1. **Overview** — 2-3 sentences covering what was discussed +2. **Key Decisions** — decisions that were made +3. **Action Items** — who needs to do what (use bullet points) +4. **Next Steps / Deadlines** — any dates, timelines, or follow-ups mentioned + +Combined chunk summaries: +{merged}""" + + +async def summarize(transcript: str) -> str: + """Summarize transcript text, using multi-stage summarization for long inputs.""" + clean = transcript.strip() + if not clean: + raise RuntimeError("Cannot summarize an empty transcript") + + if len(clean) <= SUMMARY_TRANSCRIPT_CHAR_LIMIT: + logger.info("Transcript fits single-pass summary limit: transcript_chars=%s", len(clean)) + return await _chat_completion(_summary_prompt(clean)) + + chunks = _chunk_text(clean, limit=SUMMARY_TRANSCRIPT_CHAR_LIMIT) + logger.info( + "Transcript exceeds single-pass summary limit: transcript_chars=%s chunks=%s limit=%s", + len(clean), + len(chunks), + SUMMARY_TRANSCRIPT_CHAR_LIMIT, + ) + + chunk_summaries: list[str] = [] + for index, chunk in enumerate(chunks, start=1): + logger.info( + "Summarizing transcript chunk: index=%s/%s chunk_chars=%s", + index, + len(chunks), + len(chunk), + ) + chunk_summaries.append(await _chat_completion(_intermediate_summary_prompt(chunk, index, len(chunks)))) + + logger.info("Combining %s intermediate chunk summaries into final meeting summary", len(chunk_summaries)) + return await _chat_completion(_combined_summary_prompt(chunk_summaries)) + + def _safe_json(resp: httpx.Response) -> Any: try: return resp.json() diff --git a/tests/test_openrouter_client.py b/tests/test_openrouter_client.py index 5eef260..74b2891 100644 --- a/tests/test_openrouter_client.py +++ b/tests/test_openrouter_client.py @@ -1,11 +1,22 @@ +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__))) -from openrouter_client import TRANSCRIPTION_MODELS, _audio_format, _build_transcription_payload +import openrouter_client +from openrouter_client import ( + SUMMARY_TRANSCRIPT_CHAR_LIMIT, + TRANSCRIPTION_MODELS, + _audio_format, + _build_transcription_payload, + _chunk_text, + summarize, + transcribe, +) def test_audio_format_defaults_to_wav_when_missing_suffix(tmp_path: Path): @@ -23,3 +34,83 @@ def test_build_transcription_payload_uses_base64_json_shape(tmp_path: Path): assert payload["model"] == "openai/gpt-4o-mini-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 diff --git a/voice.py b/voice.py index 2ddf2d1..0c1d80e 100644 --- a/voice.py +++ b/voice.py @@ -1,10 +1,13 @@ from __future__ import annotations +import logging from pathlib import Path from typing import Callable from discord.ext import voice_recv +logger = logging.getLogger(__name__) + class MeetingRecorder: """Wrapper around discord-ext-voice-recv's listen/stop_listening API.""" @@ -23,11 +26,14 @@ class MeetingRecorder: self.sink = voice_recv.WaveSink(self.output_path) self.vc.listen(self.sink, after=after_callback) self.recording = True + logger.info("Voice receive started: output_path=%s", self.output_path) async def stop(self) -> None: if not self.recording: + logger.info("Stop requested, but recorder was already inactive: output_path=%s", self.output_path) return if self.vc.is_listening(): self.vc.stop_listening() + logger.info("Voice receive stop requested: output_path=%s", self.output_path) self.recording = False