ltcai 0.1.28 → 0.1.30
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 +42 -16
- package/auto_setup.py +605 -0
- package/docs/CHANGELOG.md +57 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/kg_schema.py +723 -0
- package/llm_router.py +4 -4
- package/mcp_registry.py +791 -0
- package/package.json +5 -1
- package/requirements.txt +1 -0
- package/server.py +738 -823
- package/static/account.html +5 -616
- package/static/admin.html +236 -1371
- package/static/chat.html +204 -7146
- package/static/graph.html +15 -1436
- package/static/lattice-reference.css +6557 -71
- package/static/scripts/account.js +230 -0
- package/static/scripts/admin.js +1198 -0
- package/static/scripts/chat.js +4634 -0
- package/static/scripts/graph.js +1059 -0
- package/static/sw.js +11 -1
package/auto_setup.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lattice AI — Zero-Config Auto Setup
|
|
3
|
+
===================================
|
|
4
|
+
|
|
5
|
+
명세: ``lattice_ai_full_spec.pptx`` 슬라이드 16·17 (자동 환경 매트릭스 + 5단계 흐름)
|
|
6
|
+
|
|
7
|
+
5단계
|
|
8
|
+
-----
|
|
9
|
+
① PROBE OS · CPU · GPU · RAM · 디스크 · 가속 SDK 감지
|
|
10
|
+
② RECOMMEND 사양 점수 → 최적 모델 / 런타임 / 양자화 자동 선택
|
|
11
|
+
③ INSTALL OS별 패키지 매니저 어댑터 호출 (winget · brew · apt · 스토어)
|
|
12
|
+
④ VERIFY 추론 토큰/초 측정, 첫 응답 지연, 메모리 누수 점검
|
|
13
|
+
⑤ PRESET 기본/고급 모드 분기 + 단축키·MCP·테마 적용
|
|
14
|
+
|
|
15
|
+
원칙
|
|
16
|
+
----
|
|
17
|
+
- **표준 라이브러리 only.** 외부 패키지 import 는 모두 try/except 로 감싼다.
|
|
18
|
+
- **변경하지 않는다, 추천만 한다.** INSTALL 단계는 *실행 명령어* 를 생성하고
|
|
19
|
+
돌려보낼 뿐, ``--apply`` 플래그 없이는 시스템을 건드리지 않는다.
|
|
20
|
+
- **모든 출력은 JSON-직렬화 가능**해야 UI(설치 마법사 화면) 에서 그대로 표시 가능.
|
|
21
|
+
|
|
22
|
+
사용
|
|
23
|
+
----
|
|
24
|
+
```bash
|
|
25
|
+
python3 auto_setup.py probe # 1단계만
|
|
26
|
+
python3 auto_setup.py recommend # 1+2단계
|
|
27
|
+
python3 auto_setup.py plan # 1+2+3 (설치 계획 출력)
|
|
28
|
+
python3 auto_setup.py plan --apply # 실제 설치 실행 (위험)
|
|
29
|
+
python3 auto_setup.py verify # 4단계 단독
|
|
30
|
+
python3 auto_setup.py preset # 5단계
|
|
31
|
+
python3 auto_setup.py all # 전체 흐름
|
|
32
|
+
```
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import platform
|
|
41
|
+
import shutil
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
import time
|
|
45
|
+
from dataclasses import asdict, dataclass, field
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"SystemProfile", "Recommendation", "InstallPlan",
|
|
51
|
+
"probe", "recommend", "plan", "verify", "preset", "run_all",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── 1. PROBE ────────────────────────────────────────────────────────────────
|
|
56
|
+
@dataclass
|
|
57
|
+
class GPUInfo:
|
|
58
|
+
vendor: str = "unknown" # nvidia | amd | intel | apple | none
|
|
59
|
+
model: str = ""
|
|
60
|
+
vram_mb: int = 0
|
|
61
|
+
sdk: List[str] = field(default_factory=list) # ['cuda', 'metal', 'mlx', ...]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class SystemProfile:
|
|
66
|
+
os: str = "" # windows | darwin | linux | ios | android
|
|
67
|
+
os_version: str = ""
|
|
68
|
+
arch: str = "" # x86_64 | arm64 | …
|
|
69
|
+
cpu_model: str = ""
|
|
70
|
+
cpu_cores: int = 0
|
|
71
|
+
ram_mb: int = 0
|
|
72
|
+
disk_free_mb: int = 0
|
|
73
|
+
gpu: GPUInfo = field(default_factory=GPUInfo)
|
|
74
|
+
package_manager: Optional[str] = None # winget | brew | apt | dnf | pacman
|
|
75
|
+
has_internet: bool = True
|
|
76
|
+
python_version: str = ""
|
|
77
|
+
|
|
78
|
+
def score(self) -> int:
|
|
79
|
+
"""LLM 적합도 점수 (0..100). RECOMMEND 의 입력."""
|
|
80
|
+
s = 0
|
|
81
|
+
s += min(self.cpu_cores * 2, 24)
|
|
82
|
+
s += min(self.ram_mb // 1024 * 2, 40)
|
|
83
|
+
s += min(self.gpu.vram_mb // 1024 * 4, 36)
|
|
84
|
+
return min(s, 100)
|
|
85
|
+
|
|
86
|
+
def to_json(self) -> Dict[str, Any]:
|
|
87
|
+
d = asdict(self)
|
|
88
|
+
d["score"] = self.score()
|
|
89
|
+
return d
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read_text(path: str) -> str:
|
|
93
|
+
try:
|
|
94
|
+
return Path(path).read_text(encoding="utf-8", errors="replace")
|
|
95
|
+
except Exception:
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _run(cmd: List[str], timeout: float = 4.0) -> str:
|
|
100
|
+
try:
|
|
101
|
+
out = subprocess.run(cmd, capture_output=True, text=True,
|
|
102
|
+
timeout=timeout, check=False)
|
|
103
|
+
return (out.stdout or "") + (out.stderr or "")
|
|
104
|
+
except Exception:
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _detect_gpu(prof_os: str, arch: str) -> GPUInfo:
|
|
109
|
+
"""OS별 휴리스틱으로 GPU 감지. 외부 라이브러리 없이 가능한 만큼만."""
|
|
110
|
+
gpu = GPUInfo()
|
|
111
|
+
|
|
112
|
+
# NVIDIA
|
|
113
|
+
if shutil.which("nvidia-smi"):
|
|
114
|
+
info = _run(["nvidia-smi", "--query-gpu=name,memory.total",
|
|
115
|
+
"--format=csv,noheader,nounits"])
|
|
116
|
+
if info.strip():
|
|
117
|
+
first = info.strip().splitlines()[0]
|
|
118
|
+
try:
|
|
119
|
+
name, mem = [x.strip() for x in first.split(",", 1)]
|
|
120
|
+
gpu.vendor = "nvidia"
|
|
121
|
+
gpu.model = name
|
|
122
|
+
gpu.vram_mb = int(float(mem))
|
|
123
|
+
gpu.sdk.append("cuda")
|
|
124
|
+
except ValueError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
# Apple Silicon / Metal
|
|
128
|
+
if prof_os == "darwin":
|
|
129
|
+
sp = _run(["system_profiler", "SPDisplaysDataType"], timeout=6.0)
|
|
130
|
+
if "Apple" in sp and arch == "arm64":
|
|
131
|
+
gpu.vendor = "apple"
|
|
132
|
+
for line in sp.splitlines():
|
|
133
|
+
if "Chipset Model" in line:
|
|
134
|
+
gpu.model = line.split(":", 1)[-1].strip()
|
|
135
|
+
break
|
|
136
|
+
# Apple Silicon 의 GPU 메모리는 통합 메모리 = RAM. 별도 표기 안 함.
|
|
137
|
+
gpu.sdk.extend(["metal", "mlx" if _has_module("mlx") else ""])
|
|
138
|
+
gpu.sdk = [s for s in gpu.sdk if s]
|
|
139
|
+
|
|
140
|
+
# Windows
|
|
141
|
+
if prof_os == "windows" and gpu.vendor == "unknown":
|
|
142
|
+
info = _run(["wmic", "path", "win32_VideoController", "get",
|
|
143
|
+
"Name,AdapterRAM", "/format:list"])
|
|
144
|
+
if info:
|
|
145
|
+
name = ""
|
|
146
|
+
ram = 0
|
|
147
|
+
for line in info.splitlines():
|
|
148
|
+
if line.startswith("Name="):
|
|
149
|
+
name = line.split("=", 1)[-1].strip()
|
|
150
|
+
elif line.startswith("AdapterRAM="):
|
|
151
|
+
try:
|
|
152
|
+
ram = int(line.split("=", 1)[-1].strip()) // (1024 * 1024)
|
|
153
|
+
except ValueError:
|
|
154
|
+
ram = 0
|
|
155
|
+
if name:
|
|
156
|
+
gpu.model = name
|
|
157
|
+
low = name.lower()
|
|
158
|
+
if "nvidia" in low or "rtx" in low or "geforce" in low:
|
|
159
|
+
gpu.vendor = "nvidia"; gpu.sdk.append("cuda")
|
|
160
|
+
elif "amd" in low or "radeon" in low:
|
|
161
|
+
gpu.vendor = "amd"; gpu.sdk.extend(["directml", "vulkan"])
|
|
162
|
+
elif "intel" in low:
|
|
163
|
+
gpu.vendor = "intel"; gpu.sdk.extend(["directml", "vulkan"])
|
|
164
|
+
if ram > 0:
|
|
165
|
+
gpu.vram_mb = ram
|
|
166
|
+
|
|
167
|
+
# Linux (lspci)
|
|
168
|
+
if prof_os == "linux" and gpu.vendor == "unknown":
|
|
169
|
+
info = _run(["lspci"], timeout=3.0).lower()
|
|
170
|
+
if "nvidia" in info:
|
|
171
|
+
gpu.vendor = "nvidia"; gpu.sdk.append("cuda")
|
|
172
|
+
elif "amd/ati" in info or "advanced micro devices" in info:
|
|
173
|
+
gpu.vendor = "amd"; gpu.sdk.extend(["rocm", "vulkan"])
|
|
174
|
+
elif "intel corporation" in info and "vga" in info:
|
|
175
|
+
gpu.vendor = "intel"; gpu.sdk.append("vulkan")
|
|
176
|
+
|
|
177
|
+
return gpu
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _detect_package_manager(prof_os: str) -> Optional[str]:
|
|
181
|
+
if prof_os == "windows":
|
|
182
|
+
return "winget" if shutil.which("winget") else None
|
|
183
|
+
if prof_os == "darwin":
|
|
184
|
+
return "brew" if shutil.which("brew") else None
|
|
185
|
+
if prof_os == "linux":
|
|
186
|
+
for pm in ("apt", "dnf", "pacman", "zypper", "apk"):
|
|
187
|
+
if shutil.which(pm):
|
|
188
|
+
return pm
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _has_module(name: str) -> bool:
|
|
193
|
+
try:
|
|
194
|
+
__import__(name)
|
|
195
|
+
return True
|
|
196
|
+
except Exception:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def probe() -> SystemProfile:
|
|
201
|
+
"""① PROBE — 외부 의존성 없이 가능한 만큼 환경을 감지한다."""
|
|
202
|
+
prof = SystemProfile()
|
|
203
|
+
prof.os = {"Darwin": "darwin", "Windows": "windows",
|
|
204
|
+
"Linux": "linux"}.get(platform.system(), platform.system().lower())
|
|
205
|
+
prof.os_version = platform.release()
|
|
206
|
+
prof.arch = platform.machine().lower()
|
|
207
|
+
prof.cpu_model = platform.processor() or ""
|
|
208
|
+
prof.cpu_cores = os.cpu_count() or 0
|
|
209
|
+
prof.python_version = platform.python_version()
|
|
210
|
+
|
|
211
|
+
# RAM
|
|
212
|
+
try:
|
|
213
|
+
if prof.os == "linux":
|
|
214
|
+
for line in _read_text("/proc/meminfo").splitlines():
|
|
215
|
+
if line.startswith("MemTotal:"):
|
|
216
|
+
prof.ram_mb = int(line.split()[1]) // 1024
|
|
217
|
+
break
|
|
218
|
+
elif prof.os == "darwin":
|
|
219
|
+
out = _run(["sysctl", "-n", "hw.memsize"])
|
|
220
|
+
if out.strip():
|
|
221
|
+
prof.ram_mb = int(out.strip()) // (1024 * 1024)
|
|
222
|
+
elif prof.os == "windows":
|
|
223
|
+
out = _run(["wmic", "ComputerSystem", "get", "TotalPhysicalMemory",
|
|
224
|
+
"/format:list"])
|
|
225
|
+
for line in out.splitlines():
|
|
226
|
+
if line.startswith("TotalPhysicalMemory="):
|
|
227
|
+
prof.ram_mb = int(line.split("=", 1)[-1].strip()) // (1024 * 1024)
|
|
228
|
+
break
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
# Disk
|
|
233
|
+
try:
|
|
234
|
+
usage = shutil.disk_usage(Path.home())
|
|
235
|
+
prof.disk_free_mb = usage.free // (1024 * 1024)
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
prof.gpu = _detect_gpu(prof.os, prof.arch)
|
|
240
|
+
prof.package_manager = _detect_package_manager(prof.os)
|
|
241
|
+
return prof
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ── 2. RECOMMEND ────────────────────────────────────────────────────────────
|
|
245
|
+
@dataclass
|
|
246
|
+
class Recommendation:
|
|
247
|
+
runtime: str # llama.cpp | mlx | vllm | mlc-llm | tflite
|
|
248
|
+
backend: str # cuda | metal+mlx | directml | vulkan | rocm | cpu
|
|
249
|
+
model_id: str # 추천 모델 (huggingface-like id)
|
|
250
|
+
quantization: str # q4_K_M | q5_K_M | mxfp4 | f16
|
|
251
|
+
rationale: List[str] # 왜 이걸 골랐는지 (UI에 표시)
|
|
252
|
+
estimated_tokens_per_sec: Optional[float] = None
|
|
253
|
+
|
|
254
|
+
def to_json(self) -> Dict[str, Any]:
|
|
255
|
+
return asdict(self)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# 모델 카탈로그. PPT 슬라이드 16 의 "추천 모델" 열과 동기화.
|
|
259
|
+
_MODEL_CATALOG: List[Dict[str, Any]] = [
|
|
260
|
+
# (min_ram_mb, min_vram_mb, model_id, quant, runtime_preference)
|
|
261
|
+
{"ram": 24 * 1024, "vram": 16 * 1024,
|
|
262
|
+
"id": "google/gemma-3-12b-it", "q": "q5_K_M"},
|
|
263
|
+
{"ram": 16 * 1024, "vram": 8 * 1024,
|
|
264
|
+
"id": "Qwen/Qwen2.5-7B-Instruct", "q": "q4_K_M"},
|
|
265
|
+
{"ram": 12 * 1024, "vram": 6 * 1024,
|
|
266
|
+
"id": "google/gemma-3-4b-it", "q": "q4_K_M"},
|
|
267
|
+
{"ram": 8 * 1024, "vram": 4 * 1024,
|
|
268
|
+
"id": "microsoft/Phi-3.5-mini-instruct", "q": "q4_K_M"},
|
|
269
|
+
{"ram": 4 * 1024, "vram": 0,
|
|
270
|
+
"id": "google/gemma-3-2b-it", "q": "q4_K_M"},
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def recommend(profile: SystemProfile) -> Recommendation:
|
|
275
|
+
"""② RECOMMEND — 프로파일을 보고 런타임/모델/양자화를 결정한다."""
|
|
276
|
+
rationale: List[str] = []
|
|
277
|
+
|
|
278
|
+
# backend / runtime
|
|
279
|
+
if profile.os == "darwin" and profile.gpu.vendor == "apple":
|
|
280
|
+
backend = "metal+mlx"
|
|
281
|
+
runtime = "mlx" if _has_module("mlx") else "llama.cpp"
|
|
282
|
+
rationale.append("Apple Silicon → Metal + MLX")
|
|
283
|
+
elif profile.gpu.vendor == "nvidia" and profile.gpu.vram_mb >= 6000:
|
|
284
|
+
backend = "cuda"
|
|
285
|
+
runtime = "llama.cpp"
|
|
286
|
+
rationale.append(f"NVIDIA GPU {profile.gpu.vram_mb} MB VRAM → CUDA + llama.cpp")
|
|
287
|
+
elif profile.os == "windows" and profile.gpu.vendor in ("amd", "intel"):
|
|
288
|
+
backend = "directml"
|
|
289
|
+
runtime = "llama.cpp"
|
|
290
|
+
rationale.append("Windows + AMD/Intel GPU → DirectML")
|
|
291
|
+
elif profile.os == "linux" and profile.gpu.vendor == "amd":
|
|
292
|
+
backend = "rocm" if "rocm" in profile.gpu.sdk else "vulkan"
|
|
293
|
+
runtime = "llama.cpp"
|
|
294
|
+
rationale.append("Linux + AMD GPU → ROCm/Vulkan")
|
|
295
|
+
else:
|
|
296
|
+
backend = "cpu"
|
|
297
|
+
runtime = "llama.cpp"
|
|
298
|
+
rationale.append("GPU 가속이 없거나 미감지 → CPU 추론")
|
|
299
|
+
|
|
300
|
+
# model size by RAM/VRAM
|
|
301
|
+
pick = _MODEL_CATALOG[-1] # 가장 작은 모델 기본값
|
|
302
|
+
for entry in _MODEL_CATALOG:
|
|
303
|
+
if profile.ram_mb >= entry["ram"] and (
|
|
304
|
+
backend == "cpu" or profile.gpu.vram_mb >= entry["vram"]
|
|
305
|
+
):
|
|
306
|
+
pick = entry
|
|
307
|
+
break
|
|
308
|
+
rationale.append(
|
|
309
|
+
f"RAM {profile.ram_mb} MB · VRAM {profile.gpu.vram_mb} MB → {pick['id']}"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# 양자화: VRAM 충분 → 더 정밀한 양자화로 업그레이드
|
|
313
|
+
quant = pick["q"]
|
|
314
|
+
if profile.gpu.vram_mb >= 24 * 1024:
|
|
315
|
+
quant = "f16"
|
|
316
|
+
rationale.append("VRAM ≥ 24 GB → f16 풀 정밀도")
|
|
317
|
+
|
|
318
|
+
# 거친 tokens/sec 예측 (very rough)
|
|
319
|
+
est_tps = None
|
|
320
|
+
if backend == "cuda":
|
|
321
|
+
est_tps = max(8.0, profile.gpu.vram_mb / 800)
|
|
322
|
+
elif backend == "metal+mlx":
|
|
323
|
+
est_tps = max(6.0, (profile.ram_mb // 1024) * 0.7)
|
|
324
|
+
elif backend == "cpu":
|
|
325
|
+
est_tps = max(1.5, profile.cpu_cores * 0.6)
|
|
326
|
+
|
|
327
|
+
return Recommendation(
|
|
328
|
+
runtime=runtime, backend=backend,
|
|
329
|
+
model_id=pick["id"], quantization=quant,
|
|
330
|
+
rationale=rationale, estimated_tokens_per_sec=est_tps,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ── 3. INSTALL plan ─────────────────────────────────────────────────────────
|
|
335
|
+
@dataclass
|
|
336
|
+
class InstallStep:
|
|
337
|
+
name: str
|
|
338
|
+
why: str
|
|
339
|
+
command: List[str]
|
|
340
|
+
requires_admin: bool = False
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass
|
|
344
|
+
class InstallPlan:
|
|
345
|
+
package_manager: Optional[str]
|
|
346
|
+
steps: List[InstallStep]
|
|
347
|
+
notes: List[str] = field(default_factory=list)
|
|
348
|
+
|
|
349
|
+
def to_json(self) -> Dict[str, Any]:
|
|
350
|
+
return {
|
|
351
|
+
"package_manager": self.package_manager,
|
|
352
|
+
"steps": [asdict(s) for s in self.steps],
|
|
353
|
+
"notes": self.notes,
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# 패키지 카탈로그: 핵심 의존성을 OS별 명령으로 매핑
|
|
358
|
+
_PKG_MAP: Dict[str, Dict[str, Tuple[str, ...]]] = {
|
|
359
|
+
# name : { pm : (cmd parts) }
|
|
360
|
+
"python3.11+": {
|
|
361
|
+
"winget": ("winget", "install", "-e", "--id", "Python.Python.3.11"),
|
|
362
|
+
"brew": ("brew", "install", "python@3.11"),
|
|
363
|
+
"apt": ("apt-get", "install", "-y", "python3.11"),
|
|
364
|
+
"dnf": ("dnf", "install", "-y", "python3.11"),
|
|
365
|
+
},
|
|
366
|
+
"node20": {
|
|
367
|
+
"winget": ("winget", "install", "-e", "--id", "OpenJS.NodeJS.LTS"),
|
|
368
|
+
"brew": ("brew", "install", "node@20"),
|
|
369
|
+
"apt": ("apt-get", "install", "-y", "nodejs"),
|
|
370
|
+
"dnf": ("dnf", "install", "-y", "nodejs"),
|
|
371
|
+
},
|
|
372
|
+
"ollama": {
|
|
373
|
+
"brew": ("brew", "install", "ollama"),
|
|
374
|
+
"winget": ("winget", "install", "-e", "--id", "Ollama.Ollama"),
|
|
375
|
+
"apt": ("sh", "-c", "curl -fsSL https://ollama.com/install.sh | sh"),
|
|
376
|
+
},
|
|
377
|
+
"huggingface-cli": {
|
|
378
|
+
"brew": ("pip3", "install", "--upgrade", "huggingface_hub"),
|
|
379
|
+
"winget": ("pip", "install", "--upgrade", "huggingface_hub"),
|
|
380
|
+
"apt": ("pip3", "install", "--upgrade", "huggingface_hub"),
|
|
381
|
+
"dnf": ("pip3", "install", "--upgrade", "huggingface_hub"),
|
|
382
|
+
},
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def plan(profile: SystemProfile, rec: Recommendation) -> InstallPlan:
|
|
387
|
+
"""③ INSTALL — 추천을 만족시키는 *명령 계획* 을 만든다. 실행하지 않는다."""
|
|
388
|
+
pm = profile.package_manager
|
|
389
|
+
steps: List[InstallStep] = []
|
|
390
|
+
notes: List[str] = []
|
|
391
|
+
|
|
392
|
+
def need(name: str, why: str) -> None:
|
|
393
|
+
cmd_tuple = _PKG_MAP.get(name, {}).get(pm or "")
|
|
394
|
+
if cmd_tuple:
|
|
395
|
+
steps.append(InstallStep(
|
|
396
|
+
name=name, why=why,
|
|
397
|
+
command=list(cmd_tuple),
|
|
398
|
+
requires_admin=(cmd_tuple[0] in ("apt-get", "dnf", "pacman")),
|
|
399
|
+
))
|
|
400
|
+
else:
|
|
401
|
+
notes.append(f"패키지 매니저 어댑터 없음: {name} ({pm}) — 수동 설치 필요")
|
|
402
|
+
|
|
403
|
+
if sys.version_info < (3, 11):
|
|
404
|
+
need("python3.11+", "Lattice AI 서버는 Python 3.11 이상이 필요합니다.")
|
|
405
|
+
if not shutil.which("node"):
|
|
406
|
+
need("node20", "VSCode 확장 / npm CLI 부트스트랩에 필요")
|
|
407
|
+
|
|
408
|
+
# 런타임별 추가
|
|
409
|
+
if rec.runtime == "mlx" and not _has_module("mlx_lm"):
|
|
410
|
+
steps.append(InstallStep(
|
|
411
|
+
name="mlx-lm", why="Apple Silicon LLM 추론",
|
|
412
|
+
command=["pip3", "install", "--upgrade", "mlx-lm"],
|
|
413
|
+
))
|
|
414
|
+
if rec.runtime == "llama.cpp" and not shutil.which("ollama"):
|
|
415
|
+
need("ollama", "llama.cpp 가중치를 가장 쉽게 받는 경로")
|
|
416
|
+
|
|
417
|
+
if not shutil.which("huggingface-cli"):
|
|
418
|
+
need("huggingface-cli", "추천 모델 가중치 다운로드용")
|
|
419
|
+
|
|
420
|
+
# 모델 가중치 풀
|
|
421
|
+
steps.append(InstallStep(
|
|
422
|
+
name=f"weights:{rec.model_id}",
|
|
423
|
+
why="추론에 사용할 모델 가중치",
|
|
424
|
+
command=["huggingface-cli", "download", rec.model_id, "--quiet"],
|
|
425
|
+
))
|
|
426
|
+
|
|
427
|
+
return InstallPlan(package_manager=pm, steps=steps, notes=notes)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def apply_plan(plan_obj: InstallPlan, *, confirm: bool = False) -> List[Dict[str, Any]]:
|
|
431
|
+
"""위험: 실제로 설치 명령을 실행한다. ``confirm=True`` 필수."""
|
|
432
|
+
if not confirm:
|
|
433
|
+
raise RuntimeError("refuse to apply: pass confirm=True")
|
|
434
|
+
results: List[Dict[str, Any]] = []
|
|
435
|
+
for step in plan_obj.steps:
|
|
436
|
+
try:
|
|
437
|
+
r = subprocess.run(step.command, capture_output=True, text=True,
|
|
438
|
+
timeout=300, check=False)
|
|
439
|
+
results.append({
|
|
440
|
+
"name": step.name,
|
|
441
|
+
"returncode": r.returncode,
|
|
442
|
+
"stdout_tail": (r.stdout or "")[-2000:],
|
|
443
|
+
"stderr_tail": (r.stderr or "")[-2000:],
|
|
444
|
+
})
|
|
445
|
+
except Exception as exc:
|
|
446
|
+
results.append({"name": step.name, "error": str(exc)})
|
|
447
|
+
return results
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ── 4. VERIFY ───────────────────────────────────────────────────────────────
|
|
451
|
+
def verify(profile: SystemProfile, rec: Recommendation) -> Dict[str, Any]:
|
|
452
|
+
"""④ VERIFY — 가벼운 sanity check. 실제 LLM 추론 벤치는 별도 도구로.
|
|
453
|
+
여기서는 ‘설치된 것들이 import 되는가’ + ‘디스크/RAM 여유’ 정도만 본다."""
|
|
454
|
+
checks: List[Dict[str, Any]] = []
|
|
455
|
+
|
|
456
|
+
def add(label: str, ok: bool, detail: str = "") -> None:
|
|
457
|
+
checks.append({"label": label, "ok": ok, "detail": detail})
|
|
458
|
+
|
|
459
|
+
add("Python 3.11+", sys.version_info >= (3, 11), platform.python_version())
|
|
460
|
+
add("RAM ≥ 4 GB", profile.ram_mb >= 4 * 1024, f"{profile.ram_mb} MB")
|
|
461
|
+
add("디스크 여유 ≥ 8 GB", profile.disk_free_mb >= 8 * 1024,
|
|
462
|
+
f"{profile.disk_free_mb} MB free")
|
|
463
|
+
|
|
464
|
+
if rec.runtime == "mlx":
|
|
465
|
+
add("mlx_lm import", _has_module("mlx_lm"), "Apple Silicon 런타임")
|
|
466
|
+
if rec.runtime == "llama.cpp":
|
|
467
|
+
add("ollama binary", shutil.which("ollama") is not None,
|
|
468
|
+
shutil.which("ollama") or "not found")
|
|
469
|
+
|
|
470
|
+
# CPU/메모리 잠깐 측정
|
|
471
|
+
t0 = time.perf_counter()
|
|
472
|
+
_ = sum(i * i for i in range(200_000))
|
|
473
|
+
cpu_ms = (time.perf_counter() - t0) * 1000
|
|
474
|
+
add("CPU latency sample", cpu_ms < 200, f"{cpu_ms:.1f} ms / 200k ops")
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
"checks": checks,
|
|
478
|
+
"all_pass": all(c["ok"] for c in checks),
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ── 5. PRESET ───────────────────────────────────────────────────────────────
|
|
483
|
+
def preset(profile: SystemProfile, rec: Recommendation) -> Dict[str, Any]:
|
|
484
|
+
"""⑤ PRESET — UX 분기 + 단축키 + 테마 + MCP 도구 기본값.
|
|
485
|
+
|
|
486
|
+
PPT 슬라이드 3 (모드 선택) · 17 (PRESET) 명세를 따른다.
|
|
487
|
+
"""
|
|
488
|
+
# 기본 모드 vs 고급 모드 자동 선택 휴리스틱
|
|
489
|
+
advanced = (
|
|
490
|
+
profile.gpu.vendor in ("nvidia", "apple") or
|
|
491
|
+
profile.ram_mb >= 24 * 1024 or
|
|
492
|
+
"code" in (profile.cpu_model or "").lower() # 개발자 머신 추정
|
|
493
|
+
)
|
|
494
|
+
mode = "advanced" if advanced else "basic"
|
|
495
|
+
|
|
496
|
+
# 단축키는 OS별 자연 컨벤션 따름
|
|
497
|
+
mod = "Cmd" if profile.os == "darwin" else "Ctrl"
|
|
498
|
+
shortcuts = {
|
|
499
|
+
"newChat": f"{mod}+N",
|
|
500
|
+
"toggleSidebar": f"{mod}+B",
|
|
501
|
+
"openGraph": f"{mod}+G",
|
|
502
|
+
"search": f"{mod}+K",
|
|
503
|
+
"toggleMode": f"{mod}+Shift+M",
|
|
504
|
+
"submit": "Enter",
|
|
505
|
+
"newline": "Shift+Enter",
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
# 기본 MCP 도구 (PPT 슬라이드 11 의 기본 5종)
|
|
509
|
+
mcp_defaults = [
|
|
510
|
+
{"id": "filesystem", "scope": "local", "enabled": True},
|
|
511
|
+
{"id": "web-search", "scope": "remote", "enabled": True},
|
|
512
|
+
{"id": "code-execute", "scope": "local", "enabled": True},
|
|
513
|
+
{"id": "browser-automation","scope": "remote","enabled": False},
|
|
514
|
+
{"id": "database", "scope": "remote", "enabled": False},
|
|
515
|
+
]
|
|
516
|
+
|
|
517
|
+
# 테마: OS 다크 모드 추종이 기본
|
|
518
|
+
theme = {"mode": "auto", # auto | light | dark
|
|
519
|
+
"accent": "#6E4AE6", # PPT 슬라이드 19 토큰
|
|
520
|
+
"density": "comfortable" if mode == "basic" else "compact"}
|
|
521
|
+
|
|
522
|
+
# 다국어: OS locale 기반 추정
|
|
523
|
+
locale = os.environ.get("LANG", os.environ.get("LC_ALL", "ko_KR"))
|
|
524
|
+
lang = "ko" if locale.lower().startswith("ko") else (
|
|
525
|
+
"ja" if locale.lower().startswith("ja") else "en"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
"mode": mode,
|
|
530
|
+
"model": {"id": rec.model_id, "runtime": rec.runtime,
|
|
531
|
+
"backend": rec.backend, "quantization": rec.quantization},
|
|
532
|
+
"shortcuts": shortcuts,
|
|
533
|
+
"mcp": mcp_defaults,
|
|
534
|
+
"theme": theme,
|
|
535
|
+
"language": lang,
|
|
536
|
+
"tips": (
|
|
537
|
+
["기본 모드는 카드형 액션과 큰 입력창 위주. 언제든 '고급 모드' 로 전환할 수 있어요."]
|
|
538
|
+
if mode == "basic"
|
|
539
|
+
else ["고급 모드: 사이드바·디테일 패널·파이프라인 도구가 모두 활성화됩니다."]
|
|
540
|
+
),
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ── orchestrator ────────────────────────────────────────────────────────────
|
|
545
|
+
def run_all(*, apply_install: bool = False) -> Dict[str, Any]:
|
|
546
|
+
p = probe()
|
|
547
|
+
r = recommend(p)
|
|
548
|
+
pl = plan(p, r)
|
|
549
|
+
install_results = None
|
|
550
|
+
if apply_install:
|
|
551
|
+
install_results = apply_plan(pl, confirm=True)
|
|
552
|
+
v = verify(p, r)
|
|
553
|
+
ps = preset(p, r)
|
|
554
|
+
return {
|
|
555
|
+
"probe": p.to_json(),
|
|
556
|
+
"recommend": r.to_json(),
|
|
557
|
+
"plan": pl.to_json(),
|
|
558
|
+
"install": install_results,
|
|
559
|
+
"verify": v,
|
|
560
|
+
"preset": ps,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ── CLI ────────────────────────────────────────────────────────────────────
|
|
565
|
+
def _main() -> int:
|
|
566
|
+
parser = argparse.ArgumentParser(prog="auto_setup",
|
|
567
|
+
description="Lattice AI zero-config setup")
|
|
568
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
569
|
+
sub.add_parser("probe")
|
|
570
|
+
sub.add_parser("recommend")
|
|
571
|
+
sp_plan = sub.add_parser("plan")
|
|
572
|
+
sp_plan.add_argument("--apply", action="store_true",
|
|
573
|
+
help="actually run the install commands (DANGER)")
|
|
574
|
+
sub.add_parser("verify")
|
|
575
|
+
sub.add_parser("preset")
|
|
576
|
+
sub.add_parser("all")
|
|
577
|
+
args = parser.parse_args()
|
|
578
|
+
|
|
579
|
+
if args.cmd == "probe":
|
|
580
|
+
print(json.dumps(probe().to_json(), indent=2, ensure_ascii=False)); return 0
|
|
581
|
+
if args.cmd == "recommend":
|
|
582
|
+
p = probe(); r = recommend(p)
|
|
583
|
+
print(json.dumps({"probe": p.to_json(), "recommend": r.to_json()},
|
|
584
|
+
indent=2, ensure_ascii=False))
|
|
585
|
+
return 0
|
|
586
|
+
if args.cmd == "plan":
|
|
587
|
+
p = probe(); r = recommend(p); pl = plan(p, r)
|
|
588
|
+
out: Dict[str, Any] = {"plan": pl.to_json()}
|
|
589
|
+
if args.apply:
|
|
590
|
+
out["install"] = apply_plan(pl, confirm=True)
|
|
591
|
+
print(json.dumps(out, indent=2, ensure_ascii=False)); return 0
|
|
592
|
+
if args.cmd == "verify":
|
|
593
|
+
p = probe(); r = recommend(p)
|
|
594
|
+
print(json.dumps(verify(p, r), indent=2, ensure_ascii=False)); return 0
|
|
595
|
+
if args.cmd == "preset":
|
|
596
|
+
p = probe(); r = recommend(p)
|
|
597
|
+
print(json.dumps(preset(p, r), indent=2, ensure_ascii=False)); return 0
|
|
598
|
+
if args.cmd == "all":
|
|
599
|
+
print(json.dumps(run_all(apply_install=False), indent=2, ensure_ascii=False))
|
|
600
|
+
return 0
|
|
601
|
+
return 2
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
if __name__ == "__main__":
|
|
605
|
+
raise SystemExit(_main())
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.30] - 2026-05-25
|
|
4
|
+
|
|
5
|
+
### 코드 품질 및 리팩토링
|
|
6
|
+
|
|
7
|
+
- **`server.py` 모듈 분리** — 7,568줄 → 6,798줄
|
|
8
|
+
- MCP 레지스트리 상수 + 원격 레지스트리 페치 + 스킬 마켓플레이스 + 플러그인 디렉터리 로직을 `mcp_registry.py`(791줄)로 분리
|
|
9
|
+
- `server.py`의 가독성과 유지보수성 대폭 향상
|
|
10
|
+
|
|
11
|
+
- **버그 수정 6건**
|
|
12
|
+
- `requirements.txt`에 누락된 `pymupdf` 추가 (Docker 빌드 실패 방지)
|
|
13
|
+
- 비밀번호 해싱 로그 메시지 "bcrypt" → 실제 알고리즘 "scrypt"로 수정
|
|
14
|
+
- HuggingFace 모델 캐시 경로 `~/.latticeai/` → `~/.ltcai/`로 통일 (DATA_DIR과 일치)
|
|
15
|
+
- OpenRouter 모델 카탈로그: Claude 3.5 → Claude 4.x, Gemini 2.0 → 2.5 업데이트
|
|
16
|
+
- `.gitignore`에 임시 파일, 로그, 세션 파일 패턴 8개 추가
|
|
17
|
+
- 고아 파일 정리 (구버전 GIF, 캡처 스크립트 삭제)
|
|
18
|
+
|
|
19
|
+
- **README 개선**
|
|
20
|
+
- v0.1.29 실제 UI에서 새로 찍은 스크린샷 3장 + 애니메이션 데모 GIF 추가
|
|
21
|
+
- GitHub Actions CI 배지 추가
|
|
22
|
+
- 스크린샷에 이모지 레이블 + 설명 캡션 추가
|
|
23
|
+
|
|
24
|
+
### Release
|
|
25
|
+
- 배포 버전을 `0.1.30`으로 상향
|
|
26
|
+
- 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## [0.1.29] - 2026-05-25
|
|
31
|
+
|
|
32
|
+
### 관리자 UX 및 거버넌스 개선
|
|
33
|
+
|
|
34
|
+
- **관리자 대시보드 섹션 분리**
|
|
35
|
+
- 대시보드, 사용자 관리, 권한 관리, SSO 관리, 보안 모니터링, 감사 로그가 각각 독립된 역할을 갖도록 정리
|
|
36
|
+
- 사용자 관리는 활성/비활성 상태를, 권한 관리는 기본/고급/관리자 모드 권한을 명확히 표시
|
|
37
|
+
- SSO 관리는 Okta / Microsoft Entra ID OIDC 설정 저장 및 테스트 플로우를 제공
|
|
38
|
+
|
|
39
|
+
- **보안 모니터링 / 감사 로그 내보내기**
|
|
40
|
+
- 보안 모니터링 로그와 감사 로그를 각각 TXT, Excel(`.xls`), CSV로 추출 가능
|
|
41
|
+
- 모든 내보내기 파일에 UTF-8 BOM을 포함해 한글이 깨지지 않도록 처리
|
|
42
|
+
- 감사 로그의 사용자 사용량/위험도와 감사 이벤트, 보안 모니터링의 위험/준수 필드를 파일로 보존 가능
|
|
43
|
+
|
|
44
|
+
- **전역 UX 및 언어 전환 개선**
|
|
45
|
+
- account/admin/chat/graph 화면의 언어 버튼 전환 시 주요 UX 텍스트가 한국어/영어로 함께 갱신되도록 개선
|
|
46
|
+
- 홈/채팅 화면 구조를 분리해 채팅 전환 시 상태 충돌을 줄임
|
|
47
|
+
- 채팅 빈 화면에서 Lattice AI의 역할과 기능을 더 명확히 안내
|
|
48
|
+
|
|
49
|
+
- **대시보드 시각 안정화**
|
|
50
|
+
- 전체 사용자, 활성 메시지, 현재 모델, VPC 상태 카드의 줄바꿈/가독성 개선
|
|
51
|
+
- 감사 로그의 Graph nodes / Edges 수치가 `[object Object]`로 표시되던 문제 수정
|
|
52
|
+
- 분리된 정적 JS 파일(`static/scripts/*.js`)이 npm/PyPI 패키지에 포함되도록 배포 설정 보강
|
|
53
|
+
|
|
54
|
+
### Release
|
|
55
|
+
- 배포 버전을 `0.1.29`로 상향
|
|
56
|
+
- 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
3
60
|
## [0.1.28] - 2026-05-24
|
|
4
61
|
|
|
5
62
|
### 버그 수정: 추천 모델 ID 오류
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|