ltcai 0.3.1 → 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 CHANGED
@@ -21,6 +21,22 @@
21
21
 
22
22
  ---
23
23
 
24
+ ## What's new in 0.3.2
25
+
26
+ - **Consistent "current model"** — model-card click → prepare/load → smoke test →
27
+ `current` update → chat-readiness flows through one path in the web UI, so the
28
+ model you see is always the model chat uses.
29
+ - **3-way smoke status** — load-time chat probe now reports `ok` / `degraded` /
30
+ `failed` (special-token leakage, runaway repetition, over-long output), and the
31
+ UI shows a compatibility warning for `degraded` while still allowing chat.
32
+ - **Timezone-correct security dashboard** — "events today" now uses the same
33
+ timezone as audit timestamps (configurable via `LATTICE_TZ`, e.g. `Asia/Seoul`),
34
+ fixing off-by-one day counts.
35
+ - **Cleaner auto-graph** — Korean particle stripping, generic-word / file-extension
36
+ blacklists, and a single-source penalty so only concepts repeated across multiple
37
+ sources get promoted to nodes.
38
+ - **Honest docs** — toned down unverifiable claims (telemetry, skill/plugin counts).
39
+
24
40
  ## What's new in 0.3.1
25
41
 
26
42
  - **Reliable model selection** — `ModelResolution` unifies recommended card ID,
@@ -49,11 +65,11 @@ See [docs/CHANGELOG.md](./docs/CHANGELOG.md) for the full list.
49
65
 
50
66
  Most AI tools forget everything after each conversation. Your files sit in folders, your chats vanish, and nothing connects.
51
67
 
52
- **Lattice AI remembers.** It reads your local files, indexes your conversations, and builds a knowledge graph that links people, projects, concepts, and documents — all on your machine, with zero data leaving your PC.
68
+ **Lattice AI remembers.** It reads your local files, indexes your conversations, and builds a knowledge graph that links people, projects, concepts, and documents — all on your machine. With local models, nothing leaves your PC; cloud models are opt-in and clearly labeled.
53
69
 
54
70
  - **Your data stays local** — everything lives in `~/.ltcai/`, never sent to external servers
55
71
  - **Your AI gets smarter over time** — every chat and file builds your personal knowledge graph
56
- - **One install, works everywhere** — web UI, VS Code, Telegram, MCP clients, all connected to the same brain
72
+ - **One local server, many surfaces** — a single local server powers the web UI, VS Code extension, and optional integrations (Telegram, MCP)
57
73
 
58
74
  ---
59
75
 
@@ -164,7 +180,7 @@ Based on public product behavior as of 2026-05.
164
180
  | Telegram bot | **Yes** | No | No | No |
165
181
  | MCP registry (one-click install) | **Yes** | Partial | Yes | No |
166
182
  | Admin + audit log | **Yes** | Yes | No | No |
167
- | Zero telemetry, self-hosted | **Yes** | Yes | Yes | No |
183
+ | No built-in telemetry, self-hosted | **Yes** | Yes | Yes | No |
168
184
  | One-command public tunnel | **Yes** | No | No | No |
169
185
  | Free | **Yes** | Yes | Yes | No |
170
186
 
@@ -202,7 +218,7 @@ The setup wizard auto-detects your hardware and recommends the best model for yo
202
218
  | | |
203
219
  |---|---|
204
220
  | **Storage** | All data in `~/.ltcai/` on your machine |
205
- | **Telemetry** | None no analytics, no tracking, no phoning home |
221
+ | **Telemetry** | No built-in analytics or product telemetry by default |
206
222
  | **File access** | Approval-token gated — explicit consent per folder |
207
223
  | **Cloud models** | When using cloud APIs, prompts are sent to the provider. Local models keep everything offline. |
208
224
  | **Sensitive files** | `.env`, credentials, keys, certificates auto-excluded from indexing |
@@ -229,8 +245,8 @@ The setup wizard auto-detects your hardware and recommends the best model for yo
229
245
  | **Multi-LLM pipeline** | Plan → Execute → Review with different models |
