feat: Agent Office — AI 에이전트 가상 오피스 (#2)

## Summary
- 2D 픽셀아트 가상 오피스에서 AI 에이전트(Stock, Music)가 실제 작업 수행
- FastAPI + WebSocket 실시간 상태 동기화 + 텔레그램 봇 양방향 알림/승인
- BaseAgent FSM (idle/working/waiting/reporting/break), 서비스 프록시 패턴
- Docker Compose 서비스 (port 18900) + Nginx WebSocket 프록시

## Changes (13 commits)
- Backend scaffold: config, db, models, Dockerfile
- WebSocket manager + Service proxy
- BaseAgent FSM + StockAgent + MusicAgent
- Telegram bot + Scheduler
- FastAPI main (REST + WS endpoints)
- Infrastructure: docker-compose + nginx
- Code review fixes: HTTPException, async polling, input validation

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-11 13:35:24 +09:00
parent eb9bd65033
commit 6f8b199548
21 changed files with 4578 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
from .stock import StockAgent
from .music import MusicAgent
AGENT_REGISTRY = {}
def init_agents():
AGENT_REGISTRY["stock"] = StockAgent()
AGENT_REGISTRY["music"] = MusicAgent()
def get_agent(agent_id: str):
return AGENT_REGISTRY.get(agent_id)
def get_all_agent_states() -> list:
return [
{"agent_id": aid, "state": agent.state, "detail": agent.state_detail}
for aid, agent in AGENT_REGISTRY.items()
]

View File

@@ -0,0 +1,72 @@
import asyncio
import random
import time
from typing import Optional
from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX
from ..db import add_log
VALID_STATES = ("idle", "working", "waiting", "reporting", "break")
class BaseAgent:
agent_id: str = ""
display_name: str = ""
state: str = "idle"
state_detail: str = ""
_idle_since: float = 0.0
_break_until: float = 0.0
_ws_manager = None
def __init__(self):
self._idle_since = time.time()
def set_ws_manager(self, manager):
self._ws_manager = manager
async def transition(self, new_state: str, detail: str = "", task_id: str = None) -> None:
if new_state not in VALID_STATES:
return
old = self.state
self.state = new_state
self.state_detail = detail
if new_state == "idle":
self._idle_since = time.time()
elif new_state == "break":
duration = random.randint(BREAK_DURATION_MIN, BREAK_DURATION_MAX)
self._break_until = time.time() + duration
add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})")
if self._ws_manager:
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
if new_state == "break":
await self._ws_manager.send_agent_move(self.agent_id, "break_room")
elif old == "break" and new_state == "idle":
await self._ws_manager.send_agent_move(self.agent_id, "desk")
async def check_idle_break(self) -> None:
now = time.time()
if self.state == "idle" and (now - self._idle_since) > IDLE_BREAK_THRESHOLD:
if random.random() < 0.5:
break_type = random.choice(["커피 타임", "잠깐 산책", "졸고 있음"])
await self.transition("break", break_type)
elif self.state == "break" and now > self._break_until:
await self.transition("idle", "휴식 완료")
async def on_schedule(self) -> None:
raise NotImplementedError
async def on_command(self, command: str, params: dict) -> dict:
raise NotImplementedError
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
raise NotImplementedError
async def get_status(self) -> dict:
return {
"agent_id": self.agent_id,
"display_name": self.display_name,
"state": self.state,
"detail": self.state_detail,
}

View File

