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 +23 -7
- package/docs/CHANGELOG.md +35 -0
- package/latticeai/api/security_dashboard.py +6 -2
- package/latticeai/core/audit.py +4 -1
- 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 +18 -7
- package/static/scripts/chat.js +105 -16
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
|
|
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
|
|
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
|
-
|
|
|
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** |
|
|
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
|
|
233
|
-
| **Plugin
|
|
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.
|
|
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
|
-
|
|
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":
|
|
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)),
|
package/latticeai/core/audit.py
CHANGED
|
@@ -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
|
-
"
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
3068
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
package/static/scripts/chat.js
CHANGED
|
@@ -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
|
-
? `
|
|
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
|
-
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1943
|
+
const actualCurrent = resolveActualCurrent(finalData, modelId);
|
|
1944
|
+
setCurrentModel(actualCurrent);
|
|
1945
|
+
updateCurrentModelUI(actualCurrent);
|
|
1876
1946
|
let statusLine = `<b>${escapeHtml(compactModelName(actualCurrent))}</b> 로드 되었습니다.`;
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
statusLine += `<br><span class="sensitivity-preview"
|
|
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
|
-
|
|
1898
|
-
|
|
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
|
|
1902
|
-
|
|
1903
|
-
|
|
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) {
|