ltcai 3.6.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +11 -7
  2. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  3. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  4. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  5. package/docs/kg-schema.md +47 -53
  6. package/kg_schema.py +93 -10
  7. package/knowledge_graph.py +362 -33
  8. package/knowledge_graph_api.py +11 -127
  9. package/latticeai/__init__.py +1 -1
  10. package/latticeai/api/admin.py +1 -1
  11. package/latticeai/api/agents.py +7 -1
  12. package/latticeai/api/auth.py +27 -4
  13. package/latticeai/api/chat.py +112 -76
  14. package/latticeai/api/health.py +1 -1
  15. package/latticeai/api/hooks.py +1 -1
  16. package/latticeai/api/knowledge_graph.py +146 -0
  17. package/latticeai/api/local_files.py +1 -1
  18. package/latticeai/api/mcp.py +23 -11
  19. package/latticeai/api/memory.py +1 -1
  20. package/latticeai/api/models.py +1 -1
  21. package/latticeai/api/network.py +81 -0
  22. package/latticeai/api/realtime.py +1 -1
  23. package/latticeai/api/search.py +26 -2
  24. package/latticeai/api/security_dashboard.py +2 -3
  25. package/latticeai/api/setup.py +2 -2
  26. package/latticeai/api/static_routes.py +2 -4
  27. package/latticeai/api/tools.py +3 -0
  28. package/latticeai/api/workflow_designer.py +46 -0
  29. package/latticeai/api/workspace.py +71 -49
  30. package/latticeai/app_factory.py +1710 -0
  31. package/latticeai/brain/__init__.py +18 -0
  32. package/latticeai/brain/context.py +213 -0
  33. package/latticeai/brain/conversations.py +236 -0
  34. package/latticeai/brain/identity.py +175 -0
  35. package/latticeai/brain/memory.py +102 -0
  36. package/latticeai/brain/network.py +205 -0
  37. package/latticeai/core/agent.py +31 -7
  38. package/latticeai/core/audit.py +0 -7
  39. package/latticeai/core/config.py +1 -1
  40. package/latticeai/core/context_builder.py +1 -2
  41. package/latticeai/core/enterprise.py +1 -1
  42. package/latticeai/core/graph_curator.py +2 -2
  43. package/latticeai/core/marketplace.py +1 -1
  44. package/latticeai/core/mcp_registry.py +791 -0
  45. package/latticeai/core/model_compat.py +1 -1
  46. package/latticeai/core/model_resolution.py +0 -1
  47. package/latticeai/core/multi_agent.py +238 -4
  48. package/latticeai/core/security.py +1 -1
  49. package/latticeai/core/sessions.py +37 -7
  50. package/latticeai/core/workflow_engine.py +114 -2
  51. package/latticeai/core/workspace_os.py +58 -10
  52. package/latticeai/models/__init__.py +7 -0
  53. package/latticeai/models/router.py +779 -0
  54. package/latticeai/server_app.py +29 -1536
  55. package/latticeai/services/agent_runtime.py +1 -0
  56. package/latticeai/services/app_context.py +75 -14
  57. package/latticeai/services/ingestion.py +47 -0
  58. package/latticeai/services/kg_portability.py +33 -3
  59. package/latticeai/services/memory_service.py +39 -11
  60. package/latticeai/services/model_runtime.py +2 -5
  61. package/latticeai/services/platform_runtime.py +100 -23
  62. package/latticeai/services/search_service.py +17 -8
  63. package/latticeai/services/tool_dispatch.py +12 -2
  64. package/latticeai/services/triggers.py +241 -0
  65. package/latticeai/services/upload_service.py +37 -12
  66. package/latticeai/services/workspace_service.py +31 -0
  67. package/llm_router.py +29 -772
  68. package/ltcai_cli.py +1 -2
  69. package/mcp_registry.py +25 -788
  70. package/p_reinforce.py +124 -14
  71. package/package.json +9 -7
  72. package/scripts/bump_version.py +99 -0
  73. package/scripts/generate_diagrams.py +0 -1
  74. package/scripts/lint_v3.mjs +82 -18
  75. package/scripts/validate_release_artifacts.py +0 -1
  76. package/scripts/wheel_smoke.py +142 -0
  77. package/server.py +11 -7
  78. package/setup_wizard.py +1142 -0
  79. package/static/account.html +2 -4
  80. package/static/admin.html +3 -5
  81. package/static/chat.html +3 -6
  82. package/static/graph.html +2 -4
  83. package/static/sw.js +81 -52
  84. package/static/v3/asset-manifest.json +20 -19
  85. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  86. package/static/v3/css/lattice.base.css +1 -1
  87. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  88. package/static/v3/css/lattice.components.css +1 -1
  89. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  90. package/static/v3/css/lattice.shell.css +1 -1
  91. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  92. package/static/v3/css/lattice.tokens.css +3 -0
  93. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  94. package/static/v3/css/lattice.views.css +2 -2
  95. package/static/v3/index.html +3 -4
  96. package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
  97. package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
  98. package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
  99. package/static/v3/js/core/routes.js +22 -22
  100. package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
  101. package/static/v3/js/core/shell.js +1 -1
  102. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  103. package/static/v3/js/core/store.js +1 -1
  104. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  105. package/static/v3/js/views/graph-canvas.js +509 -0
  106. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  107. package/static/v3/js/views/hybrid-search.js +1 -2
  108. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
  109. package/static/v3/js/views/knowledge-graph.js +33 -37
  110. package/static/vendor/chart.umd.min.js +20 -0
  111. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  112. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  113. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  114. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  115. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  116. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  117. package/static/vendor/fonts/inter.css +44 -0
  118. package/static/vendor/icons/tabler-icons.min.css +4 -0
  119. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  120. package/static/vendor/marked.min.js +69 -0
  121. package/static/workspace.html +2 -2
  122. package/telegram_bot.py +1 -2
  123. package/tools/commands.py +4 -2
  124. package/tools/computer.py +1 -1
  125. package/tools/documents.py +1 -3
  126. package/tools/filesystem.py +0 -4
  127. package/tools/knowledge.py +1 -3
  128. package/tools/network.py +1 -3
  129. package/codex_telegram_bot.py +0 -195
  130. package/docs/assets/v3.4.0/agent-run.png +0 -0
  131. package/docs/assets/v3.4.0/agents.png +0 -0
  132. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  133. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  134. package/docs/assets/v3.4.0/chat.png +0 -0
  135. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  136. package/docs/assets/v3.4.0/files.png +0 -0
  137. package/docs/assets/v3.4.0/home.png +0 -0
  138. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  139. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  140. package/docs/assets/v3.4.0/local-agent.png +0 -0
  141. package/docs/assets/v3.4.0/memory.png +0 -0
  142. package/docs/assets/v3.4.0/settings.png +0 -0
  143. package/docs/assets/v3.4.0/vision-input.png +0 -0
  144. package/docs/assets/v3.4.0/workflows.png +0 -0
  145. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  146. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  147. package/docs/assets/v3.4.1/local-agent.png +0 -0
  148. package/docs/images/admin-dashboard.png +0 -0
  149. package/docs/images/architecture.png +0 -0
  150. package/docs/images/enterprise.png +0 -0
  151. package/docs/images/graph.png +0 -0
  152. package/docs/images/hero.gif +0 -0
  153. package/docs/images/knowledge-graph.png +0 -0
  154. package/docs/images/lattice-ai-demo.gif +0 -0
  155. package/docs/images/lattice-ai-hero.png +0 -0
  156. package/docs/images/logo.svg +0 -33
  157. package/docs/images/mobile-responsive.png +0 -0
  158. package/docs/images/model-recommendation.png +0 -0
  159. package/docs/images/onboarding.png +0 -0
  160. package/docs/images/organization.png +0 -0
  161. package/docs/images/pipeline.png +0 -0
  162. package/docs/images/screenshot-admin.png +0 -0
  163. package/docs/images/screenshot-chat.png +0 -0
  164. package/docs/images/screenshot-graph.png +0 -0
  165. package/docs/images/skills.png +0 -0
  166. package/docs/images/workspace-dark.png +0 -0
  167. package/docs/images/workspace-light.png +0 -0
  168. package/docs/images/workspace.png +0 -0
  169. package/requirements.txt +0 -16