@@ -0,0 +1,124 @@
import asyncio
from .base import BaseAgent
from ..db import create_task, update_task_status, approve_task, reject_task, add_log
from .. import service_proxy
from .. import telegram_bot
class MusicAgent(BaseAgent):
agent_id = "music"
display_name = "음악 프로듀서"
async def on_schedule(self) -> None:
pass
async def on_command(self, command: str, params: dict) -> dict:
if command == "compose":
prompt = params.get("prompt", "")
style = params.get("style", "")
model = params.get("model", "V4")
instrumental = params.get("instrumental", False)
if not prompt:
return {"ok": False, "message": "프롬프트를 입력해주세요"}
task_id = create_task(self.agent_id, "compose", {
"prompt": prompt, "style": style,
"model": model, "instrumental": instrumental,
}, requires_approval=True)
await self.transition("waiting", "프롬프트 승인 대기", task_id)
detail = f"프롬프트: {prompt}"
if style:
detail += f"\n스타일: {style}"
detail += f"\n모델: {model}"
await telegram_bot.send_approval_request(
self.agent_id, task_id,
"🎵 [음악 에이전트] 작곡 요청", detail,
)
return {"ok": True, "task_id": task_id, "message": "승인 대기 중"}
if command == "credits":
credits = await service_proxy.get_music_credits()
return {"ok": True, "credits": credits}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
if not approved:
reject_task(task_id)
await self.transition("idle", "작곡 거절됨")
await telegram_bot.send_task_result(
self.agent_id, "🎵 [음악 에이전트] 작곡 취소",
"사용자가 거절했습니다.",
)
return
from ..db import get_task
task = get_task(task_id)
if not task:
return
approve_task(task_id, via="telegram")
await self.transition("working", "작곡 중...", task_id)
asyncio.create_task(self._poll_composition(task_id, task))
async def _poll_composition(self, task_id: str, task: dict) -> None:
try:
input_data = task["input_data"]
payload = {
"provider": "suno",
"model": input_data.get("model", "V4"),
"prompt": input_data.get("prompt", ""),
"style": input_data.get("style", ""),
"instrumental": input_data.get("instrumental", False),
"custom_mode": True,
}
result = await service_proxy.generate_music(payload)
music_task_id = result.get("task_id")
if not music_task_id:
raise Exception("music-lab did not return task_id")
for _ in range(60):
await asyncio.sleep(5)
status = await service_proxy.get_music_status(music_task_id)
state = status.get("status", "")
if state == "succeeded":
tracks = status.get("tracks", [])
update_task_status(task_id, "succeeded", {
"music_task_id": music_task_id,
"tracks": tracks,
})
await self.transition("reporting", "작곡 완료!")
track_info = ""
for t in tracks:
title = t.get("title", "Untitled")
url = t.get("audio_url", "")
track_info += f"🎶 {title}\n{url}\n"
await telegram_bot.send_task_result(
self.agent_id, "🎵 [음악 에이전트] 작곡 완료",
track_info or "트랙 생성 완료",
)
await self.transition("idle", "작곡 완료")
return
if state == "failed":
raise Exception(status.get("message", "Generation failed"))
raise Exception("Timeout: 5분 초과")
except Exception as e:
add_log(self.agent_id, f"Compose failed: {e}", "error", task_id)
update_task_status(task_id, "failed", {"error": str(e)})
await self.transition("idle", f"오류: {e}")
await telegram_bot.send_task_result(
self.agent_id, "🎵 [음악 에이전트] 작곡 실패",
f"오류: {e}",
)

View File

@@ -0,0 +1,99 @@
import asyncio
from typing import Optional
from .base import BaseAgent
from ..db import create_task, update_task_status, get_agent_config, add_log
from .. import service_proxy
class StockAgent(BaseAgent):
agent_id = "stock"
display_name = "주식 트레이더"
async def on_schedule(self) -> None:
if self.state not in ("idle", "break"):
return
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
await self.transition("working", "뉴스 수집 중...", task_id)
try:
news = await service_proxy.fetch_stock_news(limit=15)
indices = await service_proxy.fetch_stock_indices()
summary = self._format_news_summary(news, indices)
update_task_status(task_id, "succeeded", {
"summary": summary,
"news_count": len(news) if isinstance(news, list) else 0,
})
await self.transition("reporting", "뉴스 요약 전송 중...")
from ..telegram_bot import send_stock_summary
await send_stock_summary(summary)
await self.transition("idle", "뉴스 요약 완료")
except Exception as e:
add_log(self.agent_id, f"News summary 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_news":
await self.on_schedule()
return {"ok": True, "message": "뉴스 수집 시작"}
if command == "add_alert":
symbol = params.get("symbol")
target_price = params.get("target_price")
if not symbol or target_price is None:
return {"ok": False, "message": "symbol과 target_price는 필수입니다"}
config = get_agent_config(self.agent_id)
alerts = config["custom_config"].get("alerts", [])
alerts.append({
"symbol": symbol,
"name": params.get("name", symbol),
"target_price": target_price,
"direction": params.get("direction", "above"),
})
from ..db import update_agent_config
update_agent_config(self.agent_id, custom_config={**config["custom_config"], "alerts": alerts})
return {"ok": True, "message": f"알람 추가: {params['symbol']}"}
if command == "list_alerts":
config = get_agent_config(self.agent_id)
alerts = config["custom_config"].get("alerts", [])
return {"ok": True, "alerts": alerts}
return {"ok": False, "message": f"Unknown command: {command}"}
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
pass
def _format_news_summary(self, news, indices) -> str:
lines = ["📈 [주식 에이전트] 아침 뉴스 요약", "" * 20]
if isinstance(news, list):
for item in news[:10]:
title = item.get("title", "")
if title:
lines.append(f"{title}")
elif isinstance(news, dict) and "articles" in news:
for item in news["articles"][:10]:
title = item.get("title", "")
if title:
lines.append(f"{title}")
if indices:
lines.append("")
lines.append("📊 주요 지수")
if isinstance(indices, dict):
for key, val in indices.items():
if isinstance(val, dict):
name = val.get("name", key)
price = val.get("price", "")
change = val.get("change", "")
lines.append(f"{name}: {price} ({change})")
return "\n".join(lines)