14 Commits

Author SHA1 Message Date
e1b1944f43 feat(insta): dedup_window_days config end-to-end wiring (spec 6.4)
- insta-lab ranked_keywords: add dedup_window_days Query param (default 14, ge=1, le=90); pass to db.list_recent_issued_topics
- service_proxy.insta_ranked: add dedup_window_days param (default 14); include in GET params
- InstaAgent.on_schedule: read dedup_window_days from custom_config (default 14); pass to insta_ranked call
- test_ranked_respects_dedup_window: verifies window param gates eligible flag correctly

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:55:46 +09:00
149e7c40fe docs(insta): 자율 발급 API 2개 문서화 (ranked, decision)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:50:24 +09:00
28d489770a test(agent-office): 하위호환(비자율 경로) + issue_regen 콜백 테스트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:48:18 +09:00
9d50aa4256 feat(agent-office): issue_* 텔레그램 콜백 디스패치
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:41:13 +09:00
bc0f583a0f feat(agent-office): issue_approve/reject/regen 콜백 처리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:38:56 +09:00
7c5ca15b64 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>
2026-06-11 02:36:26 +09:00
9fc764a78c feat(agent-office): service_proxy insta_ranked/insta_decision
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:32:43 +09:00
83398c8413 fix(insta-lab): 선별 zero-pref 크래시 가드 + judge max_tokens 상향 + 404 테스트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:31:18 +09:00
7d1857c8a4 feat(insta-lab): GET /keywords/ranked + POST /slates/{id}/decision
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:23:33 +09:00
c3a6e78954 feat(insta-lab): Claude Haiku 카드가치 판단(graceful)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:21:09 +09:00
5d0e80fb49 feat(insta-lab): selection.py 순수 선별 점수(4신호)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:19:32 +09:00
af2fb57760 feat(insta-lab): 발행 상태 컬럼 + set_slate_decision/list_recent_issued_topics
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:14:48 +09:00
4d02d9c321 docs(plan): insta 자율 카드 발급 구현 계획 (9 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:11:32 +09:00
c99017e68c docs(spec): insta 자율 카드 발급 (스마트 에이전트 3번) 설계
선별 지능(4신호)+카드별 승인 게이트+상태머신/발행이력. 접근법 A: insta-lab 선별·상태 소유, agent-office 오케스트레이션·텔레그램 승인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:05:51 +09:00
16 changed files with 1839 additions and 4 deletions

View File

@@ -318,6 +318,8 @@ docker compose up -d
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 | | POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) | | GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
| GET | `/api/insta/slates/{id}/package` | zip 패키지 (10 PNG + caption.txt) | | GET | `/api/insta/slates/{id}/package` | zip 패키지 (10 PNG + caption.txt) |
| GET | `/api/insta/keywords/ranked` | 4신호 선별 점수 + eligible (자율 발급용) |
| POST | `/api/insta/slates/{id}/decision` | 승인/반려 (approved→published) |
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 | | GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 CRUD | | GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 CRUD |
| POST | `/api/internal/insta/update` | Windows 워커 결과 webhook | | POST | `/api/internal/insta/update` | Windows 워커 결과 webhook |

View File

@@ -71,14 +71,32 @@ 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))
dedup_window_days = int(custom.get("dedup_window_days", 14))
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, dedup_window_days=dedup_window_days)
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 +179,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()
@@ -188,6 +227,38 @@ class InstaAgent(BaseAgent):
return {"ok": False} return {"ok": False}
await self._render_and_push(kid) await self._render_and_push(kid)
return {"ok": True} return {"ok": True}
if action in ("issue_approve", "issue_reject"):
sid = int(params.get("slate_id") or 0)
if not sid:
return {"ok": False}
decision = "approved" if action == "issue_approve" else "rejected"
await service_proxy.insta_decision(sid, decision)
if decision == "approved":
slate = await service_proxy.insta_get_slate(sid)
media = []
for a in slate["assets"][:10]:
data = await service_proxy.insta_get_asset_bytes(sid, a["page_index"])
media.append({"type": "photo", "_bytes": data})
cap = f"{slate.get('suggested_caption','')}\n\n{' '.join(slate.get('hashtags', []) or [])}".strip()
await _send_media_group(media, caption=cap)
await messaging.send_raw(f"✅ 발행 완료 (slate {sid})")
else:
await messaging.send_raw(f"❌ 반려됨 (slate {sid})")
return {"ok": True}
if action == "issue_regen":
sid = int(params.get("slate_id") or 0)
if not sid:
return {"ok": False}
slate = await service_proxy.insta_get_slate(sid)
await service_proxy.insta_decision(sid, "rejected")
await self._generate_and_preview({
"id": 0,
"keyword": slate["keyword"],
"category": slate["category"],
"final_score": None,
"breakdown": {},
})
return {"ok": True}
return {"ok": False} return {"ok": False}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None: async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:

View File

@@ -228,6 +228,26 @@ async def insta_put_preferences(weights: Dict[str, float]) -> Dict[str, Any]:
return resp.json() return resp.json()
async def insta_ranked(threshold: float = 0.6, limit: int = 20, dedup_window_days: int = 14) -> list:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.get(
f"{INSTA_LAB_URL}/api/insta/keywords/ranked",
params={"threshold": threshold, "limit": limit, "dedup_window_days": dedup_window_days},
)
r.raise_for_status()
return r.json()["items"]
async def insta_decision(slate_id: int, decision: str) -> dict:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/decision",
json={"decision": decision},
)
r.raise_for_status()
return r.json()
# --- realestate-lab --- # --- realestate-lab ---
async def realestate_collect() -> Dict[str, Any]: async def realestate_collect() -> Dict[str, Any]:

View File

@@ -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()

View File

@@ -40,6 +40,9 @@ async def _handle_callback(callback_query: dict) -> Optional[dict]:
if callback_id.startswith("render_"): if callback_id.startswith("render_"):
return await _handle_insta_render(callback_query, callback_id) return await _handle_insta_render(callback_query, callback_id)
if callback_id.startswith("issue_"):
return await _handle_insta_issue(callback_query, callback_id)
cb = get_telegram_callback(callback_id) cb = get_telegram_callback(callback_id)
if not cb: if not cb:
return None return None
@@ -132,6 +135,40 @@ async def _handle_insta_render(callback_query: dict, callback_id: str) -> dict:
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e)}
async def _handle_insta_issue(callback_query: dict, callback_id: str) -> dict:
"""issue_{approve|reject|regen}_{slate_id} 콜백 → InstaAgent.on_callback.
callback_data 예시: issue_approve_8, issue_reject_8, issue_regen_8
InstaAgent.on_callback("issue_approve" | "issue_reject" | "issue_regen", {"slate_id": <int>}) 로 dispatch.
"""
from .messaging import send_raw
from ..agents import AGENT_REGISTRY
await api_call(
"answerCallbackQuery",
{"callback_query_id": callback_query["id"], "text": "처리 중..."},
)
try:
rest = callback_id.removeprefix("issue_") # 예: "approve_8"
verb, sid = rest.rsplit("_", 1) # ("approve", "8")
slate_id = int(sid)
except (ValueError, AttributeError):
await send_raw("⚠️ 잘못된 issue 콜백 데이터")
return {"ok": False, "error": "invalid_callback_data"}
agent = AGENT_REGISTRY.get("insta")
if not agent:
await send_raw("⚠️ insta agent 미등록")
return {"ok": False, "error": "agent_missing"}
try:
return await agent.on_callback(f"issue_{verb}", {"slate_id": slate_id})
except Exception as e:
await send_raw(f"⚠️ issue 콜백 처리 실패: {e}")
return {"ok": False, "error": str(e)}
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]: async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
"""슬래시 명령 메시지 처리.""" """슬래시 명령 메시지 처리."""
from .router import parse_command, resolve_agent_command, HELP_TEXT from .router import parse_command, resolve_agent_command, HELP_TEXT

View File

