ltcai 0.3.1 → 0.4.0
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 +285 -208
- package/docs/CHANGELOG.md +73 -0
- package/kg_schema.py +42 -0
- package/knowledge_graph.py +232 -36
- package/latticeai/api/security_dashboard.py +6 -2
- package/latticeai/core/agent.py +453 -0
- package/latticeai/core/audit.py +4 -1
- package/latticeai/core/config.py +178 -0
- package/latticeai/core/graph_curator.py +60 -4
- package/latticeai/core/model_compat.py +67 -24
- package/latticeai/core/timezones.py +80 -0
- package/package.json +2 -2
- package/server.py +108 -441
- package/static/scripts/chat.js +105 -16
- package/tools.py +87 -115
|
@@ -72,6 +72,54 @@ _STOPWORDS: Set[str] = {
|
|
|
72
72
|
"i'll", "as", "be", "is", "it", "an", "or", "to", "of", "in", "on",
|
|
73
73
|
}
|
|
74
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
|
+
|
|
75
123
|
|
|
76
124
|
def _tokenize(text: str) -> List[str]:
|
|
77
125
|
if not text:
|
|
@@ -81,10 +129,10 @@ def _tokenize(text: str) -> List[str]:
|
|
|
81
129
|
tokens = [t for t in cleaned.split() if t]
|
|
82
130
|
out = []
|
|
83
131
|
for t in tokens:
|
|
84
|
-
low = t.lower()
|
|
132
|
+
low = _strip_josa(t.lower())
|
|
85
133
|
if len(low) < 2:
|
|
86
134
|
continue
|
|
87
|
-
if low in
|
|
135
|
+
if low in _FILTER_TOKENS:
|
|
88
136
|
continue
|
|
89
137
|
out.append(low)
|
|
90
138
|
return out
|
|
@@ -158,12 +206,20 @@ def extract_topic_candidates(
|
|
|
158
206
|
for term, score in counts.items():
|
|
159
207
|
if score < min_score:
|
|
160
208
|
continue
|
|
161
|
-
|
|
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
|
|
162
218
|
candidates.append(
|
|
163
219
|
TopicCandidate(
|
|
164
220
|
label=term,
|
|
165
221
|
score=round(normalized, 4),
|
|
166
|
-
sources=
|
|
222
|
+
sources=term_sources[:20],
|
|
167
223
|
)
|
|
168
224
|
)
|
|
169
225
|
|
|
@@ -241,38 +241,70 @@ def fast_postprocess(text: str, profile: Dict[str, Any]) -> str:
|
|
|
241
241
|
SMOKE_PROMPT = "한국어로 한 문장만 답해. 2+2는?"
|
|
242
242
|
|
|
243
243
|
|
|
244
|
-
def
|
|
245
|
-
"""Smoke test
|
|
244
|
+
def classify_smoke_response(text: str) -> Tuple[str, str]:
|
|
245
|
+
"""Smoke test 응답을 ok / degraded / failed 로 분류한다. (item 3-3)
|
|
246
|
+
|
|
247
|
+
- failed: 채팅에 쓸 수 없는 수준 (빈 응답, 특수/role 토큰 누출, 심한 반복,
|
|
248
|
+
과도하게 긴 출력).
|
|
249
|
+
- degraded: 로드/채팅은 되지만 품질이 일정하지 않음 (가벼운 반복, 기대한
|
|
250
|
+
정답 없음, 다소 긴 출력).
|
|
251
|
+
- ok: 형식·정답·길이 모두 정상.
|
|
246
252
|
|
|
247
|
-
반환: (
|
|
253
|
+
반환: (status, reason)
|
|
248
254
|
"""
|
|
249
255
|
if text is None:
|
|
250
|
-
return
|
|
256
|
+
return "failed", "empty response"
|
|
251
257
|
raw = str(text).strip()
|
|
252
258
|
if not raw:
|
|
253
|
-
return
|
|
254
|
-
|
|
259
|
+
return "failed", "empty response"
|
|
260
|
+
|
|
261
|
+
# 1. role / 특수 토큰 누출 → 채팅 형식이 깨진 것이므로 failed.
|
|
255
262
|
for marker in BAD_MARKERS:
|
|
256
263
|
if marker in raw:
|
|
257
|
-
return
|
|
258
|
-
|
|
259
|
-
|
|
264
|
+
return "failed", f"role token leakage ({marker})"
|
|
265
|
+
if re.search(r"<\|[^|]{0,40}\|>", raw):
|
|
266
|
+
return "failed", "special token leakage"
|
|
267
|
+
# role marker 줄 출력 (예: "assistant:" 로 시작)
|
|
268
|
+
if re.match(r"^\s*(?:assistant|system|user)\s*:", raw, flags=re.I):
|
|
269
|
+
return "failed", "role marker leakage"
|
|
270
|
+
|
|
271
|
+
# 2. 반복 감지.
|
|
272
|
+
sentences = [s.strip() for s in re.split(r"[.!?\n]+", raw) if len(s.strip()) >= 3]
|
|
260
273
|
counts: Dict[str, int] = {}
|
|
261
|
-
for
|
|
262
|
-
key =
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return True, "no exact answer but formed"
|
|
272
|
-
return False, "answer did not contain 4 and response too long"
|
|
274
|
+
for key in sentences:
|
|
275
|
+
counts[key] = counts.get(key, 0) + 1
|
|
276
|
+
max_rep = max(counts.values()) if counts else 0
|
|
277
|
+
if max_rep >= 5:
|
|
278
|
+
return "failed", "severe repetition"
|
|
279
|
+
# 문자열 단위 폭주 반복 (예: "안녕안녕안녕…", "AAAA…")
|
|
280
|
+
if re.search(r"(.{1,20}?)\1{6,}", raw):
|
|
281
|
+
return "failed", "runaway repetition"
|
|
282
|
+
|
|
283
|
+
# 3. 과도하게 긴 출력 → failed.
|
|
273
284
|
if len(raw) > 4000:
|
|
274
|
-
return
|
|
275
|
-
|
|
285
|
+
return "failed", "response too long"
|
|
286
|
+
|
|
287
|
+
# 4. 여기까지 왔으면 채팅은 가능. degraded 신호를 모은다.
|
|
288
|
+
degraded: List[str] = []
|
|
289
|
+
if max_rep >= 3:
|
|
290
|
+
degraded.append("mild repetition")
|
|
291
|
+
if len(raw) > 600:
|
|
292
|
+
degraded.append("response longer than expected")
|
|
293
|
+
has_answer = ("4" in raw) or ("네" in raw) or ("사" in raw)
|
|
294
|
+
if not has_answer:
|
|
295
|
+
degraded.append("answer did not contain expected result")
|
|
296
|
+
if degraded:
|
|
297
|
+
return "degraded", "; ".join(degraded)
|
|
298
|
+
return "ok", "ok"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def validate_smoke_response(text: str) -> Tuple[bool, str]:
|
|
302
|
+
"""하위호환 wrapper. (ok 또는 degraded면 채팅 가능 → True)
|
|
303
|
+
|
|
304
|
+
반환: (채팅 가능 여부, reason)
|
|
305
|
+
"""
|
|
306
|
+
status, reason = classify_smoke_response(text)
|
|
307
|
+
return status != "failed", reason
|
|
276
308
|
|
|
277
309
|
|
|
278
310
|
# ── Compat cache (Slow Path) ──────────────────────────────────────────────────
|
|
@@ -346,11 +378,21 @@ def record_smoke_result(
|
|
|
346
378
|
engine: Optional[str],
|
|
347
379
|
ok: bool,
|
|
348
380
|
reason: str,
|
|
381
|
+
*,
|
|
382
|
+
status: Optional[str] = None,
|
|
349
383
|
) -> CompatProfile:
|
|
384
|
+
"""Smoke 결과를 프로필 캐시에 기록한다.
|
|
385
|
+
|
|
386
|
+
status 가 주어지면 ok/degraded/failed 3분류를 그대로 저장한다.
|
|
387
|
+
(하위호환: status 없이 ok bool만 오면 ok→"ok", False→"degraded")
|
|
388
|
+
"""
|
|
350
389
|
profile = ensure_profile(model_id, engine)
|
|
351
390
|
profile.loaded = True
|
|
352
391
|
profile.chat_compatible = bool(ok)
|
|
353
|
-
|
|
392
|
+
if status in ("ok", "degraded", "failed"):
|
|
393
|
+
profile.quality_status = status
|
|
394
|
+
else:
|
|
395
|
+
profile.quality_status = "ok" if ok else "degraded"
|
|
354
396
|
profile.last_test_error = None if ok else reason
|
|
355
397
|
profile.validated_at = time.time()
|
|
356
398
|
remember_profile(profile)
|
|
@@ -395,6 +437,7 @@ __all__ = [
|
|
|
395
437
|
"get_model_profile",
|
|
396
438
|
"fast_postprocess",
|
|
397
439
|
"validate_smoke_response",
|
|
440
|
+
"classify_smoke_response",
|
|
398
441
|
"ensure_profile",
|
|
399
442
|
"lookup_profile",
|
|
400
443
|
"remember_profile",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared timezone helper (피드백 #5 / item 7).
|
|
2
|
+
|
|
3
|
+
문제: audit 로그 timestamp는 ``datetime.now()`` (시스템 로컬 시간)으로 기록되는데,
|
|
4
|
+
security_dashboard는 "오늘 이벤트 수"를 ``datetime.utcnow().date()`` (UTC) 기준으로
|
|
5
|
+
계산해서 한국 사용자에게 events_today가 어긋났다.
|
|
6
|
+
|
|
7
|
+
해결: 모든 날짜/시간 기준을 하나의 timezone 헬퍼로 통일한다.
|
|
8
|
+
|
|
9
|
+
- 환경변수 ``LATTICE_TZ`` (또는 호환용 ``LTCAI_TZ``)가 있으면 그 시간대를 쓴다.
|
|
10
|
+
예: ``LATTICE_TZ=Asia/Seoul``.
|
|
11
|
+
- 없으면 시스템 로컬 시간대를 쓴다 (기존 audit timestamp와 동일한 기준).
|
|
12
|
+
|
|
13
|
+
이렇게 하면 "타임스탬프를 쓸 때 쓰는 시간대" === "오늘을 계산할 때 쓰는 시간대" 가
|
|
14
|
+
항상 일치한다.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from datetime import datetime, tzinfo
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
try: # py3.9+
|
|
25
|
+
from zoneinfo import ZoneInfo
|
|
26
|
+
except Exception: # pragma: no cover - 매우 오래된 런타임
|
|
27
|
+
ZoneInfo = None # type: ignore
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_TZ_ENV_VARS = ("LATTICE_TZ", "LTCAI_TZ")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _system_local_tz() -> tzinfo:
|
|
35
|
+
# tz-aware 시스템 로컬 시간대.
|
|
36
|
+
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_timezone() -> tzinfo:
|
|
40
|
+
"""설정된 시간대를 돌려준다. (환경변수 → 시스템 로컬 순)"""
|
|
41
|
+
for var in _TZ_ENV_VARS:
|
|
42
|
+
name = (os.environ.get(var) or "").strip()
|
|
43
|
+
if not name:
|
|
44
|
+
continue
|
|
45
|
+
if ZoneInfo is None:
|
|
46
|
+
logger.warning("zoneinfo 사용 불가, %s=%s 무시하고 시스템 로컬 사용", var, name)
|
|
47
|
+
break
|
|
48
|
+
try:
|
|
49
|
+
return ZoneInfo(name)
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.warning("알 수 없는 시간대 %s=%s, 시스템 로컬로 대체", var, name)
|
|
52
|
+
break
|
|
53
|
+
return _system_local_tz()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def now(tz: Optional[tzinfo] = None) -> datetime:
|
|
57
|
+
"""설정된 시간대의 현재 시각 (tz-aware)."""
|
|
58
|
+
return datetime.now(tz or get_timezone())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def now_iso(tz: Optional[tzinfo] = None) -> str:
|
|
62
|
+
"""ISO8601 문자열. audit timestamp 기록용."""
|
|
63
|
+
return now(tz).isoformat()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def today_str(tz: Optional[tzinfo] = None) -> str:
|
|
67
|
+
"""오늘 날짜 ``YYYY-MM-DD``. events_today 계산용."""
|
|
68
|
+
return now(tz).date().isoformat()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def tz_name() -> str:
|
|
72
|
+
"""설정된 시간대 이름(있으면 환경변수 값, 없으면 'local')."""
|
|
73
|
+
for var in _TZ_ENV_VARS:
|
|
74
|
+
name = (os.environ.get(var) or "").strip()
|
|
75
|
+
if name:
|
|
76
|
+
return name
|
|
77
|
+
return "local"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["get_timezone", "now", "now_iso", "today_str", "tz_name"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Lattice AI local MLX/cloud LLM workspace server",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test:unit": "python3 -m pytest tests/unit/ -v",
|
|
24
24
|
"test:integration": "python3 -m pytest tests/integration/ -v",
|
|
25
25
|
"publish:npm": "npm publish --access public",
|
|
26
|
-
"publish:pypi": "python3 -m twine upload dist
|
|
26
|
+
"publish:pypi": "python3 -m twine upload --skip-existing dist/*.tar.gz dist/*.whl"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"ltcai",
|