feat(stock-lab): LLM provider 전환 구조 + Claude Haiku 4.5 기본 전환
PC 메모리 부하 해소를 위해 뉴스 요약 기본 provider를 Ollama qwen3:14b → Claude Haiku 4.5로 변경. LLM_PROVIDER 환경변수로 언제든 ollama 롤백 가능. - ai_summarizer.py: provider 분리 (_summarize_with_claude / _summarize_with_ollama) - OllamaError는 LLMError alias로 유지 (main.py 수정 불필요) - Anthropic Messages API 직접 호출 (httpx, 의존성 추가 없음) - docker-compose + .env.example: LLM_PROVIDER, ANTHROPIC_MODEL 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,10 +54,14 @@ WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
|||||||
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
|
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
|
||||||
ADMIN_API_KEY=
|
ADMIN_API_KEY=
|
||||||
|
|
||||||
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
|
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
|
|
||||||
# Ollama 서버 (Windows AI PC의 Ollama 엔드포인트) — 뉴스 요약용
|
# 뉴스 요약 provider 전환: claude (기본) | ollama
|
||||||
|
LLM_PROVIDER=claude
|
||||||
|
|
||||||
|
# Ollama 서버 (LLM_PROVIDER=ollama 일 때만 사용)
|
||||||
OLLAMA_URL=http://192.168.45.59:11435
|
OLLAMA_URL=http://192.168.45.59:11435
|
||||||
OLLAMA_MODEL=qwen3:14b
|
OLLAMA_MODEL=qwen3:14b
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ services:
|
|||||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
|
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
|
||||||
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
|
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- LLM_PROVIDER=${LLM_PROVIDER:-claude}
|
||||||
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
|
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
|
||||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Ollama 기반 뉴스 요약 모듈.
|
"""LLM 기반 뉴스 요약 모듈.
|
||||||
|
|
||||||
Windows AI 서버(192.168.45.59:11435)의 Ollama에 연결하여
|
LLM_PROVIDER 환경변수로 provider 전환:
|
||||||
한국어 시장 뉴스를 요약한다. 기존 WINDOWS_AI_SERVER_URL(KIS 래퍼)과는
|
- claude (기본): Anthropic Messages API (claude-haiku-4-5)
|
||||||
별개 경로이며, 본 모듈은 Ollama HTTP API(`/api/generate`)만 호출한다.
|
- ollama: Windows AI 서버의 Ollama (qwen3:14b 등)
|
||||||
|
|
||||||
|
`summarize_news(articles)` 시그니처는 provider와 무관하게 동일하며,
|
||||||
|
실패 시 `LLMError`(구 `OllamaError` alias)를 raise 한다.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -13,9 +16,18 @@ import httpx
|
|||||||
|
|
||||||
logger = logging.getLogger("stock-lab.ai_summarizer")
|
logger = logging.getLogger("stock-lab.ai_summarizer")
|
||||||
|
|
||||||
|
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "claude").lower().strip()
|
||||||
|
|
||||||
|
# Ollama
|
||||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.45.59:11435")
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.45.59:11435")
|
||||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:14b")
|
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:14b")
|
||||||
|
|
||||||
|
# Anthropic (Claude)
|
||||||
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001")
|
||||||
|
ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
|
||||||
|
ANTHROPIC_VERSION = "2023-06-01"
|
||||||
|
|
||||||
_PROMPT_TEMPLATE = """당신은 한국 주식 시장 애널리스트입니다. 아래 뉴스 목록을 읽고 투자자 관점에서 한국어로 간결하게 요약하세요.
|
_PROMPT_TEMPLATE = """당신은 한국 주식 시장 애널리스트입니다. 아래 뉴스 목록을 읽고 투자자 관점에서 한국어로 간결하게 요약하세요.
|
||||||
|
|
||||||
반드시 아래 형식을 그대로 지켜서 출력하세요. 다른 설명이나 서두, `<think>` 같은 태그는 절대 출력하지 마세요.
|
반드시 아래 형식을 그대로 지켜서 출력하세요. 다른 설명이나 서두, `<think>` 같은 태그는 절대 출력하지 마세요.
|
||||||
@@ -36,8 +48,12 @@ _PROMPT_TEMPLATE = """당신은 한국 주식 시장 애널리스트입니다.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class OllamaError(RuntimeError):
|
class LLMError(RuntimeError):
|
||||||
"""Ollama 서버 호출 실패."""
|
"""LLM provider 호출 실패."""
|
||||||
|
|
||||||
|
|
||||||
|
# 하위 호환 alias (main.py 등 기존 import 유지)
|
||||||
|
OllamaError = LLMError
|
||||||
|
|
||||||
|
|
||||||
def _build_news_block(articles: List[Dict[str, Any]]) -> str:
|
def _build_news_block(articles: List[Dict[str, Any]]) -> str:
|
||||||
@@ -52,52 +68,32 @@ def _build_news_block(articles: List[Dict[str, Any]]) -> str:
|
|||||||
return "\n".join(lines) if lines else "(뉴스 없음)"
|
return "\n".join(lines) if lines else "(뉴스 없음)"
|
||||||
|
|
||||||
|
|
||||||
async def summarize_news(articles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
async def _summarize_with_ollama(prompt: str) -> 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"
|
url = f"{OLLAMA_URL.rstrip('/')}/api/generate"
|
||||||
payload = {
|
payload = {"model": OLLAMA_MODEL, "prompt": prompt, "stream": False}
|
||||||
"model": OLLAMA_MODEL,
|
|
||||||
"prompt": prompt,
|
|
||||||
"stream": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
# qwen3:14b 첫 모델 로드 + 장문 추론은 60s로는 부족 → 180s로 확장
|
|
||||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||||
resp = await client.post(url, json=payload)
|
resp = await client.post(url, json=payload)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
err_type = type(e).__name__
|
err_type = type(e).__name__
|
||||||
err_msg = str(e) or "(no message)"
|
err_msg = str(e) or "(no message)"
|
||||||
logger.error(f"Ollama 연결 실패 ({url}): [{err_type}] {err_msg}")
|
logger.error(f"Ollama 연결 실패 ({url}): [{err_type}] {err_msg}")
|
||||||
raise OllamaError(f"Ollama 연결 실패: [{err_type}] {err_msg}") from e
|
raise LLMError(f"Ollama 연결 실패: [{err_type}] {err_msg}") from e
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.error(f"Ollama 응답 오류 {resp.status_code}: {resp.text[:200]}")
|
logger.error(f"Ollama 응답 오류 {resp.status_code}: {resp.text[:200]}")
|
||||||
raise OllamaError(f"Ollama HTTP {resp.status_code}: {resp.text[:200]}")
|
raise LLMError(f"Ollama HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise OllamaError(f"Ollama 응답 JSON 파싱 실패: {e}") from e
|
raise LLMError(f"Ollama 응답 JSON 파싱 실패: {e}") from e
|
||||||
|
|
||||||
summary = (data.get("response") or "").strip()
|
summary = (data.get("response") or "").strip()
|
||||||
prompt_tokens = int(data.get("prompt_eval_count") or 0)
|
prompt_tokens = int(data.get("prompt_eval_count") or 0)
|
||||||
completion_tokens = int(data.get("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)
|
total_duration_ns = int(data.get("total_duration") or 0)
|
||||||
if total_duration_ns > 0:
|
if total_duration_ns > 0:
|
||||||
duration_ms = total_duration_ns // 1_000_000
|
duration_ms = total_duration_ns // 1_000_000
|
||||||
@@ -114,3 +110,75 @@ async def summarize_news(articles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|||||||
"model": data.get("model") or OLLAMA_MODEL,
|
"model": data.get("model") or OLLAMA_MODEL,
|
||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _summarize_with_claude(prompt: str) -> Dict[str, Any]:
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise LLMError("ANTHROPIC_API_KEY 미설정 — Claude provider 사용 불가")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": ANTHROPIC_VERSION,
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": ANTHROPIC_MODEL,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
}
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.post(ANTHROPIC_URL, headers=headers, json=payload)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
err_type = type(e).__name__
|
||||||
|
err_msg = str(e) or "(no message)"
|
||||||
|
logger.error(f"Anthropic 연결 실패: [{err_type}] {err_msg}")
|
||||||
|
raise LLMError(f"Anthropic 연결 실패: [{err_type}] {err_msg}") from e
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Anthropic 응답 오류 {resp.status_code}: {resp.text[:300]}")
|
||||||
|
raise LLMError(f"Anthropic HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise LLMError(f"Anthropic 응답 JSON 파싱 실패: {e}") from e
|
||||||
|
|
||||||
|
# content: [{"type": "text", "text": "..."}]
|
||||||
|
blocks = data.get("content") or []
|
||||||
|
summary = "".join(b.get("text", "") for b in blocks if b.get("type") == "text").strip()
|
||||||
|
|
||||||
|
usage = data.get("usage") or {}
|
||||||
|
prompt_tokens = int(usage.get("input_tokens") or 0)
|
||||||
|
completion_tokens = int(usage.get("output_tokens") or 0)
|
||||||
|
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 ANTHROPIC_MODEL,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def summarize_news(articles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""뉴스 리스트를 LLM으로 요약. provider는 LLM_PROVIDER 환경변수로 선택.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"summary": str, "tokens": {...}, "model": str, "duration_ms": int}
|
||||||
|
Raises:
|
||||||
|
LLMError: provider 호출 실패 시.
|
||||||
|
"""
|
||||||
|
prompt = _PROMPT_TEMPLATE.format(news_block=_build_news_block(articles))
|
||||||
|
|
||||||
|
if LLM_PROVIDER == "ollama":
|
||||||
|
return await _summarize_with_ollama(prompt)
|
||||||
|
if LLM_PROVIDER == "claude":
|
||||||
|
return await _summarize_with_claude(prompt)
|
||||||
|
raise LLMError(f"지원하지 않는 LLM_PROVIDER: {LLM_PROVIDER}")
|
||||||
|
|||||||
Reference in New Issue
Block a user