From 916b04af6a67245e134eabfd3265c0b4b1cf04aa Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 13 Apr 2026 03:06:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-office):=20Blog=20+=20Realestate=20?= =?UTF-8?q?=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 Stock/Music 에이전트 패턴을 따라 2개 신규 에이전트 도입. - Blog 에이전트 (10:00 매일): 트렌드 키워드 1개 자동 선택 → blog-lab 파이프라인 전체 (research→generate→market→review) 자동 실행 → 평가 점수와 본문 요약을 텔레그램 승인 요청으로 푸시 → 승인 시 published 전환, 거절 시 작업 종료 - Realestate 에이전트 (09:15 매일): realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드를 텔레그램 리포트 → 조회한 매칭은 자동 읽음 처리 - service_proxy: blog-lab/realestate-lab REST 호출 래퍼 추가 - agents 레지스트리 + DB 시드 + 스케줄러 3개 잡 등록 - docker-compose: agent-office에 BLOG_LAB_URL/REALESTATE_LAB_URL 주입 - README: 에이전트 구성 표 + 명령어 + 스케줄러 잡 정리 Co-Authored-By: Claude Opus 4.6 --- README.md | 32 ++++- agent-office/app/agents/__init__.py | 4 + agent-office/app/agents/blog.py | 187 ++++++++++++++++++++++++++ agent-office/app/agents/realestate.py | 98 ++++++++++++++ agent-office/app/config.py | 2 + agent-office/app/db.py | 7 +- agent-office/app/scheduler.py | 12 ++ agent-office/app/service_proxy.py | 88 +++++++++++- docker-compose.yml | 4 + 9 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 agent-office/app/agents/blog.py create mode 100644 agent-office/app/agents/realestate.py diff --git a/README.md b/README.md index 55a22ca..10c90c6 100644 --- a/README.md +++ b/README.md @@ -150,19 +150,37 @@ curl http://localhost:18500/health ### 6. agent-office (`/api/agent-office/`) -AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업을 수행한다. +AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다. -- **아키텍처**: stock-lab / music-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음) +- **아키텍처**: stock-lab / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음) - **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break` - **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result) - **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인 - 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달 - Webhook 검증 후 `chat.id` 기준 라우팅 -- **에이전트**: Stock Agent (뉴스 요약), Music Agent (음악 생성) 등 확장 가능 구조 -**스케줄러** -- 08:00 매일 — 주식 뉴스 요약 (`stock_news_job`) -- 60초 — 유휴 에이전트 휴식 체크 (`idle_check_job`) +#### 에이전트 구성 + +| 에이전트 | 스케줄 | 승인 | 주요 기능 | +|---------|--------|-----|----------| +| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 | +| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 | +| ✍️ **블로그 마케터** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 | +| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) | + +#### 에이전트별 명령 + +**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram` +**Music** — `compose` (승인 필요), `credits` +**Blog** — `research {keyword}`, `add_trend_keyword`, `list_trend_keywords` +**Realestate** — `fetch_matches`, `dashboard` + +#### 스케줄러 잡 + +- 08:00 — Stock: 뉴스 요약 +- 09:15 — Realestate: 매칭 리포트 +- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기) +- 60초 interval — 유휴 에이전트 휴식 체크 ### 7. travel-proxy (`/api/travel/`) @@ -295,6 +313,8 @@ TELEGRAM_CHAT_ID= TELEGRAM_WEBHOOK_URL= STOCK_LAB_URL=http://stock-lab:8000 MUSIC_LAB_URL=http://music-lab:8000 +BLOG_LAB_URL=http://blog-lab:8000 +REALESTATE_LAB_URL=http://realestate-lab:8000 ``` --- diff --git a/agent-office/app/agents/__init__.py b/agent-office/app/agents/__init__.py index f941a04..2efe4a2 100644 --- a/agent-office/app/agents/__init__.py +++ b/agent-office/app/agents/__init__.py @@ -1,11 +1,15 @@ from .stock import StockAgent from .music import MusicAgent +from .blog import BlogAgent +from .realestate import RealestateAgent AGENT_REGISTRY = {} def init_agents(): AGENT_REGISTRY["stock"] = StockAgent() AGENT_REGISTRY["music"] = MusicAgent() + AGENT_REGISTRY["blog"] = BlogAgent() + AGENT_REGISTRY["realestate"] = RealestateAgent() def get_agent(agent_id: str): return AGENT_REGISTRY.get(agent_id) diff --git a/agent-office/app/agents/blog.py b/agent-office/app/agents/blog.py new file mode 100644 index 0000000..0223671 --- /dev/null +++ b/agent-office/app/agents/blog.py @@ -0,0 +1,187 @@ +import asyncio +from typing import Optional + +from .base import BaseAgent +from ..db import ( + create_task, update_task_status, approve_task, reject_task, + get_task, get_agent_config, add_log, +) +from .. import service_proxy +from .. import telegram_bot + + +DEFAULT_TREND_KEYWORDS = [ + "다이어트 식단", "재택근무 꿀템", "캠핑 장비 추천", + "홈트레이닝", "제주도 여행", "에어프라이어 레시피", +] + + +class BlogAgent(BaseAgent): + """블로그 마케팅 에이전트. + + 매일 10:00 자동 실행: 키워드 1개 리서치 → 글 생성 → 마케터 → 평가자 + → 평가 점수와 요약을 텔레그램 승인 요청으로 푸시 + → 승인 시 `published` 상태로 전환, 거절 시 재생성 + """ + + agent_id = "blog" + display_name = "블로그 마케터" + + async def on_schedule(self) -> None: + if self.state not in ("idle", "break"): + return + + config = get_agent_config(self.agent_id) or {} + custom = config.get("custom_config", {}) or {} + keywords = custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS + if not keywords: + return + + import random + keyword = random.choice(keywords) + + task_id = create_task( + self.agent_id, + "auto_blog_pipeline", + {"keyword": keyword}, + requires_approval=True, + ) + await self.transition("working", f"리서치: {keyword}", task_id) + asyncio.create_task(self._run_pipeline(task_id, keyword)) + + async def _run_pipeline(self, task_id: str, keyword: str) -> None: + try: + # 1) 리서치 시작 (백그라운드 task) + research = await service_proxy.blog_research(keyword) + research_task_id = research.get("task_id") + keyword_id = None + + # 2) 리서치 완료까지 폴링 (최대 3분) + for _ in range(36): + await asyncio.sleep(5) + status = await service_proxy.blog_task_status(research_task_id) + if status.get("status") == "succeeded": + keyword_id = status.get("result", {}).get("keyword_id") + break + if status.get("status") == "failed": + raise Exception(f"research failed: {status.get('error')}") + if not keyword_id: + raise Exception("research timeout") + + # 3) 작가 단계 + await self.transition("working", f"글 생성: {keyword}", task_id) + gen = await service_proxy.blog_generate(keyword_id) + post_id = gen.get("post_id") or gen.get("id") + if not post_id: + raise Exception("generate did not return post_id") + + # 4) 마케터 단계 + await self.transition("working", "링크 삽입 중", task_id) + await service_proxy.blog_market(post_id) + + # 5) 평가자 단계 + await self.transition("working", "품질 리뷰 중", task_id) + review = await service_proxy.blog_review(post_id) + score = review.get("score") + passed = review.get("passed", False) + + post = await service_proxy.blog_get_post(post_id) + title = post.get("title", "(제목 없음)") + excerpt = (post.get("body") or "")[:300] + + update_task_status(task_id, "pending", { + "keyword": keyword, + "post_id": post_id, + "score": score, + "passed": passed, + "title": title, + }) + + await self.transition("waiting", f"승인 대기 · {score}/60", task_id) + + detail = ( + f"키워드: {keyword}\n" + f"제목: {title}\n" + f"평가 점수: {score}/60 ({'통과' if passed else '미통과'})\n\n" + f"{excerpt}..." + ) + await telegram_bot.send_approval_request( + self.agent_id, task_id, + "✍️ [블로그 에이전트] 발행 승인 요청", detail, + ) + + except Exception as e: + add_log(self.agent_id, f"Blog pipeline failed: {e}", "error", task_id) + update_task_status(task_id, "failed", {"error": str(e), "keyword": keyword}) + await self.transition("idle", f"오류: {e}") + await telegram_bot.send_task_result( + self.agent_id, "✍️ [블로그 에이전트] 파이프라인 실패", + f"키워드: {keyword}\n오류: {e}", + ) + + async def on_command(self, command: str, params: dict) -> dict: + if command == "research": + keyword = (params.get("keyword") or "").strip() + if not keyword: + return {"ok": False, "message": "keyword 필수"} + task_id = create_task( + self.agent_id, "auto_blog_pipeline", + {"keyword": keyword}, requires_approval=True, + ) + await self.transition("working", f"리서치: {keyword}", task_id) + asyncio.create_task(self._run_pipeline(task_id, keyword)) + return {"ok": True, "task_id": task_id, "message": f"파이프라인 시작: {keyword}"} + + if command == "add_trend_keyword": + keyword = (params.get("keyword") or "").strip() + if not keyword: + return {"ok": False, "message": "keyword 필수"} + config = get_agent_config(self.agent_id) or {} + custom = config.get("custom_config", {}) or {} + kws = list(custom.get("trend_keywords") or []) + if keyword not in kws: + kws.append(keyword) + from ..db import update_agent_config + update_agent_config(self.agent_id, custom_config={**custom, "trend_keywords": kws}) + return {"ok": True, "keywords": kws} + + if command == "list_trend_keywords": + config = get_agent_config(self.agent_id) or {} + custom = config.get("custom_config", {}) or {} + return {"ok": True, "keywords": custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS} + + return {"ok": False, "message": f"Unknown command: {command}"} + + async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None: + task = get_task(task_id) + if not task: + return + result = task.get("result_data") or {} + post_id = result.get("post_id") + + if not approved: + reject_task(task_id) + await self.transition("idle", "발행 거절됨") + await telegram_bot.send_task_result( + self.agent_id, "✍️ [블로그 에이전트] 발행 취소", + f"키워드: {result.get('keyword', '')}\n사용자가 거절했습니다.", + ) + return + + approve_task(task_id, via="telegram") + await self.transition("reporting", "발행 중...", task_id) + + try: + if post_id: + await service_proxy.blog_publish(int(post_id)) + update_task_status(task_id, "succeeded", {**result, "published": True}) + await telegram_bot.send_task_result( + self.agent_id, "✍️ [블로그 에이전트] 발행 완료", + f"키워드: {result.get('keyword', '')}\n제목: {result.get('title', '')}\n" + f"점수: {result.get('score')}/60", + ) + await self.transition("idle", "발행 완료") + except Exception as e: + add_log(self.agent_id, f"Blog publish failed: {e}", "error", task_id) + update_task_status(task_id, "failed", {**result, "publish_error": str(e)}) + await self.transition("idle", f"발행 오류: {e}") diff --git a/agent-office/app/agents/realestate.py b/agent-office/app/agents/realestate.py new file mode 100644 index 0000000..ba0071e --- /dev/null +++ b/agent-office/app/agents/realestate.py @@ -0,0 +1,98 @@ +import asyncio +from .base import BaseAgent +from ..db import create_task, update_task_status, add_log +from .. import service_proxy +from .. import telegram_bot + + +class RealestateAgent(BaseAgent): + """부동산 청약 에이전트. + + 매일 09:15 자동 실행: realestate-lab의 수집을 트리거하고 + 신규 매칭 결과를 텔레그램으로 푸시 (승인 없는 리포트형). + """ + + agent_id = "realestate" + display_name = "청약 애널리스트" + + async def on_schedule(self) -> None: + if self.state not in ("idle", "break"): + return + + task_id = create_task(self.agent_id, "daily_match_report", {}) + await self.transition("working", "청약 공고 수집 중", task_id) + + try: + collect = await service_proxy.realestate_collect() + new_count = collect.get("new_count", 0) or 0 + + await self.transition("working", "신규 매칭 조회 중", task_id) + matches = await service_proxy.realestate_matches(limit=20) + dashboard = await service_proxy.realestate_dashboard() + + await self.transition("reporting", "리포트 전송 중", task_id) + + if not matches: + body = ( + f"수집된 신규 공고: {new_count}건\n" + f"진행 중 공고: {dashboard.get('active_count', 0)}건\n" + f"신규 매칭: 없음" + ) + else: + lines = [ + f"📌 수집 {new_count}건 / 매칭 {len(matches)}건", + "", + ] + for m in matches[:5]: + title = m.get("title") or m.get("announcement_title") or "(제목 없음)" + region = m.get("region") or "" + score = m.get("match_score") or m.get("score") or "" + lines.append(f"• [{region}] {title} (매칭 {score})") + if len(matches) > 5: + lines.append(f"… 외 {len(matches) - 5}건") + body = "\n".join(lines) + + tg = await telegram_bot.send_task_result( + self.agent_id, + "🏢 [청약 에이전트] 오늘의 매칭 리포트", + body, + ) + + # 확인한 매칭 read 처리 + for m in matches[:5]: + mid = m.get("id") + if mid: + try: + await service_proxy.realestate_mark_read(int(mid)) + except Exception: + pass + + update_task_status(task_id, "succeeded", { + "new_count": new_count, + "match_count": len(matches), + "telegram_sent": tg.get("ok", False), + "telegram_message_id": tg.get("message_id"), + }) + await self.transition("idle", f"매칭 {len(matches)}건") + + except Exception as e: + add_log(self.agent_id, f"Realestate report failed: {e}", "error", task_id) + update_task_status(task_id, "failed", {"error": str(e)}) + await self.transition("idle", f"오류: {e}") + + async def on_command(self, command: str, params: dict) -> dict: + if command == "fetch_matches": + await self.on_schedule() + return {"ok": True, "message": "매칭 리포트 시작"} + + if command == "dashboard": + try: + data = await service_proxy.realestate_dashboard() + return {"ok": True, "dashboard": data} + except Exception as e: + return {"ok": False, "message": str(e)} + + return {"ok": False, "message": f"Unknown command: {command}"} + + async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None: + pass diff --git a/agent-office/app/config.py b/agent-office/app/config.py index 66caf61..d113807 100644 --- a/agent-office/app/config.py +++ b/agent-office/app/config.py @@ -3,6 +3,8 @@ import os # Service URLs (Docker internal network) STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500") MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600") +BLOG_LAB_URL = os.getenv("BLOG_LAB_URL", "http://localhost:18700") +REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800") # Telegram TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") diff --git a/agent-office/app/db.py b/agent-office/app/db.py index 01b9aab..4731479 100644 --- a/agent-office/app/db.py +++ b/agent-office/app/db.py @@ -68,7 +68,12 @@ def init_db() -> None: ) """) # Seed default agent configs - for agent_id, name in [("stock", "주식 트레이더"), ("music", "음악 프로듀서")]: + for agent_id, name in [ + ("stock", "주식 트레이더"), + ("music", "음악 프로듀서"), + ("blog", "블로그 마케터"), + ("realestate", "청약 애널리스트"), + ]: conn.execute( "INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)", (agent_id, name), diff --git a/agent-office/app/scheduler.py b/agent-office/app/scheduler.py index c5eda69..d89bec8 100644 --- a/agent-office/app/scheduler.py +++ b/agent-office/app/scheduler.py @@ -14,7 +14,19 @@ async def _run_stock_schedule(): if agent: await agent.on_schedule() +async def _run_realestate_schedule(): + agent = AGENT_REGISTRY.get("realestate") + if agent: + await agent.on_schedule() + +async def _run_blog_schedule(): + agent = AGENT_REGISTRY.get("blog") + if agent: + await agent.on_schedule() + def init_scheduler(): scheduler.add_job(_run_stock_schedule, "cron", hour=8, minute=0, id="stock_news") + scheduler.add_job(_run_realestate_schedule, "cron", hour=9, minute=15, id="realestate_report") + scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline") scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check") scheduler.start() diff --git a/agent-office/app/service_proxy.py b/agent-office/app/service_proxy.py index e954c70..58a24dc 100644 --- a/agent-office/app/service_proxy.py +++ b/agent-office/app/service_proxy.py @@ -1,7 +1,7 @@ import httpx from typing import Any, Dict, List, Optional -from .config import STOCK_LAB_URL, MUSIC_LAB_URL +from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL _client = httpx.AsyncClient(timeout=30.0) @@ -45,3 +45,89 @@ async def get_music_credits() -> Dict[str, Any]: resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/credits") resp.raise_for_status() return resp.json() + + +# --- blog-lab --- + +async def blog_research(keyword: str) -> Dict[str, Any]: + """키워드 리서치 시작 → task_id 반환""" + resp = await _client.post( + f"{BLOG_LAB_URL}/api/blog-marketing/research", + json={"keyword": keyword}, + ) + resp.raise_for_status() + return resp.json() + + +async def blog_task_status(task_id: str) -> Dict[str, Any]: + resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/task/{task_id}") + resp.raise_for_status() + return resp.json() + + +async def blog_generate(keyword_id: int) -> Dict[str, Any]: + resp = await _client.post( + f"{BLOG_LAB_URL}/api/blog-marketing/generate", + json={"keyword_id": keyword_id}, + ) + resp.raise_for_status() + return resp.json() + + +async def blog_market(post_id: int) -> Dict[str, Any]: + resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/market/{post_id}") + resp.raise_for_status() + return resp.json() + + +async def blog_review(post_id: int) -> Dict[str, Any]: + resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/review/{post_id}") + resp.raise_for_status() + return resp.json() + + +async def blog_publish(post_id: int, url: str = "") -> Dict[str, Any]: + resp = await _client.post( + f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}/publish", + json={"url": url}, + ) + resp.raise_for_status() + return resp.json() + + +async def blog_get_post(post_id: int) -> Dict[str, Any]: + resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}") + resp.raise_for_status() + return resp.json() + + +# --- realestate-lab --- + +async def realestate_collect() -> Dict[str, Any]: + """청약 공고 수동 수집 트리거""" + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post(f"{REALESTATE_LAB_URL}/api/realestate/collect") + resp.raise_for_status() + return resp.json() + + +async def realestate_matches(limit: int = 20) -> List[Dict[str, Any]]: + resp = await _client.get( + f"{REALESTATE_LAB_URL}/api/realestate/matches", + params={"limit": limit, "unread_only": True}, + ) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, list) else data.get("matches", []) + + +async def realestate_dashboard() -> Dict[str, Any]: + resp = await _client.get(f"{REALESTATE_LAB_URL}/api/realestate/dashboard") + resp.raise_for_status() + return resp.json() + + +async def realestate_mark_read(match_id: int) -> Dict[str, Any]: + resp = await _client.patch(f"{REALESTATE_LAB_URL}/api/realestate/matches/{match_id}/read") + resp.raise_for_status() + return resp.json() diff --git a/docker-compose.yml b/docker-compose.yml index 922d15c..d6180ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -124,6 +124,8 @@ services: - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} - STOCK_LAB_URL=http://stock-lab:8000 - MUSIC_LAB_URL=http://music-lab:8000 + - BLOG_LAB_URL=http://blog-lab:8000 + - REALESTATE_LAB_URL=http://realestate-lab:8000 - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} - TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-} @@ -132,6 +134,8 @@ services: depends_on: - stock-lab - music-lab + - blog-lab + - realestate-lab healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] interval: 30s