@@ -0,0 +1,169 @@
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
@pytest.mark.asyncio
async def test_callback_approve_publishes_and_delivers(monkeypatch):
agent = InstaAgent()
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision",
AsyncMock(return_value={"status": "published"}))
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate", AsyncMock(return_value={
"assets": [{"page_index": i} for i in range(1, 11)],
"suggested_caption": "cap", "hashtags": ["#a"]}))
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_asset_bytes", AsyncMock(return_value=b"png"))
monkeypatch.setattr("app.agents.insta._send_media_group", AsyncMock(return_value={"ok": True}))
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
res = await agent.on_callback("issue_approve", {"slate_id": 8})
assert res["ok"] is True
@pytest.mark.asyncio
async def test_callback_reject_marks_rejected(monkeypatch):
agent = InstaAgent()
dec = AsyncMock(return_value={"status": "rejected"})
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
res = await agent.on_callback("issue_reject", {"slate_id": 8})
assert res["ok"] is True
dec.assert_awaited_once_with(8, "rejected")
@pytest.mark.asyncio
async def test_handle_insta_issue_dispatch(monkeypatch):
"""_handle_insta_issue: issue_approve_8 → on_callback('issue_approve', {slate_id:8})."""
import sys
# stub api_call so answerCallbackQuery doesn't hit real Telegram
import app.telegram.webhook as wh
monkeypatch.setattr(wh, "api_call", AsyncMock(return_value={"ok": True}))
agent = InstaAgent()
on_cb = AsyncMock(return_value={"ok": True})
monkeypatch.setattr(agent, "on_callback", on_cb)
from app.agents import AGENT_REGISTRY
old = AGENT_REGISTRY.get("insta")
AGENT_REGISTRY["insta"] = agent
try:
result = await wh._handle_insta_issue(
{"id": "cq1", "data": "issue_approve_8"},
"issue_approve_8",
)
finally:
if old is None:
AGENT_REGISTRY.pop("insta", None)
else:
AGENT_REGISTRY["insta"] = old
on_cb.assert_awaited_once_with("issue_approve", {"slate_id": 8})
assert result["ok"] is True
@pytest.mark.asyncio
async def test_handle_insta_issue_invalid_data(monkeypatch):
"""_handle_insta_issue: 잘못된 callback_data → ok=False, error=invalid_callback_data."""
import app.telegram.webhook as wh
monkeypatch.setattr(wh, "api_call", AsyncMock(return_value={"ok": True}))
monkeypatch.setattr("app.telegram.messaging.send_raw", AsyncMock())
result = await wh._handle_insta_issue(
{"id": "cq2", "data": "issue_bad"},
"issue_bad",
)
assert result["ok"] is False
assert result["error"] == "invalid_callback_data"
@pytest.mark.asyncio
async def test_backward_compat_non_autonomous_uses_legacy_path(monkeypatch):
"""autonomous_issue=False, auto_select=False → insta_ranked 미호출, _push_keyword_candidates 호출."""
agent = InstaAgent()
agent.state = "idle"
monkeypatch.setattr("app.agents.insta.get_agent_config",
lambda aid: {"custom_config": {"autonomous_issue": False, "auto_select": False}})
monkeypatch.setattr(agent, "transition", AsyncMock())
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
# insta_get_preferences는 try/except 안에 있으므로 예외를 던져도 안전하지만 깔끔하게 mock
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_preferences",
AsyncMock(return_value={}))
# 비자율 경로에서 insta_ranked는 호출되면 안 된다
ranked = AsyncMock()
monkeypatch.setattr("app.agents.insta.service_proxy.insta_ranked", ranked)
# insta_list_keywords: 비자율 경로에서 반드시 호출
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords",
AsyncMock(return_value=[]))
# auto_select=False → _push_keyword_candidates 경로
push = AsyncMock()
monkeypatch.setattr(agent, "_push_keyword_candidates", push)
gen = AsyncMock()
monkeypatch.setattr(agent, "_generate_and_preview", gen)
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()
ranked.assert_not_awaited() # 자율 경로(insta_ranked) 미진입 확인
gen.assert_not_awaited() # _generate_and_preview 미호출 확인
push.assert_awaited_once() # 기존 candidate-push 경로 진입 확인
@pytest.mark.asyncio
async def test_callback_regen_rejects_old_and_regenerates(monkeypatch):
"""issue_regen: 기존 슬레이트 rejected 처리 후 같은 키워드로 _generate_and_preview 재호출."""
agent = InstaAgent()
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate",
AsyncMock(return_value={"keyword": "금리", "category": "economy"}))
dec = AsyncMock(return_value={"status": "rejected"})
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
gen = AsyncMock()
monkeypatch.setattr(agent, "_generate_and_preview", gen)
res = await agent.on_callback("issue_regen", {"slate_id": 8})
assert res["ok"] is True
dec.assert_awaited_once_with(8, "rejected") # 이전 슬레이트 폐기
gen.assert_awaited_once() # 같은 키워드로 재생성
assert gen.await_args.args[0]["keyword"] == "금리"

View File

