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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
49
agent-office/tests/test_insta_autonomous.py
Normal file
49
agent-office/tests/test_insta_autonomous.py
Normal 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
|
||||
Reference in New Issue
Block a user