"""Reminder sweep for the assistant knowledge-gap queue. Open gaps are reminded after 24 hours and then at most once every 48 hours. Resolved/ignored rows are never selected, so closing a gap really stops the clock. The outbox is the delivery/audit trail; the row timestamps are the deduplication clock. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.assistant.models import ( GAP_IN_PROGRESS, GAP_OPEN, AssistantMessage, AssistantMessageFeedback, ) from app.notifications import fanout from app.notifications.service import NotificationEnvelope from app.scheduled_jobs.failure_notifier import list_admin_recipients from app.scheduled_jobs.registry import register_scheduled_job @register_scheduled_job( key="assistant.knowledge_gap_reminder", display_name="助手 · 知识缺口催办", description="对超过 24 小时未认领或超过 48 小时未推进的知识缺口提醒 IT;已解决/忽略自动停止。", ) async def run_assistant_gap_reminder(db: AsyncSession, config: dict[str, Any]) -> dict[str, Any]: now = datetime.now(UTC).replace(tzinfo=None) rows = (await db.execute( select(AssistantMessageFeedback, AssistantMessage) .join(AssistantMessage, AssistantMessage.id == AssistantMessageFeedback.message_id) .where( AssistantMessageFeedback.verdict == "unhelpful", AssistantMessageFeedback.gap_status.in_((GAP_OPEN, GAP_IN_PROGRESS)), AssistantMessageFeedback.created_at <= now - timedelta(hours=24), ) .order_by(AssistantMessageFeedback.created_at.asc()) .limit(200) )).all() due = [r for r in rows if r[0].last_reminded_at is None or r[0].last_reminded_at <= now - timedelta(hours=48)] if not due: return {"open": len(rows), "reminded": 0} admins = await list_admin_recipients(db) envelopes: list[NotificationEnvelope] = [] for feedback, answer in due: feedback.last_reminded_at = now feedback.reminder_count = int(feedback.reminder_count or 0) + 1 owner_label = ( "未认领" if not feedback.assigned_person_id else f"负责人 {feedback.assigned_person_id}" ) # Context, not a pre-formatted body: the registered # ``assistant.knowledge_gap_reminder`` template renders in each # recipient's own language. Pre-formatting here is what made every # admin read Chinese regardless of their preference. context = { "owner_label": owner_label, "reminder_count": feedback.reminder_count, "answer_excerpt": (answer.content or "")[:240], } for admin in admins: envelopes.append(NotificationEnvelope( recipient=admin, title="助手知识缺口催办", body_text="", channels=["feishu", "email"], template_key="assistant.knowledge_gap_reminder", context=context, related_resource_type="assistant_message_feedback", related_resource_id=feedback.id, url="/admin/audit?tab=assistant", )) await db.commit() if envelopes: await fanout.enqueue_then_drain(db, envelopes, trace_id="", label="assistant.knowledge_gap_reminder") return {"open": len(rows), "due": len(due), "reminded": len(envelopes), "admins": len(admins)}