ltcai 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,473 @@
1
+ """Lattice AI Auto Graph Curator.
2
+
3
+ 피드백 #4 (lattice_ai_auto_graph_direction.txt) 반영.
4
+
5
+ 핵심 방향:
6
+ - 사용자는 노드/엣지를 직접 만들지 않는다.
7
+ - 대화/파일/작업 로그 → topic candidate → cluster → promoted node
8
+ → derived thread edge → 자동 레이아웃.
9
+ - 너무 많은 노드를 만들지 않고, 알리아스를 자동 병합.
10
+ - secret/API key/private key 같은 원문은 그래프에 들어가면 안 된다.
11
+
12
+ 이 모듈은 텍스트 단위 토픽 후보 추출, 클러스터링/병합, 노드 승격 판정,
13
+ 파생 이야기 엣지 생성, 큐레이션(중요도 점수)을 담당하는 가벼운 헬퍼다.
14
+ 무거운 의존성 없이 동작하므로 기존 knowledge_graph.py 위에 얹어 쓸 수 있다.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import math
21
+ import re
22
+ import time
23
+ from dataclasses import dataclass, field, asdict
24
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # ── Secret / sensitive patterns to NEVER include in graph ─────────────────────
30
+
31
+ SECRET_PATTERNS: List[re.Pattern] = [
32
+ re.compile(r"(?i)\b(?:api[_-]?key|secret|access[_-]?token|password|passwd|pwd|bearer)\s*[:=]\s*\S+"),
33
+ re.compile(r"sk-[A-Za-z0-9]{20,}"),
34
+ re.compile(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----"),
35
+ re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key
36
+ re.compile(r"ghp_[A-Za-z0-9]{30,}"), # GitHub PAT
37
+ re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack token
38
+ ]
39
+
40
+
41
+ def contains_secret(text: str) -> bool:
42
+ if not text:
43
+ return False
44
+ for pat in SECRET_PATTERNS:
45
+ if pat.search(text):
46
+ return True
47
+ return False
48
+
49
+
50
+ def mask_secrets(text: str) -> str:
51
+ """문자열 안의 secret을 마스킹한다. 그래프 저장 직전에 한 번 더 거쳐야 한다."""
52
+ if not text:
53
+ return text
54
+ out = text
55
+ for pat in SECRET_PATTERNS:
56
+ out = pat.sub("[REDACTED]", out)
57
+ return out
58
+
59
+
60
+ # ── Stopwords (KO + EN) ───────────────────────────────────────────────────────
61
+
62
+ _STOPWORDS: Set[str] = {
63
+ # 한국어
64
+ "그리고", "그러나", "또한", "하지만", "그런데", "그래서", "이것", "저것",
65
+ "이번", "저번", "지금", "어제", "오늘", "내일", "에서", "에게", "에는",
66
+ "되어", "있다", "없다", "있는", "없는", "같은", "처럼", "위해", "통해",
67
+ "에서의", "에서는", "라고", "이라고", "이다", "이며", "이고", "되는",
68
+ # 영어
69
+ "the", "and", "for", "are", "but", "not", "you", "can", "with", "this",
70
+ "that", "from", "into", "have", "has", "your", "any", "all", "one", "out",
71
+ "use", "using", "used", "about", "via", "per", "let", "let's", "we'll",
72
+ "i'll", "as", "be", "is", "it", "an", "or", "to", "of", "in", "on",
73
+ }
74
+
75
+ # item 5: 한국어 그래프 노이즈를 줄이기 위한 일반어 blacklist 강화.
76
+ # 의미를 담지 않는 흔한 단어들. (코드/도메인 고유명사는 제외)
77
+ _GENERIC_BLACKLIST: Set[str] = {
78
+ # 한국어 일반어
79
+ "내용", "관련", "사용", "경우", "부분", "정도", "생각", "방법", "진행",
80
+ "확인", "작업", "설정", "추가", "수정", "정보", "결과", "상태", "기준",
81
+ "그것", "그거", "여기", "거기", "이거", "저거", "무엇", "어떤", "관해",
82
+ "그냥", "정말", "조금", "많이", "다시", "먼저", "현재", "다음", "이전",
83
+ # 영어 일반어
84
+ "thing", "things", "stuff", "etc", "really", "just", "like", "make",
85
+ "made", "want", "need", "good", "work", "works", "very", "more", "most",
86
+ "some", "such", "then", "than", "also", "here", "there", "what", "which",
87
+ "when", "where", "will", "would", "should", "could", "does", "done",
88
+ }
89
+
90
+ # item 5: 파일 확장자 토큰. 파일명에서 떨어져 나온 노이즈라 노드 후보로 부적절.
91
+ _FILE_EXT_TOKENS: Set[str] = {
92
+ "py", "js", "ts", "tsx", "jsx", "json", "md", "txt", "csv", "tsv",
93
+ "png", "jpg", "jpeg", "gif", "svg", "webp", "pdf", "html", "css",
94
+ "yml", "yaml", "toml", "sh", "bash", "zsh", "log", "ipynb", "xml",
95
+ "lock", "cfg", "ini", "env", "bin", "exe", "zip", "tar", "gz",
96
+ }
97
+
98
+ _FILTER_TOKENS: Set[str] = _STOPWORDS | _GENERIC_BLACKLIST | _FILE_EXT_TOKENS
99
+
100
+ # item 5: 한국어 조사. 토큰 끝에서 제거해 "그래프를"/"그래프가"/"그래프" 를 하나로 모은다.
101
+ _JOSA_SUFFIXES: List[str] = sorted(
102
+ [
103
+ "으로는", "에서는", "에서의", "에게서", "이라는", "이라고", "라는", "라고",
104
+ "으로", "에서", "에게", "한테", "까지", "부터", "보다", "처럼", "마다",
105
+ "조차", "밖에", "라도", "이나", "에는", "에도", "께서", "이란",
106
+ "은", "는", "이", "가", "을", "를", "와", "과", "에", "의", "도",
107
+ "만", "로", "나", "께", "란",
108
+ ],
109
+ key=len,
110
+ reverse=True,
111
+ )
112
+
113
+
114
+ def _strip_josa(token: str) -> str:
115
+ """한국어 토큰 끝의 조사를 제거한다. (영문/혼합 토큰은 그대로)"""
116
+ if not re.search(r"[가-힣]", token):
117
+ return token
118
+ for suf in _JOSA_SUFFIXES:
119
+ if token.endswith(suf) and len(token) - len(suf) >= 2:
120
+ return token[: -len(suf)]
121
+ return token
122
+
123
+
124
+ def _tokenize(text: str) -> List[str]:
125
+ if not text:
126
+ return []
127
+ # 한글/영문/숫자만 남김
128
+ cleaned = re.sub(r"[^0-9A-Za-z가-힣\s]", " ", text)
129
+ tokens = [t for t in cleaned.split() if t]
130
+ out = []
131
+ for t in tokens:
132
+ low = _strip_josa(t.lower())
133
+ if len(low) < 2:
134
+ continue
135
+ if low in _FILTER_TOKENS:
136
+ continue
137
+ out.append(low)
138
+ return out
139
+
140
+
141
+ def _ngrams(tokens: Sequence[str], n: int = 2) -> List[str]:
142
+ if len(tokens) < n:
143
+ return []
144
+ return [" ".join(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]
145
+
146
+
147
+ # ── Topic candidates ──────────────────────────────────────────────────────────
148
+
149
+
150
+ @dataclass
151
+ class TopicCandidate:
152
+ label: str
153
+ score: float
154
+ sources: List[str] = field(default_factory=list)
155
+ aliases: Set[str] = field(default_factory=set)
156
+
157
+ def to_dict(self) -> Dict[str, Any]:
158
+ return {
159
+ "label": self.label,
160
+ "score": self.score,
161
+ "sources": list(self.sources),
162
+ "aliases": sorted(self.aliases),
163
+ }
164
+
165
+
166
+ def extract_topic_candidates(
167
+ documents: Iterable[Dict[str, Any]],
168
+ *,
169
+ min_score: float = 1.5,
170
+ top_k: int = 50,
171
+ ) -> List[TopicCandidate]:
172
+ """대화/파일/작업 로그 documents에서 topic candidate를 뽑는다.
173
+
174
+ documents: [{"id": str, "text": str, "kind": "chat|file|task", "weight": float}]
175
+ """
176
+ counts: Dict[str, float] = {}
177
+ sources: Dict[str, List[str]] = {}
178
+
179
+ for doc in documents:
180
+ text = str(doc.get("text") or "")
181
+ # secret이 섞여 있으면 제거하고 진행
182
+ text = mask_secrets(text)
183
+ weight = float(doc.get("weight") or 1.0)
184
+ kind = str(doc.get("kind") or "chat")
185
+ if kind == "file":
186
+ weight *= 1.5 # 파일은 신호가 강함
187
+ elif kind == "task":
188
+ weight *= 1.2
189
+
190
+ tokens = _tokenize(text)
191
+ if not tokens:
192
+ continue
193
+
194
+ # 단어 + 2gram 두 가지 모두 후보로 둔다
195
+ bag = list(set(tokens + _ngrams(tokens, 2)))
196
+ seen_in_doc: Set[str] = set()
197
+ for term in bag:
198
+ if term in seen_in_doc:
199
+ continue
200
+ seen_in_doc.add(term)
201
+ counts[term] = counts.get(term, 0.0) + weight
202
+ sources.setdefault(term, []).append(str(doc.get("id") or ""))
203
+
204
+ # log-normalize and filter
205
+ candidates: List[TopicCandidate] = []
206
+ for term, score in counts.items():
207
+ if score < min_score:
208
+ continue
209
+ term_sources = sources.get(term, [])
210
+ # item 5: 같은 대화/폴더(단일 출처)에서만 반복된 단어는 감점한다.
211
+ # 여러 출처에서 반복된 개념일수록 가산해 "진짜 주제"만 위로 올린다.
212
+ distinct_sources = len({s for s in term_sources if s})
213
+ if distinct_sources <= 1:
214
+ diversity = 0.5 # 단일 출처 노이즈 감점
215
+ else:
216
+ diversity = 1.0 + 0.15 * math.log(distinct_sources)
217
+ normalized = math.log(1.0 + score) * (1.0 + 0.05 * len(term.split())) * diversity
218
+ candidates.append(
219
+ TopicCandidate(
220
+ label=term,
221
+ score=round(normalized, 4),
222
+ sources=term_sources[:20],
223
+ )
224
+ )
225
+
226
+ candidates.sort(key=lambda c: c.score, reverse=True)
227
+ return candidates[:top_k]
228
+
229
+
230
+ # ── Alias normalization / merging ─────────────────────────────────────────────
231
+
232
+ DEFAULT_ALIAS_GROUPS: List[List[str]] = [
233
+ ["lattice ai", "latticeai", "래티스 ai", "래티스ai", "내 앱", "내 ai"],
234
+ ["gpt-oss", "gpt oss", "openai gpt-oss"],
235
+ ["gemma 4", "gemma4", "google gemma 4"],
236
+ ["llama 3", "llama3", "meta llama 3"],
237
+ ]
238
+
239
+
240
+ def build_alias_index(groups: Optional[List[List[str]]] = None) -> Dict[str, str]:
241
+ groups = groups or DEFAULT_ALIAS_GROUPS
242
+ idx: Dict[str, str] = {}
243
+ for grp in groups:
244
+ if not grp:
245
+ continue
246
+ canon = grp[0].lower().strip()
247
+ for alias in grp:
248
+ idx[alias.lower().strip()] = canon
249
+ return idx
250
+
251
+
252
+ def cluster_candidates(
253
+ candidates: List[TopicCandidate],
254
+ alias_index: Optional[Dict[str, str]] = None,
255
+ ) -> List[TopicCandidate]:
256
+ """비슷한 라벨을 자동 병합한다."""
257
+ alias_index = alias_index or build_alias_index()
258
+ merged: Dict[str, TopicCandidate] = {}
259
+
260
+ def canon_of(label: str) -> str:
261
+ low = label.lower().strip()
262
+ if low in alias_index:
263
+ return alias_index[low]
264
+ # 단순 정규화: 공백/하이픈 통일
265
+ norm = re.sub(r"[-_]+", " ", low)
266
+ norm = re.sub(r"\s+", " ", norm).strip()
267
+ return norm
268
+
269
+ for c in candidates:
270
+ key = canon_of(c.label)
271
+ if key in merged:
272
+ existing = merged[key]
273
+ existing.score += c.score * 0.6 # 중복일수록 score는 약간 가산
274
+ existing.aliases.add(c.label)
275
+ existing.sources = list({*existing.sources, *c.sources})[:50]
276
+ else:
277
+ cand = TopicCandidate(
278
+ label=key,
279
+ score=c.score,
280
+ sources=list(c.sources),
281
+ aliases={c.label} if c.label.lower() != key else set(),
282
+ )
283
+ merged[key] = cand
284
+
285
+ return sorted(merged.values(), key=lambda x: x.score, reverse=True)
286
+
287
+
288
+ # ── Promotion rules ───────────────────────────────────────────────────────────
289
+
290
+
291
+ @dataclass
292
+ class PromotionDecision:
293
+ candidate: TopicCandidate
294
+ promote: bool
295
+ reason: str
296
+ importance: float
297
+
298
+
299
+ def should_promote(
300
+ candidate: TopicCandidate,
301
+ *,
302
+ existing_node_labels: Optional[Set[str]] = None,
303
+ min_sources: int = 2,
304
+ min_importance: float = 1.0,
305
+ ) -> PromotionDecision:
306
+ existing_node_labels = existing_node_labels or set()
307
+ # 1. secret 라벨이면 절대 승격 금지
308
+ if contains_secret(candidate.label):
309
+ return PromotionDecision(candidate, False, "contains secret", 0.0)
310
+ # 2. 이미 같은 라벨의 노드가 있으면 승격하지 않음 (alias로 들어감)
311
+ if candidate.label in existing_node_labels:
312
+ return PromotionDecision(candidate, False, "duplicate of existing node", candidate.score)
313
+ # 3. 출처가 너무 적으면 노이즈로 간주
314
+ if len(set(candidate.sources)) < min_sources:
315
+ return PromotionDecision(candidate, False, "too few sources", candidate.score)
316
+ # 4. 너무 짧은 라벨(단어 1자) 거부
317
+ if len(candidate.label) < 2:
318
+ return PromotionDecision(candidate, False, "label too short", candidate.score)
319
+
320
+ importance = candidate.score
321
+ if importance < min_importance:
322
+ return PromotionDecision(candidate, False, "importance below threshold", importance)
323
+
324
+ return PromotionDecision(candidate, True, "promoted", importance)
325
+
326
+
327
+ # ── Thread edges (파생 이야기) ────────────────────────────────────────────────
328
+
329
+
330
+ @dataclass
331
+ class ThreadEdge:
332
+ source: str
333
+ target: str
334
+ story: str
335
+ evidence: List[str] = field(default_factory=list)
336
+ created_at: float = field(default_factory=time.time)
337
+
338
+ def to_dict(self) -> Dict[str, Any]:
339
+ return {
340
+ "source": self.source,
341
+ "target": self.target,
342
+ "story": self.story,
343
+ "evidence": list(self.evidence),
344
+ "created_at": self.created_at,
345
+ }
346
+
347
+
348
+ def derive_thread_story(
349
+ source_label: str,
350
+ target_label: str,
351
+ *,
352
+ snippets: Iterable[str],
353
+ max_len: int = 220,
354
+ ) -> str:
355
+ """간단한 1~2문장 파생 이야기를 만든다. 빠르고 결정적."""
356
+ cleaned: List[str] = []
357
+ for s in snippets:
358
+ if not s:
359
+ continue
360
+ sm = mask_secrets(str(s))
361
+ # 가장 의미있어 보이는 첫 문장만 따온다
362
+ sentences = re.split(r"[.!?\n]+", sm)
363
+ for sent in sentences:
364
+ t = sent.strip()
365
+ if 8 <= len(t) <= max_len:
366
+ cleaned.append(t)
367
+ break
368
+ if len(cleaned) >= 2:
369
+ break
370
+ if not cleaned:
371
+ return f"{source_label}에서 {target_label}로 이어지는 흐름이 발견되었습니다."
372
+ joined = ". ".join(cleaned[:2])
373
+ return joined[:max_len]
374
+
375
+
376
+ # ── Curation (중요도 기반 hide/show) ──────────────────────────────────────────
377
+
378
+
379
+ def curate_nodes(
380
+ nodes: List[Dict[str, Any]],
381
+ *,
382
+ max_visible: int = 20,
383
+ behavior_signals: Optional[Dict[str, Dict[str, float]]] = None,
384
+ decay_seconds: float = 60 * 60 * 24 * 14, # 2주
385
+ now: Optional[float] = None,
386
+ ) -> List[Dict[str, Any]]:
387
+ """노드 리스트에 visible/score 정보를 부여한다.
388
+
389
+ nodes: [{"id": str, "label": str, "importance": float, "updated_at": float}]
390
+ behavior_signals: {node_id: {"clicks": int, "searches": int}} 형태.
391
+ """
392
+ now = now or time.time()
393
+ behavior_signals = behavior_signals or {}
394
+ enriched: List[Dict[str, Any]] = []
395
+
396
+ for n in nodes:
397
+ importance = float(n.get("importance") or 0.0)
398
+ updated_at = float(n.get("updated_at") or now)
399
+ age = max(0.0, now - updated_at)
400
+ decay = math.exp(-age / decay_seconds) if decay_seconds > 0 else 1.0
401
+ sig = behavior_signals.get(str(n.get("id") or ""), {})
402
+ boost = (
403
+ 0.4 * math.log(1.0 + float(sig.get("clicks") or 0))
404
+ + 0.6 * math.log(1.0 + float(sig.get("searches") or 0))
405
+ )
406
+ final_score = round(importance * decay + boost, 4)
407
+ enriched.append({**n, "curated_score": final_score})
408
+
409
+ enriched.sort(key=lambda x: x.get("curated_score", 0.0), reverse=True)
410
+ for i, n in enumerate(enriched):
411
+ n["visible"] = i < max_visible
412
+ return enriched
413
+
414
+
415
+ # ── End-to-end helper ─────────────────────────────────────────────────────────
416
+
417
+
418
+ def auto_build_graph_overlay(
419
+ documents: List[Dict[str, Any]],
420
+ *,
421
+ existing_node_labels: Optional[Set[str]] = None,
422
+ alias_index: Optional[Dict[str, str]] = None,
423
+ max_new_nodes: int = 8,
424
+ ) -> Dict[str, Any]:
425
+ """한 번에 토픽 추출 → 클러스터 → 승격 결정까지 수행한 결과를 돌려준다.
426
+
427
+ 실제 그래프 DB에 쓰는 작업은 호출자가 담당한다. 이 함수는 부작용 없음.
428
+ """
429
+ candidates = extract_topic_candidates(documents)
430
+ clustered = cluster_candidates(candidates, alias_index=alias_index)
431
+
432
+ promotions: List[Dict[str, Any]] = []
433
+ skipped: List[Dict[str, Any]] = []
434
+ promoted_count = 0
435
+ for cand in clustered:
436
+ if promoted_count >= max_new_nodes:
437
+ skipped.append({"label": cand.label, "reason": "max_new_nodes reached"})
438
+ continue
439
+ decision = should_promote(cand, existing_node_labels=existing_node_labels)
440
+ if decision.promote:
441
+ promotions.append({
442
+ "label": cand.label,
443
+ "importance": decision.importance,
444
+ "aliases": sorted(cand.aliases),
445
+ "sources": cand.sources,
446
+ })
447
+ promoted_count += 1
448
+ else:
449
+ skipped.append({"label": cand.label, "reason": decision.reason})
450
+
451
+ return {
452
+ "promotions": promotions,
453
+ "skipped": skipped,
454
+ "candidates_total": len(candidates),
455
+ "clustered_total": len(clustered),
456
+ }
457
+
458
+
459
+ __all__ = [
460
+ "TopicCandidate",
461
+ "PromotionDecision",
462
+ "ThreadEdge",
463
+ "contains_secret",
464
+ "mask_secrets",
465
+ "extract_topic_candidates",
466
+ "cluster_candidates",
467
+ "should_promote",
468
+ "derive_thread_story",
469
+ "curate_nodes",
470
+ "auto_build_graph_overlay",
471
+ "build_alias_index",
472
+ "DEFAULT_ALIAS_GROUPS",
473
+ ]