# 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 펜스· 추론블록·잡설 제거 후 파싱.""" s = content or "" s = re.sub(r".*?", "", 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, )