feat: Ollama qwen3:14b 기반 AI 뉴스 요약 + 텔레그램 통합 허브

- stock-lab: POST /api/stock/news/summarize 추가 (Ollama /api/generate 호출, 토큰/duration 추적)
- agent-office: telegram 패키지 분해 (client/formatter/messaging/webhook/router/agent_registry)
- send_agent_message 통합 API로 에이전트 중립 메시지 포맷 표준화
- 텔레그램 → 에이전트 명령 라우터 (/status, /stock news, /music credits 등)
- 토큰 사용량 집계 API 및 GET /agents/{id}/token-usage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 01:44:27 +09:00
parent de91f424a3
commit 86e7f727eb
17 changed files with 696 additions and 116 deletions

View File

@@ -14,22 +14,35 @@ 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)
# AI 요약 호출 (뉴스 수집 + LLM 처리는 stock-lab이 담당)
result = await service_proxy.summarize_stock_news(limit=15)
await self.transition("reporting", "뉴스 요약 전송 중...")
from ..telegram_bot import send_stock_summary
tg_result = 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": summary,
"news_count": len(news) if isinstance(news, list) else 0,
"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"),
})
@@ -50,8 +63,13 @@ class StockAgent(BaseAgent):
async def on_command(self, command: str, params: dict) -> dict:
if command == "test_telegram":
from ..telegram_bot import send_message
result = await send_message("🔔 [주식 에이전트] 텔레그램 테스트 메시지입니다.")
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 "텔레그램 전송 실패",
@@ -88,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)

View File

@@ -261,6 +261,59 @@ def mark_telegram_responded(callback_id: str, action: str) -> None:
)
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("""

View File

@@ -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"])
@@ -151,6 +167,11 @@ async def telegram_webhook(data: dict):
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)

View File

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

View 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",
]

View 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,
}

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

View 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)

View 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"},
],
)

View 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 &lt;프롬프트&gt; — 작곡 시작
"""

View 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})

View File

@@ -1,86 +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
result = await _api("sendMessage", payload)
return {
"ok": result.get("ok", False),
"message_id": result.get("result", {}).get("message_id") if result.get("ok") else None,
}
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",
]