@@ -0,0 +1,980 @@
# insta 자율 카드 발급 (스마트 에이전트 3번) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** InstaAgent가 매일 09:30 발행 가치 있는 주제만 자율 선별(4신호)해 카드를 생성·렌더하고, 카드별 텔레그램 승인 게이트로 사람이 최종 결정한 뒤 발급하며, 발행 상태·이력을 추적한다.
**Architecture:** insta-lab이 선별 점수(`selection.py` + `GET /keywords/ranked`)와 발행 상태머신(`card_slates` 컬럼 + `POST /slates/{id}/decision`)을 소유. agent-office `InstaAgent`가 cron 오케스트레이션 + 텔레그램 승인을 담당. 기존 슬레이트 생성·렌더·전달 흐름 재사용.
**Tech Stack:** Python 3.12 / FastAPI / SQLite / anthropic SDK(Haiku) / httpx / pytest. 기존 패턴: `card_writer.py`(Anthropic 클라이언트), `service_proxy.py`(insta httpx 헬퍼), `telegram/webhook.py`(콜백 prefix 디스패치).
**Spec:** `docs/superpowers/specs/2026-06-11-insta-autonomous-card-issuance-design.md`
---
## File Structure
| 파일 | 변경 | 책임 |
|------|------|------|
| `insta-lab/app/db.py` | Modify | `card_slates``published_at`/`decision_at` ALTER + `set_slate_decision`/`list_recent_issued_topics` 헬퍼 |
| `insta-lab/app/selection.py` | Create | 순수 선별 점수(dedup/freshness/account_fit/combine+threshold) |
| `insta-lab/app/selection_judge.py` | Create | Claude Haiku 일괄 카드가치 판단(외부 IO 격리) |
| `insta-lab/app/main.py` | Modify | `GET /api/insta/keywords/ranked`, `POST /api/insta/slates/{id}/decision` |
| `insta-lab/tests/test_selection.py` | Create | selection 순수 단위테스트 |
| `insta-lab/tests/test_ranked_decision_api.py` | Create | ranked·decision 엔드포인트 테스트 |
| `agent-office/app/service_proxy.py` | Modify | `insta_ranked`, `insta_decision` 헬퍼 |
| `agent-office/app/agents/insta.py` | Modify | 자율 `on_schedule` 분기 + 프리뷰 + `issue_*` 콜백 |
| `agent-office/app/telegram/webhook.py` | Modify | `issue_approve_/issue_reject_/issue_regen_` 디스패치 |
| `agent-office/tests/test_insta_autonomous.py` | Create | 자율 on_schedule + 콜백 테스트 |
| `web-backend/CLAUDE.md` + `memory/service_insta.md` | Modify | API 목록 + 메모리 갱신 |
---
## Task 1: insta-lab DB — 발행 상태 컬럼 + 헬퍼
**Files:**
- Modify: `insta-lab/app/db.py`
- Test: `insta-lab/tests/test_db_decision.py` (Create)
- [ ] **Step 1: 실패하는 테스트 작성**
`insta-lab/tests/test_db_decision.py`:
```python
import os
import pytest
from app import db, config
@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "insta.db"))
db.init_db()
def test_set_slate_decision_approved_publishes(fresh_db):
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
db.set_slate_decision(sid, "approved")
s = db.get_card_slate(sid)
assert s["status"] == "published"
assert s["published_at"] is not None
assert s["decision_at"] is not None
def test_set_slate_decision_rejected(fresh_db):
sid = db.add_card_slate({"keyword": "환율", "category": "economy"})
db.set_slate_decision(sid, "rejected")
s = db.get_card_slate(sid)
assert s["status"] == "rejected"
assert s["decision_at"] is not None
assert s["published_at"] is None
def test_set_slate_decision_idempotent(fresh_db):
sid = db.add_card_slate({"keyword": "주식", "category": "economy"})
db.set_slate_decision(sid, "approved")
first = db.get_card_slate(sid)["published_at"]
db.set_slate_decision(sid, "approved") # 재호출 no-op
assert db.get_card_slate(sid)["published_at"] == first
def test_list_recent_issued_topics(fresh_db):
a = db.add_card_slate({"keyword": "금리", "category": "economy"})
b = db.add_card_slate({"keyword": "우울증", "category": "psychology"})
db.set_slate_decision(a, "published") if False else db.set_slate_decision(a, "approved")
db.set_slate_decision(b, "rejected")
topics = db.list_recent_issued_topics(window_days=14)
pairs = {(t["keyword"], t["category"]) for t in topics}
assert ("금리", "economy") in pairs
assert ("우울증", "psychology") in pairs
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_db_decision.py -q`
Expected: FAIL — `db.set_slate_decision` 미존재 + `published_at` 컬럼 없음.
- [ ] **Step 3: `init_db()`에 idempotent ALTER 추가**
`insta-lab/app/db.py``init_db()` 함수 끝(account_preferences seed 직후)에 추가:
```python
# 발행 상태 컬럼 (idempotent ALTER) — 자율 발급 파이프라인
cs_cols = [r[1] for r in conn.execute("PRAGMA table_info(card_slates)").fetchall()]
if "published_at" not in cs_cols:
conn.execute("ALTER TABLE card_slates ADD COLUMN published_at TEXT")
if "decision_at" not in cs_cols:
conn.execute("ALTER TABLE card_slates ADD COLUMN decision_at TEXT")
```
- [ ] **Step 4: 헬퍼 함수 추가**
`insta-lab/app/db.py`의 card_slates 섹션(예: `update_slate_status` 아래)에 추가:
```python
def set_slate_decision(slate_id: int, decision: str) -> None:
"""승인/반려 결정 기록. approved→published(+published_at), rejected→rejected.
멱등: 이미 published면 published_at 유지."""
now = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"
with _conn() as conn:
if decision == "approved":
conn.execute(
f"UPDATE card_slates SET status='published', "
f"published_at=COALESCE(published_at, {now}), decision_at={now} "
f"WHERE id=?",
(slate_id,),
)
elif decision == "rejected":
conn.execute(
f"UPDATE card_slates SET status='rejected', decision_at={now} WHERE id=?",
(slate_id,),
)
else:
raise ValueError(f"invalid decision: {decision}")
def list_recent_issued_topics(window_days: int = 14) -> List[Dict[str, Any]]:
"""최근 window_days 내 published/rejected 슬레이트의 (keyword, category). dedup용."""
with _conn() as conn:
rows = conn.execute(
"SELECT keyword, category FROM card_slates "
"WHERE status IN ('published','rejected') "
"AND COALESCE(published_at, decision_at) >= datetime('now', ?)",
(f"-{int(window_days)} days",),
).fetchall()
return [dict(r) for r in rows]
```
- [ ] **Step 5: 테스트 통과 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_db_decision.py -q`
Expected: 4 PASS.
- [ ] **Step 6: 커밋**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add insta-lab/app/db.py insta-lab/tests/test_db_decision.py
git commit -m "feat(insta-lab): 발행 상태 컬럼 + set_slate_decision/list_recent_issued_topics
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: insta-lab — `selection.py` 순수 점수
**Files:**
- Create: `insta-lab/app/selection.py`
- Test: `insta-lab/tests/test_selection.py`
- [ ] **Step 1: 실패하는 테스트 작성**
`insta-lab/tests/test_selection.py`:
```python
from app.selection import score_candidates
NOW = "2026-06-11T00:00:00Z"
def _cand(kid, kw, cat, score, suggested_at):
return {"id": kid, "keyword": kw, "category": cat, "score": score, "suggested_at": suggested_at}
def test_dedup_excludes_recent_issued():
cands = [_cand(1, "금리", "economy", 0.9, "2026-06-11T00:00:00Z")]
issued = [{"keyword": "금리", "category": "economy"}]
out = score_candidates(cands, issued, prefs={}, claude_scores=None, threshold=0.0, now_iso=NOW)
assert out[0]["eligible"] is False # 최근 발행 주제 제외
def test_freshness_recent_higher():
fresh = _cand(1, "A", "economy", 0.5, "2026-06-11T00:00:00Z") # 0h
stale = _cand(2, "B", "economy", 0.5, "2026-06-04T00:00:00Z") # 168h
out = {c["id"]: c for c in score_candidates([fresh, stale], [], {}, None, threshold=0.0, now_iso=NOW)}
assert out[1]["breakdown"]["freshness"] > out[2]["breakdown"]["freshness"]
def test_account_fit_uses_weight():
cands = [_cand(1, "A", "economy", 0.8, NOW), _cand(2, "B", "psychology", 0.8, NOW)]
prefs = {"economy": 2.0, "psychology": 1.0}
out = {c["id"]: c for c in score_candidates(cands, [], prefs, None, threshold=0.0, now_iso=NOW)}
assert out[1]["breakdown"]["account_fit"] > out[2]["breakdown"]["account_fit"]
def test_threshold_gate():
cands = [_cand(1, "A", "economy", 0.1, "2026-06-01T00:00:00Z")] # 낮은 score+오래됨
out = score_candidates(cands, [], {}, None, threshold=0.6, now_iso=NOW)
assert out[0]["eligible"] is False
def test_claude_missing_renormalizes():
# claude_scores=None이면 freshness+account_fit만으로 정규화 (claude 항 제외)
cands = [_cand(1, "A", "economy", 1.0, NOW)]
out = score_candidates(cands, [], {"economy": 1.0}, None, threshold=0.0, now_iso=NOW)
assert out[0]["breakdown"]["claude"] is None
assert 0.0 <= out[0]["final_score"] <= 1.0
def test_claude_included_when_provided():
cands = [_cand(1, "A", "economy", 0.5, NOW)]
out = score_candidates(cands, [], {"economy": 1.0}, {1: 1.0}, threshold=0.0, now_iso=NOW)
assert out[0]["breakdown"]["claude"] == 1.0
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection.py -q`
Expected: FAIL — `app.selection` 미존재.
- [ ] **Step 3: `selection.py` 작성**
`insta-lab/app/selection.py`:
```python
"""발행 가치 자율 선별 — 순수 점수 함수 (외부 IO 없음, 단위테스트 대상).
신호: dedup(게이트), freshness, account_fit, claude(선택).
final = 가중합(존재하는 신호만 정규화). eligible = dedup통과 and final>=threshold.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
DEFAULT_WEIGHTS = {"freshness": 0.3, "account_fit": 0.3, "claude": 0.4}
FRESH_WINDOW_HOURS = 168.0 # 7일 → 0
def _parse_iso(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
def _norm(kw: str) -> str:
return (kw or "").strip().lower()
def _is_duplicate(keyword: str, category: str, issued: List[Dict[str, Any]]) -> bool:
n = _norm(keyword)
if not n:
return False
for it in issued:
if it.get("category") != category:
continue
m = _norm(it.get("keyword", ""))
if not m:
continue
if n == m or n in m or m in n:
return True
return False
def _freshness(suggested_at: str, now: datetime) -> float:
try:
hours = (now - _parse_iso(suggested_at)).total_seconds() / 3600.0
except Exception:
return 0.0
return max(0.0, min(1.0, 1.0 - hours / FRESH_WINDOW_HOURS))
def score_candidates(
candidates: List[Dict[str, Any]],
issued_topics: List[Dict[str, Any]],
prefs: Dict[str, float],
claude_scores: Optional[Dict[int, float]] = None,
weights: Optional[Dict[str, float]] = None,
threshold: float = 0.6,
now_iso: Optional[str] = None,
) -> List[Dict[str, Any]]:
w = weights or DEFAULT_WEIGHTS
now = _parse_iso(now_iso) if now_iso else datetime.now(timezone.utc)
max_w = max(prefs.values()) if prefs else 1.0
out: List[Dict[str, Any]] = []
for c in candidates:
cat = c.get("category", "")
dup = _is_duplicate(c.get("keyword", ""), cat, issued_topics)
freshness = _freshness(c.get("suggested_at", ""), now)
weight = prefs.get(cat, 1.0)
account_fit = max(0.0, min(1.0, (weight / max_w) * float(c.get("score", 0.0))))
claude = None
if claude_scores is not None and c["id"] in claude_scores:
claude = max(0.0, min(1.0, float(claude_scores[c["id"]])))
# 존재하는 신호만 가중 정규화
parts = [("freshness", freshness), ("account_fit", account_fit)]
if claude is not None:
parts.append(("claude", claude))
total_w = sum(w[name] for name, _ in parts)
final = sum(w[name] * val for name, val in parts) / total_w if total_w else 0.0
eligible = (not dup) and (final >= threshold)
out.append({
"id": c["id"], "keyword": c.get("keyword"), "category": cat,
"final_score": round(final, 4), "eligible": eligible,
"breakdown": {"dedup_excluded": dup, "freshness": round(freshness, 4),
"account_fit": round(account_fit, 4), "claude": claude},
})
out.sort(key=lambda x: (-x["eligible"], -x["final_score"]))
return out
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection.py -q`
Expected: 6 PASS.
- [ ] **Step 5: 커밋**
```bash
git add insta-lab/app/selection.py insta-lab/tests/test_selection.py
git commit -m "feat(insta-lab): selection.py 순수 선별 점수(4신호)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: insta-lab — Claude 카드가치 판단 (`selection_judge.py`)
**Files:**
- Create: `insta-lab/app/selection_judge.py`
- Test: `insta-lab/tests/test_selection_judge.py`
- [ ] **Step 1: 실패하는 테스트 작성**
`insta-lab/tests/test_selection_judge.py`:
```python
from app import selection_judge
def test_parse_judge_response_ok():
raw = '[{"keyword_id": 1, "score": 0.8}, {"keyword_id": 2, "score": 0.3}]'
assert selection_judge.parse_judge_response(raw) == {1: 0.8, 2: 0.3}
def test_parse_judge_response_codefence():
raw = '```json\n[{"keyword_id": 5, "score": 0.5}]\n```'
assert selection_judge.parse_judge_response(raw) == {5: 0.5}
def test_parse_judge_response_garbage_returns_empty():
assert selection_judge.parse_judge_response("not json") == {}
def test_judge_candidates_no_key_returns_empty(monkeypatch):
monkeypatch.setattr(selection_judge, "ANTHROPIC_API_KEY", "")
assert selection_judge.judge_candidates([{"id": 1, "keyword": "x", "category": "economy"}]) == {}
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection_judge.py -q`
Expected: FAIL — 모듈 미존재.
- [ ] **Step 3: `selection_judge.py` 작성**
`insta-lab/app/selection_judge.py`:
```python
"""Claude Haiku 일괄 카드가치 판단. 실패/미설정 시 빈 dict (graceful)."""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List
from anthropic import Anthropic
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU
logger = logging.getLogger(__name__)
PROMPT = """다음 인스타 카드뉴스 후보 키워드들을 카드로 만들 가치(흥미·시의성·정보성)와
리스크(민감·논란)를 종합해 0~1 점수로 평가해라. 코드펜스 없이 JSON 배열로만 출력:
[{{"keyword_id": <id>, "score": <0~1>}}, ...]
후보:
{items}"""
def _strip_codefence(s: str) -> str:
s = s.strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s).strip()
return s
def parse_judge_response(raw: str) -> Dict[int, float]:
try:
data = json.loads(_strip_codefence(raw))
return {int(d["keyword_id"]): float(d["score"]) for d in data}
except Exception:
logger.warning("judge 응답 파싱 실패")
return {}
def judge_candidates(candidates: List[Dict[str, Any]]) -> Dict[int, float]:
if not ANTHROPIC_API_KEY or not candidates:
return {}
items = "\n".join(f'- id={c["id"]}: {c["keyword"]} ({c["category"]})' for c in candidates)
try:
client = Anthropic(api_key=ANTHROPIC_API_KEY)
resp = client.messages.create(
model=ANTHROPIC_MODEL_HAIKU, max_tokens=512,
messages=[{"role": "user", "content": PROMPT.format(items=items)}],
)
return parse_judge_response(resp.content[0].text)
except Exception:
logger.exception("judge_candidates 호출 실패")
return {}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection_judge.py -q`
Expected: 4 PASS.
- [ ] **Step 5: 커밋**
```bash
git add insta-lab/app/selection_judge.py insta-lab/tests/test_selection_judge.py
git commit -m "feat(insta-lab): Claude Haiku 카드가치 판단(graceful)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: insta-lab — `GET /api/insta/keywords/ranked`
**Files:**
- Modify: `insta-lab/app/main.py`
- Test: `insta-lab/tests/test_ranked_decision_api.py` (Create)
- [ ] **Step 1: 실패하는 테스트 작성**
`insta-lab/tests/test_ranked_decision_api.py`:
```python
import pytest
from fastapi.testclient import TestClient
from app import db, config, selection_judge
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(selection_judge, "judge_candidates", lambda c: {}) # Claude mock
db.init_db()
from app.main import app
return TestClient(app)
def test_ranked_returns_sorted_eligible(client, monkeypatch):
db.add_trending_keyword({"keyword": "금리", "category": "economy", "score": 0.9})
r = client.get("/api/insta/keywords/ranked?threshold=0.0&limit=10")
assert r.status_code == 200
items = r.json()["items"]
assert len(items) >= 1
assert "final_score" in items[0] and "eligible" in items[0]
def test_decision_approve_publishes(client):
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "approved"})
assert r.status_code == 200
assert db.get_card_slate(sid)["status"] == "published"
def test_decision_reject(client):
sid = db.add_card_slate({"keyword": "환율", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "rejected"})
assert r.status_code == 200
assert db.get_card_slate(sid)["status"] == "rejected"
def test_decision_invalid_400(client):
sid = db.add_card_slate({"keyword": "x", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "maybe"})
assert r.status_code == 400
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_ranked_decision_api.py -q`
Expected: FAIL — 라우트 미존재 (404).
- [ ] **Step 3: ranked 라우트 추가**
`insta-lab/app/main.py` import 블록에 추가:
```python
from datetime import datetime, timezone
from . import selection, selection_judge
```
`list_keywords` 엔드포인트 아래에 추가:
```python
@app.get("/api/insta/keywords/ranked")
def ranked_keywords(limit: int = Query(20, ge=1, le=100), threshold: float = Query(0.6, ge=0.0, le=1.0)):
candidates = db.list_trending_keywords(used=False)
if not candidates:
return {"items": []}
issued = db.list_recent_issued_topics(window_days=14)
prefs = {p["category"]: p["weight"] for p in db.get_preferences()}
claude_scores = selection_judge.judge_candidates(candidates)
now_iso = datetime.now(timezone.utc).isoformat()
scored = selection.score_candidates(
candidates, issued, prefs, claude_scores=claude_scores,
threshold=threshold, now_iso=now_iso,
)
return {"items": scored[:limit]}
```
- [ ] **Step 4: decision 라우트 추가**
`insta-lab/app/main.py`의 슬레이트 섹션(예: `delete_slate` 위)에 추가:
```python
class DecisionBody(BaseModel):
decision: str # "approved" | "rejected"
@app.post("/api/insta/slates/{slate_id}/decision")
def slate_decision(slate_id: int, body: DecisionBody):
if not db.get_card_slate(slate_id):
raise HTTPException(404, "slate not found")
if body.decision not in ("approved", "rejected"):
raise HTTPException(400, "decision must be approved|rejected")
db.set_slate_decision(slate_id, body.decision)
return db.get_card_slate(slate_id)
```
- [ ] **Step 5: 테스트 통과 확인**
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_ranked_decision_api.py -q`
Expected: 4 PASS.
- [ ] **Step 6: 전체 insta-lab 회귀 + 커밋**
```bash
cd insta-lab && PYTHONPATH=.. python -m pytest tests/ -q # 전부 PASS 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add insta-lab/app/main.py insta-lab/tests/test_ranked_decision_api.py
git commit -m "feat(insta-lab): GET /keywords/ranked + POST /slates/{id}/decision
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: agent-office — service_proxy 헬퍼
**Files:**
- Modify: `agent-office/app/service_proxy.py`
> 기존 `insta_*` 헬퍼와 동일 패턴(httpx로 `INSTA_LAB_URL` 호출)을 따른다. Task 3 작업 전 `insta_create_slate`(167행) 본문을 열어 base URL·timeout·client 사용 방식을 그대로 모방할 것.
- [ ] **Step 1: 헬퍼 2개 추가**
`agent-office/app/service_proxy.py`의 insta 헬퍼 묶음 끝(예: `insta_put_preferences` 아래)에 추가 — 기존 헬퍼의 `async with httpx.AsyncClient(...)` / base URL 변수명을 동일하게 사용:
```python
async def insta_ranked(threshold: float = 0.6, limit: int = 20) -> list:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.get(
f"{INSTA_LAB_URL}/api/insta/keywords/ranked",
params={"threshold": threshold, "limit": limit},
)
r.raise_for_status()
return r.json()["items"]
async def insta_decision(slate_id: int, decision: str) -> dict:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/decision",
json={"decision": decision},
)
r.raise_for_status()
return r.json()
```
> 주의: 기존 헬퍼가 `INSTA_LAB_URL`이 아닌 다른 변수명(예: `_INSTA_BASE`)을 쓰면 그 이름으로 맞출 것. timeout(120s)은 ranked의 Claude 호출 대비 여유.
- [ ] **Step 2: import sanity**
Run: `cd agent-office && PYTHONPATH=.. python -c "from app import service_proxy; print('OK')"`
Expected: OK (httpx 미설치면 pip install httpx 후).
- [ ] **Step 3: 커밋**
```bash
git add agent-office/app/service_proxy.py
git commit -m "feat(agent-office): service_proxy insta_ranked/insta_decision
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: agent-office — InstaAgent 자율 발급 경로 + 프리뷰
**Files:**
- Modify: `agent-office/app/agents/insta.py`
- Test: `agent-office/tests/test_insta_autonomous.py` (Create)
- [ ] **Step 1: 실패하는 테스트 작성**
`agent-office/tests/test_insta_autonomous.py`:
```python
import pytest
from unittest.mock import AsyncMock, patch
from app.agents.insta import InstaAgent
@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())
sp = "app.agents.insta.service_proxy"
monkeypatch.setattr(f"{sp}.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()
# eligible 1건만 프리뷰
assert preview.await_count == 1
assert preview.await_args.args[0]["id"] == 1
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py -q`
Expected: FAIL — 자율 분기/`_generate_and_preview` 미존재.
- [ ] **Step 3: `on_schedule`에 자율 분기 추가**
`agent-office/app/agents/insta.py``on_schedule`에서 `auto_select` 분기 직전에 자율 경로를 추가. `custom` 읽은 직후:
```python
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))
```
그리고 `add_log(...) → _run_collect_and_extract()` 다음의 분기를 교체:
```python
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:
... # 기존 유지
```
(기존 `kws = ... / if auto_select` 블록은 그대로 둔다.)
- [ ] **Step 4: `_generate_and_preview` 메서드 추가**
`insta.py`에 추가 — 슬레이트 생성·렌더(기존 흐름) 후 커버 프리뷰 발송:
```python
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)
```
> `messaging.send_photo(bytes, caption, reply_markup)`가 없으면 Task 6.5로 추가(아래). 있으면 그대로 사용.
- [ ] **Step 5: 테스트 통과 확인**
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py::test_autonomous_issue_previews_eligible -q`
Expected: PASS.
- [ ] **Step 6: 커밋**
```bash
git add agent-office/app/agents/insta.py agent-office/tests/test_insta_autonomous.py
git commit -m "feat(agent-office): InstaAgent 자율 발급 경로 + 커버 프리뷰
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6.5: agent-office — `messaging.send_photo` (없을 경우만)
**Files:**
- Modify: `agent-office/app/telegram/messaging.py`
- [ ] **Step 1: 존재 확인**
Run: `grep -n "def send_photo" agent-office/app/telegram/messaging.py`
이미 있으면 이 Task 건너뜀.
- [ ] **Step 2: 없으면 추가**
`messaging.py``send_raw` 패턴(TELEGRAM_BOT_TOKEN/CHAT_ID 사용)을 따라 추가:
```python
async def send_photo(photo_bytes: bytes, caption: str = "", reply_markup: dict = None) -> dict:
if not TELEGRAM_BOT_TOKEN:
return {"ok": False, "reason": "no token"}
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
data = {"chat_id": 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()
```
(상단에 `import json`, `import httpx`, `from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID` 필요분 확인.)
- [ ] **Step 3: 커밋**
```bash
git add agent-office/app/telegram/messaging.py
git commit -m "feat(agent-office): messaging.send_photo (인라인 키보드 첨부 사진)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: agent-office — `issue_*` 콜백 처리
**Files:**
- Modify: `agent-office/app/agents/insta.py` (`on_callback`)
- Test: `agent-office/tests/test_insta_autonomous.py` (추가)
- [ ] **Step 1: 실패하는 테스트 추가**
`test_insta_autonomous.py`에 추가:
```python
@pytest.mark.asyncio
async def test_callback_approve_publishes_and_delivers(monkeypatch):
agent = InstaAgent()
sp = "app.agents.insta.service_proxy"
dec = AsyncMock(return_value={"status": "published"})
monkeypatch.setattr(f"{sp}.insta_decision", dec)
monkeypatch.setattr(f"{sp}.insta_get_slate", AsyncMock(return_value={
"assets": [{"page_index": i} for i in range(1, 11)],
"suggested_caption": "cap", "hashtags": ["#a"]}))
monkeypatch.setattr(f"{sp}.insta_get_asset_bytes", AsyncMock(return_value=b"png"))
monkeypatch.setattr("app.agents.insta._send_media_group", AsyncMock(return_value={"ok": True}))
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
res = await agent.on_callback("issue_approve", {"slate_id": 8})
assert res["ok"] is True
dec.assert_awaited_once_with(8, "approved")
@pytest.mark.asyncio
async def test_callback_reject_marks_rejected(monkeypatch):
agent = InstaAgent()
dec = AsyncMock(return_value={"status": "rejected"})
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
res = await agent.on_callback("issue_reject", {"slate_id": 8})
assert res["ok"] is True
dec.assert_awaited_once_with(8, "rejected")
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py -q`
Expected: FAIL — issue_* 액션 미처리.
- [ ] **Step 3: `on_callback`에 issue_* 분기 추가**
`insta.py``on_callback`을 확장:
```python
async def on_callback(self, action: str, params: dict) -> dict:
if action == "render":
kid = int(params.get("keyword_id") or 0)
if not kid:
return {"ok": False}
await self._render_and_push(kid)
return {"ok": True}
if action in ("issue_approve", "issue_reject"):
sid = int(params.get("slate_id") or 0)
if not sid:
return {"ok": False}
decision = "approved" if action == "issue_approve" else "rejected"
await service_proxy.insta_decision(sid, decision)
if decision == "approved":
slate = await service_proxy.insta_get_slate(sid)
media = []
for a in slate["assets"][:10]:
data = await service_proxy.insta_get_asset_bytes(sid, a["page_index"])
media.append({"type": "photo", "_bytes": data})
cap = f"{slate.get('suggested_caption','')}\n\n{' '.join(slate.get('hashtags',[]) or [])}".strip()
await _send_media_group(media, caption=cap)
await messaging.send_raw(f"✅ 발행 완료 (slate {sid})")
else:
await messaging.send_raw(f"❌ 반려됨 (slate {sid})")
return {"ok": True}
if action == "issue_regen":
sid = int(params.get("slate_id") or 0)
if not sid:
return {"ok": False}
slate = await service_proxy.insta_get_slate(sid)
await service_proxy.insta_decision(sid, "rejected") # 이전 폐기
await self._generate_and_preview({
"id": 0, "keyword": slate["keyword"], "category": slate["category"],
"final_score": None, "breakdown": {},
})
return {"ok": True}
return {"ok": False}
```
> `insta_create_slate`는 `keyword_id` 없이도 동작(기존 시그니처 `keyword_id: Optional`). regen은 keyword_id=0 → mark_keyword_used 생략.
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py -q`
Expected: 모두 PASS.
- [ ] **Step 5: 커밋**
```bash
git add agent-office/app/agents/insta.py agent-office/tests/test_insta_autonomous.py
git commit -m "feat(agent-office): issue_approve/reject/regen 콜백 처리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 8: agent-office — 텔레그램 콜백 디스패치
**Files:**
- Modify: `agent-office/app/telegram/webhook.py`
- [ ] **Step 1: `_handle_callback`에 issue_* 분기 추가**
`webhook.py``_handle_callback`에서 `render_` 분기 아래에 추가:
```python
if callback_id.startswith("issue_"):
return await _handle_insta_issue(callback_query, callback_id)
```
- [ ] **Step 2: `_handle_insta_issue` 추가**
`_handle_insta_render`(103행~)를 본떠 추가:
```python
async def _handle_insta_issue(callback_query: dict, callback_id: str) -> dict:
"""issue_{approve|reject|regen}_{slate_id} → InstaAgent.on_callback."""
from ..agents import AGENT_REGISTRY # _handle_insta_render와 동일 방식으로 에이전트 해석
try:
rest = callback_id.removeprefix("issue_") # "approve_8"
verb, sid = rest.rsplit("_", 1)
slate_id = int(sid)
except (ValueError, AttributeError):
return {"ok": False, "error": "invalid_callback_data"}
agent = AGENT_REGISTRY.get("insta")() if callable(AGENT_REGISTRY.get("insta")) else AGENT_REGISTRY.get("insta")
return await agent.on_callback(f"issue_{verb}", {"slate_id": slate_id})
```
> `_handle_insta_render`가 에이전트를 얻는 정확한 방식(레지스트리/팩토리)을 그대로 복사할 것. 위 `AGENT_REGISTRY` 줄은 그 방식으로 대체한다.
- [ ] **Step 3: import sanity + 수동 점검**
Run: `cd agent-office && PYTHONPATH=.. python -c "from app.telegram import webhook; print('OK')"`
Expected: OK.
- [ ] **Step 4: 커밋**
```bash
git add agent-office/app/telegram/webhook.py
git commit -m "feat(agent-office): issue_* 텔레그램 콜백 디스패치
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 9: 문서 + 배포 + 검증
**Files:**
- Modify: `web-backend/CLAUDE.md`, `memory/service_insta.md`
- [ ] **Step 1: CLAUDE.md insta API 목록에 2개 추가**
`### insta-lab` API 표에 추가:
```
| GET | `/api/insta/keywords/ranked` | 4신호 선별 점수 + eligible (자율 발급용) |
| POST | `/api/insta/slates/{id}/decision` | 승인/반려 (approved→published) |
```
- [ ] **Step 2: 전체 테스트 회귀**
Run:
```bash
cd insta-lab && PYTHONPATH=.. python -m pytest tests/ app/test_package_api.py -q
cd ../agent-office && PYTHONPATH=.. python -m pytest tests/ -q
```
Expected: 모두 PASS (사전존재 stale 제외).
- [ ] **Step 3: 커밋 + push (NAS 배포)**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add CLAUDE.md docs/superpowers/plans/2026-06-11-insta-autonomous-card-issuance.md
git commit -m "docs(insta): 자율 발급 API 문서 + 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git push origin main
```
- [ ] **Step 4: 활성화 + 프로덕션 검증**
배포 완료 후 (deployer rebuild ~3분):
```bash
# autonomous_issue 켜기 (agent_config custom_config)
curl -X PUT https://gahusb.synology.me/api/agent-office/agents/insta \
-H "Content-Type: application/json" \
-d '{"custom_config": {"autonomous_issue": true, "select_threshold": 0.6, "max_per_day": 2}}'
# 수동 트리거 대신 ranked 직접 확인
curl -s "https://gahusb.synology.me/api/insta/keywords/ranked?threshold=0.0&limit=5" | python -m json.tool
```
Expected: ranked 응답에 `final_score`/`eligible`/`breakdown`. 09:30 cron 또는 수동 command로 프리뷰가 텔레그램에 도착하는지 확인.
- [ ] **Step 5: 메모리 갱신**
`memory/service_insta.md`에 자율 발급 파이프라인(4신호 선별·승인 게이트·상태머신) 추가 + 스마트에이전트 3종 완료 표시.
---
## Self-Review
**Spec coverage:**
- 선별 4신호 → Task 2(freshness/account_fit/dedup) + Task 3(claude). ✓
- threshold 게이트 0~N → Task 2 + Task 6(max_per_day). ✓
- 승인 게이트 + 콜백 → Task 6(프리뷰) + Task 7(approve/reject/regen) + Task 8(디스패치). ✓
- 상태머신 + 발행이력 → Task 1. ✓
- 하위호환(autonomous_issue=false) → Task 6 Step 3(기존 블록 유지). ✓
- graceful Claude 실패 → Task 3(빈 dict) + Task 2(renormalize). ✓
- 성과지표 제외(YAGNI) → 계획에 없음. ✓
**Placeholder scan:** 모든 코드 스텝에 실제 코드 포함. 단 Task 5/8의 "기존 변수명/에이전트 해석 방식 모방"은 실제 파일 확인을 요구하는 의도적 지시(해당 파일이 코드 소유) — placeholder 아님.
**Type consistency:** `score_candidates(candidates, issued_topics, prefs, claude_scores, weights, threshold, now_iso)` Task2 정의 ↔ Task4 호출 일치. `set_slate_decision(slate_id, decision)` Task1 ↔ Task4 일치. `insta_ranked(threshold, limit)`/`insta_decision(slate_id, decision)` Task5 ↔ Task6/7 일치. 콜백 액션 `issue_approve/issue_reject/issue_regen` Task7 ↔ Task8 prefix 파싱 일치. `_generate_and_preview(pick)` Task6 정의 ↔ Task7(regen) 호출 일치.

View File

@@ -0,0 +1,120 @@
# insta 자율 카드 발급 (스마트 에이전트 3번) — 설계
> 작성 2026-06-11. InstaAgent를 "후보 푸시/단순 auto_select"에서 **선별 지능 + 승인 게이트 + 카덴스/추적**을 갖춘 자율 발급 파이프라인으로 확장.
## 1. 목표
매일 09:30, InstaAgent가 **발행할 가치 있는 주제만 자율 선별**해 카드를 생성·렌더하고, **카드별 승인 게이트**로 사람이 최종 결정(브랜드 안전)한 뒤 업로드용 카드를 발급한다. 발행 상태·이력을 추적해 중복 회피·카덴스 판단에 환류한다.
Instagram Graph API는 사용하지 않는다(수동 업로드). "발행(published)" = 승인되어 업로드 준비가 끝난 카드 상태 + 텔레그램으로 전달.
## 2. 현재 상태 (배경)
- insta-lab: 뉴스수집→키워드추출→슬레이트 생성(`POST /slates`)→Redis push→Windows 워커 렌더→webhook이 `card_assets` 등록. (2026-06-11 렌더 갭 복구 완료, slate 상태 `draft→rendered`.)
- agent-office `InstaAgent`: 09:30 cron에서 collect+extract 후 (기본) 텔레그램 후보 버튼 푸시 / (`auto_select=True`) 카테고리 1위 키워드 자동 렌더+미디어그룹 발송. 버튼 탭 → `render_{kid}` 콜백 → 슬레이트 생성·렌더·발송.
- `account_preferences`(카테고리 가중치) 존재. 발행 성과 추적은 없음.
즉 "생성→렌더→전달"은 동작한다. 본 설계는 그 앞단의 **자율 선별**과 뒷단의 **승인·추적**을 추가한다.
## 3. 요구사항 (확정)
- **선별 신호 4종**: ① 중복 회피(최근 발행/반려 주제 제외) ② 신선도(뉴스 최신성) ③ 계정 컨셉 적합도(카테고리 가중치) ④ Claude 판단(카드가치·흥미·리스크). 가중합 → threshold 게이트.
- **카덴스**: 에이전트 결정 — 매일 09:30, threshold 이상인 픽만 `max_per_day`까지(0~N 가변). 가치 없으면 발행 안 함.
- **승인**: 카드별 게이트. 자동 생성 후 텔레그램 프리뷰 `[✅승인][❌반려][🔄재생성]`. 승인만 published.
- **추적**: slate 상태 ∈ `{draft, rendered, rejected, published}` + 발행 이력. decision=approved→`published`, decision=rejected→`rejected`("approved"는 별도 저장 상태가 아니라 decision 액션). 성과 지표(좋아요·도달)는 범위 외(YAGNI — IG API 없어 수동).
## 4. 아키텍처 (접근법 A: 데이터 있는 곳에서 선별, 에이전트는 오케스트레이션)
```
[09:30 cron] InstaAgent.on_schedule (autonomous_issue=True)
1. collect + extract (기존 재사용)
2. GET /api/insta/keywords/ranked?threshold&limit ← insta-lab: 4신호 점수
3. eligible 픽마다(max_per_day): create_slate → wait render (기존 재사용)
4. 텔레그램 프리뷰(커버1장+요약) + [✅][❌][🔄] + agent_task(requires_approval) → waiting
[telegram webhook] → InstaAgent.on_callback
issue_approve_{id} → POST /slates/{id}/decision{approved} → published + 10장 미디어그룹 + /package zip
issue_reject_{id} → POST /slates/{id}/decision{rejected}
issue_regen_{id} → 같은 키워드로 슬레이트 재생성(새 카피) → 새 프리뷰 (이전 슬레이트 폐기)
```
경계: **insta-lab = 선별 점수 + 상태머신(DB 소유)**, **agent-office = cron 오케스트레이션 + 텔레그램 승인**.
## 5. insta-lab 상세
### 5.1 `app/selection.py` (순수 함수)
입력: 후보 키워드 리스트, 발행/반려 이력, 카테고리 선호 가중치, (선택) Claude 판단 점수.
출력: 후보별 `{keyword_id, final_score, breakdown:{dedup,freshness,account_fit,claude}, eligible}`.
신호별 정의:
- **dedup** (0 또는 1, exclude 게이트): 최근 `dedup_window_days`(기본 14) 내 `published`/`rejected` 슬레이트와 동일 키워드(정규화 후 exact/substring) + 동일 카테고리면 `eligible=False`로 제외.
- **freshness** (0~1): 키워드 `suggested_at`이 최근일수록 높음(예: 24h=1.0, 선형 감쇠, 7일+=0).
- **account_fit** (0~1): `account_preferences[category].weight`(정규화) × 키워드 자체 score.
- **claude** (0~1): Claude Haiku가 후보 일괄 평가(아래 5.3). 실패 시 이 항 제외하고 나머지로 정규화(graceful).
- **final_score** = 가중합 `w_fresh*freshness + w_fit*account_fit + w_claude*claude` (dedup 제외 통과한 것만). 기본 가중치 `{fresh:0.3, fit:0.3, claude:0.4}`. `eligible = (dedup 통과) and (final_score >= threshold)`.
### 5.2 엔드포인트
- `GET /api/insta/keywords/ranked?limit=N&threshold=T`
- 내부에서: 미사용 키워드 조회 + 발행/반려 이력 조회 + 선호 조회 + Claude 일괄 호출 → `selection.py` → 정렬된 후보 + breakdown + `eligible` 반환.
- `POST /api/insta/slates/{id}/decision` body `{"decision": "approved"|"rejected"}`
- approved → `status='published'`, `published_at=now`, `decision_at=now` (멱등: 이미 published면 no-op).
- rejected → `status='rejected'`, `decision_at=now`.
### 5.3 Claude 판단 프롬프트 (insta-lab, 기존 ANTHROPIC 클라이언트 재사용)
- 1회 호출로 후보 N개 일괄 평가. 입력: 각 후보 `{keyword, category}`. 출력: JSON `[{keyword_id, score(0~1), reason}]`.
- 기준: 카드뉴스로 만들 가치(흥미·시의성·정보성) 및 리스크(민감·논란). 모델 `ANTHROPIC_MODEL_HAIKU`.
- 실패/파싱오류 → 빈 결과 반환 → selection이 claude 항 제외.
### 5.4 스키마 (idempotent ALTER)
- `card_slates``published_at TEXT NULL`, `decision_at TEXT NULL` 추가.
- 상태값: `draft → rendered → approved/rejected → published`. (approved는 과도기 상태 없이 decision=approved 시 바로 published로 둔다 — 단순화. rejected는 종결.)
- 발행 이력 = `SELECT keyword, category, published_at FROM card_slates WHERE status IN ('published','rejected') AND COALESCE(published_at,decision_at) >= datetime('now', '-D days')`.
## 6. agent-office 상세
### 6.1 `InstaAgent.on_schedule`
- `custom_config.autonomous_issue` 분기. False면 **기존 동작 유지**(candidate-push / auto_select) — 하위호환.
- True면: collect+extract(기존) → `service_proxy.insta_ranked(threshold, limit=max_per_day)``eligible` 픽 순회(최대 `max_per_day`):
- 슬레이트 생성·렌더 대기(기존 `_render_and_push`의 생성·대기 부분 재사용/분리) → **프리뷰 발송**(6.3) → `create_task(requires_approval=True)``waiting` 상태.
- eligible 0개 → "오늘 발행할 가치 있는 주제 없음" 1통.
### 6.2 콜백 (telegram webhook → `on_callback`)
- `issue_approve_{slate_id}`: `insta_decision(slate_id, "approved")` → 전체 10장 미디어그룹 + `/package` zip 전달 + "✅ 발행 완료" → 해당 task succeeded.
- `issue_reject_{slate_id}`: `insta_decision(slate_id, "rejected")` → "❌ 반려됨" → task 종료.
- `issue_regen_{slate_id}`: 해당 슬레이트의 키워드로 새 슬레이트 생성(새 Claude 카피)·렌더 → 새 프리뷰. 이전 슬레이트는 rejected 처리.
### 6.3 텔레그램 프리뷰 (미디어그룹은 인라인 키보드 불가)
- 커버(01.png) 단장 사진 + 캡션: 키워드·카테고리·`final_score`·breakdown 요약 + inline `[✅승인][❌반려][🔄재생성]` (`callback_data=issue_*_{slate_id}`).
### 6.4 설정 (`agent_config.custom_config`)
- `autonomous_issue` (bool, 기본 false), `select_threshold` (기본 0.6), `max_per_day` (기본 2), `dedup_window_days` (기본 14).
### 6.5 service_proxy 추가
- `insta_ranked(threshold, limit)``GET /keywords/ranked`
- `insta_decision(slate_id, decision)``POST /slates/{id}/decision`
## 7. 에러 처리 / 엣지
- ranked의 Claude 실패 → 룰 점수만으로 진행(graceful), 경고 로그.
- eligible 0개 → 안내 1통(또는 무음 옵션, 기본 안내).
- 렌더 실패 → task failed 통지, 프리뷰 미발송.
- 승인 미응답 → 슬레이트 pending(rendered) 유지, 자동 발행 안 함(안전). 만료 없음.
- 멱등: 중복 승인/반려 no-op. cron 재실행 시 이미 발행/반려 주제는 dedup으로 회피.
- regen 무한루프 방지: regen은 사용자 트리거(버튼)라 자동 반복 없음.
## 8. 테스트
- **insta-lab**: `selection.py` 순수 단위테스트(dedup 최근 제외 / freshness 정렬 / account_fit 가중 / 가중합·threshold 게이트 / claude 실패 시 정규화). ranked 엔드포인트(Claude mock). decision 엔드포인트(approved→published+published_at, rejected, 멱등).
- **agent-office**: 자율 `on_schedule`(proxy mock: ranked eligible→슬레이트 생성→프리뷰 발송 + task requires_approval). 콜백 approve/reject/regen(proxy·messaging mock).
## 9. 범위 외 (YAGNI)
- 발행 성과 지표(좋아요·도달) 수집/학습 — IG API 미사용, 수동 입력 부담으로 제외.
- 신뢰도 하이브리드 자동발행(승인 생략) — 승인 게이트로 통일.
- 임베딩 기반 유사도 dedup — 정규화 exact/substring + 카테고리로 충분(추후 필요 시 확장).
## 10. 영향받는 파일
- insta-lab: `app/selection.py`(신규), `app/main.py`(ranked·decision 라우트), `app/db.py`(컬럼 ALTER + 발행이력/상태 헬퍼), `tests/`.
- agent-office: `app/agents/insta.py`(자율 경로·콜백), `app/service_proxy.py`(2 헬퍼), `app/webhook.py`(issue_* 콜백 디스패치), `tests/`.
- web-backend/CLAUDE.md insta API 목록 + `service_insta.md` 메모리 갱신.

View File

@@ -124,6 +124,13 @@ def init_db() -> None:
(cat, 1.0), (cat, 1.0),
) )
# 발행 상태 컬럼 (idempotent ALTER) — 자율 발급 파이프라인
cs_cols = [r[1] for r in conn.execute("PRAGMA table_info(card_slates)").fetchall()]
if "published_at" not in cs_cols:
conn.execute("ALTER TABLE card_slates ADD COLUMN published_at TEXT")
if "decision_at" not in cs_cols:
conn.execute("ALTER TABLE card_slates ADD COLUMN decision_at TEXT")
# ── news_articles ──────────────────────────────────────────────── # ── news_articles ────────────────────────────────────────────────
def add_news_article(row: Dict[str, Any]) -> int: def add_news_article(row: Dict[str, Any]) -> int:
@@ -217,6 +224,39 @@ def update_slate_status(slate_id: int, status: str) -> None:
) )
def set_slate_decision(slate_id: int, decision: str) -> None:
"""승인/반려 결정 기록. approved→published(+published_at), rejected→rejected.
멱등: 이미 published면 published_at 유지."""
now = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"
with _conn() as conn:
if decision == "approved":
conn.execute(
f"UPDATE card_slates SET status='published', "
f"published_at=COALESCE(published_at, {now}), decision_at={now} "
f"WHERE id=?",
(slate_id,),
)
elif decision == "rejected":
conn.execute(
f"UPDATE card_slates SET status='rejected', decision_at={now} WHERE id=?",
(slate_id,),
)
else:
raise ValueError(f"invalid decision: {decision}")
def list_recent_issued_topics(window_days: int = 14) -> List[Dict[str, Any]]:
"""최근 window_days 내 published/rejected 슬레이트의 (keyword, category). dedup용."""
with _conn() as conn:
rows = conn.execute(
"SELECT keyword, category FROM card_slates "
"WHERE status IN ('published','rejected') "
"AND COALESCE(published_at, decision_at) >= datetime('now', ?)",
(f"-{int(window_days)} days",),
).fetchall()
return [dict(r) for r in rows]
def get_card_slate(slate_id: int) -> Optional[Dict[str, Any]]: def get_card_slate(slate_id: int) -> Optional[Dict[str, Any]]:
with _conn() as conn: with _conn() as conn:
row = conn.execute("SELECT * FROM card_slates WHERE id=?", (slate_id,)).fetchone() row = conn.execute("SELECT * FROM card_slates WHERE id=?", (slate_id,)).fetchone()

View File

@@ -6,6 +6,7 @@ import json
import logging import logging
import os import os
import zipfile import zipfile
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query
@@ -21,7 +22,7 @@ from .config import (
) )
import redis.asyncio as aioredis import redis.asyncio as aioredis
from . import db, news_collector, keyword_extractor, card_writer, trend_collector from . import db, news_collector, keyword_extractor, card_writer, trend_collector, selection, selection_judge
from .internal_router import router as internal_router from .internal_router import router as internal_router
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -152,6 +153,26 @@ def list_keywords(
return {"items": db.list_trending_keywords(category=category, used=used)} return {"items": db.list_trending_keywords(category=category, used=used)}
@app.get("/api/insta/keywords/ranked")
def ranked_keywords(
limit: int = Query(20, ge=1, le=100),
threshold: float = Query(0.6, ge=0.0, le=1.0),
dedup_window_days: int = Query(14, ge=1, le=90),
):
candidates = db.list_trending_keywords(used=False)
if not candidates:
return {"items": []}
issued = db.list_recent_issued_topics(window_days=dedup_window_days)
prefs = {p["category"]: p["weight"] for p in db.get_preferences()}
claude_scores = selection_judge.judge_candidates(candidates)
now_iso = datetime.now(timezone.utc).isoformat()
scored = selection.score_candidates(
candidates, issued, prefs, claude_scores=claude_scores,
threshold=threshold, now_iso=now_iso,
)
return {"items": scored[:limit]}
# ── Slates ─────────────────────────────────────────────────────── # ── Slates ───────────────────────────────────────────────────────
class SlateRequest(BaseModel): class SlateRequest(BaseModel):
keyword: str keyword: str
@@ -282,6 +303,20 @@ def download_package(slate_id: int):
}) })
class DecisionBody(BaseModel):
decision: str # "approved" | "rejected"
@app.post("/api/insta/slates/{slate_id}/decision")
def slate_decision(slate_id: int, body: DecisionBody):
if not db.get_card_slate(slate_id):
raise HTTPException(404, "slate not found")
if body.decision not in ("approved", "rejected"):
raise HTTPException(400, "decision must be approved|rejected")
db.set_slate_decision(slate_id, body.decision)
return db.get_card_slate(slate_id)
@app.delete("/api/insta/slates/{slate_id}") @app.delete("/api/insta/slates/{slate_id}")
def delete_slate(slate_id: int): def delete_slate(slate_id: int):
if not db.get_card_slate(slate_id): if not db.get_card_slate(slate_id):

View File

@@ -0,0 +1,83 @@
"""발행 가치 자율 선별 — 순수 점수 함수 (외부 IO 없음, 단위테스트 대상).
신호: dedup(게이트), freshness, account_fit, claude(선택).
final = 가중합(존재하는 신호만 정규화). eligible = dedup통과 and final>=threshold.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
DEFAULT_WEIGHTS = {"freshness": 0.3, "account_fit": 0.3, "claude": 0.4}
FRESH_WINDOW_HOURS = 168.0 # 7일 → 0
def _parse_iso(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
def _norm(kw: str) -> str:
return (kw or "").strip().lower()
def _is_duplicate(keyword: str, category: str, issued: List[Dict[str, Any]]) -> bool:
n = _norm(keyword)
if not n:
return False
for it in issued:
if it.get("category") != category:
continue
m = _norm(it.get("keyword", ""))
if not m:
continue
if n == m or n in m or m in n:
return True
return False
def _freshness(suggested_at: str, now: datetime) -> float:
try:
hours = (now - _parse_iso(suggested_at)).total_seconds() / 3600.0
except Exception:
return 0.0
return max(0.0, min(1.0, 1.0 - hours / FRESH_WINDOW_HOURS))
def score_candidates(
candidates: List[Dict[str, Any]],
issued_topics: List[Dict[str, Any]],
prefs: Dict[str, float],
claude_scores: Optional[Dict[int, float]] = None,
weights: Optional[Dict[str, float]] = None,
threshold: float = 0.6,
now_iso: Optional[str] = None,
) -> List[Dict[str, Any]]:
w = weights or DEFAULT_WEIGHTS
now = _parse_iso(now_iso) if now_iso else datetime.now(timezone.utc)
max_w = max(prefs.values()) if prefs else 1.0
if max_w <= 0:
max_w = 1.0
out: List[Dict[str, Any]] = []
for c in candidates:
cat = c.get("category", "")
dup = _is_duplicate(c.get("keyword", ""), cat, issued_topics)
freshness = _freshness(c.get("suggested_at", ""), now)
weight = prefs.get(cat, 1.0)
account_fit = max(0.0, min(1.0, (weight / max_w) * float(c.get("score", 0.0))))
claude = None
if claude_scores is not None and c["id"] in claude_scores:
claude = max(0.0, min(1.0, float(claude_scores[c["id"]])))
parts = [("freshness", freshness), ("account_fit", account_fit)]
if claude is not None:
parts.append(("claude", claude))
total_w = sum(w[name] for name, _ in parts)
final = sum(w[name] * val for name, val in parts) / total_w if total_w else 0.0
eligible = (not dup) and (final >= threshold)
out.append({
"id": c["id"], "keyword": c.get("keyword"), "category": cat,
"final_score": round(final, 4), "eligible": eligible,
"breakdown": {"dedup_excluded": dup, "freshness": round(freshness, 4),
"account_fit": round(account_fit, 4), "claude": claude},
})
out.sort(key=lambda x: (-x["eligible"], -x["final_score"]))
return out

View File

@@ -0,0 +1,52 @@
"""Claude Haiku 일괄 카드가치 판단. 실패/미설정 시 빈 dict (graceful)."""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List
from anthropic import Anthropic
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU
logger = logging.getLogger(__name__)
PROMPT = """다음 인스타 카드뉴스 후보 키워드들을 카드로 만들 가치(흥미·시의성·정보성)와
리스크(민감·논란)를 종합해 0~1 점수로 평가해라. 코드펜스 없이 JSON 배열로만 출력:
[{{"keyword_id": <id>, "score": <0~1>}}, ...]
후보:
{items}"""
def _strip_codefence(s: str) -> str:
s = s.strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s).strip()
return s
def parse_judge_response(raw: str) -> Dict[int, float]:
try:
data = json.loads(_strip_codefence(raw))
return {int(d["keyword_id"]): float(d["score"]) for d in data}
except Exception:
logger.warning("judge 응답 파싱 실패")
return {}
def judge_candidates(candidates: List[Dict[str, Any]]) -> Dict[int, float]:
if not ANTHROPIC_API_KEY or not candidates:
return {}
items = "\n".join(f'- id={c["id"]}: {c["keyword"]} ({c["category"]})' for c in candidates)
try:
client = Anthropic(api_key=ANTHROPIC_API_KEY)
resp = client.messages.create(
model=ANTHROPIC_MODEL_HAIKU, max_tokens=1024,
messages=[{"role": "user", "content": PROMPT.format(items=items)}],
)
return parse_judge_response(resp.content[0].text)
except Exception:
logger.exception("judge_candidates 호출 실패")
return {}

