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.
- package/README.md +47 -7
- package/docs/CHANGELOG.md +117 -0
- package/knowledge_graph_api.py +10 -2
- package/latticeai/api/security_dashboard.py +584 -0
- package/latticeai/core/audit.py +4 -1
- package/latticeai/core/graph_curator.py +473 -0
- package/latticeai/core/model_compat.py +450 -0
- package/latticeai/core/model_resolution.py +227 -0
- package/latticeai/core/timezones.py +80 -0
- package/package.json +2 -2
- package/server.py +265 -16
- package/static/account.html +2 -2
- package/static/admin.html +75 -1
- package/static/chat.html +2 -2
- package/static/graph.html +2 -2
- package/static/lattice-reference.css +82 -50
- package/static/scripts/account.js +10 -2
- package/static/scripts/admin.js +296 -0
- package/static/scripts/chat.js +173 -11
- package/static/scripts/graph.js +6 -2
- package/static/sw.js +1 -1
|
@@ -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
|
+
]
|