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.
@@ -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 _STOPWORDS:
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
- normalized = math.log(1.0 + score) * (1.0 + 0.05 * len(term.split()))
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=sources.get(term, [])[:20],
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 validate_smoke_response(text: str) -> Tuple[bool, str]:
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
- 반환: (정상 여부, reason)
253
+ 반환: (status, reason)
248
254
  """
249
255
  if text is None:
250
- return False, "empty response"
256
+ return "failed", "empty response"
251
257
  raw = str(text).strip()
252
258
  if not raw:
253
- return False, "empty response"
254
- # 특수 토큰 leakage
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 False, f"role token leakage ({marker})"
258
- # 같은 문장 5회 이상 반복
259
- sentences = re.split(r"[.!?\n]+", raw)
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 s in sentences:
262
- key = s.strip()
263
- if len(key) >= 3:
264
- counts[key] = counts.get(key, 0) + 1
265
- if counts and max(counts.values()) >= 5:
266
- return False, "repetition detected"
267
- # 4 라는 답이 포함되어 있는지(약한 정상성 휴리스틱)
268
- if "4" not in raw and "네" not in raw and "사" not in raw:
269
- # 정답이 아니더라도 채팅 형식이 깨지지 않았으면 degraded로 통과
270
- if len(raw) < 200:
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 False, "response too long"
275
- return True, "ok"
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
- profile.quality_status = "ok" if ok else "degraded"
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.1",
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",