Compare commits
2 Commits
cce84de8be
...
86e7f727eb
| Author | SHA1 | Date | |
|---|---|---|---|
| 86e7f727eb | |||
| de91f424a3 |
@@ -57,6 +57,10 @@ ADMIN_API_KEY=
|
||||
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Ollama 서버 (Windows AI PC의 Ollama 엔드포인트) — 뉴스 요약용
|
||||
OLLAMA_URL=http://192.168.45.59:11435
|
||||
OLLAMA_MODEL=qwen3:14b
|
||||
|
||||
# [BLOG LAB]
|
||||
# Naver Search API (https://developers.naver.com 에서 발급)
|
||||
NAVER_CLIENT_ID=
|
||||
|
||||
@@ -40,6 +40,14 @@ class BaseAgent:
|
||||
|
||||
if self._ws_manager:
|
||||
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
|
||||
if new_state == "working" and old != "working":
|
||||
await self._ws_manager.send_notification(
|
||||
self.agent_id, "task_assigned", task_id, detail or "새 작업 시작"
|
||||
)
|
||||
elif new_state == "idle" and old in ("working", "reporting"):
|
||||
await self._ws_manager.send_notification(
|
||||
self.agent_id, "task_completed", task_id, detail or "작업 완료"
|
||||
)
|
||||
if new_state == "break":
|
||||
await self._ws_manager.send_agent_move(self.agent_id, "break_room")
|
||||
elif old == "break" and new_state == "idle":
|
||||
|
||||
@@ -14,23 +14,45 @@ class StockAgent(BaseAgent):
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
|
||||
await self.transition("working", "뉴스 수집 중...", task_id)
|
||||
await self.transition("working", "AI 뉴스 요약 생성 중...", 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,
|
||||
})
|
||||
# AI 요약 호출 (뉴스 수집 + LLM 처리는 stock-lab이 담당)
|
||||
result = await service_proxy.summarize_stock_news(limit=15)
|
||||
|
||||
await self.transition("reporting", "뉴스 요약 전송 중...")
|
||||
|
||||
from ..telegram_bot import send_stock_summary
|
||||
await send_stock_summary(summary)
|
||||
# 새 통합 텔레그램 API 사용
|
||||
from ..telegram import send_agent_message
|
||||
tg_result = await send_agent_message(
|
||||
agent_id=self.agent_id,
|
||||
kind="report",
|
||||
title="아침 시장 브리핑",
|
||||
body=result["summary"],
|
||||
task_id=task_id,
|
||||
metadata={
|
||||
"tokens": result["tokens"]["total"],
|
||||
"duration_ms": result["duration_ms"],
|
||||
"model": result["model"],
|
||||
},
|
||||
)
|
||||
|
||||
update_task_status(task_id, "succeeded", {
|
||||
"summary": result["summary"],
|
||||
"article_count": result.get("article_count", 0),
|
||||
"tokens": result["tokens"],
|
||||
"model": result["model"],
|
||||
"duration_ms": result["duration_ms"],
|
||||
"telegram_sent": tg_result.get("ok", False),
|
||||
"telegram_message_id": tg_result.get("message_id"),
|
||||
})
|
||||
|
||||
if not tg_result.get("ok"):
|
||||
add_log(self.agent_id, "Telegram send failed", "warning", task_id)
|
||||
if self._ws_manager:
|
||||
await self._ws_manager.send_notification(
|
||||
self.agent_id, "telegram_failed", task_id, "텔레그램 전송 실패"
|
||||
)
|
||||
|
||||
await self.transition("idle", "뉴스 요약 완료")
|
||||
|
||||
@@ -40,6 +62,20 @@ class StockAgent(BaseAgent):
|
||||
await self.transition("idle", f"오류: {e}")
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "test_telegram":
|
||||
from ..telegram import send_agent_message
|
||||
result = await send_agent_message(
|
||||
agent_id=self.agent_id,
|
||||
kind="info",
|
||||
title="연결 테스트",
|
||||
body="텔레그램 연동이 정상적으로 동작합니다.",
|
||||
)
|
||||
return {
|
||||
"ok": result.get("ok", False),
|
||||
"message": "텔레그램 전송 성공" if result.get("ok") else "텔레그램 전송 실패",
|
||||
"telegram_message_id": result.get("message_id"),
|
||||
}
|
||||
|
||||
if command == "fetch_news":
|
||||
await self.on_schedule()
|
||||
return {"ok": True, "message": "뉴스 수집 시작"}
|
||||
@@ -70,30 +106,3 @@ class StockAgent(BaseAgent):
|
||||
|
||||
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)
|
||||
|
||||
@@ -259,3 +259,117 @@ def mark_telegram_responded(callback_id: str, action: str) -> None:
|
||||
"UPDATE telegram_state SET responded=1, action=? WHERE callback_id=?",
|
||||
(action, callback_id),
|
||||
)
|
||||
|
||||
|
||||
def get_token_usage_stats(agent_id: str, days: int = 1) -> dict:
|
||||
"""지정 에이전트의 최근 N일 토큰 사용량 집계.
|
||||
|
||||
agent_tasks 테이블의 result_data JSON에서 tokens.total을 합산.
|
||||
반환: {"total_tokens": int, "task_count": int, "by_day": [{"date": "YYYY-MM-DD", "tokens": int}]}
|
||||
"""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT completed_at, result_data
|
||||
FROM agent_tasks
|
||||
WHERE agent_id = ?
|
||||
AND status = 'succeeded'
|
||||
AND completed_at IS NOT NULL
|
||||
AND completed_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
|
||||
""",
|
||||
(agent_id, f"-{int(days)} days"),
|
||||
).fetchall()
|
||||
|
||||
total_tokens = 0
|
||||
task_count = 0
|
||||
by_day_map: Dict[str, int] = {}
|
||||
for r in rows:
|
||||
result_data = r["result_data"]
|
||||
if not result_data:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(result_data)
|
||||
except Exception:
|
||||
continue
|
||||
tokens = parsed.get("tokens") if isinstance(parsed, dict) else None
|
||||
total = 0
|
||||
if isinstance(tokens, dict):
|
||||
total = int(tokens.get("total", 0) or 0)
|
||||
if total <= 0:
|
||||
continue
|
||||
total_tokens += total
|
||||
task_count += 1
|
||||
completed_at = r["completed_at"] or ""
|
||||
day = completed_at[:10] if completed_at else "unknown"
|
||||
by_day_map[day] = by_day_map.get(day, 0) + total
|
||||
|
||||
by_day = [
|
||||
{"date": d, "tokens": t}
|
||||
for d, t in sorted(by_day_map.items())
|
||||
]
|
||||
return {
|
||||
"total_tokens": total_tokens,
|
||||
"task_count": task_count,
|
||||
"by_day": by_day,
|
||||
}
|
||||
|
||||
|
||||
def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
|
||||
with _conn() as conn:
|
||||
total_row = conn.execute("""
|
||||
SELECT (SELECT COUNT(*) FROM agent_tasks) + (SELECT COUNT(*) FROM agent_logs) AS total
|
||||
""").fetchone()
|
||||
total = total_row["total"] if total_row else 0
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT 'task' AS type, agent_id, id AS task_id, task_type,
|
||||
status, NULL AS level,
|
||||
COALESCE(
|
||||
json_extract(result_data, '$.summary'),
|
||||
task_type
|
||||
) AS message,
|
||||
created_at, completed_at,
|
||||
result_data
|
||||
FROM agent_tasks
|
||||
UNION ALL
|
||||
SELECT 'log' AS type, agent_id, task_id, NULL AS task_type,
|
||||
NULL AS status, level,
|
||||
message,
|
||||
created_at, NULL AS completed_at,
|
||||
NULL AS result_data
|
||||
FROM agent_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", (limit, offset)).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
item = {
|
||||
"type": r["type"],
|
||||
"agent_id": r["agent_id"],
|
||||
"task_id": r["task_id"],
|
||||
"message": r["message"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
if r["type"] == "task":
|
||||
item["task_type"] = r["task_type"]
|
||||
item["status"] = r["status"]
|
||||
item["completed_at"] = r["completed_at"]
|
||||
if r["created_at"] and r["completed_at"]:
|
||||
try:
|
||||
from datetime import datetime
|
||||
start = datetime.fromisoformat(r["created_at"].replace("Z", "+00:00"))
|
||||
end = datetime.fromisoformat(r["completed_at"].replace("Z", "+00:00"))
|
||||
item["duration_seconds"] = round((end - start).total_seconds())
|
||||
except Exception:
|
||||
item["duration_seconds"] = None
|
||||
else:
|
||||
item["duration_seconds"] = None
|
||||
result_data = json.loads(r["result_data"]) if r["result_data"] else None
|
||||
if result_data and "telegram_sent" in result_data:
|
||||
item["telegram_sent"] = result_data["telegram_sent"]
|
||||
else:
|
||||
item["level"] = r["level"]
|
||||
items.append(item)
|
||||
|
||||
return {"items": items, "total": total}
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import CORS_ALLOW_ORIGINS
|
||||
from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs
|
||||
from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs, get_activity_feed
|
||||
from .models import CommandRequest, ApprovalRequest, AgentConfigUpdate
|
||||
from .websocket_manager import ws_manager
|
||||
from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
|
||||
@@ -138,10 +138,26 @@ async def approve(body: ApprovalRequest):
|
||||
|
||||
# --- Telegram Webhook ---
|
||||
|
||||
async def _agent_dispatcher(agent_id: str, command: str, params: dict) -> dict:
|
||||
"""텔레그램 라우터가 호출하는 에이전트 디스패처."""
|
||||
# 전역 상태 조회
|
||||
if agent_id == "__global__" and command == "status":
|
||||
result = {}
|
||||
for aid, agent in AGENT_REGISTRY.items():
|
||||
result[aid] = {"state": agent.state, "detail": agent.state_detail}
|
||||
return result
|
||||
|
||||
agent = AGENT_REGISTRY.get(agent_id)
|
||||
if agent is None:
|
||||
return {"ok": False, "message": f"Unknown agent: {agent_id}"}
|
||||
return await agent.on_command(command, params or {})
|
||||
|
||||
|
||||
@app.post("/api/agent-office/telegram/webhook")
|
||||
async def telegram_webhook(data: dict):
|
||||
result = await telegram_bot.handle_webhook(data)
|
||||
if result:
|
||||
result = await telegram_bot.handle_webhook(data, agent_dispatcher=_agent_dispatcher)
|
||||
# callback_query (승인/거절) → 기존 승인 흐름
|
||||
if result and "approved" in result:
|
||||
agent = get_agent(result["agent_id"])
|
||||
if agent:
|
||||
await agent.on_approval(result["task_id"], result["approved"])
|
||||
@@ -150,3 +166,12 @@ async def telegram_webhook(data: dict):
|
||||
@app.get("/api/agent-office/states")
|
||||
def all_states():
|
||||
return {"agents": get_all_agent_states()}
|
||||
|
||||
@app.get("/api/agent-office/agents/{agent_id}/token-usage")
|
||||
def agent_token_usage(agent_id: str, days: int = 1):
|
||||
from .db import get_token_usage_stats
|
||||
return get_token_usage_stats(agent_id, days)
|
||||
|
||||
@app.get("/api/agent-office/activity")
|
||||
def activity_feed(limit: int = 50, offset: int = 0):
|
||||
return get_activity_feed(limit, offset)
|
||||
|
||||
@@ -18,6 +18,18 @@ async def fetch_stock_indices() -> Dict[str, Any]:
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
|
||||
"""stock-lab의 AI 요약 엔드포인트 호출.
|
||||
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_LAB_URL}/api/stock/news/summarize",
|
||||
json={"limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def generate_music(payload: dict) -> Dict[str, Any]:
|
||||
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
||||
resp.raise_for_status()
|
||||
|
||||
19
agent-office/app/telegram/__init__.py
Normal file
19
agent-office/app/telegram/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Telegram 통합 메시지 패키지."""
|
||||
from .agent_registry import AGENT_META, get_agent_meta, register_agent
|
||||
from .messaging import send_agent_message, send_approval_request, send_raw
|
||||
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
||||
from .webhook import handle_webhook, setup_webhook
|
||||
|
||||
__all__ = [
|
||||
"send_agent_message",
|
||||
"send_approval_request",
|
||||
"send_raw",
|
||||
"handle_webhook",
|
||||
"setup_webhook",
|
||||
"get_agent_meta",
|
||||
"register_agent",
|
||||
"AGENT_META",
|
||||
"parse_command",
|
||||
"resolve_agent_command",
|
||||
"HELP_TEXT",
|
||||
]
|
||||
30
agent-office/app/telegram/agent_registry.py
Normal file
30
agent-office/app/telegram/agent_registry.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""에이전트 메타 등록소."""
|
||||
|
||||
AGENT_META = {
|
||||
"stock": {
|
||||
"display_name": "주식 트레이더",
|
||||
"emoji": "📈",
|
||||
"color": "#4488cc",
|
||||
},
|
||||
"music": {
|
||||
"display_name": "음악 프로듀서",
|
||||
"emoji": "🎵",
|
||||
"color": "#44aa88",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_agent_meta(agent_id: str) -> dict:
|
||||
return AGENT_META.get(
|
||||
agent_id,
|
||||
{"display_name": agent_id, "emoji": "🤖", "color": "#888"},
|
||||
)
|
||||
|
||||
|
||||
def register_agent(agent_id: str, display_name: str, emoji: str, color: str = "#888"):
|
||||
"""향후 에이전트 동적 등록용"""
|
||||
AGENT_META[agent_id] = {
|
||||
"display_name": display_name,
|
||||
"emoji": emoji,
|
||||
"color": color,
|
||||
}
|
||||
18
agent-office/app/telegram/client.py
Normal file
18
agent-office/app/telegram/client.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Telegram Bot API 저수준 래퍼."""
|
||||
import httpx
|
||||
|
||||
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_WEBHOOK_URL
|
||||
|
||||
_BASE = "https://api.telegram.org/bot"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)
|
||||
|
||||
|
||||
async def api_call(method: str, payload: dict) -> dict:
|
||||
if not _enabled():
|
||||
return {"ok": False, "description": "Telegram not configured"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(f"{_BASE}{TELEGRAM_BOT_TOKEN}/{method}", json=payload)
|
||||
return resp.json()
|
||||
43
agent-office/app/telegram/formatter.py
Normal file
43
agent-office/app/telegram/formatter.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""에이전트 메시지 포맷팅."""
|
||||
from typing import Literal, Optional
|
||||
|
||||
from .agent_registry import get_agent_meta
|
||||
|
||||
MessageKind = Literal["report", "alert", "approval", "error", "info"]
|
||||
|
||||
KIND_ICONS = {
|
||||
"report": "📊",
|
||||
"alert": "🔔",
|
||||
"approval": "✋",
|
||||
"error": "⚠️",
|
||||
"info": "ℹ️",
|
||||
}
|
||||
|
||||
|
||||
def format_agent_message(
|
||||
agent_id: str,
|
||||
kind: MessageKind,
|
||||
title: str,
|
||||
body: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> str:
|
||||
meta = get_agent_meta(agent_id)
|
||||
icon = KIND_ICONS.get(kind, "")
|
||||
header = f"{icon} <b>[{meta['emoji']} {meta['display_name']}]</b> {title}"
|
||||
|
||||
lines = [header, "━" * 20, body]
|
||||
|
||||
if metadata:
|
||||
footer_parts = []
|
||||
if "tokens" in metadata:
|
||||
footer_parts.append(f"🧮 {metadata['tokens']:,} tokens")
|
||||
if "duration_ms" in metadata:
|
||||
seconds = metadata["duration_ms"] / 1000
|
||||
footer_parts.append(f"⏱ {seconds:.1f}s")
|
||||
if "model" in metadata:
|
||||
footer_parts.append(f"🤖 {metadata['model']}")
|
||||
if footer_parts:
|
||||
lines.append("")
|
||||
lines.append(f"<i>{' · '.join(footer_parts)}</i>")
|
||||
|
||||
return "\n".join(lines)
|
||||
68
agent-office/app/telegram/messaging.py
Normal file
68
agent-office/app/telegram/messaging.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""고수준 메시지 전송 API."""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from ..config import TELEGRAM_CHAT_ID
|
||||
from ..db import save_telegram_callback
|
||||
from .client import _enabled, api_call
|
||||
from .formatter import MessageKind, format_agent_message
|
||||
|
||||
|
||||
async def send_raw(text: str, reply_markup: Optional[dict] = None) -> dict:
|
||||
"""가장 저수준. 원문 텍스트 그대로 전송."""
|
||||
if not _enabled():
|
||||
return {"ok": False, "message_id": None}
|
||||
payload = {
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
if reply_markup:
|
||||
payload["reply_markup"] = reply_markup
|
||||
result = await api_call("sendMessage", payload)
|
||||
return {
|
||||
"ok": result.get("ok", False),
|
||||
"message_id": result.get("result", {}).get("message_id") if result.get("ok") else None,
|
||||
}
|
||||
|
||||
|
||||
async def send_agent_message(
|
||||
agent_id: str,
|
||||
kind: MessageKind,
|
||||
title: str,
|
||||
body: str,
|
||||
task_id: Optional[str] = None,
|
||||
actions: Optional[list] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""통합 에이전트 메시지 API. 모든 에이전트가 이걸 씀."""
|
||||
text = format_agent_message(agent_id, kind, title, body, metadata)
|
||||
reply_markup = None
|
||||
if actions:
|
||||
buttons = []
|
||||
for action in actions:
|
||||
cb_id = f"{action['action']}_{uuid.uuid4().hex[:8]}"
|
||||
save_telegram_callback(cb_id, task_id or "", agent_id)
|
||||
buttons.append({"text": action["label"], "callback_data": cb_id})
|
||||
reply_markup = {"inline_keyboard": [buttons]}
|
||||
return await send_raw(text, reply_markup)
|
||||
|
||||
|
||||
async def send_approval_request(
|
||||
agent_id: str,
|
||||
task_id: str,
|
||||
title: str,
|
||||
detail: str,
|
||||
) -> dict:
|
||||
"""승인/거절 단축 헬퍼."""
|
||||
return await send_agent_message(
|
||||
agent_id=agent_id,
|
||||
kind="approval",
|
||||
title=title,
|
||||
body=detail,
|
||||
task_id=task_id,
|
||||
actions=[
|
||||
{"label": "✅ 승인", "action": "approve"},
|
||||
{"label": "❌ 거절", "action": "reject"},
|
||||
],
|
||||
)
|
||||
87
agent-office/app/telegram/router.py
Normal file
87
agent-office/app/telegram/router.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""텔레그램 메시지 명령 → 에이전트 라우팅.
|
||||
새 명령을 추가하려면 AGENT_COMMAND_MAP에 등록만 하면 됨."""
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_command(text: str) -> Optional[tuple]:
|
||||
"""슬래시 명령 파싱.
|
||||
|
||||
반환: (agent_id_or_None, command, args_list) 또는 None
|
||||
|
||||
예시:
|
||||
/stock news -> ("stock", "news", [])
|
||||
/status -> (None, "status", [])
|
||||
/music compose 잔잔한 피아노 -> ("music", "compose", ["잔잔한 피아노"])
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
text = text.strip()
|
||||
if not text.startswith("/"):
|
||||
return None
|
||||
parts = text[1:].split(maxsplit=2)
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
first = parts[0].lower()
|
||||
|
||||
# 전역 명령
|
||||
if first in ("status", "agents", "help"):
|
||||
return (None, first, parts[1:] if len(parts) > 1 else [])
|
||||
|
||||
# 에이전트 명령: /<agent> <command> [args...]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
agent_id = first
|
||||
command = parts[1].lower()
|
||||
args = [parts[2]] if len(parts) > 2 else []
|
||||
return (agent_id, command, args)
|
||||
|
||||
|
||||
# 에이전트별 텔레그램 → 내부 command 매핑
|
||||
# 텔레그램에서 친숙한 이름 -> (실제 on_command의 command, 기본 params)
|
||||
AGENT_COMMAND_MAP = {
|
||||
"stock": {
|
||||
"news": ("fetch_news", {}),
|
||||
"alerts": ("list_alerts", {}),
|
||||
"test": ("test_telegram", {}),
|
||||
},
|
||||
"music": {
|
||||
"credits": ("credits", {}),
|
||||
# compose는 인자 필요 — 아래 특수 케이스에서 처리
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def resolve_agent_command(agent_id: str, command: str, args: list) -> Optional[tuple]:
|
||||
"""(internal_command, params) 반환. 매핑 없으면 None."""
|
||||
mapping = AGENT_COMMAND_MAP.get(agent_id, {}).get(command)
|
||||
if mapping is None:
|
||||
# 특수 케이스: music compose <prompt>
|
||||
if agent_id == "music" and command == "compose" and args:
|
||||
return ("compose", {"prompt": " ".join(args)})
|
||||
return None
|
||||
internal_cmd, base_params = mapping
|
||||
params = dict(base_params)
|
||||
if args:
|
||||
# args가 있으면 첫 번째(합쳐진 나머지)를 message로 자동 주입
|
||||
params["message"] = " ".join(args)
|
||||
return (internal_cmd, params)
|
||||
|
||||
|
||||
HELP_TEXT = """<b>🤖 Agent Office 텔레그램 명령</b>
|
||||
|
||||
<b>전역</b>
|
||||
/status — 모든 에이전트 상태
|
||||
/agents — 에이전트 목록
|
||||
/help — 이 도움말
|
||||
|
||||
<b>📈 주식 트레이더</b>
|
||||
/stock news — 뉴스 AI 요약 실행
|
||||
/stock alerts — 알람 목록
|
||||
/stock test — 텔레그램 테스트
|
||||
|
||||
<b>🎵 음악 프로듀서</b>
|
||||
/music credits — Suno 크레딧 조회
|
||||
/music compose <프롬프트> — 작곡 시작
|
||||
"""
|
||||
149
agent-office/app/telegram/webhook.py
Normal file
149
agent-office/app/telegram/webhook.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""텔레그램 Webhook 이벤트 처리."""
|
||||
from typing import Optional
|
||||
|
||||
from ..db import get_telegram_callback, mark_telegram_responded
|
||||
from .client import _enabled, api_call
|
||||
|
||||
|
||||
async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]:
|
||||
"""텔레그램에서 들어오는 이벤트 처리.
|
||||
|
||||
- callback_query(인라인 버튼)는 항상 처리 → 승인/거절 dict 반환
|
||||
- message(텍스트 슬래시 명령)는 `agent_dispatcher`가 주입된 경우에만 처리
|
||||
|
||||
agent_dispatcher: async (agent_id, command, params) -> dict
|
||||
- agent_id == "__global__", command == "status" 특수 케이스는
|
||||
{agent_id: {state, detail}} dict를 반환해야 함.
|
||||
"""
|
||||
callback_query = data.get("callback_query")
|
||||
if callback_query:
|
||||
return await _handle_callback(callback_query)
|
||||
|
||||
message = data.get("message")
|
||||
if message and message.get("text") and agent_dispatcher is not None:
|
||||
return await _handle_message(message, agent_dispatcher)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
||||
"""기존 승인/거절 콜백 처리 로직."""
|
||||
callback_id = callback_query.get("data", "")
|
||||
cb = get_telegram_callback(callback_id)
|
||||
if not cb:
|
||||
return None
|
||||
|
||||
action = callback_id.split("_")[0]
|
||||
mark_telegram_responded(callback_id, action)
|
||||
|
||||
feedback_text = {
|
||||
"approve": "승인됨 ✅",
|
||||
"reject": "거절됨 ❌",
|
||||
}.get(action, f"처리됨: {action}")
|
||||
|
||||
await api_call(
|
||||
"answerCallbackQuery",
|
||||
{
|
||||
"callback_query_id": callback_query["id"],
|
||||
"text": feedback_text,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": cb["task_id"],
|
||||
"agent_id": cb["agent_id"],
|
||||
"action": action,
|
||||
"approved": action == "approve",
|
||||
}
|
||||
|
||||
|
||||
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
|
||||
"""슬래시 명령 메시지 처리."""
|
||||
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
||||
from .messaging import send_raw, send_agent_message
|
||||
from .agent_registry import AGENT_META
|
||||
|
||||
text = message.get("text", "")
|
||||
parsed = parse_command(text)
|
||||
if not parsed:
|
||||
return None
|
||||
|
||||
agent_id, command, args = parsed
|
||||
|
||||
# 전역 명령
|
||||
if agent_id is None:
|
||||
if command == "help":
|
||||
await send_raw(HELP_TEXT)
|
||||
return {"handled": "help"}
|
||||
|
||||
if command == "agents":
|
||||
lines = ["<b>📋 등록된 에이전트</b>", ""]
|
||||
for aid, meta in AGENT_META.items():
|
||||
lines.append(
|
||||
f"{meta['emoji']} <b>{meta['display_name']}</b> <code>/{aid}</code>"
|
||||
)
|
||||
await send_raw("\n".join(lines))
|
||||
return {"handled": "agents"}
|
||||
|
||||
if command == "status":
|
||||
try:
|
||||
result = await agent_dispatcher("__global__", "status", {})
|
||||
body_lines = []
|
||||
if isinstance(result, dict):
|
||||
for aid, info in result.items():
|
||||
meta = AGENT_META.get(
|
||||
aid, {"emoji": "🤖", "display_name": aid}
|
||||
)
|
||||
state = info.get("state", "unknown") if isinstance(info, dict) else "unknown"
|
||||
body_lines.append(
|
||||
f"{meta['emoji']} <b>{meta['display_name']}</b>: <code>{state}</code>"
|
||||
)
|
||||
detail = info.get("detail") if isinstance(info, dict) else None
|
||||
if detail:
|
||||
body_lines.append(f" └ {detail}")
|
||||
await send_raw("<b>📊 전체 상태</b>\n\n" + "\n".join(body_lines))
|
||||
except Exception as e:
|
||||
await send_raw(f"⚠️ 상태 조회 실패: {e}")
|
||||
return {"handled": "status"}
|
||||
|
||||
return None
|
||||
|
||||
# 에이전트 명령
|
||||
if agent_id not in AGENT_META:
|
||||
await send_raw(
|
||||
f"⚠️ 알 수 없는 에이전트: <code>{agent_id}</code>\n/help 로 사용 가능한 명령 확인"
|
||||
)
|
||||
return {"handled": "unknown_agent"}
|
||||
|
||||
resolved = resolve_agent_command(agent_id, command, args)
|
||||
if resolved is None:
|
||||
await send_raw(
|
||||
f"⚠️ <code>{agent_id}</code>에서 <code>{command}</code> 명령은 지원하지 않습니다."
|
||||
)
|
||||
return {"handled": "unknown_command"}
|
||||
|
||||
internal_cmd, params = resolved
|
||||
|
||||
try:
|
||||
result = await agent_dispatcher(agent_id, internal_cmd, params)
|
||||
ok = result.get("ok", False) if isinstance(result, dict) else False
|
||||
msg = result.get("message", "") if isinstance(result, dict) else str(result)
|
||||
|
||||
await send_agent_message(
|
||||
agent_id=agent_id,
|
||||
kind="info" if ok else "error",
|
||||
title=f"{internal_cmd} 실행 결과",
|
||||
body=msg or str(result),
|
||||
)
|
||||
except Exception as e:
|
||||
await send_raw(f"⚠️ 명령 실행 실패: {e}")
|
||||
|
||||
return {"handled": "command", "agent_id": agent_id, "command": internal_cmd}
|
||||
|
||||
|
||||
async def setup_webhook() -> dict:
|
||||
from ..config import TELEGRAM_WEBHOOK_URL
|
||||
|
||||
if not _enabled() or not TELEGRAM_WEBHOOK_URL:
|
||||
return {"ok": False, "description": "Webhook URL not configured"}
|
||||
return await api_call("setWebhook", {"url": TELEGRAM_WEBHOOK_URL})
|
||||
@@ -1,82 +1,27 @@
|
||||
import json
|
||||
import uuid
|
||||
import httpx
|
||||
from typing import Optional
|
||||
"""Deprecated: app.telegram 패키지 사용 권장. 하위 호환용 re-export."""
|
||||
from .telegram import handle_webhook, send_approval_request, send_raw, setup_webhook
|
||||
from .telegram.messaging import send_agent_message
|
||||
|
||||
from .config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_WEBHOOK_URL
|
||||
from .db import save_telegram_callback, get_telegram_callback, mark_telegram_responded
|
||||
|
||||
_BASE = "https://api.telegram.org/bot"
|
||||
|
||||
def _enabled() -> bool:
|
||||
return bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)
|
||||
|
||||
async def _api(method: str, payload: dict) -> dict:
|
||||
if not _enabled():
|
||||
return {"ok": False, "description": "Telegram not configured"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(f"{_BASE}{TELEGRAM_BOT_TOKEN}/{method}", json=payload)
|
||||
return resp.json()
|
||||
|
||||
# 기존 호출자가 쓰던 이름들
|
||||
async def send_message(text: str, reply_markup: dict = None) -> dict:
|
||||
payload = {
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
if reply_markup:
|
||||
payload["reply_markup"] = reply_markup
|
||||
return await _api("sendMessage", payload)
|
||||
return await send_raw(text, reply_markup)
|
||||
|
||||
|
||||
async def send_stock_summary(summary: str) -> dict:
|
||||
return await send_message(summary)
|
||||
return await send_raw(summary)
|
||||
|
||||
async def send_approval_request(agent_id: str, task_id: str, title: str, detail: str) -> dict:
|
||||
approve_id = f"approve_{uuid.uuid4().hex[:8]}"
|
||||
reject_id = f"reject_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
save_telegram_callback(approve_id, task_id, agent_id)
|
||||
save_telegram_callback(reject_id, task_id, agent_id)
|
||||
|
||||
text = f"{title}\n{'━' * 20}\n{detail}"
|
||||
reply_markup = {
|
||||
"inline_keyboard": [[
|
||||
{"text": "✅ 승인", "callback_data": approve_id},
|
||||
{"text": "❌ 거절", "callback_data": reject_id},
|
||||
]]
|
||||
}
|
||||
return await send_message(text, reply_markup)
|
||||
|
||||
async def send_task_result(agent_id: str, title: str, result: str) -> dict:
|
||||
text = f"{title}\n{'━' * 20}\n{result}"
|
||||
return await send_message(text)
|
||||
return await send_agent_message(agent_id, "report", title, result)
|
||||
|
||||
async def handle_webhook(data: dict) -> Optional[dict]:
|
||||
callback_query = data.get("callback_query")
|
||||
if not callback_query:
|
||||
return None
|
||||
|
||||
callback_id = callback_query.get("data", "")
|
||||
cb = get_telegram_callback(callback_id)
|
||||
if not cb:
|
||||
return None
|
||||
|
||||
action = "approve" if callback_id.startswith("approve_") else "reject"
|
||||
mark_telegram_responded(callback_id, action)
|
||||
|
||||
await _api("answerCallbackQuery", {
|
||||
"callback_query_id": callback_query["id"],
|
||||
"text": "승인됨 ✅" if action == "approve" else "거절됨 ❌",
|
||||
})
|
||||
|
||||
return {
|
||||
"task_id": cb["task_id"],
|
||||
"agent_id": cb["agent_id"],
|
||||
"action": action,
|
||||
"approved": action == "approve",
|
||||
}
|
||||
|
||||
async def setup_webhook() -> dict:
|
||||
if not _enabled() or not TELEGRAM_WEBHOOK_URL:
|
||||
return {"ok": False, "description": "Webhook URL not configured"}
|
||||
return await _api("setWebhook", {"url": TELEGRAM_WEBHOOK_URL})
|
||||
__all__ = [
|
||||
"send_message",
|
||||
"send_stock_summary",
|
||||
"send_task_result",
|
||||
"send_approval_request",
|
||||
"send_agent_message",
|
||||
"handle_webhook",
|
||||
"setup_webhook",
|
||||
]
|
||||
|
||||
@@ -43,4 +43,13 @@ class WebSocketManager:
|
||||
async def send_agent_move(self, agent_id: str, target: str) -> None:
|
||||
await self.broadcast({"type": "agent_move", "agent": agent_id, "target": target})
|
||||
|
||||
async def send_notification(self, agent_id: str, event: str, task_id: str = None, message: str = "") -> None:
|
||||
await self.broadcast({
|
||||
"type": "notification",
|
||||
"agent": agent_id,
|
||||
"event": event,
|
||||
"task_id": task_id,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
ws_manager = WebSocketManager()
|
||||
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
|
||||
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||
|
||||
113
stock-lab/app/ai_summarizer.py
Normal file
113
stock-lab/app/ai_summarizer.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Ollama 기반 뉴스 요약 모듈.
|
||||
|
||||
Windows AI 서버(192.168.45.59:11435)의 Ollama에 연결하여
|
||||
한국어 시장 뉴스를 요약한다. 기존 WINDOWS_AI_SERVER_URL(KIS 래퍼)과는
|
||||
별개 경로이며, 본 모듈은 Ollama HTTP API(`/api/generate`)만 호출한다.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Dict, Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("stock-lab.ai_summarizer")
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.45.59:11435")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:14b")
|
||||
|
||||
_PROMPT_TEMPLATE = """당신은 한국 주식 시장 애널리스트입니다. 아래 뉴스 목록을 읽고 투자자 관점에서 한국어로 간결하게 요약하세요.
|
||||
|
||||
반드시 아래 형식을 그대로 지켜서 출력하세요. 다른 설명이나 서두, `<think>` 같은 태그는 절대 출력하지 마세요.
|
||||
|
||||
📌 시장 흐름
|
||||
(2줄 요약)
|
||||
|
||||
🔥 주목 이슈
|
||||
• (이슈 1)
|
||||
• (이슈 2)
|
||||
• (이슈 3)
|
||||
|
||||
💡 투자 관점
|
||||
(1줄 인사이트)
|
||||
|
||||
=== 뉴스 목록 ===
|
||||
{news_block}
|
||||
"""
|
||||
|
||||
|
||||
class OllamaError(RuntimeError):
|
||||
"""Ollama 서버 호출 실패."""
|
||||
|
||||
|
||||
def _build_news_block(articles: List[Dict[str, Any]]) -> str:
|
||||
lines = []
|
||||
for i, art in enumerate(articles, start=1):
|
||||
title = (art.get("title") or "").strip()
|
||||
content = (art.get("content") or art.get("summary") or "").strip()
|
||||
if content:
|
||||
lines.append(f"{i}. {title} — {content}")
|
||||
else:
|
||||
lines.append(f"{i}. {title}")
|
||||
return "\n".join(lines) if lines else "(뉴스 없음)"
|
||||
|
||||
|
||||
async def summarize_news(articles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""뉴스 리스트를 Ollama로 요약.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"summary": str,
|
||||
"tokens": {"prompt": int, "completion": int, "total": int},
|
||||
"model": str,
|
||||
"duration_ms": int,
|
||||
}
|
||||
Raises:
|
||||
OllamaError: Ollama 호출 실패 시.
|
||||
"""
|
||||
prompt = _PROMPT_TEMPLATE.format(news_block=_build_news_block(articles))
|
||||
|
||||
url = f"{OLLAMA_URL.rstrip('/')}/api/generate"
|
||||
payload = {
|
||||
"model": OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Ollama 연결 실패 ({url}): {e}")
|
||||
raise OllamaError(f"Ollama 연결 실패: {e}") from e
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Ollama 응답 오류 {resp.status_code}: {resp.text[:200]}")
|
||||
raise OllamaError(f"Ollama HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError as e:
|
||||
raise OllamaError(f"Ollama 응답 JSON 파싱 실패: {e}") from e
|
||||
|
||||
summary = (data.get("response") or "").strip()
|
||||
prompt_tokens = int(data.get("prompt_eval_count") or 0)
|
||||
completion_tokens = int(data.get("eval_count") or 0)
|
||||
# total_duration은 나노초 단위
|
||||
total_duration_ns = int(data.get("total_duration") or 0)
|
||||
if total_duration_ns > 0:
|
||||
duration_ms = total_duration_ns // 1_000_000
|
||||
else:
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"tokens": {
|
||||
"prompt": prompt_tokens,
|
||||
"completion": completion_tokens,
|
||||
"total": prompt_tokens + completion_tokens,
|
||||
},
|
||||
"model": data.get("model") or OLLAMA_MODEL,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
@@ -23,6 +23,7 @@ from .db import (
|
||||
)
|
||||
from .scraper import fetch_market_news, fetch_major_indices
|
||||
from .price_fetcher import get_current_prices
|
||||
from .ai_summarizer import summarize_news, OllamaError
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -144,6 +145,33 @@ def trigger_scrap():
|
||||
run_scraping_job()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class NewsSummarizeRequest(BaseModel):
|
||||
limit: Optional[int] = 10
|
||||
|
||||
|
||||
@app.post("/api/stock/news/summarize")
|
||||
async def summarize_latest_news(req: NewsSummarizeRequest = NewsSummarizeRequest()):
|
||||
"""최근 뉴스를 Ollama(qwen3:14b)로 요약"""
|
||||
limit = req.limit if (req and req.limit) else 10
|
||||
articles = get_latest_articles(limit)
|
||||
if not articles:
|
||||
raise HTTPException(status_code=404, detail="요약할 뉴스가 없습니다.")
|
||||
|
||||
try:
|
||||
result = await summarize_news(articles)
|
||||
except OllamaError as e:
|
||||
logger.error(f"뉴스 요약 실패: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ollama 호출 실패: {e}")
|
||||
except Exception as e:
|
||||
logger.exception("뉴스 요약 중 예상치 못한 오류")
|
||||
raise HTTPException(status_code=500, detail=f"뉴스 요약 실패: {e}")
|
||||
|
||||
return {
|
||||
**result,
|
||||
"article_count": len(articles),
|
||||
}
|
||||
|
||||
# --- Trading API (Windows Proxy, 인증 필요) ---
|
||||
|
||||
@app.get("/api/trade/balance", dependencies=[Depends(verify_admin)])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# 주식 서비스용 라이브러리
|
||||
requests==2.32.3
|
||||
httpx==0.27.2
|
||||
beautifulsoup4==4.12.3
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.30.6
|
||||
|
||||
Reference in New Issue
Block a user