View File

@@ -0,0 +1,47 @@
import os
import pytest
from app import db, config
@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "insta.db"))
db.init_db()
def test_set_slate_decision_approved_publishes(fresh_db):
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
db.set_slate_decision(sid, "approved")
s = db.get_card_slate(sid)
assert s["status"] == "published"
assert s["published_at"] is not None
assert s["decision_at"] is not None
def test_set_slate_decision_rejected(fresh_db):
sid = db.add_card_slate({"keyword": "환율", "category": "economy"})
db.set_slate_decision(sid, "rejected")
s = db.get_card_slate(sid)
assert s["status"] == "rejected"
assert s["decision_at"] is not None
assert s["published_at"] is None
def test_set_slate_decision_idempotent(fresh_db):
sid = db.add_card_slate({"keyword": "주식", "category": "economy"})
db.set_slate_decision(sid, "approved")
first = db.get_card_slate(sid)["published_at"]
db.set_slate_decision(sid, "approved")
assert db.get_card_slate(sid)["published_at"] == first
def test_list_recent_issued_topics(fresh_db):
a = db.add_card_slate({"keyword": "금리", "category": "economy"})
b = db.add_card_slate({"keyword": "우울증", "category": "psychology"})
db.set_slate_decision(a, "approved")
db.set_slate_decision(b, "rejected")
topics = db.list_recent_issued_topics(window_days=14)
pairs = {(t["keyword"], t["category"]) for t in topics}
assert ("금리", "economy") in pairs
assert ("우울증", "psychology") in pairs

