feat(agent-office): YouTubeResearchAgent + 스케줄러 + /youtube/research API

- db.py: youtube_research_jobs 테이블 추가 + CRUD 3종 (add/update/get_latest)
- agents/youtube.py: YouTubeResearchAgent 신규 구현 (on_schedule/on_command/on_approval/_run_research/send_weekly_report)
- agents/__init__.py: YouTubeResearchAgent 등록
- scheduler.py: youtube_research(매일 09:00) + youtube_weekly_report(월 08:00) cron 추가
- main.py: POST /api/agent-office/youtube/research + GET /api/agent-office/youtube/research/status 엔드포인트 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 12:13:12 +09:00
parent 8604c6292d
commit 1d4354e402
5 changed files with 180 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ from .music import MusicAgent
from .blog import BlogAgent from .blog import BlogAgent
from .realestate import RealestateAgent from .realestate import RealestateAgent
from .lotto import LottoAgent from .lotto import LottoAgent
from .youtube import YouTubeResearchAgent
AGENT_REGISTRY = {} AGENT_REGISTRY = {}
@@ -12,6 +13,7 @@ def init_agents():
AGENT_REGISTRY["blog"] = BlogAgent() AGENT_REGISTRY["blog"] = BlogAgent()
AGENT_REGISTRY["realestate"] = RealestateAgent() AGENT_REGISTRY["realestate"] = RealestateAgent()
AGENT_REGISTRY["lotto"] = LottoAgent() AGENT_REGISTRY["lotto"] = LottoAgent()
AGENT_REGISTRY["youtube"] = YouTubeResearchAgent()
def get_agent(agent_id: str): def get_agent(agent_id: str):
return AGENT_REGISTRY.get(agent_id) return AGENT_REGISTRY.get(agent_id)

View File

@@ -0,0 +1,85 @@
# agent-office/app/agents/youtube.py
import asyncio
from datetime import date
from .base import BaseAgent
from ..db import add_youtube_research_job, update_youtube_research_job
from ..youtube_researcher import (
TARGET_COUNTRIES, TREND_KEYWORDS,
fetch_youtube_trending, fetch_google_trends, fetch_billboard_top20,
push_to_music_lab,
)
class YouTubeResearchAgent(BaseAgent):
agent_id = "youtube"
display_name = "YouTube 리서치"
async def on_schedule(self) -> None:
await self._run_research(TARGET_COUNTRIES)
async def on_command(self, command: str, params: dict) -> dict:
if command == "research":
countries = params.get("countries", TARGET_COUNTRIES)
asyncio.create_task(self._run_research(countries))
return {"ok": True, "message": f"리서치 시작: {countries}"}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
pass
async def _run_research(self, countries: list) -> None:
job_id = add_youtube_research_job(countries)
await self.transition("working", f"트렌드 수집 중 ({','.join(countries)})", str(job_id))
all_trends = []
try:
for country in countries:
trends = await fetch_youtube_trending(country)
all_trends.extend(trends)
gt = await fetch_google_trends(TREND_KEYWORDS, countries)
all_trends.extend(gt)
bb = await fetch_billboard_top20()
all_trends.extend(bb)
ok = await push_to_music_lab(all_trends, date.today().isoformat())
if not ok:
raise RuntimeError("music-lab push 실패")
update_youtube_research_job(job_id, "completed", len(all_trends))
await self.transition("reporting", f"수집 완료: {len(all_trends)}", str(job_id))
except Exception as e:
update_youtube_research_job(job_id, "failed", len(all_trends), str(e))
await self.transition("idle", f"수집 실패: {e}")
return
await self.transition("idle", "리서치 완료")
async def send_weekly_report(self) -> None:
"""매주 월요일 08:00 — 주간 인사이트 텔레그램 발송."""
import httpx
from ..youtube_researcher import MUSIC_LAB_URL
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/market/report/latest")
if resp.status_code != 200:
return
report = resp.json()
except Exception:
return
top = report.get("top_genres", [])[:3]
insights = report.get("insights", "")
text = "📊 *YouTube 시장 주간 리포트*\n\n🔥 인기 장르:\n"
for g in top:
text += f"{g['genre']} (score: {g['score']:.2f})\n"
if insights:
text += f"\n💡 {insights[:300]}"
try:
from ..telegram_bot import send_message
await send_message(text)
except (ImportError, Exception):
pass

