From 7c5ca15b64690df64d4852be5facd7cb1421108f Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 11 Jun 2026 02:36:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-office):=20InstaAgent=20=EC=9E=90?= =?UTF-8?q?=EC=9C=A8=20=EB=B0=9C=EA=B8=89=20=EA=B2=BD=EB=A1=9C=20+=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=20=ED=94=84=EB=A6=AC=EB=B7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- agent-office/app/agents/insta.py | 42 +++++++++++++++++- agent-office/app/telegram/messaging.py | 28 +++++++++++- agent-office/tests/test_insta_autonomous.py | 49 +++++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 agent-office/tests/test_insta_autonomous.py diff --git a/agent-office/app/agents/insta.py b/agent-office/app/agents/insta.py index 15e74d8..461f322 100644 --- a/agent-office/app/agents/insta.py +++ b/agent-office/app/agents/insta.py @@ -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"🎴 {pick['keyword']} ({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() diff --git a/agent-office/app/telegram/messaging.py b/agent-office/app/telegram/messaging.py index 7f962ed..e28be4d 100644 --- a/agent-office/app/telegram/messaging.py +++ b/agent-office/app/telegram/messaging.py @@ -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() diff --git a/agent-office/tests/test_insta_autonomous.py b/agent-office/tests/test_insta_autonomous.py new file mode 100644 index 0000000..a846d7a --- /dev/null +++ b/agent-office/tests/test_insta_autonomous.py @@ -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