230
246
  | **MCP server** | Use Lattice tools in Claude Desktop / Cursor |
231
247
  | **MCP registry** | One-click install from registry.modelcontextprotocol.io |
232
- | **Skills marketplace** | 77 official skills (Anthropic + verified third-party) |
233
- | **Plugin directory** | Browse 149 open-source plugins |
248
+ | **Skills browser** | Optional browser for Anthropic + third-party skills |
249
+ | **Plugin browser** | Browse open-source plugins from the registry |
234
250
 
235
251
  ### Access & Communication
236
252
  | Feature | Description |
@@ -387,7 +403,7 @@ Full reference: [docs/mcp-tools.md](docs/mcp-tools.md)
387
403
  | VS Code Marketplace | [marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai) |
388
404
  | Open VSX | [open-vsx.org](https://open-vsx.org/extension/parktaesoo/ltcai) |
389
405
 
390
- Current version: **0.2.2** — [Changelog](docs/CHANGELOG.md)
406
+ Current version: **0.3.2** — [Changelog](docs/CHANGELOG.md)
391
407
 
392
408
  ---
393
409
 
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2] - 2026-05-29
4
+
5
+ > 안정화 릴리스 — 모델 current 일관성, smoke test 3분류, 보안 대시보드 timezone
6
+ > 버그 수정, 자동 그래프 한국어 노이즈 개선, README 과장 표현 정리.
7
+
8
+ ### Model loading & UI
9
+
10
+ - 웹 UI 모델 선택을 단일 흐름으로 통일(`selectModelByCard` → `prepareAndLoadModel`
11
+ → smoke test → `current` 반영 → 채팅 가능 여부 표시). cloud(`loadSelectedModel`)
12
+ 경로도 백엔드 `current`를 단일 진실원으로 사용. "보이는 모델 ≠ 채팅에 쓰이는
13
+ 모델" 문제 제거.
14
+ - Smoke test 결과를 **ok / degraded / failed** 3분류로 확장
15
+ (`model_compat.classify_smoke_response()`). 특수/role 토큰 누출, 폭주 반복,
16
+ 과도한 길이를 감지. `degraded`는 채팅은 가능하되 UI에 호환성 경고 표시.
17
+ `/models/load`·`/engines/prepare-model/stream` 응답의 `compatibility_status`가
18
+ 3분류 값을 그대로 노출.
19
+
20
+ ### Security dashboard
21
+
22
+ - **Timezone 버그 수정** — audit timestamp는 로컬 시간으로 기록되는데
23
+ "events_today"는 UTC로 계산해 한국 사용자에게 날짜가 어긋나던 문제 수정.
24
+ 새 모듈 `latticeai/core/timezones.py`로 기준 시간대를 통일(`LATTICE_TZ` /
25
+ `LTCAI_TZ` 환경변수, 기본 시스템 로컬). overview 응답에 `timezone` 필드 추가.
26
+
27
+ ### Auto graph curator
28
+
29
+ - 한국어 노이즈 감소 — 조사 제거, 일반어/파일확장자 blacklist, 단일 출처
30
+ 후보 score 감점(여러 출처에서 반복된 개념만 승격).
31
+
32
+ ### Docs & tests
33
+
34
+ - README/확장 설명의 과장 표현 완화(telemetry, skills/plugins 수치 등).
35
+ - 단위 테스트 추가: timezone, smoke 3분류, graph 노이즈, export secret redaction.
36
+ (tests/unit 149 passed)
37
+
3
38
  ## [0.3.1] - 2026-05-29
4
39
 
5
40
  > Model loading reliability + auto-graph curation + AI Security & Audit Command Center.
@@ -36,6 +36,8 @@ from fastapi import APIRouter, HTTPException, Query, Request
36
36
  from fastapi.responses import Response, StreamingResponse
37
37
  from pydantic import BaseModel
38
38
 
39
+ from ..core import timezones
40
+
39
41
  logger = logging.getLogger(__name__)
40
42
 
41
43
 
