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 {}
|
config = get_agent_config(self.agent_id) or {}
|
||||||
custom = config.get("custom_config", {}) or {}
|
custom = config.get("custom_config", {}) or {}
|
||||||
auto_select = bool(custom.get("auto_select", False))
|
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},
|
task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select},
|
||||||
requires_approval=False)
|
requires_approval=False)
|
||||||
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
|
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
|
||||||
try:
|
try:
|
||||||
prefs = await service_proxy.insta_get_preferences()
|
try:
|
||||||
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
|
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()
|
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)
|
kws = await service_proxy.insta_list_keywords(used=False)
|
||||||
if auto_select:
|
if auto_select:
|
||||||
await self._auto_render(kws)
|
await self._auto_render(kws)
|
||||||
@@ -161,6 +178,27 @@ class InstaAgent(BaseAgent):
|
|||||||
full_caption = f"{caption}\n\n{hashtags}".strip()
|
full_caption = f"{caption}\n\n{hashtags}".strip()
|
||||||
await _send_media_group(media, caption=full_caption)
|
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:
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
if command == "extract":
|
if command == "extract":
|
||||||
await self._run_collect_and_extract()
|
await self._run_collect_and_extract()
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""고수준 메시지 전송 API."""
|
"""고수준 메시지 전송 API."""
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
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 ..db import save_telegram_callback
|
||||||
from .client import _enabled, api_call
|
from .client import _enabled, api_call
|
||||||
from .formatter import MessageKind, format_agent_message
|
from .formatter import MessageKind, format_agent_message
|
||||||
@@ -81,3 +84,26 @@ async def send_approval_request(
|
|||||||
{"label": "❌ 거절", "action": "reject"},
|
{"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