View File

@@ -0,0 +1,78 @@
import pytest
from fastapi.testclient import TestClient
from app import db, config, selection_judge
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "insta.db"))
monkeypatch.setattr(selection_judge, "judge_candidates", lambda c: {})
db.init_db()
from app.main import app
return TestClient(app)
def test_ranked_returns_sorted_eligible(client, monkeypatch):
db.add_trending_keyword({"keyword": "금리", "category": "economy", "score": 0.9})
r = client.get("/api/insta/keywords/ranked?threshold=0.0&limit=10")
assert r.status_code == 200
items = r.json()["items"]
assert len(items) >= 1
assert "final_score" in items[0] and "eligible" in items[0]
def test_decision_approve_publishes(client):
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "approved"})
assert r.status_code == 200
assert db.get_card_slate(sid)["status"] == "published"
def test_decision_reject(client):
sid = db.add_card_slate({"keyword": "환율", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "rejected"})
assert r.status_code == 200
assert db.get_card_slate(sid)["status"] == "rejected"
def test_decision_invalid_400(client):
sid = db.add_card_slate({"keyword": "x", "category": "economy"})
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "maybe"})
assert r.status_code == 400
def test_decision_unknown_slate_404(client):
r = client.post("/api/insta/slates/99999/decision", json={"decision": "approved"})
assert r.status_code == 404
def test_ranked_respects_dedup_window(client):
"""dedup_window_days param이 list_recent_issued_topics window에 반영되는지 검증.
'금리' 키워드를 방금 approved(published) 상태로 기록한 뒤:
- dedup_window_days=30 → 방금 발행 = window 안 → eligible False
- dedup_window_days=1 → DB datetime이 정각 경계 직전이라도 여전히 1일 안이므로 eligible False
(확인: 반드시 eligible=False)
추가로 두 번째 키워드(word2)는 아직 발행 이력 없으므로 window 무관하게 eligible True.
"""
# 방금 발행된 키워드 등록 + 슬레이트 approved 처리
db.add_trending_keyword({"keyword": "금리", "category": "economy", "score": 0.9})
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
db.set_slate_decision(sid, "approved") # published_at = now
# 발행 이력 없는 키워드 추가
db.add_trending_keyword({"keyword": "환율", "category": "economy", "score": 0.8})
# window=30 → '금리'는 최근 발행이라 dedup 대상 → eligible False
r = client.get("/api/insta/keywords/ranked?threshold=0.0&limit=10&dedup_window_days=30")
assert r.status_code == 200
items = r.json()["items"]
keumni = next((i for i in items if i["keyword"] == "금리"), None)
assert keumni is not None, "'금리' 항목이 ranked 응답에 없음"
assert keumni["eligible"] is False, "dedup_window_days=30 내 발행 → eligible은 False여야 함"
# 발행 이력 없는 '환율'은 어떤 window에서도 eligible True
hwanul = next((i for i in items if i["keyword"] == "환율"), None)
assert hwanul is not None, "'환율' 항목이 ranked 응답에 없음"
assert hwanul["eligible"] is True, "발행 이력 없는 키워드는 eligible True여야 함"

