|
| 1 | +"""LLM-based NodeKind classifier — 풍부한 메타데이터 자동 생성. |
| 2 | +
|
| 3 | +LLM이 나중에 꺼내 쓸 지식을, LLM이 잘 찾을 수 있는 구조로 적재한다. |
| 4 | +적재 시점에 "이 지식을 나중에 언제 찾게 될지"까지 예측하여 메타데이터 생성. |
| 5 | +
|
| 6 | +classify()는 동기 프로토콜 호환 — 캐시 히트면 반환, 아니면 fallback. |
| 7 | +classify_async()가 LLM 호출로 ClassificationResult를 생성하며, |
| 8 | +결과는 content 해시 기반 LRU 캐시에 보관된다. |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import hashlib |
| 14 | +import json |
| 15 | +import logging |
| 16 | +import re |
| 17 | +from collections import OrderedDict |
| 18 | +from dataclasses import dataclass |
| 19 | +from typing import TYPE_CHECKING |
| 20 | + |
| 21 | +from synaptic.models import NodeKind |
| 22 | + |
| 23 | +if TYPE_CHECKING: |
| 24 | + from synaptic.extensions.llm_provider import LLMProvider |
| 25 | + from synaptic.protocols import KindClassifier |
| 26 | + |
| 27 | +logger = logging.getLogger(__name__) |
| 28 | + |
| 29 | +# --------------------------------------------------------------------------- |
| 30 | +# 분류 결과 |
| 31 | +# --------------------------------------------------------------------------- |
| 32 | + |
| 33 | +@dataclass(slots=True) |
| 34 | +class ClassificationResult: |
| 35 | + """LLM 분류 결과 — 검색 최적화 메타데이터 포함.""" |
| 36 | + |
| 37 | + kind: NodeKind |
| 38 | + tags: list[str] |
| 39 | + search_keywords: list[str] |
| 40 | + search_scenarios: list[str] |
| 41 | + summary: str |
| 42 | + confidence: float = 0.8 |
| 43 | + |
| 44 | + |
| 45 | +# --------------------------------------------------------------------------- |
| 46 | +# 시스템 프롬프트 |
| 47 | +# --------------------------------------------------------------------------- |
| 48 | + |
| 49 | +_SYSTEM_PROMPT = """\ |
| 50 | +너는 지식 그래프 온톨로지 엔지니어다. 주어진 텍스트를 분석하여 지식 노드의 메타데이터를 생성한다. |
| 51 | +
|
| 52 | +이 지식은 LLM 에이전트가 나중에 검색해서 사용할 것이다. |
| 53 | +"이 지식을 나중에 언제, 어떤 상황에서 찾게 될까?"를 예측하여 메타데이터를 설계하라. |
| 54 | +
|
| 55 | +반드시 아래 JSON 형식으로 응답하라: |
| 56 | +{ |
| 57 | + "kind": "concept|entity|lesson|decision|rule|artifact", |
| 58 | + "confidence": 0.0~1.0, |
| 59 | + "tags": ["태그1", "태그2"], |
| 60 | + "search_keywords": ["이 지식을 찾을 때 사용할 검색어들"], |
| 61 | + "search_scenarios": ["이 지식이 필요한 상황 묘사"], |
| 62 | + "summary": "1줄 요약" |
| 63 | +} |
| 64 | +
|
| 65 | +kind 분류 기준: |
| 66 | +- concept: 일반적인 개념, 정의, 설명 |
| 67 | +- entity: 회사, 제품, 인물, 시스템 등 고유 대상 |
| 68 | +- lesson: 경험에서 배운 교훈, 장애 사례, 실패/성공 패턴 |
| 69 | +- decision: 의사결정과 근거, 대안 비교 |
| 70 | +- rule: 정책, 규정, 가이드라인, 제약 조건 |
| 71 | +- artifact: API, 문서, 코드, 시스템 컴포넌트 |
| 72 | +
|
| 73 | +tags: 도메인/기술 키워드 3~7개, 한글+영어 포함 |
| 74 | +search_keywords: 이 지식을 찾을 에이전트가 입력할 검색 쿼리 3~5개, "~하려면", "~할 때" 같은 실용적 질문형 포함 |
| 75 | +search_scenarios: 이 지식이 필요한 구체적 상황 1~2개 |
| 76 | +
|
| 77 | +JSON만 출력하라. 설명이나 사고 과정을 쓰지 마라. /no_think""" |
| 78 | + |
| 79 | +# content 최대 길이 (토큰 절약) |
| 80 | +_MAX_CONTENT_LEN = 2000 |
| 81 | + |
| 82 | +# NodeKind로 변환 가능한 값 |
| 83 | +_VALID_KINDS = {k.value for k in NodeKind} |
| 84 | + |
| 85 | + |
| 86 | +# --------------------------------------------------------------------------- |
| 87 | +# LLM 캐시 (content 해시 기반 LRU) |
| 88 | +# --------------------------------------------------------------------------- |
| 89 | + |
| 90 | +class _LRUCache: |
| 91 | + """Thread-unsafe LRU cache backed by OrderedDict.""" |
| 92 | + |
| 93 | + __slots__ = ("_maxsize", "_data") |
| 94 | + |
| 95 | + def __init__(self, maxsize: int = 512) -> None: |
| 96 | + self._maxsize = maxsize |
| 97 | + self._data: OrderedDict[str, ClassificationResult] = OrderedDict() |
| 98 | + |
| 99 | + def get(self, key: str) -> ClassificationResult | None: |
| 100 | + if key in self._data: |
| 101 | + self._data.move_to_end(key) |
| 102 | + return self._data[key] |
| 103 | + return None |
| 104 | + |
| 105 | + def put(self, key: str, value: ClassificationResult) -> None: |
| 106 | + if key in self._data: |
| 107 | + self._data.move_to_end(key) |
| 108 | + self._data[key] = value |
| 109 | + if len(self._data) > self._maxsize: |
| 110 | + self._data.popitem(last=False) |
| 111 | + |
| 112 | + |
| 113 | +# --------------------------------------------------------------------------- |
| 114 | +# LLMClassifier |
| 115 | +# --------------------------------------------------------------------------- |
| 116 | + |
| 117 | +class LLMClassifier: |
| 118 | + """LLM 기반 NodeKind 분류기 — 검색 최적화 메타데이터 자동 생성. |
| 119 | +
|
| 120 | + Parameters |
| 121 | + ---------- |
| 122 | + llm: |
| 123 | + LLMProvider 프로토콜 구현체 (OllamaLLMProvider, OpenAILLMProvider 등). |
| 124 | + fallback: |
| 125 | + LLM 실패 시 사용할 KindClassifier. 기본값 None이면 CONCEPT 반환. |
| 126 | + cache_maxsize: |
| 127 | + content 해시 기반 LRU 캐시 크기. |
| 128 | + """ |
| 129 | + |
| 130 | + __slots__ = ("_llm", "_fallback", "_cache") |
| 131 | + |
| 132 | + def __init__( |
| 133 | + self, |
| 134 | + llm: LLMProvider, |
| 135 | + *, |
| 136 | + fallback: KindClassifier | None = None, |
| 137 | + cache_maxsize: int = 512, |
| 138 | + ) -> None: |
| 139 | + self._llm = llm |
| 140 | + self._fallback = fallback |
| 141 | + self._cache = _LRUCache(maxsize=cache_maxsize) |
| 142 | + |
| 143 | + # -- 동기 프로토콜 호환 (KindClassifier) -- |
| 144 | + |
| 145 | + def classify(self, title: str, content: str) -> NodeKind: |
| 146 | + """동기 분류 — 캐시 히트면 반환, 아니면 fallback. |
| 147 | +
|
| 148 | + asyncio.run()은 사용하지 않는다. 비동기 결과가 필요하면 |
| 149 | + classify_async()를 사용하고, 이후 get_cached_result()로 조회. |
| 150 | + """ |
| 151 | + cached = self.get_cached_result(title, content) |
| 152 | + if cached is not None: |
| 153 | + return cached.kind |
| 154 | + |
| 155 | + if self._fallback is not None: |
| 156 | + return self._fallback.classify(title, content) |
| 157 | + |
| 158 | + return NodeKind.CONCEPT |
| 159 | + |
| 160 | + # -- 비동기 LLM 분류 -- |
| 161 | + |
| 162 | + async def classify_async(self, title: str, content: str) -> ClassificationResult: |
| 163 | + """LLM 호출로 풍부한 분류 메타데이터 생성. |
| 164 | +
|
| 165 | + 결과는 캐시에 저장되며, 이후 classify()나 get_cached_result()로 |
| 166 | + 동기적으로 조회할 수 있다. |
| 167 | + """ |
| 168 | + cache_key = self._make_cache_key(title, content) |
| 169 | + |
| 170 | + cached = self._cache.get(cache_key) |
| 171 | + if cached is not None: |
| 172 | + return cached |
| 173 | + |
| 174 | + try: |
| 175 | + result = await self._call_llm(title, content) |
| 176 | + except Exception: |
| 177 | + logger.exception("LLM classification failed, using fallback") |
| 178 | + result = self._make_fallback_result(title, content) |
| 179 | + |
| 180 | + self._cache.put(cache_key, result) |
| 181 | + return result |
| 182 | + |
| 183 | + # -- 캐시 조회 -- |
| 184 | + |
| 185 | + def get_cached_result(self, title: str, content: str) -> ClassificationResult | None: |
| 186 | + """캐시에서 분류 결과 조회. graph.py 등에서 classify_async 이후 사용.""" |
| 187 | + cache_key = self._make_cache_key(title, content) |
| 188 | + return self._cache.get(cache_key) |
| 189 | + |
| 190 | + # -- 내부 메서드 -- |
| 191 | + |
| 192 | + async def _call_llm(self, title: str, content: str) -> ClassificationResult: |
| 193 | + """LLM에 분류 요청 후 응답 파싱.""" |
| 194 | + truncated = content[:_MAX_CONTENT_LEN] |
| 195 | + user_msg = f"제목: {title}\n내용: {truncated}" |
| 196 | + |
| 197 | + raw = await self._llm.generate( |
| 198 | + system=_SYSTEM_PROMPT, |
| 199 | + user=user_msg, |
| 200 | + max_tokens=512, |
| 201 | + ) |
| 202 | + |
| 203 | + return self._parse_response(raw) |
| 204 | + |
| 205 | + def _parse_response(self, raw: str) -> ClassificationResult: |
| 206 | + """LLM 응답 JSON 파싱. 실패 시 정규식 추출 시도.""" |
| 207 | + data = self._extract_json(raw) |
| 208 | + |
| 209 | + kind_str = data.get("kind", "concept") |
| 210 | + if kind_str not in _VALID_KINDS: |
| 211 | + kind_str = "concept" |
| 212 | + |
| 213 | + return ClassificationResult( |
| 214 | + kind=NodeKind(kind_str), |
| 215 | + tags=self._ensure_str_list(data.get("tags", [])), |
| 216 | + search_keywords=self._ensure_str_list(data.get("search_keywords", [])), |
| 217 | + search_scenarios=self._ensure_str_list(data.get("search_scenarios", [])), |
| 218 | + summary=str(data.get("summary", "")), |
| 219 | + confidence=self._clamp(float(data.get("confidence", 0.8)), 0.0, 1.0), |
| 220 | + ) |
| 221 | + |
| 222 | + @staticmethod |
| 223 | + def _extract_json(raw: str) -> dict[str, object]: |
| 224 | + """JSON 파싱 — 직접 시도 후 코드블록 추출 fallback.""" |
| 225 | + # 1차: 직접 파싱 |
| 226 | + try: |
| 227 | + return json.loads(raw) # type: ignore[return-value] |
| 228 | + except (json.JSONDecodeError, ValueError): |
| 229 | + pass |
| 230 | + |
| 231 | + # 2차: ```json ... ``` 블록 추출 |
| 232 | + match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL) |
| 233 | + if match: |
| 234 | + try: |
| 235 | + return json.loads(match.group(1)) # type: ignore[return-value] |
| 236 | + except (json.JSONDecodeError, ValueError): |
| 237 | + pass |
| 238 | + |
| 239 | + # 3차: 첫 번째 { ... } 블록 추출 |
| 240 | + match = re.search(r"\{[^{}]*\}", raw, re.DOTALL) |
| 241 | + if match: |
| 242 | + try: |
| 243 | + return json.loads(match.group(0)) # type: ignore[return-value] |
| 244 | + except (json.JSONDecodeError, ValueError): |
| 245 | + pass |
| 246 | + |
| 247 | + logger.warning("Failed to parse LLM response as JSON: %s", raw[:200]) |
| 248 | + return {} |
| 249 | + |
| 250 | + def _make_fallback_result(self, title: str, content: str) -> ClassificationResult: |
| 251 | + """LLM 실패 시 fallback 기반 결과 생성.""" |
| 252 | + if self._fallback is not None: |
| 253 | + kind = self._fallback.classify(title, content) |
| 254 | + else: |
| 255 | + kind = NodeKind.CONCEPT |
| 256 | + |
| 257 | + return ClassificationResult( |
| 258 | + kind=kind, |
| 259 | + tags=[], |
| 260 | + search_keywords=[], |
| 261 | + search_scenarios=[], |
| 262 | + summary=title, |
| 263 | + confidence=0.3, |
| 264 | + ) |
| 265 | + |
| 266 | + @staticmethod |
| 267 | + def _make_cache_key(title: str, content: str) -> str: |
| 268 | + """title + content 해시로 캐시 키 생성.""" |
| 269 | + h = hashlib.sha256() |
| 270 | + h.update(title.encode()) |
| 271 | + h.update(content[:_MAX_CONTENT_LEN].encode()) |
| 272 | + return h.hexdigest()[:24] |
| 273 | + |
| 274 | + @staticmethod |
| 275 | + def _ensure_str_list(val: object) -> list[str]: |
| 276 | + """값이 list[str]인지 확인하고 변환.""" |
| 277 | + if isinstance(val, list): |
| 278 | + return [str(v) for v in val] |
| 279 | + return [] |
| 280 | + |
| 281 | + @staticmethod |
| 282 | + def _clamp(value: float, lo: float, hi: float) -> float: |
| 283 | + return max(lo, min(hi, value)) |
0 commit comments