"""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 = """당신은 한국 주식 시장 애널리스트입니다. 아래 뉴스 목록을 읽고 투자자 관점에서 한국어로 간결하게 요약하세요. 반드시 아래 형식을 그대로 지켜서 출력하세요. 다른 설명이나 서두, `` 같은 태그는 절대 출력하지 마세요. 📌 시장 흐름 (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, }