Add manual retry flow for failed meeting summaries

This commit is contained in:
2026-06-09 11:42:13 +00:00
parent d101c34156
commit 53d73df433
4 changed files with 308 additions and 24 deletions
+159 -19
View File
@@ -72,6 +72,44 @@ async def send_chunked(channel, text: str):
await channel.send(chunk) await channel.send(chunk)
async def safe_send_chunked(primary_channel, text: str, *, fallback_channels: list | None = None, purpose: str = "message"):
candidates = []
seen_ids = set()
for channel in [primary_channel, *(fallback_channels or [])]:
if channel is None:
continue
channel_id = getattr(channel, "id", id(channel))
if channel_id in seen_ids:
continue
seen_ids.add(channel_id)
candidates.append(channel)
for channel in candidates:
try:
await send_chunked(channel, text)
logger.info(
"Delivered %s to channel_id=%s",
purpose,
getattr(channel, "id", "unknown"),
)
return channel
except discord.Forbidden:
logger.exception(
"Missing permission while sending %s to channel_id=%s",
purpose,
getattr(channel, "id", "unknown"),
)
except discord.DiscordException:
logger.exception(
"Discord API error while sending %s to channel_id=%s",
purpose,
getattr(channel, "id", "unknown"),
)
logger.error("Failed to deliver %s to any candidate channel", purpose)
return None
async def ensure_command_channel(interaction: discord.Interaction) -> str | None: async def ensure_command_channel(interaction: discord.Interaction) -> str | None:
guild_id = interaction.guild_id guild_id = interaction.guild_id
if guild_id is None: if guild_id is None:
@@ -117,17 +155,39 @@ async def wait_for_file_ready(file_path: str, attempts: int = 20, delay: float =
return False return False
def build_retry_notice(file_path: str, reason: str) -> str:
file_name = Path(file_path).name
return (
"❌ Meeting summary failed. "
f"Reason: {reason}. "
f"Original recording kept on disk ({file_name}). "
"Use `/retry` later to try the latest saved recording again."
)
async def _save_retry_state(guild_id: int, file_path: str, source_channel_id: int | None, reason: str) -> None:
await config.set_retry_state(guild_id, file_path, source_channel_id, reason)
logger.info(
"Saved retry state: guild_id=%s path=%s source_channel_id=%s reason=%s",
guild_id,
file_path,
source_channel_id,
reason,
)
async def process_recording( async def process_recording(
file_path: str, file_path: str,
guild_id: int, guild_id: int,
fallback_channel_id: int | None, fallback_channel_id: int | None,
error: Exception | None, error: Exception | None,
): ):
try: output_channel_id = await config.get_output_channel(guild_id)
target_channel = await resolve_text_channel( output_channel = await resolve_text_channel(output_channel_id)
await config.get_output_channel(guild_id) or fallback_channel_id fallback_channel = await resolve_text_channel(fallback_channel_id)
) notify_channels = [channel for channel in [output_channel, fallback_channel] if channel is not None]
try:
logger.info( logger.info(
"Starting post-recording processing: guild_id=%s path=%s fallback_channel_id=%s", "Starting post-recording processing: guild_id=%s path=%s fallback_channel_id=%s",
guild_id, guild_id,
@@ -136,18 +196,25 @@ async def process_recording(
) )
if error is not None: if error is not None:
reason = f"recording stopped with callback error: {error}"
logger.exception("Recording callback reported an error for guild_id=%s", guild_id, exc_info=error) logger.exception("Recording callback reported an error for guild_id=%s", guild_id, exc_info=error)
if target_channel: await _save_retry_state(guild_id, file_path, fallback_channel_id, reason)
await send_chunked( await safe_send_chunked(
target_channel, output_channel,
"❌ Recording failed. Check the bot logs for details. The original recording was kept if it was written to disk.", build_retry_notice(file_path, reason),
fallback_channels=[fallback_channel],
purpose="recording failure notice",
) )
return return
if not await wait_for_file_ready(file_path): if not await wait_for_file_ready(file_path):
if target_channel: reason = "recording file was not finalized in time"
await target_channel.send( await _save_retry_state(guild_id, file_path, fallback_channel_id, reason)
"⚠️ Recording finished, but the audio file was not finalized in time. Check the bot logs. The original recording path was kept for retry." await safe_send_chunked(
output_channel,
build_retry_notice(file_path, reason),
fallback_channels=[fallback_channel],
purpose="file-finalization warning",
) )
return return
@@ -163,20 +230,27 @@ async def process_recording(
logger.info("Transcription complete for guild_id=%s transcript_chars=%s", guild_id, len(transcript)) 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)) logger.info("Summarization complete for guild_id=%s summary_chars=%s", guild_id, len(summary))
await config.clear_retry_state(guild_id)
if target_channel: await safe_send_chunked(
await send_chunked(target_channel, f"📋 **Meeting Summary**\n\n{summary}") output_channel,
except Exception: f"📋 **Meeting Summary**\n\n{summary}",
fallback_channels=[fallback_channel],
purpose="meeting summary",
)
except Exception as exc:
reason = str(exc).strip() or exc.__class__.__name__
logger.exception( logger.exception(
"Meeting summary processing failed: guild_id=%s path=%s", "Meeting summary processing failed: guild_id=%s path=%s",
guild_id, guild_id,
file_path, file_path,
) )
error_channel = await resolve_text_channel(fallback_channel_id) await _save_retry_state(guild_id, file_path, fallback_channel_id, reason)
if error_channel: await safe_send_chunked(
await send_chunked( output_channel,
error_channel, build_retry_notice(file_path, reason),
"❌ Meeting summary failed. Check the bot logs for details. The original recording was kept on disk for manual retry.", fallback_channels=[fallback_channel],
purpose="processing failure notice",
) )
finally: finally:
processing.discard(guild_id) processing.discard(guild_id)
@@ -335,6 +409,70 @@ async def leave(interaction: discord.Interaction):
await interaction.followup.send("❌ Failed to stop recording. Check the bot logs for details.", ephemeral=True) await interaction.followup.send("❌ Failed to stop recording. Check the bot logs for details.", ephemeral=True)
@app_commands.guild_only()
@bot.tree.command(name="retry", description="Retry the latest saved recording for this server")
async def retry(interaction: discord.Interaction):
guild_id = interaction.guild_id
if guild_id is None:
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
return
channel_error = await ensure_command_channel(interaction)
if channel_error:
await interaction.response.send_message(channel_error, ephemeral=True)
return
if guild_id in processing:
await interaction.response.send_message(
"⚠️ I'm already processing a recording for this server.",
ephemeral=True,
)
return
retry_state = await config.get_retry_state(guild_id)
if not retry_state:
await interaction.response.send_message(
"️ There isn't a saved failed recording to retry right now.",
ephemeral=True,
)
return
file_path = retry_state.get("file_path")
if not file_path or not os.path.exists(file_path):
await config.clear_retry_state(guild_id)
await interaction.response.send_message(
"⚠️ I found retry metadata, but the saved recording is no longer on disk. I cleared the stale retry state.",
ephemeral=True,
)
return
await interaction.response.defer(ephemeral=True, thinking=True)
processing.add(guild_id)
logger.info(
"Manual retry requested: guild_id=%s path=%s source_channel_id=%s reason=%s",
guild_id,
file_path,
retry_state.get("source_channel_id"),
retry_state.get("reason"),
)
future = asyncio.run_coroutine_threadsafe(
process_recording(file_path, guild_id, interaction.channel_id, None),
bot.loop,
)
def _consume_retry_future(fut):
try:
fut.result()
except Exception:
logger.exception("Retry processing future failed: guild_id=%s", guild_id)
future.add_done_callback(_consume_retry_future)
await interaction.followup.send(
f"🔁 Retrying the latest saved recording: `{Path(file_path).name}`. I'll post the result in the configured output channel if it works.",
ephemeral=True,
)
@app_commands.guild_only() @app_commands.guild_only()
@bot.tree.command(name="status", description="Check bot state") @bot.tree.command(name="status", description="Check bot state")
async def status(interaction: discord.Interaction): async def status(interaction: discord.Interaction):
@@ -356,6 +494,8 @@ async def status(interaction: discord.Interaction):
lines.append(f"Processing summary: {'Yes' if guild_id in processing else 'No'}") lines.append(f"Processing summary: {'Yes' if guild_id in processing else 'No'}")
ch_id = await config.get_output_channel(guild_id) ch_id = await config.get_output_channel(guild_id)
lines.append(f"Output channel: {'<#' + str(ch_id) + '>' if ch_id else 'Not set'}") lines.append(f"Output channel: {'<#' + str(ch_id) + '>' if ch_id else 'Not set'}")
retry_state = await config.get_retry_state(guild_id)
lines.append(f"Saved retry recording: {'Yes' if retry_state else 'No'}")
await interaction.response.send_message("\n".join(lines), ephemeral=True) await interaction.response.send_message("\n".join(lines), ephemeral=True)
+37 -2
View File
@@ -24,9 +24,14 @@ async def save_config(config: dict) -> None:
await f.write(json.dumps(config, indent=2)) await f.write(json.dumps(config, indent=2))
async def get_output_channel(guild_id: int) -> Optional[int]: async def _get_guild_config(guild_id: int) -> dict:
config = await load_config() config = await load_config()
return config.get(str(guild_id), {}).get("output_channel_id") return config.get(str(guild_id), {})
async def get_output_channel(guild_id: int) -> Optional[int]:
guild_config = await _get_guild_config(guild_id)
return guild_config.get("output_channel_id")
async def set_output_channel(guild_id: int, channel_id: int) -> None: async def set_output_channel(guild_id: int, channel_id: int) -> None:
@@ -36,3 +41,33 @@ async def set_output_channel(guild_id: int, channel_id: int) -> None:
guild_config["output_channel_id"] = channel_id guild_config["output_channel_id"] = channel_id
config[guild_key] = guild_config config[guild_key] = guild_config
await save_config(config) await save_config(config)
async def get_retry_state(guild_id: int) -> Optional[dict]:
guild_config = await _get_guild_config(guild_id)
retry_state = guild_config.get("retry_state")
return retry_state if isinstance(retry_state, dict) else None
async def set_retry_state(guild_id: int, file_path: str, source_channel_id: int | None, reason: str) -> None:
config = await load_config()
guild_key = str(guild_id)
guild_config = config.get(guild_key, {})
guild_config["retry_state"] = {
"file_path": file_path,
"source_channel_id": source_channel_id,
"reason": reason,
}
config[guild_key] = guild_config
await save_config(config)
async def clear_retry_state(guild_id: int) -> None:
config = await load_config()
guild_key = str(guild_id)
guild_config = config.get(guild_key)
if not guild_config:
return
guild_config.pop("retry_state", None)
config[guild_key] = guild_config
await save_config(config)
+68
View File
@@ -0,0 +1,68 @@
import asyncio
import os
import sys
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import discord
import bot
class FakeChannel:
def __init__(self, failures_before_success=0, channel_id=999):
self.failures_before_success = failures_before_success
self.messages = []
self.attempts = 0
self.id = channel_id
async def send(self, text):
self.attempts += 1
if self.attempts <= self.failures_before_success:
response = SimpleNamespace(status=403, reason="Forbidden")
raise discord.Forbidden(response, {"message": "Missing Permissions", "code": 50013})
self.messages.append(text)
def test_safe_send_chunked_falls_back_to_secondary_channel():
async def run():
primary = FakeChannel(failures_before_success=1, channel_id=100)
fallback = FakeChannel(channel_id=200)
delivered = await bot.safe_send_chunked(
primary,
"hello world",
fallback_channels=[fallback],
purpose="test notification",
)
return delivered, fallback.messages
delivered, messages = asyncio.run(run())
assert messages == ["hello world"]
assert delivered is not None
def test_safe_send_chunked_returns_none_when_all_channels_fail():
async def run():
primary = FakeChannel(failures_before_success=10, channel_id=100)
fallback = FakeChannel(failures_before_success=10, channel_id=200)
return await bot.safe_send_chunked(
primary,
"hello world",
fallback_channels=[fallback],
purpose="test notification",
)
assert asyncio.run(run()) is None
def test_build_retry_notice_mentions_reason_and_path():
message = bot.build_retry_notice(
file_path=str(Path("recordings") / "1" / "session" / "meeting.wav"),
reason="OpenRouter 502",
)
assert "OpenRouter 502" in message
assert "meeting.wav" in message
assert "`/retry`" in message
+41
View File
@@ -0,0 +1,41 @@
import asyncio
import os
import sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import config
def _reset_config_path(tmp_path: Path):
config.CONFIG_PATH = tmp_path / "config.json"
def test_retry_state_round_trip(tmp_path):
async def run():
_reset_config_path(tmp_path)
await config.set_retry_state(
guild_id=123,
file_path="recordings/123/session/meeting.wav",
source_channel_id=456,
reason="transcription failed",
)
return await config.get_retry_state(123)
state = asyncio.run(run())
assert state == {
"file_path": "recordings/123/session/meeting.wav",
"source_channel_id": 456,
"reason": "transcription failed",
}
def test_clear_retry_state_removes_saved_entry(tmp_path):
async def run():
_reset_config_path(tmp_path)
await config.set_retry_state(123, "a.wav", 456, "oops")
await config.clear_retry_state(123)
return await config.get_retry_state(123)
assert asyncio.run(run()) is None