feat: add chunked meeting transcription and logging
This commit is contained in:
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user