feat(agent-office): InstaAgent 자율 발급 경로 + 커버 프리뷰

- on_schedule에 autonomous_issue 분기 추가 (eligible 픽만 선별·max_per_day 제한)
- _generate_and_preview 메서드: 슬레이트 생성 → 커버 PNG → 인라인 승인 버튼
- messaging.send_photo 신규 추가 (multipart/form-data, reply_markup 지원)
- insta_get_preferences 실패를 warning으로 격리해 자율 경로 중단 방지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 02:36:26 +09:00
parent 9fc764a78c
commit 7c5ca15b64
3 changed files with 116 additions and 3 deletions

View File

@@ -71,14 +71,31 @@ class InstaAgent(BaseAgent):
config = get_agent_config(self.agent_id) or {}
custom = config.get("custom_config", {}) or {}
auto_select = bool(custom.get("auto_select", False))
autonomous = bool(custom.get("autonomous_issue", False))
threshold = float(custom.get("select_threshold", 0.6))
max_per_day = int(custom.get("max_per_day", 2))
task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select},
requires_approval=False)
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
try:
prefs = await service_proxy.insta_get_preferences()
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
try:
prefs = await service_proxy.insta_get_preferences()
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
except Exception as _pref_err:
add_log(self.agent_id, f"insta preferences unavailable: {_pref_err}", "warning", task_id)
await self._run_collect_and_extract()
if autonomous:
ranked = await service_proxy.insta_ranked(threshold=threshold, limit=20)
eligible = [r for r in ranked if r.get("eligible")][:max_per_day]
if not eligible:
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 발행할 가치 있는 주제가 없습니다.")
else:
for pick in eligible:
await self._generate_and_preview(pick)
update_task_status(task_id, "succeeded", {"issued": len(eligible)})
await self.transition("idle", "자율 발급 후보 프리뷰 완료")
return
kws = await service_proxy.insta_list_keywords(used=False)
if auto_select:
await self._auto_render(kws)
@@ -161,6 +178,27 @@ class InstaAgent(BaseAgent):
full_caption = f"{caption}\n\n{hashtags}".strip()
await _send_media_group(media, caption=full_caption)
async def _generate_and_preview(self, pick: dict) -> None:
"""eligible 픽 → 슬레이트 생성·렌더 → 커버 프리뷰 + 승인 버튼."""
created = await service_proxy.insta_create_slate(
keyword=pick["keyword"], category=pick["category"], keyword_id=pick["id"],
)
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
slate_id = st["result_id"]
cover = await service_proxy.insta_get_asset_bytes(slate_id, 1)
bd = pick.get("breakdown", {})
caption = (f"🎴 <b>{pick['keyword']}</b> ({pick['category']})\n"
f"점수 {pick.get('final_score')} · fresh {bd.get('freshness')} "
f"fit {bd.get('account_fit')} claude {bd.get('claude')}\n승인하시겠어요?")
kb = {"inline_keyboard": [[
{"text": "✅ 승인", "callback_data": f"issue_approve_{slate_id}"},
{"text": "❌ 반려", "callback_data": f"issue_reject_{slate_id}"},
{"text": "🔄 재생성", "callback_data": f"issue_regen_{slate_id}"},
]]}
await messaging.send_photo(cover, caption=caption, reply_markup=kb)
create_task(self.agent_id, "insta_issue", {"slate_id": slate_id, "keyword_id": pick["id"]},
requires_approval=True)
async def on_command(self, command: str, params: dict) -> dict:
if command == "extract":
await self._run_collect_and_extract()

View File

@@ -1,8 +1,11 @@
"""고수준 메시지 전송 API."""
import json
import uuid
from typing import Optional
from ..config import TELEGRAM_CHAT_ID
import httpx
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
from ..db import save_telegram_callback
from .client import _enabled, api_call
from .formatter import MessageKind, format_agent_message
@@ -81,3 +84,26 @@ async def send_approval_request(
{"label": "❌ 거절", "action": "reject"},
],
)
async def send_photo(
photo_bytes: bytes,
caption: str = "",
reply_markup: Optional[dict] = None,
chat_id: Optional[str] = None,
) -> dict:
"""PNG/JPEG 바이트를 sendPhoto로 전송. reply_markup으로 인라인 키보드 첨부 가능."""
if not TELEGRAM_BOT_TOKEN:
return {"ok": False, "reason": "no token"}
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
data: dict = {
"chat_id": chat_id or TELEGRAM_CHAT_ID,
"caption": caption[:1024],
"parse_mode": "HTML",
}
if reply_markup:
data["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
files = {"photo": ("cover.png", photo_bytes, "image/png")}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, data=data, files=files)
return resp.json()

View File

@@ -0,0 +1,49 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from unittest.mock import AsyncMock
from app.agents.insta import InstaAgent
@pytest.fixture(autouse=True)
def _init_db():
import gc
gc.collect()
if os.path.exists(_TMP):
os.remove(_TMP)
from app.db import init_db
init_db()
yield
gc.collect()
@pytest.mark.asyncio
async def test_autonomous_issue_previews_eligible(monkeypatch):
agent = InstaAgent()
agent.state = "idle"
monkeypatch.setattr("app.agents.insta.get_agent_config",
lambda aid: {"custom_config": {"autonomous_issue": True,
"select_threshold": 0.5, "max_per_day": 2}})
monkeypatch.setattr(agent, "transition", AsyncMock())
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
monkeypatch.setattr("app.agents.insta.service_proxy.insta_ranked", AsyncMock(return_value=[
{"id": 1, "keyword": "금리", "category": "economy", "eligible": True, "final_score": 0.8, "breakdown": {}},
{"id": 2, "keyword": "x", "category": "economy", "eligible": False, "final_score": 0.1, "breakdown": {}},
]))
preview = AsyncMock()
monkeypatch.setattr(agent, "_generate_and_preview", preview)
monkeypatch.setattr("app.agents.insta.create_task", lambda *a, **k: "t1")
monkeypatch.setattr("app.agents.insta.update_task_status", lambda *a, **k: None)
monkeypatch.setattr("app.agents.insta.add_log", lambda *a, **k: None)
await agent.on_schedule()
assert preview.await_count == 1
assert preview.await_args.args[0]["id"] == 1