ltcai 0.1.27 → 0.1.29

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
@@ -135,7 +135,8 @@ OpenAI · Groq · Together · OpenRouter · any OpenAI-compatible endpoint
135
135
  | **Multi-step agent** | File edit/create, grep, todo, terminal (25 steps) |
136
136
  | **Multi-LLM pipeline** | Plan → Execute → Review with different models |
137
137
  | **Human-in-the-loop** | Approve agent plan before execution |
138
- | **Audit dashboard** | Per-user AI usage, sensitive data detection, event log |
138
+ | **Admin governance** | User status, role permissions, Okta / Entra ID SSO, security monitoring |
139
+ | **Audit dashboard** | Per-user AI usage, sensitive data detection, event log, UTF-8 TXT/CSV/Excel exports |
139
140
  | **PWA** | Install on iPad / Android home screen |
140
141
  | **SSO** | Entra ID / Okta OIDC |
141
142
 
@@ -241,6 +242,9 @@ Report vulnerabilities: [SECURITY.md](SECURITY.md)
241
242
  | GET | `/skills/marketplace` | Skills marketplace |
242
243
  | POST | `/skills/install` | Install a skill |
243
244
  | GET | `/plugins/directory` | Plugin directory |
245
+ | GET | `/admin/audit` | Admin audit report with per-user usage and recent events |
246
+ | GET | `/admin/sensitivity` | Security monitoring report for risky/compliant fields |
247
+ | GET/PATCH | `/admin/sso` | Okta / Entra ID OIDC configuration |
244
248
  | GET | `/permissions/pending` | Pending file-access approvals (admin) |
245
249
  | POST | `/permissions/approve/{token}` | Approve file access (admin) |
246
250
 
@@ -294,7 +298,7 @@ Or: `./start_ai.sh` (auto-restart + caffeinate)
294
298
  | VS Code Marketplace | [marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai) |
295
299
  | Open VSX | [open-vsx.org](https://open-vsx.org/extension/parktaesoo/ltcai) |
296
300
 
297
- Current version: **0.1.27** — [Changelog](docs/CHANGELOG.md)
301
+ Current version: **0.1.29** — [Changelog](docs/CHANGELOG.md)
298
302
 
299
303
  ---
300
304
 
@@ -333,7 +337,8 @@ LTCAI --tunnel # + Cloudflare 공개 URL 자동 발급
333
337
  - Graph RAG — 채팅·문서를 SQLite 지식 그래프로 자동 구조화
334
338
  - 멀티 LLM 파이프라인 (Plan → Execute → Review)
335
339
  - Human-in-the-loop 에이전트 승인
336
- - 감사 로그 & 데이터 거버넌스 대시보드
340
+ - 감사 로그 & 데이터 거버넌스 대시보드, UTF-8 TXT/CSV/Excel 추출
341
+ - 사용자/권한/SSO/보안 모니터링이 분리된 관리자 화면
337
342
  - 텔레메트리 없음 — 모든 데이터 로컬 저장
338
343
 
339
344
  ### 추천 로컬 모델 (M-series Mac)
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())