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:
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