View File

@@ -86,6 +86,17 @@ def init_db() -> None:
CREATE INDEX IF NOT EXISTS idx_conv_chat CREATE INDEX IF NOT EXISTS idx_conv_chat
ON conversation_messages(chat_id, created_at DESC) ON conversation_messages(chat_id, created_at DESC)
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS youtube_research_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL DEFAULT 'running',
countries TEXT NOT NULL DEFAULT '[]',
trends_collected INTEGER NOT NULL DEFAULT 0,
error TEXT,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
completed_at TEXT
)
""")
# Seed default agent configs # Seed default agent configs
for agent_id, name in [ for agent_id, name in [
("stock", "주식 트레이더"), ("stock", "주식 트레이더"),
@@ -93,6 +104,7 @@ def init_db() -> None:
("blog", "블로그 마케터"), ("blog", "블로그 마케터"),
("realestate", "청약 애널리스트"), ("realestate", "청약 애널리스트"),
("lotto", "로또 큐레이터"), ("lotto", "로또 큐레이터"),
("youtube", "YouTube 리서치"),
]: ]:
conn.execute( conn.execute(
"INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)", "INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)",
@@ -501,3 +513,45 @@ def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
items.append(item) items.append(item)
return {"items": items, "total": total} return {"items": items, "total": total}
# ── youtube_research_jobs CRUD ────────────────────────────────────────────────
def add_youtube_research_job(countries: list) -> int:
with _conn() as conn:
conn.execute(
"INSERT INTO youtube_research_jobs (countries) VALUES (?)",
(json.dumps(countries),),
)
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
def update_youtube_research_job(
job_id: int, status: str, trends_collected: int, error: Optional[str] = None
) -> None:
with _conn() as conn:
conn.execute(
"""UPDATE youtube_research_jobs
SET status=?, trends_collected=?, error=?,
completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id=?""",
(status, trends_collected, error, job_id),
)
def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM youtube_research_jobs ORDER BY started_at DESC LIMIT 1"
).fetchone()
if not row:
return None
return {
"id": row["id"],
"status": row["status"],
"countries": json.loads(row["countries"]),
"trends_collected": row["trends_collected"],
"error": row["error"],
"started_at": row["started_at"],
"completed_at": row["completed_at"],
}

View File

@@ -4,7 +4,7 @@ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .config import CORS_ALLOW_ORIGINS from .config import CORS_ALLOW_ORIGINS
from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs, get_activity_feed from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs, get_activity_feed, get_latest_youtube_research_job
from .models import CommandRequest, ApprovalRequest, AgentConfigUpdate from .models import CommandRequest, ApprovalRequest, AgentConfigUpdate
from .websocket_manager import ws_manager from .websocket_manager import ws_manager
from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
@@ -199,3 +199,29 @@ async def realestate_notify(body: RealestateNotifyBody):
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=503, detail="RealestateAgent not initialized") raise HTTPException(status_code=503, detail="RealestateAgent not initialized")
return await agent.on_new_matches(body.matches) return await agent.on_new_matches(body.matches)
# --- YouTube Research Agent Endpoints ---
class YouTubeResearchBody(BaseModel):
countries: List[str] = []
@app.post("/api/agent-office/youtube/research")
async def trigger_youtube_research(body: YouTubeResearchBody = None):
agent = AGENT_REGISTRY.get("youtube")
if not agent:
raise HTTPException(status_code=503, detail="YouTubeResearchAgent 없음")
params = {}
if body and body.countries:
params["countries"] = body.countries
result = await agent.on_command("research", params)
return result
@app.get("/api/agent-office/youtube/research/status")
def youtube_research_status():
job = get_latest_youtube_research_job()
if not job:
return {"status": "never_run"}
return job

View File

@@ -24,9 +24,21 @@ async def _run_lotto_schedule():
if agent: if agent:
await agent.on_schedule() await agent.on_schedule()
async def _run_youtube_research():
agent = AGENT_REGISTRY.get("youtube")
if agent:
await agent.on_schedule()
async def _send_youtube_weekly_report():
agent = AGENT_REGISTRY.get("youtube")
if agent:
await agent.send_weekly_report()
def init_scheduler(): def init_scheduler():
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news") scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline") scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline")
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate") scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check") scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
scheduler.start() scheduler.start()