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
+162 -22
View File
@@ -72,6 +72,44 @@ async def send_chunked(channel, text: str):
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:
guild_id = interaction.guild_id
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
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(
file_path: str,
guild_id: int,
fallback_channel_id: int | None,
error: Exception | None,
):
try:
target_channel = await resolve_text_channel(
await config.get_output_channel(guild_id) or fallback_channel_id
)
output_channel_id = await config.get_output_channel(guild_id)
output_channel = await resolve_text_channel(output_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(
"Starting post-recording processing: guild_id=%s path=%s fallback_channel_id=%s",
guild_id,
@@ -136,19 +196,26 @@ async def process_recording(
)
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)
if target_channel:
await send_chunked(
target_channel,
"❌ Recording failed. Check the bot logs for details. The original recording was kept if it was written to disk.",
)
await _save_retry_state(guild_id, file_path, fallback_channel_id, reason)
await safe_send_chunked(
output_channel,
build_retry_notice(file_path, reason),
fallback_channels=[fallback_channel],
purpose="recording failure notice",
)
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. Check the bot logs. The original recording path was kept for retry."
)
reason = "recording file was not finalized in time"
await _save_retry_state(guild_id, file_path, fallback_channel_id, reason)
await safe_send_chunked(
output_channel,
build_retry_notice(file_path, reason),
fallback_channels=[fallback_channel],
purpose="file-finalization warning",
)
return
if os.path.exists(file_path):
@@ -163,21 +230,28 @@ async def process_recording(
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))
await config.clear_retry_state(guild_id)
if target_channel:
await send_chunked(target_channel, f"📋 **Meeting Summary**\n\n{summary}")
except Exception:
await safe_send_chunked(
output_channel,
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(
"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,
"❌ Meeting summary failed. Check the bot logs for details. The original recording was kept on disk for manual retry.",
)
await _save_retry_state(guild_id, file_path, fallback_channel_id, reason)
await safe_send_chunked(
output_channel,
build_retry_notice(file_path, reason),
fallback_channels=[fallback_channel],
purpose="processing failure notice",
)
finally:
processing.discard(guild_id)
recorders.pop(guild_id, None)
@@ -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)
@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()
@bot.tree.command(name="status", description="Check bot state")
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'}")
ch_id = await config.get_output_channel(guild_id)
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)