feat: add chunked meeting transcription and logging

This commit is contained in:
2026-06-08 12:11:00 +00:00
parent eca1a386d0
commit d101c34156
4 changed files with 439 additions and 74 deletions
+89 -30
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import shutil
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -17,8 +16,13 @@ from helpers import chunk_message, command_channel_error
from openrouter_client import summarize, transcribe from openrouter_client import summarize, transcribe
from voice import MeetingRecorder 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.reader").setLevel(logging.WARNING)
logging.getLogger("discord.ext.voice_recv.gateway").setLevel(logging.WARNING) logging.getLogger("discord.ext.voice_recv.gateway").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
load_dotenv() load_dotenv()
@@ -38,14 +42,14 @@ commands_synced = False
@bot.event @bot.event
async def on_ready(): async def on_ready():
global commands_synced global commands_synced
print(f"Logged in as {bot.user}") logger.info("Logged in as %s", bot.user)
if not commands_synced: if not commands_synced:
try: try:
synced = await bot.tree.sync() synced = await bot.tree.sync()
commands_synced = True commands_synced = True
print(f"Synced {len(synced)} commands") logger.info("Synced %s commands", len(synced))
except Exception as exc: except Exception:
print(f"Sync error: {exc}") logger.exception("Sync error while registering slash commands")
async def resolve_text_channel(channel_id: int | None): async def resolve_text_channel(channel_id: int | None):
@@ -57,6 +61,7 @@ async def resolve_text_channel(channel_id: int | None):
try: try:
channel = await bot.fetch_channel(channel_id) channel = await bot.fetch_channel(channel_id)
except discord.DiscordException: except discord.DiscordException:
logger.warning("Failed to resolve text channel: channel_id=%s", channel_id)
return None return None
return channel return channel
@@ -80,19 +85,35 @@ async def wait_for_file_ready(file_path: str, attempts: int = 20, delay: float =
last_size = -1 last_size = -1
stable_count = 0 stable_count = 0
for _ in range(attempts): for attempt in range(1, attempts + 1):
if os.path.exists(file_path): if os.path.exists(file_path):
size = os.path.getsize(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: if size > 0 and size == last_size:
stable_count += 1 stable_count += 1
if stable_count >= 2: if stable_count >= 2:
logger.info("Recording file finalized: path=%s size_bytes=%s", file_path, size)
return True return True
else: else:
stable_count = 0 stable_count = 0
last_size = size last_size = size
else:
logger.info(
"Recording file not present yet: path=%s attempt=%s/%s",
file_path,
attempt,
attempts,
)
await asyncio.sleep(delay) await asyncio.sleep(delay)
logger.warning("Recording file did not stabilize before timeout: path=%s", file_path)
return False return False
@@ -107,44 +128,72 @@ async def process_recording(
await config.get_output_channel(guild_id) or fallback_channel_id 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: if error is not None:
logger.exception("Recording callback reported an error for guild_id=%s", guild_id, exc_info=error)
if target_channel: 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 return
if not await wait_for_file_ready(file_path): if not await wait_for_file_ready(file_path):
if target_channel: if target_channel:
await target_channel.send( 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 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) transcript = await transcribe(file_path)
logger.info("Transcription complete for guild_id=%s transcript_chars=%s", guild_id, len(transcript))
summary = await summarize(transcript) summary = await summarize(transcript)
logger.info("Summarization complete for guild_id=%s summary_chars=%s", guild_id, len(summary))
if target_channel: if target_channel:
await send_chunked(target_channel, f"📋 **Meeting Summary**\n\n{summary}") 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) error_channel = await resolve_text_channel(fallback_channel_id)
if error_channel: 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: finally:
processing.discard(guild_id) processing.discard(guild_id)
recorders.pop(guild_id, None) recorders.pop(guild_id, None)
try: if os.path.exists(file_path):
os.remove(file_path) logger.info("Finished processing guild_id=%s; original recording retained at %s", guild_id, str(Path(file_path).resolve()))
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
def make_after_callback(file_path: str, guild_id: int, fallback_channel_id: int | None): def make_after_callback(file_path: str, guild_id: int, fallback_channel_id: int | None):
def _after(error: Exception | 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( future = asyncio.run_coroutine_threadsafe(
process_recording(file_path, guild_id, fallback_channel_id, error), process_recording(file_path, guild_id, fallback_channel_id, error),
bot.loop, bot.loop,
@@ -153,8 +202,8 @@ def make_after_callback(file_path: str, guild_id: int, fallback_channel_id: int
def _consume_future(fut): def _consume_future(fut):
try: try:
fut.result() fut.result()
except Exception as exc: except Exception:
print(f"Post-recording processing failed: {exc}") logger.exception("Post-recording processing future failed: guild_id=%s", guild_id)
future.add_done_callback(_consume_future) future.add_done_callback(_consume_future)
@@ -171,6 +220,7 @@ async def set_output(interaction: discord.Interaction, channel: discord.TextChan
return return
await config.set_output_channel(guild_id, channel.id) 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( await interaction.response.send_message(
f"✅ Output channel set to {channel.mention}", f"✅ Output channel set to {channel.mention}",
ephemeral=True, ephemeral=True,
@@ -221,6 +271,12 @@ async def join(interaction: discord.Interaction):
audio_path = session_dir / "meeting.wav" audio_path = session_dir / "meeting.wav"
after_callback = make_after_callback(str(audio_path), guild_id, interaction.channel_id) 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) voice_client = await user_voice.channel.connect(cls=voice_recv.VoiceRecvClient)
recorder = MeetingRecorder(voice_client, str(audio_path)) recorder = MeetingRecorder(voice_client, str(audio_path))
await recorder.start(after_callback) 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.", f"🎙️ Joined **{user_voice.channel.name}** and started recording. Use `/leave` to stop.",
ephemeral=True, 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(): if voice_client and voice_client.is_connected():
try: try:
await voice_client.disconnect(force=True) await voice_client.disconnect(force=True)
except Exception: except Exception:
pass logger.exception("Failed to disconnect voice client after startup error: guild_id=%s", guild_id)
await interaction.followup.send(f"❌ Failed to start recording: {exc}", ephemeral=True) await interaction.followup.send("❌ Failed to start recording. Check the bot logs for details.", ephemeral=True)
@app_commands.guild_only() @app_commands.guild_only()
@@ -264,16 +321,18 @@ async def leave(interaction: discord.Interaction):
try: try:
processing.add(guild_id) processing.add(guild_id)
logger.info("Stopping recording session: guild_id=%s", guild_id)
await recorder.stop() await recorder.stop()
recorders.pop(guild_id, None) recorders.pop(guild_id, None)
await voice_client.disconnect(force=True) await voice_client.disconnect(force=True)
await interaction.followup.send( 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, ephemeral=True,
) )
except Exception as exc: except Exception:
processing.discard(guild_id) 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() @app_commands.guild_only()
@@ -302,9 +361,9 @@ async def status(interaction: discord.Interaction):
if __name__ == "__main__": if __name__ == "__main__":
if not TOKEN: 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) raise SystemExit(1)
if not OPENROUTER_KEY: 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) raise SystemExit(1)
bot.run(TOKEN) bot.run(TOKEN)
+245 -36
View File
@@ -2,8 +2,9 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import json import logging
import os import os
import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,7 +16,16 @@ from helpers import summarize_error
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__)
OPENROUTER_BASE = "https://openrouter.ai/api/v1" 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: def _api_key() -> str:
@@ -36,12 +46,6 @@ def _audio_format(audio_path: str) -> str:
return suffix or "wav" 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]: def _build_transcription_payload(audio_path: str, model: str) -> dict[str, Any]:
with open(audio_path, "rb") as audio_file: with open(audio_path, "rb") as audio_file:
encoded = base64.b64encode(audio_file.read()).decode("ascii") 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: async def _normalize_audio_for_transcription(audio_path: str) -> str:
source = Path(audio_path) source = Path(audio_path)
fd, normalized_path = tempfile.mkstemp(prefix="meeting-normalized-", suffix=".wav") fd, normalized_path = tempfile.mkstemp(prefix="meeting-normalized-", suffix=".wav")
@@ -85,24 +122,68 @@ 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}") "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 return normalized_path
async def transcribe(audio_path: str) -> str: async def _split_audio_for_transcription(audio_path: str) -> list[str]:
"""Send audio to OpenRouter STT models and return transcript text.""" chunk_dir = tempfile.mkdtemp(prefix="meeting-chunks-")
headers = _auth_headers() chunk_pattern = str(Path(chunk_dir) / "chunk-%03d.wav")
headers["Content-Type"] = "application/json"
headers["X-OpenRouter-Title"] = "discord-meeting-bot"
normalized_path = await _normalize_audio_for_transcription(audio_path) 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] = [] failures: list[str] = []
try:
async with httpx.AsyncClient(timeout=300) as client:
for model in TRANSCRIPTION_MODELS: for model in TRANSCRIPTION_MODELS:
resp = await client.post( resp = await client.post(
f"{OPENROUTER_BASE}/audio/transcriptions", f"{OPENROUTER_BASE}/audio/transcriptions",
headers=headers, headers=headers,
json=_build_transcription_payload(normalized_path, model), json=_build_transcription_payload(audio_path, model),
) )
try: try:
resp.raise_for_status() resp.raise_for_status()
@@ -111,38 +192,91 @@ async def transcribe(audio_path: str) -> str:
generation_id = resp.headers.get("x-generation-id") generation_id = resp.headers.get("x-generation-id")
suffix = f"; generation_id={generation_id}" if generation_id else "" suffix = f"; generation_id={generation_id}" if generation_id else ""
failures.append(f"{model}: HTTP {resp.status_code}: {detail}{suffix}") 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 continue
data = resp.json() data = resp.json()
text = data.get("text", "") text = data.get("text", "")
if text.strip(): if text.strip():
return text.strip() logger.info(
failures.append(f"{model}: returned empty text") "Transcription chunk completed: chunk=%s model=%s transcript_chars=%s",
Path(audio_path).name,
raise RuntimeError( model,
"OpenRouter transcription failed across all models: " + " | ".join(failures) 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)
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 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),
)
transcripts.append(await _transcribe_chunk(client, chunk_path, headers))
combined = "\n\n".join(part for part in transcripts if part.strip()).strip()
if not combined:
raise RuntimeError("Transcription completed but produced no text")
logger.info(
"Completed transcription pipeline: chunks=%s transcript_chars=%s",
len(chunk_paths),
len(combined),
)
return combined
finally: 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: try:
os.remove(normalized_path) os.remove(normalized_path)
except OSError: except OSError:
pass pass
async def summarize(transcript: str) -> str: async def _chat_completion(prompt: str, *, timeout: int = 180) -> 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]}"""
body = { body = {
"model": "@preset/cheap-deepseek-v4-flash", "model": "@preset/cheap-deepseek-v4-flash",
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
@@ -152,7 +286,7 @@ Transcript:
headers = _auth_headers() headers = _auth_headers()
headers["Content-Type"] = "application/json" 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( resp = await client.post(
f"{OPENROUTER_BASE}/chat/completions", f"{OPENROUTER_BASE}/chat/completions",
headers=headers, headers=headers,
@@ -162,6 +296,7 @@ Transcript:
resp.raise_for_status() resp.raise_for_status()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
detail = summarize_error(_safe_json(resp), fallback=resp.text) 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 raise RuntimeError(f"OpenRouter summarization failed: {detail}") from exc
data = resp.json() data = resp.json()
@@ -175,6 +310,80 @@ Transcript:
return content.strip() 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: def _safe_json(resp: httpx.Response) -> Any:
try: try:
return resp.json() return resp.json()
+92 -1
View File
@@ -1,11 +1,22 @@
import asyncio
import base64 import base64
import logging
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 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): 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["model"] == "openai/gpt-4o-mini-transcribe"
assert payload["input_audio"]["format"] == "wav" assert payload["input_audio"]["format"] == "wav"
assert payload["input_audio"]["data"] == base64.b64encode(b"RIFFdemo").decode("ascii") 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
+6
View File
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import logging
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from discord.ext import voice_recv from discord.ext import voice_recv
logger = logging.getLogger(__name__)
class MeetingRecorder: class MeetingRecorder:
"""Wrapper around discord-ext-voice-recv's listen/stop_listening API.""" """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.sink = voice_recv.WaveSink(self.output_path)
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)
async def stop(self) -> None: async def stop(self) -> None:
if not self.recording: if not self.recording:
logger.info("Stop requested, but recorder was already inactive: output_path=%s", self.output_path)
return return
if self.vc.is_listening(): if self.vc.is_listening():
self.vc.stop_listening() self.vc.stop_listening()
logger.info("Voice receive stop requested: output_path=%s", self.output_path)
self.recording = False self.recording = False