View File

@@ -0,0 +1,55 @@
from app.selection import score_candidates
NOW = "2026-06-11T00:00:00Z"
def _cand(kid, kw, cat, score, suggested_at):
return {"id": kid, "keyword": kw, "category": cat, "score": score, "suggested_at": suggested_at}
def test_dedup_excludes_recent_issued():
cands = [_cand(1, "금리", "economy", 0.9, "2026-06-11T00:00:00Z")]
issued = [{"keyword": "금리", "category": "economy"}]
out = score_candidates(cands, issued, prefs={}, claude_scores=None, threshold=0.0, now_iso=NOW)
assert out[0]["eligible"] is False
def test_freshness_recent_higher():
fresh = _cand(1, "A", "economy", 0.5, "2026-06-11T00:00:00Z")
stale = _cand(2, "B", "economy", 0.5, "2026-06-04T00:00:00Z")
out = {c["id"]: c for c in score_candidates([fresh, stale], [], {}, None, threshold=0.0, now_iso=NOW)}
assert out[1]["breakdown"]["freshness"] > out[2]["breakdown"]["freshness"]
def test_account_fit_uses_weight():
cands = [_cand(1, "A", "economy", 0.8, NOW), _cand(2, "B", "psychology", 0.8, NOW)]
prefs = {"economy": 2.0, "psychology": 1.0}
out = {c["id"]: c for c in score_candidates(cands, [], prefs, None, threshold=0.0, now_iso=NOW)}
assert out[1]["breakdown"]["account_fit"] > out[2]["breakdown"]["account_fit"]
def test_threshold_gate():
cands = [_cand(1, "A", "economy", 0.1, "2026-06-01T00:00:00Z")]
out = score_candidates(cands, [], {}, None, threshold=0.6, now_iso=NOW)
assert out[0]["eligible"] is False
def test_claude_missing_renormalizes():
cands = [_cand(1, "A", "economy", 1.0, NOW)]
out = score_candidates(cands, [], {"economy": 1.0}, None, threshold=0.0, now_iso=NOW)
assert out[0]["breakdown"]["claude"] is None
assert 0.0 <= out[0]["final_score"] <= 1.0
def test_claude_included_when_provided():
cands = [_cand(1, "A", "economy", 0.5, NOW)]
out = score_candidates(cands, [], {"economy": 1.0}, {1: 1.0}, threshold=0.0, now_iso=NOW)
assert out[0]["breakdown"]["claude"] == 1.0
def test_all_zero_prefs_no_crash():
cands = [{"id": 1, "keyword": "A", "category": "economy", "score": 0.8,
"suggested_at": "2026-06-11T00:00:00Z"}]
prefs = {"economy": 0.0, "psychology": 0.0}
out = score_candidates(cands, [], prefs, None, threshold=0.0, now_iso="2026-06-11T00:00:00Z")
assert out[0]["breakdown"]["account_fit"] == 0.0 # 0가중 → fit 0, 크래시 없음

View File

@@ -0,0 +1,20 @@
from app import selection_judge
def test_parse_judge_response_ok():
raw = '[{"keyword_id": 1, "score": 0.8}, {"keyword_id": 2, "score": 0.3}]'
assert selection_judge.parse_judge_response(raw) == {1: 0.8, 2: 0.3}
def test_parse_judge_response_codefence():
raw = '```json\n[{"keyword_id": 5, "score": 0.5}]\n```'
assert selection_judge.parse_judge_response(raw) == {5: 0.5}
def test_parse_judge_response_garbage_returns_empty():
assert selection_judge.parse_judge_response("not json") == {}
def test_judge_candidates_no_key_returns_empty(monkeypatch):
monkeypatch.setattr(selection_judge, "ANTHROPIC_API_KEY", "")
assert selection_judge.judge_candidates([{"id": 1, "keyword": "x", "category": "economy"}]) == {}