372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""
|
|
GUARDiA ITSM — 로컬 LLM 클라이언트 (Ollama 래퍼)
|
|
|
|
온프레미스 보안 정책: 외부 AI/LLM API 완전 금지.
|
|
모든 추론은 localhost:11434 (Ollama) 에서 처리한다.
|
|
|
|
사용 모델:
|
|
- guardia-agent : GUARDiA 전용 파인튜닝 (권장)
|
|
- llama3.1:8b : 일반 에이전트 (fallback)
|
|
- codellama:7b : 코드 생성 에이전트
|
|
|
|
외부 호출 방지 확인:
|
|
get_llm_client() 는 항상 OllamaClient 반환.
|
|
Claude/OpenAI 등 외부 Provider는 개발·테스트 환경에서만 --dev-mode 플래그로 활성화.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
|
|
|
OLLAMA_BASE_URL: str = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
|
DEFAULT_MODEL: str = os.getenv("GUARDIA_LLM_MODEL", "guardia-agent")
|
|
FALLBACK_MODEL: str = "llama3.1:8b"
|
|
REQUEST_TIMEOUT: float = float(os.getenv("LLM_TIMEOUT_SEC", "120"))
|
|
|
|
# 개발 환경에서만 외부 API 활성화 (온프레미스 배포 시 반드시 False)
|
|
_DEV_MODE: bool = os.getenv("GUARDIA_DEV_MODE", "false").lower() == "true"
|
|
|
|
|
|
# ── 데이터 클래스 ────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class LLMResponse:
|
|
content: str
|
|
model: str
|
|
tokens_prompt: int = 0
|
|
tokens_completion: int = 0
|
|
|
|
@property
|
|
def tokens_total(self) -> int:
|
|
return self.tokens_prompt + self.tokens_completion
|
|
|
|
|
|
@dataclass
|
|
class ModelInfo:
|
|
name: str
|
|
size: int = 0
|
|
modified_at: str = ""
|
|
|
|
|
|
# ── Ollama 클라이언트 ────────────────────────────────────────────────────────
|
|
|
|
class OllamaClient:
|
|
"""
|
|
Ollama 로컬 LLM 클라이언트.
|
|
외부 API 호출 없음 — 모든 추론은 localhost:11434.
|
|
"""
|
|
|
|
def __init__(self, base_url: str = OLLAMA_BASE_URL) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
|
|
# ── 헬스체크 ─────────────────────────────────────────────────────────────
|
|
|
|
async def health_check(self) -> bool:
|
|
"""Ollama 서버 응답 여부 확인."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
r = await client.get(f"{self.base_url}/api/tags")
|
|
return r.status_code == 200
|
|
except Exception:
|
|
return False
|
|
|
|
async def list_models(self) -> list[ModelInfo]:
|
|
"""설치된 모델 목록 반환."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
r = await client.get(f"{self.base_url}/api/tags")
|
|
r.raise_for_status()
|
|
return [
|
|
ModelInfo(
|
|
name=m.get("name", ""),
|
|
size=m.get("size", 0),
|
|
modified_at=m.get("modified_at", ""),
|
|
)
|
|
for m in r.json().get("models", [])
|
|
]
|
|
except Exception as exc:
|
|
logger.error("Ollama 모델 목록 조회 실패: %s", exc)
|
|
return []
|
|
|
|
async def resolve_model(self, preferred: str) -> str:
|
|
"""
|
|
요청 모델이 설치 여부 확인 → 없으면 fallback 모델 반환.
|
|
"""
|
|
available = [m.name for m in await self.list_models()]
|
|
if not available:
|
|
logger.warning("Ollama 모델 없음 — fallback: %s", FALLBACK_MODEL)
|
|
return FALLBACK_MODEL
|
|
if preferred in available:
|
|
return preferred
|
|
# 접두사 매칭 (e.g. "llama3.1" → "llama3.1:8b")
|
|
base = preferred.split(":")[0]
|
|
matched = next((m for m in available if m.startswith(base)), None)
|
|
if matched:
|
|
logger.info("모델 '%s' → '%s' 으로 대체", preferred, matched)
|
|
return matched
|
|
logger.warning("모델 '%s' 미설치 — fallback: %s", preferred, FALLBACK_MODEL)
|
|
return FALLBACK_MODEL
|
|
|
|
# ── 추론 메서드 ───────────────────────────────────────────────────────────
|
|
|
|
async def chat(
|
|
self,
|
|
messages: list[dict],
|
|
model: str = DEFAULT_MODEL,
|
|
temperature: float = 0.2,
|
|
timeout: float = REQUEST_TIMEOUT,
|
|
) -> LLMResponse:
|
|
"""
|
|
채팅 완성 (messages 리스트 형식).
|
|
messages: [{"role": "system"|"user"|"assistant", "content": "..."}]
|
|
"""
|
|
resolved = await self.resolve_model(model)
|
|
payload = {
|
|
"model": resolved,
|
|
"messages": messages,
|
|
"stream": False,
|
|
"options": {
|
|
"temperature": temperature,
|
|
"num_predict": 2048,
|
|
},
|
|
}
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
resp = await client.post(
|
|
f"{self.base_url}/api/chat",
|
|
json=payload,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
content = data.get("message", {}).get("content", "")
|
|
return LLMResponse(
|
|
content=content,
|
|
model=resolved,
|
|
tokens_prompt=data.get("prompt_eval_count", 0),
|
|
tokens_completion=data.get("eval_count", 0),
|
|
)
|
|
|
|
async def generate(
|
|
self,
|
|
prompt: str,
|
|
model: str = DEFAULT_MODEL,
|
|
system: Optional[str] = None,
|
|
temperature: float = 0.2,
|
|
timeout: float = REQUEST_TIMEOUT,
|
|
) -> LLMResponse:
|
|
"""단일 프롬프트 → 응답."""
|
|
messages: list[dict] = []
|
|
if system:
|
|
messages.append({"role": "system", "content": system})
|
|
messages.append({"role": "user", "content": prompt})
|
|
return await self.chat(messages=messages, model=model, temperature=temperature, timeout=timeout)
|
|
|
|
async def json_generate(
|
|
self,
|
|
prompt: str,
|
|
model: str = DEFAULT_MODEL,
|
|
system: Optional[str] = None,
|
|
timeout: float = REQUEST_TIMEOUT,
|
|
) -> dict:
|
|
"""
|
|
JSON 응답 전용 메서드.
|
|
모델 응답에서 JSON 블록을 추출하여 dict로 반환.
|
|
실패 시 빈 dict 반환 (예외 미발생).
|
|
"""
|
|
full_system = (system or "") + (
|
|
"\n\n반드시 순수 JSON만 응답하세요. 코드블록(```)이나 설명 없이 JSON 객체만 출력하세요."
|
|
)
|
|
resp = await self.generate(
|
|
prompt=prompt,
|
|
model=model,
|
|
system=full_system,
|
|
temperature=0.1,
|
|
timeout=timeout,
|
|
)
|
|
raw = resp.content.strip()
|
|
|
|
# 코드블록 제거
|
|
if raw.startswith("```"):
|
|
lines = raw.split("\n")
|
|
raw = "\n".join(
|
|
l for l in lines
|
|
if not l.strip().startswith("```")
|
|
).strip()
|
|
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
# 첫 번째 { ... } 블록 추출 시도
|
|
start = raw.find("{")
|
|
end = raw.rfind("}")
|
|
if start != -1 and end != -1:
|
|
try:
|
|
return json.loads(raw[start : end + 1])
|
|
except json.JSONDecodeError:
|
|
pass
|
|
logger.warning("LLM JSON 파싱 실패 (model=%s): %s", model, raw[:200])
|
|
return {}
|
|
|
|
async def pull_model(self, model: str) -> bool:
|
|
"""모델 다운로드 (비동기 스트리밍)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=600.0) as client:
|
|
async with client.stream(
|
|
"POST", f"{self.base_url}/api/pull",
|
|
json={"name": model},
|
|
) as resp:
|
|
async for line in resp.aiter_lines():
|
|
if line:
|
|
data = json.loads(line)
|
|
status = data.get("status", "")
|
|
logger.info("모델 다운로드 [%s]: %s", model, status)
|
|
if status == "success":
|
|
return True
|
|
return False
|
|
except Exception as exc:
|
|
logger.error("모델 다운로드 실패 [%s]: %s", model, exc)
|
|
return False
|
|
|
|
async def fine_tune(self, dataset_path: str, model_name: str) -> bool:
|
|
"""
|
|
Ollama 커스텀 모델 파인튜닝 실행.
|
|
|
|
tb_agent_task(COMPLETED) 데이터를 JSONL로 내보내고
|
|
Modelfile을 생성하여 ollama create 명령으로 새 모델을 생성한다.
|
|
|
|
보안: 외부 API 호출 없음 — Ollama localhost 전용.
|
|
|
|
Args:
|
|
dataset_path: JSONL 파인튜닝 데이터셋 경로
|
|
(예: /opt/guardia/finetune/guardia-agent-v2.jsonl)
|
|
model_name: 생성할 모델 이름 (예: guardia-agent-v2)
|
|
|
|
Returns:
|
|
True: 파인튜닝 성공, False: 실패
|
|
"""
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
|
|
dataset = Path(dataset_path)
|
|
if not dataset.exists():
|
|
logger.error("[fine_tune] 데이터셋 파일 없음: %s", dataset_path)
|
|
return False
|
|
|
|
# Modelfile 생성
|
|
modelfile_path = dataset.parent / f"Modelfile.{model_name}"
|
|
base_model = DEFAULT_MODEL or "llama3.1:8b"
|
|
modelfile_content = (
|
|
f"FROM {base_model}\n"
|
|
f"TRAIN {dataset_path}\n"
|
|
f"PARAMETER temperature 0.1\n"
|
|
f'SYSTEM "당신은 GUARDiA ITSM 전문 AI 에이전트입니다. 보안 규칙을 항상 준수하세요."\n'
|
|
)
|
|
modelfile_path.write_text(modelfile_content, encoding="utf-8")
|
|
|
|
logger.info("[fine_tune] 파인튜닝 시작: model=%s dataset=%s", model_name, dataset_path)
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"ollama", "create", model_name,
|
|
"-f", str(modelfile_path),
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await asyncio.wait_for(
|
|
proc.communicate(),
|
|
timeout=3600, # 1시간
|
|
)
|
|
|
|
if proc.returncode == 0:
|
|
logger.info("[fine_tune] 파인튜닝 완료: model=%s", model_name)
|
|
return True
|
|
else:
|
|
err = stderr.decode("utf-8", errors="replace")[:500]
|
|
logger.error("[fine_tune] 파인튜닝 실패: model=%s err=%s", model_name, err)
|
|
return False
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.error("[fine_tune] 파인튜닝 타임아웃 (1시간 초과): model=%s", model_name)
|
|
if "proc" in dir():
|
|
proc.kill()
|
|
return False
|
|
except Exception as exc:
|
|
logger.error("[fine_tune] 파인튜닝 예외: model=%s err=%s", model_name, exc)
|
|
return False
|
|
|
|
async def export_finetune_dataset(self, output_path: str, limit: int = 1000) -> int:
|
|
"""
|
|
tb_agent_task(COMPLETED) 데이터를 Ollama 파인튜닝용 JSONL로 내보낸다.
|
|
|
|
Args:
|
|
output_path: 저장할 JSONL 파일 경로
|
|
limit: 최대 레코드 수
|
|
|
|
Returns:
|
|
내보낸 레코드 수
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from database import SessionLocal
|
|
from models import AgentTask, AgentTaskStatus
|
|
from sqlalchemy import select
|
|
|
|
async with SessionLocal() as db:
|
|
tasks = (await db.execute(
|
|
select(AgentTask).where(
|
|
AgentTask.status == AgentTaskStatus.COMPLETED,
|
|
AgentTask.tokens_used > 0,
|
|
).limit(limit)
|
|
)).scalars().all()
|
|
|
|
out_path = Path(output_path)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
count = 0
|
|
with out_path.open("w", encoding="utf-8") as f:
|
|
for task in tasks:
|
|
if not task.input_data or not task.output_data:
|
|
continue
|
|
record = {
|
|
"prompt": json.dumps(task.input_data, ensure_ascii=False),
|
|
"response": json.dumps(task.output_data, ensure_ascii=False),
|
|
}
|
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
count += 1
|
|
|
|
logger.info("[export_finetune] %d건 내보내기 완료: %s", count, output_path)
|
|
return count
|
|
|
|
except Exception as exc:
|
|
logger.error("[export_finetune] 내보내기 실패: %s", exc)
|
|
return 0
|
|
|
|
|
|
# ── 싱글턴 ──────────────────────────────────────────────────────────────────
|
|
|
|
_client: Optional[OllamaClient] = None
|
|
|
|
|
|
def get_llm_client() -> OllamaClient:
|
|
"""
|
|
LLM 클라이언트 싱글턴 반환.
|
|
항상 OllamaClient (로컬) 반환 — 외부 API 없음.
|
|
"""
|
|
global _client
|
|
if _client is None:
|
|
_client = OllamaClient(base_url=OLLAMA_BASE_URL)
|
|
logger.info("LLM 클라이언트 초기화: %s (모델: %s)", OLLAMA_BASE_URL, DEFAULT_MODEL)
|
|
return _client
|