@@ -304,11 +306,13 @@ def create_security_router(
304
306
  report = build_sensitivity_report(history) or {}
305
307
  summary = report.get("summary", {})
306
308
  sev = summary.get("severity_counts", {}) or {}
307
- today = datetime.utcnow().date().isoformat()
309
+ # item 7: audit timestamp(로컬/설정 시간대)와 동일한 기준으로 "오늘"을 계산한다.
310
+ today = timezones.today_str()
308
311
  today_events = [e for e in events if str(e.get("timestamp", ""))[:10] == today]
309
312
 
310
313
  return {
311
- "generated_at": datetime.utcnow().isoformat() + "Z",
314
+ "generated_at": timezones.now_iso(),
315
+ "timezone": timezones.tz_name(),
312
316
  "cards": {
313
317
  "events_today": len(today_events),
314
318
  "high_risk_events": int(sev.get("high", 0)),
@@ -9,6 +9,8 @@ from datetime import datetime
9
9
  from pathlib import Path
10
10
  from typing import Any, Callable, Dict, List, Optional
11
11
 
12
+ from . import timezones
13
+
12
14
  _history_lock = threading.Lock()
13
15
 
14
16
  SENSITIVE_PATTERNS = [
@@ -41,7 +43,8 @@ def append_audit_event(audit_file: Path, event_type: str, **payload) -> None:
41
43
  try:
42
44
  event = {
43
45
  "event_type": event_type,
44
- "timestamp": datetime.now().isoformat(),
46
+ # item 7: 대시보드 "오늘" 계산과 동일한 시간대 기준으로 기록한다.
47
+ "timestamp": timezones.now_iso(),
45
48
  **payload,
46
49
  }
47
50
  with _history_lock:
@@ -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.3.2",
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",
package/server.py CHANGED
@@ -78,6 +78,7 @@ from latticeai.core.model_compat import (
78
78
  record_smoke_result as _record_smoke_result,
79
79
  fast_postprocess as _compat_fast_postprocess,
80
80
  validate_smoke_response as _validate_smoke_response,
81
+ classify_smoke_response as _classify_smoke_response,
81
82
  list_cached_profiles as _list_compat_profiles,
82
83
  SMOKE_PROMPT as _SMOKE_PROMPT,
83
84
  )
@@ -1125,7 +1126,7 @@ async def lifespan(app: FastAPI):
1125
1126
  except Exception:
1126
1127
  pass
1127
1128
 
1128
- app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.3.0", lifespan=lifespan)
1129
+ app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.3.2", lifespan=lifespan)
1129
1130
 
1130
1131
  CORS_ALLOWED_ORIGINS = [
1131
1132
  f"http://localhost:{DEFAULT_PORT}",
@@ -3054,9 +3055,12 @@ async def _smoke_test_loaded_model(
3054
3055
  )
3055
3056
  except Exception as exc: # pragma: no cover - generator may not exist on all engines
3056
3057
  reason = str(exc)[:200] or "generation_failed"
3057
- profile = _record_smoke_result(resolution.load_id, resolution.engine, False, reason)
3058
+ profile = _record_smoke_result(
3059
+ resolution.load_id, resolution.engine, False, reason, status="failed"
3060
+ )
3058
3061
  return {
3059
3062
  "ok": False,
3063
+ "status": "failed",
3060
3064
  "reason": reason,
3061
3065
  "answer": None,
3062
3066
  "profile": profile.to_dict(),
@@ -3064,10 +3068,15 @@ async def _smoke_test_loaded_model(
3064
3068
 
3065
3069
  profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
3066
3070
  cleaned = _compat_fast_postprocess(str(text or ""), profile.to_dict())
3067
- ok, reason = _validate_smoke_response(cleaned)
3068
- profile = _record_smoke_result(resolution.load_id, resolution.engine, ok, reason)
3071
+ # item 3-3: ok / degraded / failed 3분류. degraded는 채팅은 가능하다.
3072
+ status, reason = _classify_smoke_response(cleaned)
3073
+ ok = status != "failed"
3074
+ profile = _record_smoke_result(
3075
+ resolution.load_id, resolution.engine, ok, reason, status=status
3076
+ )
3069
3077
  return {
3070
3078
  "ok": ok,
3079
+ "status": status,
3071
3080
  "reason": reason,
3072
3081
  "answer": cleaned,
3073
3082
  "profile": profile.to_dict(),
@@ -3159,7 +3168,8 @@ async def prepare_and_load_model(
3159
3168
  try:
3160
3169
  smoke_result = await _smoke_test_loaded_model(resolution, api_key_override=user_api_key)
3161
3170
  ready_to_chat = bool(smoke_result.get("ok"))
3162
- compat_status = "ok" if ready_to_chat else "degraded"
3171
+ # item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
3172
+ compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
3163
3173
  except Exception as exc: # never break load on smoke test failures
3164
3174
  logging.warning("smoke test failed for %s: %s", resolution.load_id, exc)
3165
3175
  compat_status = "unknown"
@@ -3402,7 +3412,8 @@ async def prepare_and_load_model_stream(
3402
3412
  try:
3403
3413
  smoke_result = await _smoke_test_loaded_model(resolution_stream, api_key_override=user_api_key)
3404
3414
  ready_to_chat = bool(smoke_result.get("ok"))
3405
- compat_status = "ok" if ready_to_chat else "degraded"
3415
+ # item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
3416
+ compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
3406
3417
  except Exception as exc:
3407
3418
  logging.warning("smoke test (stream) failed for %s: %s", resolution_stream.load_id, exc)
3408
3419
  compat_status = "unknown"
@@ -3491,7 +3502,7 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
3491
3502
 
3492
3503
  @app.get("/health")
3493
3504
  async def health(request: Request):
3494
- base = {"status": "ok", "version": "0.3.0", "mode": APP_MODE}
3505
+ base = {"status": "ok", "version": "0.3.2", "mode": APP_MODE}
3495
3506
  if not get_current_user(request) and REQUIRE_AUTH:
3496
3507
  return base
3497
3508
  engines = await asyncio.to_thread(engine_status)
@@ -1469,7 +1469,7 @@ const chatViewport = document.getElementById('chat-viewport');
1469
1469
  const icon = isUnavailable ? 'ti-lock' : (engineMissing || needsPull) ? 'ti-cloud-download' : verifyUnknown ? 'ti-activity' : 'ti-switch-3';
1470
1470
  const cls = (engineMissing || needsPull) && isLocalEngine ? ' needs-pull' : '';
1471
1471
  const action = isLocalEngine
1472
- ? `prepareAndLoadModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
1472
+ ? `selectModelByCard('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
1473
1473
  : `loadSelectedModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`;
1474
1474
  return `
1475
1475
  <button class="model-option${cls}" ${isUnavailable ? 'disabled' : ''} onclick="${action}">
@@ -1669,7 +1669,17 @@ const chatViewport = document.getElementById('chat-viewport');
1669
1669
  if (!res.ok) throw new Error(data.detail || '모델 로드에 실패했습니다.');
1670
1670
  closeModelPanel();
1671
1671
  await loadModelStatus();
1672
- addMessage('ai', `모델을 <b>${escapeHtml(compactModelName(data.current || modelId))}</b>로 전환했습니다.`);
1672
+ // 피드백 #1/#2: 클라우드 경로도 백엔드 current 단일 진실원으로 사용한다.
1673
+ const actualCurrent = resolveActualCurrent(data, modelId);
1674
+ setCurrentModel(actualCurrent);
1675
+ updateCurrentModelUI(actualCurrent);
1676
+ let statusLine = `모델을 <b>${escapeHtml(compactModelName(actualCurrent))}</b>로 전환했습니다.`;
1677
+ const compat = describeCompatibility(data);
1678
+ if (compat) {
1679
+ statusLine += `<br><span class="sensitivity-preview">${escapeHtml(compat.message)}</span>`;
1680
+ showModelCompatibilityWarning(data);
1681
+ }
1682
+ addMessage('ai', statusLine);
1673
1683
  } catch (e) {
1674
1684
  document.getElementById('model-list').innerHTML = `
1675
1685
  <div class="sensitivity-preview">${escapeHtml(e.message)}</div>
@@ -1829,6 +1839,66 @@ const chatViewport = document.getElementById('chat-viewport');
1829
1839
  if (buffer.trim()) dispatchBlock(buffer.trim());
1830
1840
  }
1831
1841
 
1842
+ // 피드백 #1/#2: "사용자가 보는 현재 모델" === "실제로 채팅에 사용되는 모델".
1843
+ // 백엔드가 돌려준 current/resolution을 단일 진실원으로 사용한다.
1844
+ function resolveActualCurrent(finalData, fallbackId) {
1845
+ if (!finalData) return fallbackId || '';
1846
+ return (
1847
+ finalData.current
1848
+ || (finalData.resolution && (finalData.resolution.expected_current || finalData.resolution.resolved_model))
1849
+ || fallbackId
1850
+ || ''
1851
+ );
1852
+ }
1853
+
1854
+ function setCurrentModel(modelId) {
1855
+ if (!modelId) return;
1856
+ window.__latticeActiveModel = modelId;
1857
+ }
1858
+
1859
+ function updateCurrentModelUI(modelId) {
1860
+ if (!modelId) return;
1861
+ const modelEl = document.getElementById('ops-model');
1862
+ if (modelEl) {
1863
+ modelEl.textContent = compactModelName(modelId);
1864
+ modelEl.title = modelId;
1865
+ }
1866
+ const metaEl = document.getElementById('ops-model-meta');
1867
+ if (metaEl && !metaEl.dataset.loaded) {
1868
+ metaEl.dataset.loaded = 'true';
1869
+ }
1870
+ }
1871
+
1872
+ function describeCompatibility(finalData) {
1873
+ if (!finalData) return null;
1874
+ if (finalData.ready_to_chat === false) {
1875
+ const reason = (finalData.smoke_test && finalData.smoke_test.reason) || '채팅 호환성 검사 실패';
1876
+ return {
1877
+ severity: 'degraded',
1878
+ message: `⚠️ 채팅 호환성이 낮습니다 (${reason}). 다른 실행 엔진을 추천합니다.`,
1879
+ };
1880
+ }
1881
+ if (finalData.compatibility_status === 'degraded') {
1882
+ return {
1883
+ severity: 'degraded',
1884
+ message: '⚠️ 모델은 로드됐지만 호환성 테스트가 degraded로 나왔습니다. 답변 품질이 일정하지 않을 수 있어요.',
1885
+ };
1886
+ }
1887
+ if (finalData.compatibility_status === 'unknown') {
1888
+ return {
1889
+ severity: 'unknown',
1890
+ message: '호환성 테스트를 완료하지 못했습니다. 채팅이 가능하지만 답변 품질이 일정하지 않을 수 있어요.',
1891
+ };
1892
+ }
1893
+ return null;
1894
+ }
1895
+
1896
+ function showModelCompatibilityWarning(finalData) {
1897
+ const info = describeCompatibility(finalData);
1898
+ if (!info) return;
1899
+ try { showToast(info.message); } catch (_) {}
1900
+ }
1901
+
1832
1902
  async function prepareAndLoadModel(encodedId, engine = '') {
1833
1903
  const modelId = decodeURIComponent(encodedId);
1834
1904
  const displayName = compactModelName(modelId);
@@ -1870,22 +1940,22 @@ const chatViewport = document.getElementById('chat-viewport');
1870
1940
  if (!finalData) throw new Error('모델 준비 응답이 비어 있습니다.');
1871
1941
  closeModelPanel();
1872
1942
  await loadModelStatus();
1873
- // 피드백 #1/#2: 사용자가 클릭한 modelId가 아니라 백엔드가 돌려준 current를 신뢰한다.
1874
- const actualCurrent = finalData.current || (finalData.resolution && finalData.resolution.expected_current) || modelId;
1875
- window.__latticeActiveModel = actualCurrent;
1943
+ const actualCurrent = resolveActualCurrent(finalData, modelId);
1944
+ setCurrentModel(actualCurrent);
1945
+ updateCurrentModelUI(actualCurrent);
1876
1946
  let statusLine = `<b>${escapeHtml(compactModelName(actualCurrent))}</b> 로드 되었습니다.`;
1877
- if (finalData.ready_to_chat === false) {
1878
- const reason = (finalData.smoke_test && finalData.smoke_test.reason) || '채팅 호환성 검사 실패';
1879
- statusLine += `<br><span class="sensitivity-preview">⚠️ 현재 채팅 호환성이 낮습니다 (${escapeHtml(reason)}). 다른 실행 엔진을 추천합니다.</span>`;
1880
- } else if (finalData.compatibility_status === 'unknown') {
1881
- statusLine += `<br><span class="sensitivity-preview">호환성 테스트를 완료하지 못했습니다. 채팅이 가능하지만 답변 품질이 일정하지 않을 수 있어요.</span>`;
1947
+ const compat = describeCompatibility(finalData);
1948
+ if (compat) {
1949
+ statusLine += `<br><span class="sensitivity-preview">${escapeHtml(compat.message)}</span>`;
1882
1950
  }
1883
1951
  addMessage('ai', statusLine);
1952
+ return finalData;
1884
1953
  } catch (e) {
1885
1954
  document.getElementById('model-list').innerHTML = `
1886
1955
  <div class="sensitivity-preview">${escapeHtml(e.message)}</div>
1887
1956
  <button class="admin-action" onclick="openModelPanel()" style="margin-top: 12px;">목록으로 돌아가기</button>
1888
1957
  `;
1958
+ throw e;
1889
1959
  }
1890
1960
  }
1891
1961
 
@@ -1893,17 +1963,36 @@ const chatViewport = document.getElementById('chat-viewport');
1893
1963
  return prepareAndLoadModel(encodedId, engine);
1894
1964
  }
1895
1965
 
1896
- // 피드백 #1/#2: 사용자가 직접 모델을 선택했을 때도 같은 표준 흐름을 타도록 노출.
1897
- async function selectModelByCard(card) {
1898
- if (!card || !card.id) {
1966
+ // 피드백 #1/#2: 모델 카드 클릭 prepare/load smoke test current 반영 → 채팅 가능 여부 표시
1967
+ // 하나의 흐름으로 이어지도록 한다. encodedId/engine 또는 card 객체 양쪽 모두 받는다.
1968
+ async function selectModelByCard(modelIdOrCard, engineArg) {
1969
+ let encoded;
1970
+ let engine = engineArg || '';
1971
+ if (typeof modelIdOrCard === 'string') {
1972
+ encoded = modelIdOrCard.includes('%') ? modelIdOrCard : encodeURIComponent(modelIdOrCard);
1973
+ } else if (modelIdOrCard && modelIdOrCard.id) {
1974
+ encoded = encodeURIComponent(modelIdOrCard.id);
1975
+ if (!engine) {
1976
+ engine = modelIdOrCard.engine
1977
+ || (Array.isArray(modelIdOrCard.engine_options) && modelIdOrCard.engine_options[0]?.engine)
1978
+ || '';
1979
+ }
1980
+ } else {
1899
1981
  throw new Error('모델 카드가 비어 있습니다.');
1900
1982
  }
1901
- const encoded = encodeURIComponent(card.id);
1902
- const engine = card.engine || (Array.isArray(card.engine_options) && card.engine_options[0]?.engine) || '';
1903
- return prepareAndLoadModel(encoded, engine);
1983
+ const result = await prepareAndLoadModel(encoded, engine);
1984
+ if (result && result.current) {
1985
+ setCurrentModel(result.current);
1986
+ updateCurrentModelUI(result.current);
1987
+ }
1988
+ if (result && (result.ready_to_chat === false || result.compatibility_status === 'degraded')) {
1989
+ showModelCompatibilityWarning(result);
1990
+ }
1991
+ return result;
1904
1992
  }
1905
1993
  if (typeof window !== 'undefined') {
1906
1994
  window.selectModelByCard = selectModelByCard;
1995
+ window.prepareAndLoadModel = prepareAndLoadModel;
1907
1996
  }
1908
1997
 
1909
1998
  function fillVpcForm(config) {