You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
123 lines
4.9 KiB
Python
123 lines
4.9 KiB
Python
# backend/app/llm/ollama.py
|
|
import json
|
|
import re
|
|
|
|
import httpx
|
|
|
|
from ..runtime_config import effective_llm
|
|
from .prompts import CLASSIFY_SYSTEM, build_classify_prompt
|
|
from .provider import Classification, LLMProvider
|
|
|
|
|
|
def _lenient_json(content: str) -> dict:
|
|
"""모델 출력에서 JSON 객체 추출 — ```json 펜스·<think> 추론블록·잡설 제거 후 파싱."""
|
|
s = content or ""
|
|
s = re.sub(r"<think>.*?</think>", "", s, flags=re.DOTALL) # 추론형 모델 사고블록
|
|
i, j = s.find("{"), s.rfind("}")
|
|
if i != -1 and j != -1 and j > i:
|
|
s = s[i : j + 1] # 첫 { ~ 마지막 } (펜스·머리말 제거)
|
|
return json.loads(s)
|
|
|
|
|
|
class OllamaProvider(LLMProvider):
|
|
name = "ollama"
|
|
|
|
def __init__(
|
|
self,
|
|
host: str | None = None,
|
|
model: str | None = None,
|
|
timeout: float | None = None,
|
|
):
|
|
# 유효 설정(런타임 오버레이 > .env). 인자가 주어지면 그 값으로 덮어쓴다(연결 테스트용).
|
|
eff = effective_llm()
|
|
# 끝 슬래시 제거 — 원격 엔드포인트(예: https://host/)를 그대로 받아도 `{host}/api/..` 가
|
|
# `//api/..` 로 깨지지 않게 정규화.
|
|
self.host = (host or eff.host).rstrip("/")
|
|
self.model = model or eff.model # 비종속: 주입값 그대로 사용
|
|
self.timeout = timeout if timeout is not None else eff.timeout
|
|
|
|
def list_models(self) -> list[str]:
|
|
try:
|
|
r = httpx.get(f"{self.host}/api/tags", timeout=3.0)
|
|
if r.status_code != 200:
|
|
return []
|
|
return [m.get("name", "") for m in r.json().get("models", []) if m.get("name")]
|
|
except Exception:
|
|
return []
|
|
|
|
def health(self) -> dict:
|
|
try:
|
|
r = httpx.get(f"{self.host}/api/tags", timeout=3.0)
|
|
ok = r.status_code == 200
|
|
tags = [m.get("name") for m in r.json().get("models", [])] if ok else []
|
|
return {
|
|
"reachable": ok,
|
|
"provider": "ollama",
|
|
"model": self.model,
|
|
"host": self.host,
|
|
"detail": f"models={tags}",
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"reachable": False,
|
|
"provider": "ollama",
|
|
"model": self.model,
|
|
"host": self.host,
|
|
"detail": str(e),
|
|
}
|
|
|
|
def generate_json(self, prompt: str, schema: dict | None = None) -> dict:
|
|
# Ollama 구조화 출력: format="json" (모델 비종속). chat API 사용.
|
|
# think=False: 추론형 모델의 사고 단계를 끈다(분류는 즉답이 더 적합·빠름).
|
|
# num_predict: 충분한 토큰(미지정 시 잘려 빈 응답 → 파싱 실패).
|
|
payload = {
|
|
"model": self.model,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"stream": False,
|
|
"format": "json",
|
|
"think": False,
|
|
"options": {"temperature": 0.2, "num_predict": 2048},
|
|
}
|
|
r = httpx.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
|
|
r.raise_for_status()
|
|
content = r.json()["message"]["content"]
|
|
return _lenient_json(content)
|
|
|
|
def classify_capture(self, raw: str, context: dict) -> Classification:
|
|
# 실 LLM 전용: 미가용/파싱 실패는 예외로 전파(휴리스틱 폴백 없음, phase-16+).
|
|
prompt = build_classify_prompt(raw, context)
|
|
payload = {
|
|
"model": self.model,
|
|
"messages": [
|
|
{"role": "system", "content": CLASSIFY_SYSTEM},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
"stream": False,
|
|
"format": "json",
|
|
"think": False, # 추론 단계 off → 분류 즉답(수초).
|
|
"options": {"temperature": 0.2, "num_predict": 2048},
|
|
}
|
|
r = httpx.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
|
|
r.raise_for_status()
|
|
data = _lenient_json(r.json()["message"]["content"])
|
|
return _coerce(data, raw, context, model=f"ollama:{self.model}")
|
|
|
|
|
|
def _coerce(data: dict, raw: str, context: dict, model: str) -> Classification:
|
|
"""LLM JSON을 Classification으로. 누락 필드는 명시적 기본값으로 보강(휴리스틱 의존 없음)."""
|
|
t = data.get("type") if data.get("type") in ("task", "event", "idea") else "idea"
|
|
sp = data.get("sphere") if data.get("sphere") in ("work", "life") else "work"
|
|
return Classification(
|
|
type=t,
|
|
sphere=sp,
|
|
project_id=data.get("project_id"),
|
|
proj_label=data.get("proj_label", ""),
|
|
tone=data.get("tone") or "ink",
|
|
due_text=data.get("due_text", ""),
|
|
when_text=data.get("when_text", ""),
|
|
extra=data.get("extra", ""),
|
|
reason=data.get("reason", ""),
|
|
confidence=float(data.get("confidence", 0.85)),
|
|
model=model,
|
|
)
|