Files
web-page-backend/stock-lab/app/ai_summarizer.py

117 lines
3.7 KiB
Python

"""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:
# qwen3:14b 첫 모델 로드 + 장문 추론은 60s로는 부족 → 180s로 확장
async with httpx.AsyncClient(timeout=180.0) as client:
resp = await client.post(url, json=payload)
except httpx.HTTPError as e:
err_type = type(e).__name__
err_msg = str(e) or "(no message)"
logger.error(f"Ollama 연결 실패 ({url}): [{err_type}] {err_msg}")
raise OllamaError(f"Ollama 연결 실패: [{err_type}] {err_msg}") 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,
}