youtube_publisher._notified_failed(인메모리 set)가 컨테이너 재시작 시 소실되어 기존 failed 파이프라인(예: video 인코딩 구버전 실패 #3)을 매 재시작마다 "신규"로 재알림하던 스팸 버그를 notified_failed_pipelines 테이블로 영속화해 해결. 부수 버그 fix: failed 폴링이 예외를 던지면 failed=[]로 오해해 원장을 통째로 비우던 코드 → 예외 시 early-return(원장 보존). 진행 중 *_pending 승인 dedup(_notified_state_per_pipeline)은 의도적으로 인메모리 유지(재시작 시 살아있는 파이프라인 승인 재알림은 유용한 리마인더). 테스트: 재시작 지속성 + 일시적 폴링 예외 재현 테스트 2종 추가(TDD Red→Green). DB_PATH 첫 import 고정으로 인한 테스트 간 영속 테이블 누수를 monkeypatch로 격리. agent-office 전체 140개 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
149 lines
6.2 KiB
Python
149 lines
6.2 KiB
Python
"""텔레그램 단일 채널로 단계별 승인 인터랙션 오케스트레이션."""
|
|
import logging
|
|
|
|
from .base import BaseAgent
|
|
from . import classify_intent
|
|
from .. import service_proxy
|
|
from ..db import (
|
|
add_log,
|
|
get_notified_failed_pipelines,
|
|
add_notified_failed_pipeline,
|
|
prune_notified_failed_pipelines,
|
|
)
|
|
from ..telegram.messaging import send_raw
|
|
|
|
logger = logging.getLogger("agent-office.youtube_publisher")
|
|
|
|
|
|
_STEP_TITLES = {
|
|
"cover_pending": ("커버 아트", "cover"),
|
|
"video_pending": ("영상 비주얼", "video"),
|
|
"thumb_pending": ("썸네일", "thumb"),
|
|
"meta_pending": ("메타데이터", "meta"),
|
|
"publish_pending": ("최종 검토 + 발행", "publish"),
|
|
}
|
|
|
|
|
|
class YoutubePublisherAgent(BaseAgent):
|
|
agent_id = "youtube_publisher"
|
|
display_name = "YouTube 퍼블리셔"
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# 진행 중(*_pending) 승인 요청 dedup — 인메모리 유지(의도적).
|
|
# 재시작 시 살아있는 파이프라인 승인 재알림은 유용한 리마인더라 스팸 아님.
|
|
self._notified_state_per_pipeline: dict[int, tuple] = {}
|
|
|
|
async def poll_state_changes(self) -> None:
|
|
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
|
|
try:
|
|
pipelines = await service_proxy.list_active_pipelines()
|
|
except Exception as e:
|
|
logger.warning("폴링 실패: %s", e)
|
|
return
|
|
|
|
for p in pipelines:
|
|
state = p.get("state")
|
|
pid = p.get("id")
|
|
if pid is None:
|
|
continue
|
|
if state in _STEP_TITLES:
|
|
_, step = _STEP_TITLES[state]
|
|
fb_count = (p.get("feedback_count_per_step") or {}).get(step, 0)
|
|
key = (state, fb_count)
|
|
if self._notified_state_per_pipeline.get(pid) != key:
|
|
await self._notify_step(p)
|
|
self._notified_state_per_pipeline[pid] = key
|
|
|
|
try:
|
|
failed = await service_proxy.list_failed_pipelines()
|
|
except Exception as e:
|
|
# 일시적 폴링 실패를 "failed 없음"으로 오해하면 원장을 비워 재알림 스팸이 남.
|
|
# → 원장을 건드리지 않고 조용히 종료(다음 폴링에서 재시도).
|
|
logger.warning("failed 폴링 실패: %s", e)
|
|
return
|
|
notified = get_notified_failed_pipelines()
|
|
for p in failed:
|
|
pid = p.get("id")
|
|
if pid is None:
|
|
continue
|
|
if pid not in notified:
|
|
await self._notify_failed(p)
|
|
add_notified_failed_pipeline(pid)
|
|
# 재개되어 failed에서 벗어난 파이프라인은 재알림 가능하도록 원장에서 제거
|
|
failed_ids = {p.get("id") for p in failed if p.get("id") is not None}
|
|
prune_notified_failed_pipelines(failed_ids)
|
|
|
|
async def _notify_failed(self, p: dict) -> None:
|
|
reason = p.get("failed_reason") or "?"
|
|
step = reason.split(":", 1)[0].strip()
|
|
title = p.get("track_title") or f"Pipeline #{p['id']}"
|
|
text = f"⚠️ [{title}] 파이프라인 #{p['id']} '{step}' 실패\n사유: {reason}"
|
|
kb = {"inline_keyboard": [[{"text": "🔄 재시도", "callback_data": f"ytpub_retry_{p['id']}"}]]}
|
|
sent = await send_raw(text=text, reply_markup=kb)
|
|
if sent.get("ok"):
|
|
add_log(self.agent_id, f"pipeline {p['id']} 실패 알림", "warning")
|
|
|
|
async def _notify_step(self, pipeline: dict) -> None:
|
|
state = pipeline["state"]
|
|
title_name, step = _STEP_TITLES[state]
|
|
body = self._format_body(pipeline, step)
|
|
track_title = pipeline.get("track_title") or f"Pipeline #{pipeline['id']}"
|
|
text = (
|
|
f"🎵 [{track_title}] {title_name} 검토\n\n"
|
|
f"{body}\n\n"
|
|
f"➡️ 답장으로 알려주세요: '승인' 또는 '반려 + 수정 방향'"
|
|
)
|
|
sent = await send_raw(text=text)
|
|
if sent.get("ok"):
|
|
msg_id = sent.get("message_id")
|
|
try:
|
|
await service_proxy.save_pipeline_telegram_msg(pipeline["id"], step, msg_id)
|
|
except Exception as e:
|
|
logger.warning("telegram-msg 저장 실패: %s", e)
|
|
add_log(self.agent_id, f"pipeline {pipeline['id']} {step} 알림 전송", "info")
|
|
|
|
def _format_body(self, p: dict, step: str) -> str:
|
|
if step == "cover":
|
|
return f"🖼️ 커버: {p.get('cover_url', '-')}"
|
|
if step == "video":
|
|
return f"🎬 영상: {p.get('video_url', '-')}"
|
|
if step == "thumb":
|
|
return f"🎴 썸네일: {p.get('thumbnail_url', '-')}"
|
|
if step == "meta":
|
|
m = p.get("metadata", {}) or {}
|
|
tags = m.get("tags", []) or []
|
|
description = (m.get("description", "") or "")
|
|
return (
|
|
f"📝 제목: {m.get('title', '')}\n"
|
|
f"🏷️ 태그: {', '.join(tags[:8])}\n"
|
|
f"📄 설명(앞부분): {description[:200]}"
|
|
)
|
|
if step == "publish":
|
|
r = p.get("review", {}) or {}
|
|
return (
|
|
f"AI 검토 결과: {r.get('verdict', '?')} "
|
|
f"(가중 {r.get('weighted_total', '?')}/100)\n"
|
|
f"{r.get('summary', '')}"
|
|
)
|
|
return ""
|
|
|
|
async def on_telegram_reply(self, pipeline_id: int, step: str, user_text: str) -> None:
|
|
intent, feedback = classify_intent.classify(user_text)
|
|
if intent == "unclear":
|
|
await send_raw("다시 입력해주세요. 예: '승인' 또는 '반려, 제목 짧게'")
|
|
return
|
|
try:
|
|
await service_proxy.post_pipeline_feedback(pipeline_id, step, intent, feedback)
|
|
except Exception as e:
|
|
await send_raw(f"⚠️ 처리 실패: {e}")
|
|
|
|
async def on_schedule(self) -> None:
|
|
await self.poll_state_changes()
|
|
|
|
async def on_command(self, command: str, params: dict) -> dict:
|
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
|
|
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
|
pass
|