@@ -0,0 +1,1142 @@
1
+ """
2
+ Smart Setup Wizard — Environment Scanner, Recommender & Auto-Installer
3
+ Detects hardware, tools, and API keys; returns tailored recommendations;
4
+ streams SSE installation progress.
5
+
6
+ Formerly the root ``setup.py``; renamed in v4 so it no longer collides with
7
+ the setuptools build entrypoint and actually ships in the wheel
8
+ (``pyproject.toml`` py-modules). Packaging is owned entirely by
9
+ ``pyproject.toml`` — there is deliberately no root ``setup.py``.
10
+ """
11
+
12
+ import asyncio
13
+ import json as _json
14
+ import os
15
+ import platform
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any, AsyncIterator, Dict, List, Tuple
23
+
24
+ # ── Helpers ───────────────────────────────────────────────────────────────────
25
+
26
+ def _cmd(args: List[str], timeout: int = 10) -> str:
27
+ try:
28
+ r = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
29
+ return (r.stdout or r.stderr or "").strip()
30
+ except Exception:
31
+ return ""
32
+
33
+ def _sse(data: Dict) -> str:
34
+ return f"data: {_json.dumps(data, ensure_ascii=False)}\n\n"
35
+
36
+ OFFICIAL_DOWNLOADS: Dict[str, str] = {
37
+ "homebrew": "https://brew.sh",
38
+ "python": "https://www.python.org/downloads/",
39
+ "node": "https://nodejs.org/en/download",
40
+ "git": "https://git-scm.com/downloads",
41
+ "ollama": "https://ollama.com/download",
42
+ "lmstudio": "https://lmstudio.ai/download",
43
+ "cuda": "https://developer.nvidia.com/cuda-downloads",
44
+ "mlx": "https://ml-explore.github.io/mlx/build/html/install.html",
45
+ "cloudflared": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
46
+ "tesseract": "https://tesseract-ocr.github.io/tessdoc/Installation.html",
47
+ }
48
+
49
+ COMMON_PATH_DIRS = [
50
+ "/opt/homebrew/bin",
51
+ "/usr/local/bin",
52
+ "/usr/bin",
53
+ "/bin",
54
+ str(Path.home() / ".local" / "bin"),
55
+ str(Path.home() / ".cargo" / "bin"),
56
+ str(Path.home() / ".latticeai" / "bin"),
57
+ ]
58
+
59
+ if platform.system() == "Windows":
60
+ _local_appdata = os.environ.get("LOCALAPPDATA", "")
61
+ _program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
62
+ _program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
63
+ COMMON_PATH_DIRS.extend([
64
+ str(Path(_local_appdata) / "Programs" / "Ollama") if _local_appdata else "",
65
+ str(Path(_program_files) / "Ollama"),
66
+ str(Path(_program_files) / "LM Studio"),
67
+ str(Path(_program_files) / "NVIDIA Corporation" / "NVSMI"),
68
+ str(Path(_program_files_x86) / "NVIDIA Corporation" / "NVSMI"),
69
+ ])
70
+ COMMON_PATH_DIRS = [p for p in COMMON_PATH_DIRS if p]
71
+
72
+ WINDOWS_BINARY_CANDIDATES: Dict[str, List[str]] = {
73
+ "ollama": [
74
+ str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Ollama" / "ollama.exe"),
75
+ str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Ollama" / "ollama.exe"),
76
+ ],
77
+ "lms": [
78
+ str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
79
+ str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
80
+ ],
81
+ "nvidia-smi": [
82
+ str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
83
+ str(Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
84
+ ],
85
+ }
86
+
87
+ PACKAGE_MODULES: Dict[str, str] = {
88
+ "mlx-vlm": "mlx_vlm",
89
+ "huggingface_hub[cli]": "huggingface_hub",
90
+ "openai-whisper": "whisper",
91
+ }
92
+
93
+
94
+ def _project_env_file() -> Path:
95
+ return Path(__file__).resolve().parent / ".env"
96
+
97
+
98
+ def _update_env_file(env_file: Path, key: str, value: str) -> None:
99
+ lines: List[str] = []
100
+ found = False
101
+ if env_file.exists():
102
+ lines = env_file.read_text(encoding="utf-8").splitlines()
103
+ updated: List[str] = []
104
+ for line in lines:
105
+ if line.startswith(f"{key}="):
106
+ updated.append(f"{key}={value}")
107
+ found = True
108
+ else:
109
+ updated.append(line)
110
+ if not found:
111
+ updated.append(f"{key}={value}")
112
+ env_file.write_text("\n".join(updated) + "\n", encoding="utf-8")
113
+
114
+
115
+ def _merge_path_dirs(dirs: List[str]) -> List[str]:
116
+ current = os.environ.get("PATH", "")
117
+ parts = [p for p in current.split(os.pathsep) if p]
118
+ for item in dirs:
119
+ expanded = str(Path(item).expanduser())
120
+ if Path(expanded).exists() and expanded not in parts:
121
+ parts.insert(0, expanded)
122
+ os.environ["PATH"] = os.pathsep.join(parts)
123
+ return parts
124
+
125
+
126
+ def _persist_extra_path(dirs: List[str]) -> None:
127
+ existing = [
128
+ p for p in os.environ.get("LATTICEAI_EXTRA_PATH", "").split(os.pathsep)
129
+ if p
130
+ ]
131
+ merged = existing[:]
132
+ for item in dirs:
133
+ expanded = str(Path(item).expanduser())
134
+ if Path(expanded).exists() and expanded not in merged:
135
+ merged.append(expanded)
136
+ if merged:
137
+ os.environ["LATTICEAI_EXTRA_PATH"] = os.pathsep.join(merged)
138
+ _update_env_file(_project_env_file(), "LATTICEAI_EXTRA_PATH", os.environ["LATTICEAI_EXTRA_PATH"])
139
+
140
+
141
+ def repair_path_for(binary: str | None = None) -> List[str]:
142
+ before = _which_any(binary) if binary else None
143
+ paths = _merge_path_dirs(COMMON_PATH_DIRS)
144
+ if binary and not before and _which_any(binary):
145
+ _persist_extra_path(COMMON_PATH_DIRS)
146
+ return paths
147
+
148
+
149
+ def _which_any(binary: str) -> str | None:
150
+ path = shutil.which(binary)
151
+ if path:
152
+ return path
153
+ if platform.system() == "Windows":
154
+ for candidate in WINDOWS_BINARY_CANDIDATES.get(binary, []):
155
+ if candidate and Path(candidate).exists():
156
+ return candidate
157
+ return None
158
+
159
+
160
+ def _which_detail(binary: str) -> Dict[str, Any]:
161
+ path = _which_any(binary)
162
+ return {"installed": path is not None, "path": path}
163
+
164
+
165
+ def _module_available(module_name: str) -> bool:
166
+ import importlib.util
167
+ return importlib.util.find_spec(module_name) is not None
168
+
169
+
170
+ def _package_module(package: str) -> str:
171
+ return PACKAGE_MODULES.get(package, package.replace("-", "_").split("[", 1)[0])
172
+
173
+
174
+ def _component_detail(name: str, binary: str | None = None, module: str | None = None) -> Dict[str, Any]:
175
+ detail: Dict[str, Any] = {"official_url": OFFICIAL_DOWNLOADS.get(name)}
176
+ if binary:
177
+ detail.update(_which_detail(binary))
178
+ if module:
179
+ detail["module_available"] = _module_available(module)
180
+ detail["installed"] = bool(detail.get("installed") or detail["module_available"])
181
+ return detail
182
+
183
+
184
+ def _verify_binary(binary: str, version_args: List[str] | None = None, timeout: int = 20) -> Tuple[bool, str]:
185
+ repair_path_for(binary)
186
+ found = _which_any(binary)
187
+ if not found:
188
+ return False, f"{binary} 실행 파일을 PATH에서 찾지 못했습니다."
189
+ args = [found, *(version_args or ["--version"])]
190
+ try:
191
+ completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
192
+ except Exception as e:
193
+ return False, str(e)
194
+ output = (completed.stdout or completed.stderr or "").strip().splitlines()
195
+ if completed.returncode == 0:
196
+ return True, output[0] if output else found
197
+ return False, (completed.stderr or completed.stdout or f"returncode={completed.returncode}")[-400:]
198
+
199
+
200
+ async def _wait_for_binary(binary: str, seconds: int = 300) -> Tuple[bool, str]:
201
+ deadline = time.time() + seconds
202
+ while time.time() < deadline:
203
+ ok, msg = _verify_binary(binary)
204
+ if ok:
205
+ return True, msg
206
+ await asyncio.sleep(2)
207
+ return False, f"{binary} 설치 완료를 제한 시간 안에 감지하지 못했습니다."
208
+
209
+ # ── Environment Detection ─────────────────────────────────────────────────────
210
+
211
+ def _detect_chip() -> Dict[str, Any]:
212
+ arch = platform.machine()
213
+ is_apple = arch == "arm64" and platform.system() == "Darwin"
214
+ name = "Unknown CPU"
215
+ gen: Any = None
216
+
217
+ if is_apple:
218
+ profiler = _cmd(["system_profiler", "SPHardwareDataType"], timeout=8)
219
+ m = re.search(r"Chip:\s+(Apple M\S+)", profiler)
220
+ name = m.group(1) if m else "Apple Silicon"
221
+ gm = re.search(r"M(\d+)", name)
222
+ gen = int(gm.group(1)) if gm else 1
223
+ else:
224
+ brand = ""
225
+ if platform.system() == "Darwin":
226
+ brand = _cmd(["sysctl", "-n", "machdep.cpu.brand_string"])
227
+ elif platform.system() == "Windows":
228
+ raw = _cmd(["wmic", "cpu", "get", "Name", "/value"], timeout=5)
229
+ if "Name=" in raw:
230
+ brand = raw.split("Name=", 1)[-1].splitlines()[0].strip()
231
+ elif platform.system() == "Linux":
232
+ try:
233
+ for line in Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace").splitlines():
234
+ if line.lower().startswith("model name"):
235
+ brand = line.split(":", 1)[-1].strip()
236
+ break
237
+ except Exception:
238
+ pass
239
+ name = brand or platform.processor() or "Unknown CPU"
240
+
241
+ return {"name": name, "arch": arch, "is_apple_silicon": is_apple, "gen": gen}
242
+
243
+
244
+ def _detect_cpu() -> Dict[str, Any]:
245
+ flags: List[str] = []
246
+ physical_cores = os.cpu_count() or 0
247
+ logical_cores = os.cpu_count() or 0
248
+ model = _detect_chip()["name"]
249
+ if platform.system() == "Darwin":
250
+ flags = [item.lower() for item in _cmd(["sysctl", "-n", "machdep.cpu.features"], timeout=5).split()]
251
+ try:
252
+ physical_cores = int(_cmd(["sysctl", "-n", "hw.physicalcpu"], timeout=5) or physical_cores)
253
+ logical_cores = int(_cmd(["sysctl", "-n", "hw.logicalcpu"], timeout=5) or logical_cores)
254
+ except ValueError:
255
+ pass
256
+ elif platform.system() == "Linux":
257
+ try:
258
+ text = Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace")
259
+ for line in text.splitlines():
260
+ if line.lower().startswith(("flags", "features")):
261
+ flags = line.split(":", 1)[-1].strip().lower().split()
262
+ break
263
+ except Exception:
264
+ pass
265
+ elif platform.system() == "Windows":
266
+ raw = _cmd(["wmic", "cpu", "get", "Name,NumberOfCores,NumberOfLogicalProcessors", "/format:list"], timeout=5)
267
+ for line in raw.splitlines():
268
+ key, _, value = line.partition("=")
269
+ if key == "Name" and value.strip():
270
+ model = value.strip()
271
+ elif key == "NumberOfCores" and value.strip():
272
+ try:
273
+ physical_cores = int(value.strip())
274
+ except ValueError:
275
+ pass
276
+ elif key == "NumberOfLogicalProcessors" and value.strip():
277
+ try:
278
+ logical_cores = int(value.strip())
279
+ except ValueError:
280
+ pass
281
+ try:
282
+ import ctypes
283
+ kernel32 = ctypes.windll.kernel32
284
+ feature_map = {
285
+ 6: "sse",
286
+ 10: "sse2",
287
+ 13: "sse3",
288
+ 19: "neon",
289
+ 28: "rdrand",
290
+ }
291
+ flags.extend(name for code, name in feature_map.items() if kernel32.IsProcessorFeaturePresent(code))
292
+ except Exception:
293
+ pass
294
+ interesting = {"avx", "avx2", "avx512f", "fma", "neon", "sse4_2"}
295
+ if platform.system() == "Windows":
296
+ interesting.update({"sse", "sse2", "sse3", "rdrand"})
297
+ return {
298
+ "model": model,
299
+ "physical_cores": physical_cores,
300
+ "logical_cores": logical_cores,
301
+ "instructions": sorted({flag for flag in flags if flag in interesting}),
302
+ }
303
+
304
+ def _detect_ram_gb() -> float:
305
+ if platform.system() == "Windows":
306
+ raw = _cmd(["wmic", "ComputerSystem", "get", "TotalPhysicalMemory", "/format:list"], timeout=5)
307
+ for line in raw.splitlines():
308
+ if line.startswith("TotalPhysicalMemory="):
309
+ try:
310
+ return round(int(line.split("=", 1)[-1].strip()) / 1_073_741_824, 1)
311
+ except ValueError:
312
+ break
313
+ raw = _cmd(["sysctl", "-n", "hw.memsize"])
314
+ if raw:
315
+ try:
316
+ return round(int(raw) / 1_073_741_824, 1)
317
+ except ValueError:
318
+ pass
319
+ if platform.system() == "Darwin":
320
+ profiler = _cmd(["system_profiler", "SPHardwareDataType"], timeout=8)
321
+ m = re.search(r"Memory:\s+([\d.]+)\s*(TB|GB|MB)", profiler, re.IGNORECASE)
322
+ if m:
323
+ value = float(m.group(1))
324
+ unit = m.group(2).lower()
325
+ if unit == "tb":
326
+ return round(value * 1024, 1)
327
+ if unit == "gb":
328
+ return round(value, 1)
329
+ return round(value / 1024, 1)
330
+ hostinfo = _cmd(["hostinfo"], timeout=5)
331
+ m = re.search(r"Primary memory available:\s+([\d.]+)\s+gigabytes", hostinfo, re.IGNORECASE)
332
+ if m:
333
+ return round(float(m.group(1)), 1)
334
+ try:
335
+ with open("/proc/meminfo") as f:
336
+ for line in f:
337
+ if line.startswith("MemTotal:"):
338
+ return round(int(line.split()[1]) / 1_048_576, 1)
339
+ except Exception:
340
+ pass
341
+ return 0.0
342
+
343
+ def _detect_disk_free_gb() -> float:
344
+ try:
345
+ path = "C:\\" if platform.system() == "Windows" else "/"
346
+ return round(shutil.disk_usage(path).free / 1_073_741_824, 1)
347
+ except Exception:
348
+ return 0.0
349
+
350
+
351
+ def _parse_windows_video_controllers(raw: str) -> List[Dict[str, Any]]:
352
+ controllers: List[Dict[str, Any]] = []
353
+ if not raw:
354
+ return controllers
355
+ try:
356
+ data = _json.loads(raw)
357
+ if isinstance(data, dict):
358
+ data = [data]
359
+ if isinstance(data, list):
360
+ for item in data:
361
+ name = str(item.get("Name") or "").strip()
362
+ try:
363
+ ram_mb = int(item.get("AdapterRAM") or 0) // (1024 * 1024)
364
+ except Exception:
365
+ ram_mb = 0
366
+ if name:
367
+ controllers.append({"name": name, "vram_mb": ram_mb})
368
+ if controllers:
369
+ return controllers
370
+ except Exception:
371
+ pass
372
+ current: Dict[str, Any] = {}
373
+ for line in raw.splitlines():
374
+ if line.startswith("Name="):
375
+ if current:
376
+ controllers.append(current)
377
+ current = {"name": line.split("=", 1)[-1].strip(), "vram_mb": 0}
378
+ elif line.startswith("AdapterRAM=") and current:
379
+ try:
380
+ current["vram_mb"] = int(line.split("=", 1)[-1].strip()) // (1024 * 1024)
381
+ except ValueError:
382
+ current["vram_mb"] = 0
383
+ if current:
384
+ controllers.append(current)
385
+ return controllers
386
+
387
+
388
+ def _detect_gpu() -> Dict[str, Any]:
389
+ devices: List[Dict[str, Any]] = []
390
+ nvidia_smi = _which_any("nvidia-smi")
391
+ if nvidia_smi:
392
+ info = _cmd([nvidia_smi, "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], timeout=8)
393
+ for line in [item.strip() for item in info.splitlines() if item.strip()]:
394
+ try:
395
+ name, mem = [part.strip() for part in line.split(",", 1)]
396
+ devices.append({"vendor": "nvidia", "name": name, "vram_mb": int(float(mem)), "backend": "cuda"})
397
+ except Exception:
398
+ continue
399
+
400
+ if platform.system() == "Windows":
401
+ shell = _which_any("powershell") or _which_any("pwsh")
402
+ raw = ""
403
+ if shell:
404
+ raw = _cmd([
405
+ shell, "-NoProfile", "-Command",
406
+ "Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM | ConvertTo-Json -Compress",
407
+ ], timeout=8)
408
+ if not raw:
409
+ raw = _cmd(["wmic", "path", "win32_VideoController", "get", "Name,AdapterRAM", "/format:list"], timeout=8)
410
+ for item in _parse_windows_video_controllers(raw):
411
+ if any(existing.get("name") == item["name"] for existing in devices):
412
+ continue
413
+ low = item["name"].lower()
414
+ vendor, backend = "unknown", "cpu"
415
+ if "nvidia" in low or "geforce" in low or "rtx" in low:
416
+ vendor, backend = "nvidia", "cuda"
417
+ elif "amd" in low or "radeon" in low:
418
+ vendor, backend = "amd", "directml/vulkan"
419
+ elif "intel" in low or "arc" in low or "iris" in low:
420
+ vendor, backend = "intel", "directml/vulkan"
421
+ devices.append({"vendor": vendor, "name": item["name"], "vram_mb": item["vram_mb"], "backend": backend})
422
+ elif platform.system() == "Darwin":
423
+ sp = _cmd(["system_profiler", "SPDisplaysDataType"], timeout=8)
424
+ for line in sp.splitlines():
425
+ if "Chipset Model" in line:
426
+ devices.append({"vendor": "apple", "name": line.split(":", 1)[-1].strip(), "vram_mb": 0, "backend": "metal/mlx"})
427
+ break
428
+ elif platform.system() == "Linux" and not devices:
429
+ info = _cmd(["lspci"], timeout=5)
430
+ for line in info.splitlines():
431
+ low = line.lower()
432
+ if not any(token in low for token in ("vga", "3d controller", "display")):
433
+ continue
434
+ if "nvidia" in low:
435
+ devices.append({"vendor": "nvidia", "name": line.strip(), "vram_mb": 0, "backend": "cuda"})
436
+ elif "amd" in low or "advanced micro devices" in low or "radeon" in low:
437
+ devices.append({"vendor": "amd", "name": line.strip(), "vram_mb": 0, "backend": "rocm/vulkan"})
438
+ elif "intel" in low:
439
+ devices.append({"vendor": "intel", "name": line.strip(), "vram_mb": 0, "backend": "vulkan"})
440
+
441
+ primary = max(devices, key=lambda item: int(item.get("vram_mb") or 0), default={})
442
+ vram_mb = int(primary.get("vram_mb") or 0)
443
+ return {
444
+ "devices": devices,
445
+ "vendor": primary.get("vendor", "none"),
446
+ "name": primary.get("name", ""),
447
+ "vram_mb": vram_mb,
448
+ "vram_gb": round(vram_mb / 1024, 1),
449
+ "backend": primary.get("backend", "cpu"),
450
+ }
451
+
452
+
453
+ def _detect_cuda() -> Dict[str, Any]:
454
+ nvidia_smi = _which_any("nvidia-smi")
455
+ nvcc = _which_any("nvcc")
456
+ version = ""
457
+ if nvidia_smi:
458
+ raw = _cmd([nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], timeout=5)
459
+ version = raw.splitlines()[0].strip() if raw.splitlines() else ""
460
+ if nvcc:
461
+ raw = _cmd([nvcc, "--version"], timeout=5)
462
+ m = re.search(r"release\s+([\d.]+)", raw)
463
+ if m:
464
+ version = m.group(1)
465
+ return {"available": bool(nvidia_smi or nvcc), "nvidia_smi": nvidia_smi, "nvcc": nvcc, "version": version}
466
+
467
+
468
+ def _detect_wsl() -> Dict[str, Any]:
469
+ if platform.system() != "Linux":
470
+ return {"is_wsl": False, "version": ""}
471
+ raw = ""
472
+ try:
473
+ raw = Path("/proc/version").read_text(encoding="utf-8", errors="replace")
474
+ except Exception:
475
+ pass
476
+ is_wsl = "microsoft" in raw.lower() or "wsl" in raw.lower()
477
+ version = "2" if "microsoft-standard" in raw.lower() or "wsl2" in raw.lower() else ("1" if is_wsl else "")
478
+ return {"is_wsl": is_wsl, "version": version}
479
+
480
+
481
+ def _detect_tools() -> Dict[str, bool]:
482
+ repair_path_for()
483
+ return {t: _which_any(t) is not None
484
+ for t in ["brew", "ollama", "python3", "python", "node", "npm", "git", "tesseract", "lms", "nvidia-smi", "nvcc"]}
485
+
486
+ def _detect_mlx() -> Dict[str, Any]:
487
+ return {
488
+ "available": _module_available("mlx"),
489
+ "mlx_vlm": _module_available("mlx_vlm"),
490
+ }
491
+
492
+ def _detect_api_keys() -> Dict[str, bool]:
493
+ return {
494
+ "openai": bool(os.getenv("OPENAI_API_KEY")),
495
+ "openrouter": bool(os.getenv("OPENROUTER_API_KEY")),
496
+ "groq": bool(os.getenv("GROQ_API_KEY")),
497
+ "together": bool(os.getenv("TOGETHER_API_KEY")),
498
+ }
499
+
500
+ def scan_environment() -> Dict[str, Any]:
501
+ chip = _detect_chip()
502
+ cpu = _detect_cpu()
503
+ gpu = _detect_gpu()
504
+ cuda = _detect_cuda()
505
+ wsl = _detect_wsl()
506
+ tools = _detect_tools()
507
+ python_binary = "python3" if tools.get("python3") else "python"
508
+ return {
509
+ "os": platform.system(),
510
+ "os_version": platform.mac_ver()[0] if platform.system() == "Darwin" else platform.version(),
511
+ "chip": chip,
512
+ "cpu": cpu,
513
+ "gpu": gpu,
514
+ "cuda": cuda,
515
+ "wsl": wsl,
516
+ "ram_gb": _detect_ram_gb(),
517
+ "disk_free_gb": _detect_disk_free_gb(),
518
+ "tools": tools,
519
+ "components": {
520
+ "homebrew": _component_detail("homebrew", "brew"),
521
+ "python": {**_component_detail("python", python_binary), "version": platform.python_version()},
522
+ "node": _component_detail("node", "node"),
523
+ "npm": _component_detail("node", "npm"),
524
+ "git": _component_detail("git", "git"),
525
+ "ollama": _component_detail("ollama", "ollama"),
526
+ "lmstudio": _component_detail("lmstudio", "lms"),
527
+ "cuda": {**_component_detail("cuda", "nvcc"), **cuda},
528
+ "tesseract": _component_detail("tesseract", "tesseract"),
529
+ "mlx": _component_detail("mlx", module="mlx"),
530
+ "mlx_vlm": _component_detail("mlx", module="mlx_vlm"),
531
+ },
532
+ "path": {
533
+ "active": os.environ.get("PATH", ""),
534
+ "extra": os.environ.get("LATTICEAI_EXTRA_PATH", ""),
535
+ },
536
+ "mlx": _detect_mlx(),
537
+ "api_keys": _detect_api_keys(),
538
+ }
539
+
540
+ # ── Model Catalog ─────────────────────────────────────────────────────────────
541
+ # (model_id, display_name, size_gb, tag, description, min_ram_gb)
542
+ _MODEL_CATALOG = [
543
+ ("mlx-community/Qwen3-VL-4B-Instruct-4bit", "Qwen3-VL 4B", 2.7, "VLM", "최신 Qwen 멀티모달 · 저사양", 4),
544
+ ("mlx-community/gemma-4-e2b-it-4bit", "Gemma 4 E2B", 3.6, "VLM", "Gemma 4 소형 멀티모달", 8),
545
+ ("mlx-community/Qwen3-VL-8B-Instruct-4bit", "Qwen3-VL 8B", 4.8, "VLM", "최신 Qwen 멀티모달 · 균형 추천", 16),
546
+ ("mlx-community/gemma-4-12b-it-4bit", "Gemma 4 12B", 7.6, "VLM", "Gemma 4 기본 추천 · 4bit", 16),
547
+ ("mlx-community/Llama-4-Scout-17B-16E-Instruct-4bit", "Llama 4 Scout", 11.8, "VLM", "Meta 최신 멀티모달 Scout", 24),
548
+ ("mlx-community/gemma-4-26b-a4b-it-4bit", "Gemma 4 26B", 15.6, "VLM", "이미지 지원 · 대형 추천", 32),
549
+ ("mlx-community/gemma-4-31b-it-4bit", "Gemma 4 31B", 18.4, "VLM+", "Gemma 4 최신 31B instruct", 48),
550
+ ("mlx-community/Qwen3-VL-30B-A3B-Instruct-4bit", "Qwen3-VL 30B A3B", 18.0, "VLM+", "최신 Qwen 대형 멀티모달", 48),
551
+ ]
552
+
553
+ _CROSS_PLATFORM_MODEL_CATALOG: Dict[str, List[Tuple[str, str, float, str, str, int]]] = {
554
+ "ollama": [
555
+ ("ollama:qwen3-vl:4b", "Qwen3-VL 4B", 2.7, "VLM", "Ollama 멀티모달 · 저사양", 4),
556
+ ("ollama:qwen3-vl:8b", "Qwen3-VL 8B", 4.8, "VLM", "Ollama 멀티모달 · 균형 추천", 16),
557
+ ("ollama:hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M", "Gemma 4 12B Q4", 7.9, "VLM", "Hugging Face GGUF 기반 Gemma 4", 16),
558
+ ("ollama:hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M", "Gemma 4 31B Q4", 18.7, "VLM+", "Hugging Face GGUF 기반 Gemma 4", 48),
559
+ ("ollama:hf.co/ggml-org/Llama-4-Scout-17B-16E-Instruct-GGUF:Q4_K_M", "Llama 4 Scout Q4", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
560
+ ],
561
+ "lmstudio": [
562
+ ("lmstudio:Qwen/Qwen3-VL-4B-Instruct", "Qwen3-VL 4B", 2.7, "VLM", "LM Studio 멀티모달 · 저사양", 4),
563
+ ("lmstudio:Qwen/Qwen3-VL-8B-Instruct", "Qwen3-VL 8B", 4.8, "VLM", "LM Studio 멀티모달 · 균형 추천", 16),
564
+ ("lmstudio:ggml-org/gemma-4-12B-it-GGUF", "Gemma 4 12B 4-bit", 7.9, "VLM", "LM Studio GGUF Gemma 4", 16),
565
+ ("lmstudio:ggml-org/gemma-4-31B-it-GGUF", "Gemma 4 31B 4-bit", 18.7, "VLM+", "LM Studio GGUF Gemma 4", 48),
566
+ ("lmstudio:Qwen/Qwen3-VL-30B-A3B-Instruct", "Qwen3-VL 30B A3B", 18.0, "VLM+", "대형 Qwen 멀티모달 · 24GB+ VRAM 권장", 32),
567
+ ("lmstudio:meta-llama/Llama-4-Scout-17B-16E-Instruct", "Llama 4 Scout", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
568
+ ],
569
+ "vllm": [
570
+ ("vllm:Qwen/Qwen3-VL-4B-Instruct", "Qwen3-VL 4B", 2.7, "VLM", "내 컴퓨터 GPU 실행 도구 권장", 4),
571
+ ("vllm:Qwen/Qwen3-VL-8B-Instruct", "Qwen3-VL 8B", 4.8, "VLM", "내 컴퓨터 NVIDIA 실행 도구 권장", 16),
572
+ ("vllm:google/gemma-4-12b-it", "Gemma 4 12B", 7.6, "VLM", "Gemma 4 기본 추천 · 4bit", 16),
573
+ ("vllm:Qwen/Qwen3-VL-30B-A3B-Instruct", "Qwen3-VL 30B A3B", 18.0, "VLM+", "대형 Qwen 멀티모달 · 24GB+ VRAM 권장", 32),
574
+ ("vllm:suitch/gemma-4-31B-it-4bit", "Gemma 4 31B", 18.7, "VLM+", "Gemma 4 최신 31B instruct", 48),
575
+ ("vllm:meta-llama/Llama-4-Scout-17B-16E-Instruct", "Llama 4 Scout", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
576
+ ],
577
+ "llamacpp": [
578
+ ("llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF", "Qwen3-VL 4B GGUF", 2.7, "GGUF", "CPU/Vulkan 백업 · 멀티모달 GGUF", 4),
579
+ ("llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF", "Qwen3-VL 8B GGUF", 4.8, "GGUF", "CPU/Vulkan 백업 · 균형형", 16),
580
+ ("llamacpp:ggml-org/gemma-4-12B-it-GGUF", "Gemma 4 12B GGUF", 7.9, "GGUF", "Gemma 4 12B Q4_K_M", 16),
581
+ ("llamacpp:ggml-org/gemma-4-31B-it-GGUF", "Gemma 4 31B GGUF", 18.7, "GGUF", "Gemma 4 31B Q4_K_M", 48),
582
+ ("llamacpp:ggml-org/Llama-4-Scout-17B-16E-Instruct-GGUF", "Llama 4 Scout GGUF", 12.0, "GGUF", "Meta 최신 멀티모달 Scout", 24),
583
+ ],
584
+ }
585
+
586
+ _VERSIONED_MODEL_PATTERNS = (
587
+ ("gemma", re.compile(r"\bgemma[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
588
+ ("qwen", re.compile(r"\bqwen[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
589
+ ("llama", re.compile(r"\bllama[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
590
+ )
591
+
592
+ _BEST_MODEL_TIERS: Dict[str, List[Tuple[int, str]]] = {
593
+ "local_mlx": [
594
+ (48, "mlx-community/gemma-4-31b-it-4bit"),
595
+ (32, "mlx-community/gemma-4-26b-a4b-it-4bit"),
596
+ (16, "mlx-community/gemma-4-12b-it-4bit"),
597
+ (16, "mlx-community/Qwen3-VL-8B-Instruct-4bit"),
598
+ (4, "mlx-community/Qwen3-VL-4B-Instruct-4bit"),
599
+ ],
600
+ "ollama": [
601
+ (48, "ollama:hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M"),
602
+ (16, "ollama:hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M"),
603
+ (16, "ollama:qwen3-vl:8b"),
604
+ (4, "ollama:qwen3-vl:4b"),
605
+ ],
606
+ "lmstudio": [
607
+ (48, "lmstudio:ggml-org/gemma-4-31B-it-GGUF"),
608
+ (16, "lmstudio:ggml-org/gemma-4-12B-it-GGUF"),
609
+ (16, "lmstudio:Qwen/Qwen3-VL-8B-Instruct"),
610
+ (4, "lmstudio:Qwen/Qwen3-VL-4B-Instruct"),
611
+ ],
612
+ "vllm": [
613
+ (48, "vllm:suitch/gemma-4-31B-it-4bit"),
614
+ (16, "vllm:google/gemma-4-12b-it"),
615
+ (16, "vllm:Qwen/Qwen3-VL-8B-Instruct"),
616
+ (4, "vllm:Qwen/Qwen3-VL-4B-Instruct"),
617
+ ],
618
+ "llamacpp": [
619
+ (48, "llamacpp:ggml-org/gemma-4-31B-it-GGUF"),
620
+ (16, "llamacpp:ggml-org/gemma-4-12B-it-GGUF"),
621
+ (16, "llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF"),
622
+ (4, "llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF"),
623
+ ],
624
+ }
625
+
626
+
627
+ def _version_tuple(raw: str) -> Tuple[int, ...]:
628
+ return tuple(int(part) for part in raw.split(".") if part.isdigit())
629
+
630
+
631
+ def _catalog_row_family_version(row: Tuple[str, str, float, str, str, int]) -> Tuple[str, Tuple[int, ...]] | None:
632
+ text = f"{row[0]} {row[1]}"
633
+ for family, pattern in _VERSIONED_MODEL_PATTERNS:
634
+ match = pattern.search(text)
635
+ if match:
636
+ version = _version_tuple(match.group(1))
637
+ if version:
638
+ return family, version
639
+ return None
640
+
641
+
642
+ def _filter_lower_family_versions(
643
+ rows: List[Tuple[str, str, float, str, str, int]],
644
+ ) -> List[Tuple[str, str, float, str, str, int]]:
645
+ max_versions: Dict[str, Tuple[int, ...]] = {}
646
+ detected: List[Tuple[Tuple[str, str, float, str, str, int], Tuple[str, Tuple[int, ...]] | None]] = []
647
+ for row in rows:
648
+ version_info = _catalog_row_family_version(row)
649
+ detected.append((row, version_info))
650
+ if not version_info:
651
+ continue
652
+ family, version = version_info
653
+ if version > max_versions.get(family, (0,)):
654
+ max_versions[family] = version
655
+ return [
656
+ row for row, version_info in detected
657
+ if not version_info or version_info[1] >= max_versions.get(version_info[0], version_info[1])
658
+ ]
659
+
660
+
661
+ def _best_model_for_engine(engine: str, ram_gb: float, rows: List[Tuple[str, str, float, str, str, int]]) -> str:
662
+ available_ids = {row[0] for row in rows}
663
+ for min_ram, model_id in _BEST_MODEL_TIERS.get(engine, []):
664
+ if ram_gb >= min_ram and model_id in available_ids:
665
+ return model_id
666
+ return rows[0][0] if rows else ""
667
+
668
+
669
+ # ── Recommendation Logic ──────────────────────────────────────────────────────
670
+
671
+ def get_recommendations(env: Dict[str, Any]) -> Dict[str, Any]:
672
+ ram = env["ram_gb"]
673
+ chip = env["chip"]
674
+ mlx = env["mlx"]
675
+ tools = env["tools"]
676
+ api_keys = env["api_keys"]
677
+ disk_free = env["disk_free_gb"]
678
+ is_apple = chip["is_apple_silicon"]
679
+ gpu = env.get("gpu", {})
680
+ cuda = env.get("cuda", {})
681
+ wsl = env.get("wsl", {})
682
+ cpu = env.get("cpu", {})
683
+ os_name = env.get("os", "")
684
+
685
+ max_model_gb = ram * 0.72 # ~28% headroom for OS + apps
686
+
687
+ if is_apple:
688
+ preferred_engine = "local_mlx"
689
+ elif gpu.get("vendor") == "nvidia" and cuda.get("available") and (os_name == "Linux" or wsl.get("is_wsl")):
690
+ preferred_engine = "vllm"
691
+ elif tools.get("lms"):
692
+ preferred_engine = "lmstudio"
693
+ elif tools.get("ollama"):
694
+ preferred_engine = "ollama"
695
+ else:
696
+ preferred_engine = "llamacpp"
697
+
698
+ apple_catalog = _filter_lower_family_versions(_MODEL_CATALOG)
699
+ engine_catalog = (
700
+ []
701
+ if is_apple
702
+ else _filter_lower_family_versions(_CROSS_PLATFORM_MODEL_CATALOG[preferred_engine])
703
+ )
704
+ best_id = _best_model_for_engine(
705
+ "local_mlx" if is_apple else preferred_engine,
706
+ ram,
707
+ apple_catalog if is_apple else engine_catalog,
708
+ )
709
+
710
+ # ── Engines ──────────────────────────────────────────────────────────────
711
+ engines: List[Dict] = []
712
+
713
+ if is_apple:
714
+ if mlx["available"] and mlx["mlx_vlm"]:
715
+ engines.append({
716
+ "id": "engine_mlx", "name": "MLX",
717
+ "subtitle": f"{chip['name']} GPU 가속 · MLX-VLM 멀티모달 실행",
718
+ "status": "installed", "priority": "recommended",
719
+ "checked": True, "action": None, "badge": "설치됨",
720
+ })
721
+ else:
722
+ engines.append({
723
+ "id": "engine_mlx", "name": "MLX",
724
+ "subtitle": f"{chip['name']} 전용 MLX-VLM 멀티모달 실행",
725
+ "status": "available", "priority": "recommended",
726
+ "checked": True,
727
+ "action": {"type": "pip", "packages": ["mlx-vlm"], "verify_modules": ["mlx", "mlx_vlm"]},
728
+ "badge": "설치 필요",
729
+ })
730
+
731
+ if tools.get("ollama"):
732
+ engines.append({
733
+ "id": "engine_ollama", "name": "Ollama",
734
+ "subtitle": "범용 로컬 LLM 서버 · 크로스 플랫폼",
735
+ "status": "installed", "priority": "recommended" if preferred_engine == "ollama" else "optional",
736
+ "checked": preferred_engine == "ollama", "action": None, "badge": "설치됨",
737
+ })
738
+ else:
739
+ hint = "brew install 가능" if (tools.get("brew") or env["os"] == "Darwin") else "수동 설치 필요"
740
+ engines.append({
741
+ "id": "engine_ollama", "name": "Ollama",
742
+ "subtitle": "범용 로컬 LLM 서버 · 크로스 플랫폼",
743
+ "status": "available", "priority": "recommended" if preferred_engine == "ollama" else "optional",
744
+ "checked": preferred_engine == "ollama",
745
+ "action": (
746
+ {"type": "brew", "package": "ollama", "binary": "ollama", "official_url": OFFICIAL_DOWNLOADS["ollama"]}
747
+ if tools.get("brew")
748
+ else {"type": "url", "url": OFFICIAL_DOWNLOADS["ollama"], "binary": "ollama"}
749
+ ),
750
+ "badge": hint,
751
+ })
752
+
753
+ if not is_apple:
754
+ lmstudio_installed = bool(tools.get("lms"))
755
+ engines.append({
756
+ "id": "engine_lmstudio", "name": "LM Studio",
757
+ "subtitle": "Windows/macOS/Linux 데스크톱 GPU 서버 · 모델 다운로드 UI 포함",
758
+ "status": "installed" if lmstudio_installed else "available",
759
+ "priority": "recommended" if preferred_engine == "lmstudio" else "optional",
760
+ "checked": preferred_engine == "lmstudio",
761
+ "action": None if lmstudio_installed else {"type": "url", "url": OFFICIAL_DOWNLOADS["lmstudio"], "binary": "lms"},
762
+ "badge": "설치됨" if lmstudio_installed else "설치 필요",
763
+ })
764
+ if gpu.get("vendor") == "nvidia":
765
+ engines.append({
766
+ "id": "engine_cuda", "name": "CUDA",
767
+ "subtitle": f"NVIDIA {gpu.get('name') or 'GPU'} · VRAM {gpu.get('vram_gb') or 0} GB",
768
+ "status": "installed" if cuda.get("available") else "available",
769
+ "priority": "recommended",
770
+ "checked": False,
771
+ "action": None if cuda.get("available") else {"type": "url", "url": OFFICIAL_DOWNLOADS["cuda"], "binary": "nvcc"},
772
+ "badge": cuda.get("version") or ("감지됨" if cuda.get("available") else "설치 필요"),
773
+ })
774
+ engines.append({
775
+ "id": "engine_vllm", "name": "vLLM",
776
+ "subtitle": "NVIDIA 서버형 추론 · Windows는 WSL/Linux 권장",
777
+ "status": "available",
778
+ "priority": "recommended" if preferred_engine == "vllm" else "optional",
779
+ "checked": preferred_engine == "vllm",
780
+ "action": {"type": "pip", "packages": ["vllm", "huggingface_hub[cli]"], "verify_modules": ["vllm", "huggingface_hub"]},
781
+ "badge": "WSL/Linux 권장" if os_name == "Windows" and not wsl.get("is_wsl") else "설치 가능",
782
+ })
783
+ elif gpu.get("vendor") in {"amd", "intel"}:
784
+ engines.append({
785
+ "id": "engine_vulkan_directml", "name": "Vulkan/DirectML",
786
+ "subtitle": f"{gpu.get('vendor', '').upper()} GPU 감지 · LM Studio 또는 llama.cpp 백엔드 권장",
787
+ "status": "available",
788
+ "priority": "recommended" if preferred_engine in {"lmstudio", "llamacpp"} else "optional",
789
+ "checked": False,
790
+ "action": None,
791
+ "badge": gpu.get("backend") or "GPU",
792
+ })
793
+
794
+ components: List[Dict] = []
795
+ component_specs = [
796
+ ("homebrew", "Homebrew", "macOS 패키지 관리자 · 자동 설치 기반", "brew", None, "recommended"),
797
+ ("git", "Git", "저장소 · 확장 · MCP 도구 연동에 필요", "git", "git", "recommended"),
798
+ ("node", "Node.js", "npm 패키지와 VS Code 확장 개발에 필요", "node", "node", "optional"),
799
+ ("tesseract", "Tesseract OCR", "이미지/PDF OCR 기능에 필요", "tesseract", "tesseract", "optional"),
800
+ ]
801
+ for cid, name, subtitle, binary, brew_pkg, priority in component_specs:
802
+ installed = bool(tools.get(binary))
803
+ if cid == "homebrew" and env["os"] != "Darwin":
804
+ continue
805
+ if installed:
806
+ components.append({
807
+ "id": f"component_{cid}", "name": name,
808
+ "subtitle": subtitle, "status": "installed",
809
+ "priority": priority, "checked": False, "action": None,
810
+ "badge": "설치됨",
811
+ })
812
+ continue
813
+ if cid == "homebrew":
814
+ action = {"type": "url", "url": OFFICIAL_DOWNLOADS["homebrew"], "binary": "brew"}
815
+ elif tools.get("brew") and brew_pkg:
816
+ action = {"type": "brew", "package": brew_pkg, "binary": binary, "official_url": OFFICIAL_DOWNLOADS.get(cid)}
817
+ else:
818
+ action = {"type": "url", "url": OFFICIAL_DOWNLOADS.get(cid, ""), "binary": binary}
819
+ components.append({
820
+ "id": f"component_{cid}", "name": name,
821
+ "subtitle": subtitle, "status": "available",
822
+ "priority": priority, "checked": priority == "recommended",
823
+ "action": action, "badge": "설치 필요",
824
+ })
825
+
826
+ python_ok = sys.version_info >= (3, 11)
827
+ if not python_ok:
828
+ components.insert(0, {
829
+ "id": "component_python", "name": "Python 3.11+",
830
+ "subtitle": "Lattice AI 서버 실행에 필요한 Python 런타임",
831
+ "status": "available", "priority": "recommended", "checked": True,
832
+ "action": {"type": "url", "url": OFFICIAL_DOWNLOADS["python"], "binary": "python3"},
833
+ "badge": "업데이트 필요",
834
+ })
835
+
836
+ for provider, has_key in api_keys.items():
837
+ if has_key:
838
+ engines.append({
839
+ "id": f"engine_{provider}", "name": provider.title(),
840
+ "subtitle": f"{provider.upper()}_API_KEY 감지됨 · 클라우드 API",
841
+ "status": "ready", "priority": "optional",
842
+ "checked": False, "action": None, "badge": "준비됨",
843
+ })
844
+
845
+ # ── Models ───────────────────────────────────────────────────────────────
846
+ models: List[Dict] = []
847
+
848
+ if is_apple:
849
+ for mid, mname, size_gb, tag, desc, min_ram in apple_catalog:
850
+ fits = ram >= min_ram and size_gb <= max_model_gb and disk_free >= size_gb + 2
851
+ is_best = mid == best_id
852
+ models.append({
853
+ "id": f"model_{mid.replace('/', '__').replace('-', '_')}",
854
+ "model_id": mid,
855
+ "name": mname,
856
+ "subtitle": desc,
857
+ "size_gb": size_gb,
858
+ "tag": tag,
859
+ "fits": fits,
860
+ "priority": "recommended" if is_best else "optional",
861
+ "checked": is_best and fits,
862
+ "disabled": not fits,
863
+ "badge": f"{size_gb} GB",
864
+ "action": {"type": "load_model", "model_id": mid} if fits else None,
865
+ })
866
+ else:
867
+ vram_gb = float(gpu.get("vram_gb") or 0)
868
+ gpu_budget_gb = vram_gb * 1.15 if gpu.get("vendor") in {"nvidia", "amd", "intel"} and vram_gb else max_model_gb
869
+ model_budget_gb = min(max_model_gb, gpu_budget_gb)
870
+ for mid, mname, size_gb, tag, desc, min_ram in engine_catalog:
871
+ fits = ram >= min_ram and size_gb <= model_budget_gb and disk_free >= size_gb + 2
872
+ is_best = mid == best_id
873
+ models.append({
874
+ "id": f"model_{mid.replace('/', '__').replace(':', '__').replace('-', '_')}",
875
+ "model_id": mid,
876
+ "name": mname,
877
+ "subtitle": desc,
878
+ "size_gb": size_gb,
879
+ "tag": tag,
880
+ "fits": fits,
881
+ "priority": "recommended" if is_best else "optional",
882
+ "checked": is_best and fits,
883
+ "disabled": not fits,
884
+ "badge": f"{size_gb} GB · {preferred_engine}",
885
+ "action": {"type": "load_model", "model_id": mid} if fits else None,
886
+ })
887
+ if models and not any(item.get("checked") for item in models):
888
+ for item in models:
889
+ if not item.get("disabled"):
890
+ item["priority"] = "recommended"
891
+ item["checked"] = True
892
+ break
893
+
894
+ # ── MCPs ─────────────────────────────────────────────────────────────────
895
+ mcps: List[Dict] = [
896
+ {
897
+ "id": "mcp_files", "name": "Workspace Files",
898
+ "subtitle": "파일 읽기/쓰기 · 코드 생성 · 미리보기",
899
+ "status": "active", "priority": "recommended",
900
+ "checked": True, "action": None,
901
+ "badge": "기본 탑재", "needs_auth": False,
902
+ },
903
+ {
904
+ "id": "mcp_presentations", "name": "Presentations",
905
+ "subtitle": "PPTX · 슬라이드 자동 생성",
906
+ "status": "active", "priority": "optional",
907
+ "checked": False, "action": None,
908
+ "badge": "기본 탑재", "needs_auth": False,
909
+ },
910
+ {
911
+ "id": "mcp_github", "name": "GitHub",
912
+ "subtitle": "저장소 · PR · 이슈 · CI 연동",
913
+ "status": "available", "priority": "optional",
914
+ "checked": False,
915
+ "action": {"type": "auth", "url": "https://github.com/apps", "mcp_id": "github"},
916
+ "badge": "인증 필요", "needs_auth": True,
917
+ },
918
+ {
919
+ "id": "mcp_googledrive", "name": "Google Drive",
920
+ "subtitle": "Docs · Sheets · Drive 파일 연동",
921
+ "status": "available", "priority": "optional",
922
+ "checked": False,
923
+ "action": {"type": "auth", "url": "https://chatgpt.com/connectors", "mcp_id": "google-drive"},
924
+ "badge": "인증 필요", "needs_auth": True,
925
+ },
926
+ {
927
+ "id": "mcp_slack", "name": "Slack",
928
+ "subtitle": "팀 채널 공유 · 알림 워크플로",
929
+ "status": "available", "priority": "optional",
930
+ "checked": False,
931
+ "action": {"type": "auth", "url": "https://chatgpt.com/connectors", "mcp_id": "slack"},
932
+ "badge": "인증 필요", "needs_auth": True,
933
+ },
934
+ ]
935
+
936
+ return {
937
+ "components": components,
938
+ "engines": engines,
939
+ "models": models,
940
+ "mcps": mcps,
941
+ "summary": {
942
+ "chip": chip["name"],
943
+ "cpu_cores": cpu.get("logical_cores"),
944
+ "cpu_instructions": cpu.get("instructions", []),
945
+ "gpu": gpu.get("name") or gpu.get("vendor"),
946
+ "gpu_vendor": gpu.get("vendor"),
947
+ "vram_gb": gpu.get("vram_gb"),
948
+ "cuda": cuda.get("available"),
949
+ "cuda_version": cuda.get("version"),
950
+ "wsl": wsl,
951
+ "preferred_engine": preferred_engine,
952
+ "ram_gb": ram,
953
+ "disk_free_gb": disk_free,
954
+ "is_apple_silicon": is_apple,
955
+ "max_model_gb": round(max_model_gb, 1),
956
+ },
957
+ }
958
+
959
+ # ── Installation Stream ───────────────────────────────────────────────────────
960
+
961
+ def _verify_action(action: Dict[str, Any]) -> Tuple[bool, str]:
962
+ atype = action.get("type")
963
+ if atype == "pip":
964
+ modules = action.get("verify_modules") or [_package_module(pkg) for pkg in action.get("packages", [])]
965
+ missing = [module for module in modules if not _module_available(module)]
966
+ if missing:
967
+ return False, "Python 모듈 감지 실패: " + ", ".join(missing)
968
+ return True, "Python 모듈 import 테스트 통과"
969
+ binary = action.get("binary")
970
+ if binary:
971
+ return _verify_binary(binary)
972
+ return True, "검증 항목 없음"
973
+
974
+
975
+ async def _repair_action(action: Dict[str, Any]) -> Tuple[bool, str]:
976
+ binary = action.get("binary")
977
+ if binary:
978
+ repair_path_for(binary)
979
+ ok, msg = _verify_binary(binary)
980
+ if ok:
981
+ return True, f"PATH 자동 보정 완료: {msg}"
982
+ if action.get("type") == "pip":
983
+ packages = action.get("packages", [])
984
+ if packages:
985
+ for pkg in packages:
986
+ success, err = await _pip_install(pkg)
987
+ if not success:
988
+ return False, err
989
+ return _verify_action(action)
990
+ return False, "자동 복구 방법을 찾지 못했습니다."
991
+
992
+
993
+ async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
994
+ for item in items:
995
+ item_id = item.get("id", "unknown")
996
+ name = item.get("name", item_id)
997
+ action = item.get("action") or {}
998
+ atype = action.get("type")
999
+
1000
+ if not atype:
1001
+ yield _sse({"id": item_id, "status": "skipped", "msg": f"{name} — 이미 준비됨"})
1002
+ await asyncio.sleep(0.04)
1003
+ continue
1004
+
1005
+ yield _sse({"id": item_id, "status": "starting", "msg": f"{name} 준비 중..."})
1006
+
1007
+ if atype == "pip":
1008
+ packages = action.get("packages", [])
1009
+ ok = True
1010
+ for pkg in packages:
1011
+ yield _sse({"id": item_id, "status": "running", "msg": f"pip install {pkg} ..."})
1012
+ success, err = await _pip_install(pkg)
1013
+ if success:
1014
+ yield _sse({"id": item_id, "status": "progress", "msg": f"{pkg} 설치 완료"})
1015
+ else:
1016
+ yield _sse({"id": item_id, "status": "error", "msg": f"{pkg} 실패: {err[:400]}"})
1017
+ ok = False
1018
+ break
1019
+ if ok:
1020
+ yield _sse({"id": item_id, "status": "running", "msg": f"{name} 동작 테스트 중..."})
1021
+ verified, detail = _verify_action(action)
1022
+ if verified:
1023
+ yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 검증 완료 ✅\n{detail}"})
1024
+ else:
1025
+ yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
1026
+ repaired, repair_msg = await _repair_action(action)
1027
+ yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
1028
+
1029
+ elif atype == "brew":
1030
+ pkg = action.get("package", "")
1031
+ yield _sse({"id": item_id, "status": "running", "msg": f"brew install {pkg} ..."})
1032
+ success, err = await _brew_install(pkg)
1033
+ if success:
1034
+ yield _sse({"id": item_id, "status": "running", "msg": "설치 완료 감지 · PATH 보정 중..."})
1035
+ binary = action.get("binary")
1036
+ if binary:
1037
+ repair_path_for(binary)
1038
+ verified, detail = _verify_action(action)
1039
+ if verified:
1040
+ yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 연결 · 검증 완료 ✅\n{detail}"})
1041
+ else:
1042
+ yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
1043
+ repaired, repair_msg = await _repair_action(action)
1044
+ yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
1045
+ else:
1046
+ url = action.get("official_url") or action.get("url")
1047
+ if url:
1048
+ yield _sse({"id": item_id, "status": "auth", "msg": f"자동 설치 실패 — 공식 다운로드 페이지를 엽니다.\n{err[:240]}", "auth_url": url})
1049
+ open_url(url)
1050
+ yield _sse({"id": item_id, "status": "error", "msg": f"실패: {err[:400]}"})
1051
+
1052
+ elif atype == "load_model":
1053
+ model_id = action.get("model_id", "")
1054
+ yield _sse({"id": item_id, "status": "running",
1055
+ "msg": f"모델 다운로드 · 로딩 중...\n{model_id}\n(용량에 따라 수 분 소요)"})
1056
+ try:
1057
+ msg = await router.load_model(model_id)
1058
+ yield _sse({"id": item_id, "status": "done", "msg": f"{name} 로드 완료 ✅"})
1059
+ except Exception as e:
1060
+ yield _sse({"id": item_id, "status": "error", "msg": f"로드 실패: {str(e)[:400]}"})
1061
+
1062
+ elif atype == "auth":
1063
+ url = action.get("url", "")
1064
+ yield _sse({"id": item_id, "status": "auth",
1065
+ "msg": "브라우저에서 인증 페이지를 엽니다...", "auth_url": url})
1066
+ open_url(url)
1067
+ yield _sse({"id": item_id, "status": "waiting",
1068
+ "msg": "브라우저에서 인증 완료 후 계속하세요"})
1069
+
1070
+ elif atype == "url":
1071
+ url = action.get("url", "")
1072
+ yield _sse({"id": item_id, "status": "auth",
1073
+ "msg": "설치 페이지를 브라우저에서 엽니다...", "auth_url": url})
1074
+ open_url(url)
1075
+ binary = action.get("binary")
1076
+ if binary:
1077
+ yield _sse({"id": item_id, "status": "waiting",
1078
+ "msg": f"{binary} 설치 완료를 자동 감지하는 중입니다..."})
1079
+ ok, detail = await _wait_for_binary(binary)
1080
+ if ok:
1081
+ repair_path_for(binary)
1082
+ yield _sse({"id": item_id, "status": "done",
1083
+ "msg": f"{name} 설치 · PATH 연결 · 검증 완료 ✅\n{detail}"})
1084
+ else:
1085
+ yield _sse({"id": item_id, "status": "error",
1086
+ "msg": f"{detail}\n공식 페이지에서 설치 후 다시 시도하세요."})
1087
+ else:
1088
+ yield _sse({"id": item_id, "status": "waiting",
1089
+ "msg": "브라우저에서 설치 또는 인증을 완료한 뒤 다시 시도하세요"})
1090
+
1091
+ else:
1092
+ yield _sse({"id": item_id, "status": "error", "msg": f"알 수 없는 액션: {atype}"})
1093
+
1094
+ yield _sse({"status": "complete", "msg": "모든 항목 처리 완료!"})
1095
+
1096
+
1097
+ async def _pip_install(package: str) -> Tuple[bool, str]:
1098
+ try:
1099
+ proc = await asyncio.create_subprocess_exec(
1100
+ sys.executable, "-m", "pip", "install", "--upgrade", package,
1101
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
1102
+ )
1103
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
1104
+ if proc.returncode == 0:
1105
+ return True, ""
1106
+ return False, stderr.decode(errors="replace")
1107
+ except asyncio.TimeoutError:
1108
+ return False, "설치 시간 초과 (10분)"
1109
+ except Exception as e:
1110
+ return False, str(e)
1111
+
1112
+
1113
+ async def _brew_install(package: str) -> Tuple[bool, str]:
1114
+ brew = shutil.which("brew")
1115
+ if not brew:
1116
+ return False, "Homebrew 미설치 — https://brew.sh 에서 설치하세요"
1117
+ try:
1118
+ proc = await asyncio.create_subprocess_exec(
1119
+ brew, "install", package,
1120
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
1121
+ )
1122
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
1123
+ if proc.returncode == 0:
1124
+ return True, ""
1125
+ return False, stderr.decode(errors="replace")
1126
+ except asyncio.TimeoutError:
1127
+ return False, "설치 시간 초과 (5분)"
1128
+ except Exception as e:
1129
+ return False, str(e)
1130
+
1131
+
1132
+ def open_url(url: str) -> None:
1133
+ try:
1134
+ system = platform.system()
1135
+ if system == "Darwin":
1136
+ subprocess.Popen(["open", url])
1137
+ elif system == "Windows":
1138
+ subprocess.Popen(["start", "", url], shell=True)
1139
+ else:
1140
+ subprocess.Popen(["xdg-open", url])
1141
+ except Exception:
1142
+ pass