ltcai 3.5.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 (181) hide show
  1. package/README.md +73 -35
  2. package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
  3. package/docs/CHANGELOG.md +32 -0
  4. package/docs/HANDOVER_v3.6.0.md +46 -0
  5. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
  6. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  8. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  9. package/docs/architecture.md +13 -12
  10. package/docs/kg-schema.md +102 -53
  11. package/docs/privacy.md +18 -2
  12. package/docs/security-model.md +17 -0
  13. package/kg_schema.py +139 -10
  14. package/knowledge_graph.py +874 -26
  15. package/knowledge_graph_api.py +11 -127
  16. package/latticeai/__init__.py +1 -1
  17. package/latticeai/api/admin.py +1 -1
  18. package/latticeai/api/agents.py +7 -1
  19. package/latticeai/api/auth.py +27 -4
  20. package/latticeai/api/browser.py +217 -0
  21. package/latticeai/api/chat.py +112 -76
  22. package/latticeai/api/health.py +1 -1
  23. package/latticeai/api/hooks.py +1 -1
  24. package/latticeai/api/knowledge_graph.py +146 -0
  25. package/latticeai/api/local_files.py +1 -1
  26. package/latticeai/api/mcp.py +23 -11
  27. package/latticeai/api/memory.py +1 -1
  28. package/latticeai/api/models.py +1 -1
  29. package/latticeai/api/network.py +81 -0
  30. package/latticeai/api/portability.py +93 -0
  31. package/latticeai/api/realtime.py +1 -1
  32. package/latticeai/api/search.py +26 -2
  33. package/latticeai/api/security_dashboard.py +2 -3
  34. package/latticeai/api/setup.py +2 -2
  35. package/latticeai/api/static_routes.py +2 -4
  36. package/latticeai/api/tools.py +3 -0
  37. package/latticeai/api/workflow_designer.py +46 -0
  38. package/latticeai/api/workspace.py +71 -49
  39. package/latticeai/app_factory.py +1710 -0
  40. package/latticeai/brain/__init__.py +18 -0
  41. package/latticeai/brain/context.py +213 -0
  42. package/latticeai/brain/conversations.py +236 -0
  43. package/latticeai/brain/identity.py +175 -0
  44. package/latticeai/brain/memory.py +102 -0
  45. package/latticeai/brain/network.py +205 -0
  46. package/latticeai/core/agent.py +31 -7
  47. package/latticeai/core/audit.py +0 -7
  48. package/latticeai/core/config.py +1 -1
  49. package/latticeai/core/context_builder.py +1 -2
  50. package/latticeai/core/enterprise.py +1 -1
  51. package/latticeai/core/graph_curator.py +2 -2
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/mcp_registry.py +791 -0
  54. package/latticeai/core/model_compat.py +1 -1
  55. package/latticeai/core/model_resolution.py +0 -1
  56. package/latticeai/core/multi_agent.py +238 -4
  57. package/latticeai/core/security.py +1 -1
  58. package/latticeai/core/sessions.py +37 -7
  59. package/latticeai/core/workflow_engine.py +114 -2
  60. package/latticeai/core/workspace_os.py +58 -10
  61. package/latticeai/models/__init__.py +7 -0
  62. package/latticeai/models/router.py +779 -0
  63. package/latticeai/server_app.py +29 -1504
  64. package/latticeai/services/agent_runtime.py +1 -0
  65. package/latticeai/services/app_context.py +75 -14
  66. package/latticeai/services/ingestion.py +318 -0
  67. package/latticeai/services/kg_portability.py +207 -0
  68. package/latticeai/services/memory_service.py +39 -11
  69. package/latticeai/services/model_runtime.py +2 -5
  70. package/latticeai/services/platform_runtime.py +100 -23
  71. package/latticeai/services/search_service.py +17 -8
  72. package/latticeai/services/tool_dispatch.py +12 -2
  73. package/latticeai/services/triggers.py +241 -0
  74. package/latticeai/services/upload_service.py +37 -12
  75. package/latticeai/services/workspace_service.py +31 -0
  76. package/llm_router.py +29 -772
  77. package/ltcai_cli.py +1 -2
  78. package/mcp_registry.py +25 -788
  79. package/p_reinforce.py +124 -14
  80. package/package.json +11 -8
  81. package/scripts/build_vsix.mjs +72 -0
  82. package/scripts/bump_version.py +99 -0
  83. package/scripts/generate_diagrams.py +0 -1
  84. package/scripts/lint_v3.mjs +82 -18
  85. package/scripts/validate_release_artifacts.py +0 -1
  86. package/scripts/wheel_smoke.py +142 -0
  87. package/server.py +11 -7
  88. package/setup_wizard.py +1142 -0
  89. package/static/account.html +2 -4
  90. package/static/admin.html +3 -5
  91. package/static/chat.html +3 -6
  92. package/static/graph.html +2 -4
  93. package/static/sw.js +81 -52
  94. package/static/v3/asset-manifest.json +20 -19
  95. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  96. package/static/v3/css/lattice.base.css +1 -1
  97. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  98. package/static/v3/css/lattice.components.css +1 -1
  99. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  100. package/static/v3/css/lattice.shell.css +1 -1
  101. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  102. package/static/v3/css/lattice.tokens.css +3 -0
  103. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  104. package/static/v3/css/lattice.views.css +2 -2
  105. package/static/v3/index.html +3 -4
  106. package/static/v3/js/{app.d086489d.js → app.356e6452.js} +1 -1
  107. package/static/v3/js/core/{api.12b568ad.js → api.7a308b89.js} +39 -1
  108. package/static/v3/js/core/api.js +38 -0
  109. package/static/v3/js/core/{routes.d214b399.js → routes.7222343d.js} +22 -22
  110. package/static/v3/js/core/routes.js +22 -22
  111. package/static/v3/js/core/{shell.d05266f5.js → shell.a1657f20.js} +4 -4
  112. package/static/v3/js/core/shell.js +1 -1
  113. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  114. package/static/v3/js/core/store.js +1 -1
  115. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  116. package/static/v3/js/views/graph-canvas.js +509 -0
  117. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  118. package/static/v3/js/views/hybrid-search.js +1 -2
  119. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +509 -0
  120. package/static/v3/js/views/knowledge-graph.js +326 -54
  121. package/static/vendor/chart.umd.min.js +20 -0
  122. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  123. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  124. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  125. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  126. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  127. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  128. package/static/vendor/fonts/inter.css +44 -0
  129. package/static/vendor/icons/tabler-icons.min.css +4 -0
  130. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  131. package/static/vendor/marked.min.js +69 -0
  132. package/static/workspace.html +2 -2
  133. package/telegram_bot.py +1 -2
  134. package/tools/commands.py +4 -2
  135. package/tools/computer.py +1 -1
  136. package/tools/documents.py +1 -3
  137. package/tools/filesystem.py +0 -4
  138. package/tools/knowledge.py +1 -3
  139. package/tools/network.py +1 -3
  140. package/codex_telegram_bot.py +0 -195
  141. package/docs/assets/v3.4.0/agent-run.png +0 -0
  142. package/docs/assets/v3.4.0/agents.png +0 -0
  143. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  144. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  145. package/docs/assets/v3.4.0/chat.png +0 -0
  146. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  147. package/docs/assets/v3.4.0/files.png +0 -0
  148. package/docs/assets/v3.4.0/home.png +0 -0
  149. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  150. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  151. package/docs/assets/v3.4.0/local-agent.png +0 -0
  152. package/docs/assets/v3.4.0/memory.png +0 -0
  153. package/docs/assets/v3.4.0/settings.png +0 -0
  154. package/docs/assets/v3.4.0/vision-input.png +0 -0
  155. package/docs/assets/v3.4.0/workflows.png +0 -0
  156. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  157. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  158. package/docs/assets/v3.4.1/local-agent.png +0 -0
  159. package/docs/images/admin-dashboard.png +0 -0
  160. package/docs/images/architecture.png +0 -0
  161. package/docs/images/enterprise.png +0 -0
  162. package/docs/images/graph.png +0 -0
  163. package/docs/images/hero.gif +0 -0
  164. package/docs/images/knowledge-graph.png +0 -0
  165. package/docs/images/lattice-ai-demo.gif +0 -0
  166. package/docs/images/lattice-ai-hero.png +0 -0
  167. package/docs/images/logo.svg +0 -33
  168. package/docs/images/mobile-responsive.png +0 -0
  169. package/docs/images/model-recommendation.png +0 -0
  170. package/docs/images/onboarding.png +0 -0
  171. package/docs/images/organization.png +0 -0
  172. package/docs/images/pipeline.png +0 -0
  173. package/docs/images/screenshot-admin.png +0 -0
  174. package/docs/images/screenshot-chat.png +0 -0
  175. package/docs/images/screenshot-graph.png +0 -0
  176. package/docs/images/skills.png +0 -0
  177. package/docs/images/workspace-dark.png +0 -0
  178. package/docs/images/workspace-light.png +0 -0
  179. package/docs/images/workspace.png +0 -0
  180. package/requirements.txt +0 -16
  181. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
@@ -1,1521 +1,46 @@
1
+ """Lazy compatibility facade over :mod:`latticeai.app_factory`.
2
+
3
+ The application used to be assembled here at import time (MLX/GPU init,
4
+ ~15 singletons, file creation under the data dir). All of that now lives in
5
+ ``latticeai.app_factory.create_app``. This module keeps every historical
6
+ module-level name (``app``, ``KNOWLEDGE_GRAPH``, ``load_users``,
7
+ ``save_to_history``, …) importable for tests and legacy callers via module
8
+ ``__getattr__`` — construction happens on *first attribute access*, never on
9
+ import. Importing ``latticeai.server_app`` has no side effects.
1
10
  """
2
- Lattice AI MLX — Local LLM Bridge Server
3
- Apple Silicon (M1-M5) 전용 | MLX-VLM 기반
4
- """
5
-
6
- import asyncio
7
- import hashlib
8
- import json
9
- import logging
10
- import os
11
- import re
12
- import secrets
13
- import threading
14
- import subprocess
15
- import sys
16
- import time
17
- from contextlib import asynccontextmanager
18
- from pathlib import Path
19
-
20
- try:
21
- import mlx.core as mx
22
- mx.set_default_device(mx.gpu)
23
- print("✅ MLX Metal context initialized in main thread.")
24
- except Exception as e:
25
- print(f"⚠️ MLX Metal context unavailable: {e}")
26
- mx = None
27
- from typing import Optional, List, Dict
28
-
29
- import uvicorn
30
- from fastapi import FastAPI, HTTPException, Request
31
- from fastapi.middleware.cors import CORSMiddleware
32
- from fastapi.staticfiles import StaticFiles
33
- from pydantic import BaseModel
34
-
35
- from llm_router import LLMRouter, normalize_branding
36
- from knowledge_graph import KnowledgeGraphStore, set_llm_router
37
- from local_knowledge_api import LocalKnowledgeWatcher
38
- from latticeai.core.security import (
39
- hash_password,
40
- verify_password,
41
- host_is_loopback as _host_is_loopback_impl,
42
- client_ip as _client_ip_impl,
43
- configure_trusted_proxies as _configure_trusted_proxies,
44
- bytes_match_extension as _bytes_match_extension_impl,
45
- redact_secret_text as _redact_secret_text,
46
- check_ip_rate_limit as _check_ip_rate_limit,
47
- enforce_rate_limit as _enforce_rate_limit,
48
- )
49
- from latticeai.core.sessions import SessionStore as _SessionStore
50
- from latticeai.core.audit import (
51
- get_audit_log as _get_audit_log,
52
- append_audit_event as _append_audit_event,
53
- classify_sensitive_message as _classify_sensitive_message,
54
- mask_sensitive_text as _mask_sensitive_text,
55
- build_sensitivity_report as _build_sensitivity_report,
56
- build_admin_audit_report as _build_admin_audit_report,
57
- )
58
- from latticeai.api.auth import create_auth_router
59
- from latticeai.api.admin import create_admin_router
60
- from latticeai.api.security_dashboard import create_security_router as _create_security_router
61
- from latticeai.core.model_compat import list_cached_profiles as _list_compat_profiles
62
- from latticeai.core.config import Config
63
- from latticeai.core.workspace_os import (
64
- WORKSPACE_OS_VERSION,
65
- WorkspaceOSStore,
66
- remove_skill_directory,
67
- )
68
- from latticeai.core.enterprise import (
69
- EnterpriseCapability,
70
- capability_registry,
71
- detect_edition,
72
- )
73
- from latticeai.services.workspace_service import WorkspaceService
74
- from latticeai.services.model_service import ModelService
75
- from latticeai.services.chat_service import ChatService
76
- from latticeai.services.search_service import SearchService
77
- from latticeai.core.embedding_providers import resolve_embedder, resolve_embedding_profile
78
- from latticeai.services.agent_runtime import AgentRuntime
79
- from latticeai.services.model_runtime import (
80
- CLOUD_VERIFY_TTL_SECONDS,
81
- ENGINE_MODEL_CATALOG,
82
- LOCAL_SERVER_PROCESSES,
83
- MODEL_ENGINE_ALIASES,
84
- configure_model_runtime,
85
- download_hf_model,
86
- engine_status,
87
- filter_lower_family_versions,
88
- install_engine,
89
- local_binary,
90
- normalize_local_model_request,
91
- prepare_and_load_model,
92
- prepare_and_load_model_stream,
93
- runtime_features,
94
- sse_event,
95
- verify_cloud_models,
96
- ensure_ollama_server,
97
- )
98
- from latticeai.api.workspace import create_workspace_router, _workspace_scope_from_request
99
- from latticeai.api.health import create_health_router
100
- # ── v2 Agentic Workspace Platform layers ─────────────────────────────────────
101
- from latticeai.core.plugins import PluginRegistry
102
- from latticeai.core.realtime import RealtimeBus
103
- from latticeai.core.marketplace import TemplateCatalog
104
- from latticeai.services.platform_runtime import PlatformRuntime
105
- from latticeai.api.plugins import create_plugins_router
106
- from latticeai.api.workflow_designer import create_workflow_designer_router
107
- from latticeai.api.agents import create_agents_router
108
- from latticeai.api.realtime import create_realtime_router
109
- from latticeai.api.marketplace import create_marketplace_router
110
- from latticeai.api.models import create_models_router
111
- from latticeai.api.chat import create_chat_router
112
- from latticeai.api.search import create_search_router
113
- from latticeai.api.tools import create_tools_router
114
- from latticeai.api.static_routes import create_static_routes_router
115
- from latticeai.api.garden import create_garden_router
116
- from latticeai.api.setup import create_setup_router
117
- from latticeai.api.hooks import create_hooks_router
118
- from latticeai.core.hooks import HooksRegistry
119
- from latticeai.core.builtin_hooks import register_builtin_hook_runners
120
- from latticeai.api.agent_registry import create_agent_registry_router
121
- from latticeai.core.agent_registry import AgentRegistry
122
- from latticeai.api.memory import create_memory_router
123
- from latticeai.services.memory_service import MemoryService
124
- from latticeai.services.tool_dispatch import (
125
- LOCAL_WRITE_BLOCKED_PREFIXES as _LOCAL_WRITE_BLOCKED_PREFIXES,
126
- TOOL_GOVERNANCE,
127
- TOOL_GOVERNANCE_DEFAULT as _TOOL_GOVERNANCE_DEFAULT,
128
- agent_risk as _agent_risk,
129
- check_tool_role as _check_tool_role,
130
- configure_tool_dispatch,
131
- get_tool_permission,
132
- list_tool_permissions,
133
- tool_response as _tool_response,
134
- )
135
- from latticeai.core.tool_registry import TOOL_CATALOG_BRIEF as _TOOL_CATALOG_BRIEF
136
- from mcp_registry import (
137
- MCP_REGISTRY, _THIRD_PARTY_SKILL_SOURCES, _KNOWN_REPO_LICENSES,
138
- _MARKETPLACE_RAW, _MARKETPLACE_API,
139
- _fetch_remote_mcp_registry, _get_combined_registry,
140
- _extract_skill_desc, _fetch_plugin_skills,
141
- _fetch_skills_marketplace, _fetch_plugin_directory,
142
- install_skill, SKILLS_DIR,
143
- )
144
- from p_reinforce import PReinforceGardener
145
- from setup import get_recommendations, scan_environment
146
- from tools import ensure_agent_root
147
-
148
- try:
149
- import keyring
150
- except Exception:
151
- keyring = None
152
-
153
- from datetime import datetime
154
-
155
- # ── App-level config — parsed once, in one place (latticeai.core.config) ──────
156
- # The module-level names below are kept as a compatibility surface for the rest
157
- # of server.py; all of them are now derived from a single CONFIG instance.
158
- CONFIG = Config.from_env()
159
- APP_VERSION = WORKSPACE_OS_VERSION
160
-
161
- # Forwarded headers (X-Forwarded-For / CF-Connecting-IP) are only honoured for
162
- # IP rate limiting when the direct peer is one of these trusted proxies. Empty by
163
- # default (local-first): the peer address is used and client-supplied headers are
164
- # ignored, so per-IP rate limits cannot be spoofed.
165
- _configure_trusted_proxies(CONFIG.trusted_proxies)
166
-
167
- APP_MODE = CONFIG.app_mode
168
- IS_PUBLIC_MODE = CONFIG.is_public
169
- DEFAULT_HOST = CONFIG.host
170
- DEFAULT_PORT = CONFIG.port
171
- def _host_is_loopback(host: str) -> bool:
172
- return _host_is_loopback_impl(host)
173
-
174
- NETWORK_EXPOSED = CONFIG.network_exposed
175
- ENABLE_TELEGRAM = CONFIG.enable_telegram
176
- ENABLE_GRAPH = CONFIG.enable_graph
177
- AUTOLOAD_MODELS = CONFIG.autoload_models
178
- MODEL_IDLE_UNLOAD_SECONDS = CONFIG.model_idle_unload_seconds
179
- ALLOW_LOCAL_MODELS = CONFIG.allow_local_models
180
- REQUIRE_AUTH = CONFIG.require_auth
181
- ALLOW_PLAINTEXT_API_KEYS = CONFIG.allow_plaintext_api_keys
182
- CORS_ALLOW_NETWORK = CONFIG.cors_allow_network
183
- CORS_EXTRA_ORIGINS = CONFIG.cors_extra_origins
184
- PUBLIC_MODEL = CONFIG.public_model
185
- LOCAL_MODEL = CONFIG.local_model
186
- LOCAL_DRAFT_MODEL = CONFIG.local_draft_model
187
-
188
- # ── SSO / OIDC config ─────────────────────────────────────────────────────────
189
- SSO_DISCOVERY_URL = CONFIG.sso_discovery_url
190
- SSO_CLIENT_ID = CONFIG.sso_client_id
191
- SSO_CLIENT_SECRET = CONFIG.sso_client_secret
192
- SSO_REDIRECT_URI = CONFIG.sso_redirect_uri
193
- SSO_PROVIDER_NAME = CONFIG.sso_provider_name
194
- _sso_discovery_cache: Optional[Dict] = None
195
- _sso_discovery_cache_url: str = ""
196
- _sso_states: Dict[str, float] = {} # state → timestamp (CSRF protection)
197
-
198
- async def _get_sso_discovery() -> Optional[Dict]:
199
- global _sso_discovery_cache, _sso_discovery_cache_url
200
- settings = get_sso_settings()
201
- discovery_url = settings.get("discovery_url", "")
202
- if _sso_discovery_cache and _sso_discovery_cache_url == discovery_url:
203
- return _sso_discovery_cache
204
- if not discovery_url:
205
- return None
206
- try:
207
- import httpx as _httpx
208
- async with _httpx.AsyncClient() as c:
209
- r = await c.get(discovery_url, timeout=10)
210
- r.raise_for_status()
211
- _sso_discovery_cache = r.json()
212
- _sso_discovery_cache_url = discovery_url
213
- except Exception as e:
214
- logging.warning("SSO discovery failed: %s", e)
215
- return None
216
- return _sso_discovery_cache
217
-
218
- # ── Password hashing — used directly from latticeai.core.security ──────────────
219
- # (hash_password / verify_password are imported above; no local wrapper needed)
220
- def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict) -> bool:
221
- """평문 비밀번호를 투명하게 해시로 마이그레이션. 마이그레이션 발생 시 audit log 남김."""
222
- if ":" in stored and len(stored) > 64:
223
- return verify_password(plain, stored)
224
- if plain == stored:
225
- users[email]["password"] = hash_password(plain)
226
- save_users(users)
227
- try:
228
- append_audit_event("password_migrated_from_plaintext", user_email=email)
229
- except Exception as e:
230
- logging.warning("audit log failed on password migration: %s", e)
231
- logging.info("Migrated plaintext password to scrypt hash for %s", email)
232
- return True
233
- return False
234
-
235
- # ── Session store — delegated to latticeai.core.sessions ──────────────────────
236
- _SESSION_TTL = 60 * 60 * 24
237
- _session_store = _SessionStore()
238
-
239
- def _check_rate_limit(ip: str, action: str, max_calls: int, window_secs: float) -> None:
240
- _check_ip_rate_limit(ip, action, max_calls=max_calls, window_secs=window_secs)
241
-
242
- def _client_ip(request: Request) -> str:
243
- return _client_ip_impl(request)
244
-
245
- def create_session(email: str) -> str:
246
- return _session_store.create(email)
247
-
248
- def get_session_email(token: str) -> Optional[str]:
249
- return _session_store.get_email(token)
250
-
251
- def invalidate_session(token: str) -> None:
252
- _session_store.invalidate(token)
253
-
254
- # ── User Management Logic ──────────────────────────────────────────────────
255
- BASE_DIR = Path(__file__).resolve().parent.parent
256
- DATA_DIR = CONFIG.data_dir
257
- DATA_DIR.mkdir(parents=True, exist_ok=True)
258
- STATIC_DIR = CONFIG.static_dir
259
-
260
- USERS_FILE = DATA_DIR / "users.json"
261
- HISTORY_FILE = DATA_DIR / "chat_history.json"
262
- VPC_FILE = DATA_DIR / "vpc_config.json"
263
- MCP_FILE = DATA_DIR / "mcp_installs.json"
264
- AUDIT_FILE = DATA_DIR / "audit_log.json"
265
- SSO_FILE = DATA_DIR / "sso_config.json"
266
- # Resolve the configured embedding provider once at startup. Degrades to the
267
- # offline hash fallback when the requested provider is unavailable, while
268
- # recording the requested-vs-active provider for the Embeddings status surface.
269
- try:
270
- EMBEDDING_PROFILE = resolve_embedding_profile(CONFIG.embedding_profile)
271
- except ValueError as exc:
272
- logging.warning("Embedding profile ignored: %s", exc)
273
- EMBEDDING_PROFILE = {}
274
- _embedding_provider = CONFIG.embedding_provider
275
- _embedding_model = CONFIG.embedding_model or str(EMBEDDING_PROFILE.get("model") or "")
276
- _embedding_dim = CONFIG.embedding_dim or int(EMBEDDING_PROFILE.get("dimensions") or 0)
277
- if CONFIG.embedding_profile and CONFIG.embedding_provider in {"", "hash", "local", "fallback"}:
278
- _embedding_provider = str(EMBEDDING_PROFILE.get("provider") or CONFIG.embedding_provider)
279
-
280
- EMBEDDER = resolve_embedder(
281
- _embedding_provider,
282
- model=_embedding_model,
283
- base_url=CONFIG.embedding_base_url,
284
- api_key=CONFIG.embedding_api_key,
285
- dim=_embedding_dim,
286
- timeout=CONFIG.embedding_timeout,
287
- extra={"target": CONFIG.embedding_custom_target},
288
- probe=_embedding_provider not in {"", "hash", "local", "fallback"},
289
- )
290
- if EMBEDDER.fell_back:
291
- logging.warning("Embedding provider %s unavailable: %s", EMBEDDER.requested, EMBEDDER.detail)
292
- KNOWLEDGE_GRAPH = KnowledgeGraphStore(
293
- DATA_DIR / "knowledge_graph.sqlite",
294
- DATA_DIR / "knowledge_graph_blobs",
295
- embedder=EMBEDDER.provider,
296
- ) if ENABLE_GRAPH else None
297
- # Hooks registry is constructed here (ahead of the watcher) so folder-watch
298
- # reindexes can fire the pre_index/post_index lifecycle hooks.
299
- HOOKS_REGISTRY = HooksRegistry(DATA_DIR / "hooks.json")
300
- LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH, hooks=HOOKS_REGISTRY) if ENABLE_GRAPH else None
301
- # ── v2 Realtime bus: constructed first so the store can fan every timeline
302
- # event into the realtime feed via a single additive sink (no per-call wiring).
303
- REALTIME_BUS = RealtimeBus()
304
- WORKSPACE_OS = WorkspaceOSStore(DATA_DIR, event_sink=REALTIME_BUS)
305
- # Service layer (latticeai.services) wraps the store with scope/permission
306
- # guardrails; routers and the app assembly share this single instance.
307
- WORKSPACE_SERVICE = WorkspaceService(WORKSPACE_OS)
308
- # ── v2 Plugin SDK registry (extends skills; discovers plugins/<id>/plugin.json)
309
- PLUGINS_DIR = Path(os.getenv("LATTICEAI_PLUGINS_DIR") or (BASE_DIR / "plugins"))
310
- PLUGIN_REGISTRY = PluginRegistry(PLUGINS_DIR, store=WORKSPACE_OS)
311
- TEMPLATE_CATALOG = TemplateCatalog()
312
- # ── v3.2 platform registries: lifecycle hooks + agent registry, persisted under
313
- # DATA_DIR so the /app Hooks and Agent Registry views read/write real state.
314
- # (HOOKS_REGISTRY is constructed earlier, before the local-knowledge watcher.)
315
- AGENT_REGISTRY = AgentRegistry(DATA_DIR / "agent_registry.json")
316
- # Unified long-term memory platform fronting workspace memories, agent
317
- # snapshots, conversation history, and the KG graph/vector index.
318
- MEMORY_SERVICE = MemoryService(
319
- store=WORKSPACE_OS,
320
- data_dir=DATA_DIR,
321
- knowledge_graph=KNOWLEDGE_GRAPH,
322
- enable_graph=ENABLE_GRAPH,
323
- history_file=HISTORY_FILE,
324
- )
325
-
326
- def _require_graph():
327
- if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
328
- raise HTTPException(status_code=404, detail="지식 그래프가 비활성화되어 있습니다. LATTICEAI_ENABLE_GRAPH=true 설정 후 다시 시도해 주세요.")
329
-
330
- class UserRegister(BaseModel):
331
- email: str
332
- password: str
333
- name: str
334
- nickname: str
335
-
336
- class UserLogin(BaseModel):
337
- email: str
338
- password: str
339
-
340
- class AdminUserUpdate(BaseModel):
341
- role: Optional[str] = None
342
- disabled: Optional[bool] = None
343
-
344
- class VpcConfigUpdate(BaseModel):
345
- provider: Optional[str] = None
346
- region: Optional[str] = None
347
- cidr_block: Optional[str] = None
348
- private_subnets: Optional[List[str]] = None
349
- endpoint: Optional[str] = None
350
- vpn_status: Optional[str] = None
351
- peering_status: Optional[str] = None
352
- notes: Optional[str] = None
353
-
354
- class SsoConfigUpdate(BaseModel):
355
- enabled: Optional[bool] = None
356
- provider_name: Optional[str] = None
357
- discovery_url: Optional[str] = None
358
- client_id: Optional[str] = None
359
- client_secret: Optional[str] = None
360
- redirect_uri: Optional[str] = None
361
- scopes: Optional[str] = None
362
-
363
- def _sso_env_defaults() -> Dict[str, object]:
364
- return {
365
- "enabled": bool(SSO_DISCOVERY_URL and SSO_CLIENT_ID and SSO_CLIENT_SECRET),
366
- "provider_name": SSO_PROVIDER_NAME,
367
- "discovery_url": SSO_DISCOVERY_URL,
368
- "client_id": SSO_CLIENT_ID,
369
- "client_secret": SSO_CLIENT_SECRET,
370
- "redirect_uri": SSO_REDIRECT_URI,
371
- "scopes": "openid email profile",
372
- }
373
-
374
- def load_sso_config() -> Dict[str, object]:
375
- config = _sso_env_defaults()
376
- if SSO_FILE.exists():
377
- try:
378
- data = json.loads(SSO_FILE.read_text(encoding="utf-8"))
379
- if isinstance(data, dict):
380
- config.update({k: v for k, v in data.items() if v is not None})
381
- except Exception as e:
382
- logging.warning("load_sso_config failed (using env/defaults): %s", e)
383
- config["provider_name"] = str(config.get("provider_name") or "SSO")
384
- config["discovery_url"] = str(config.get("discovery_url") or "")
385
- config["client_id"] = str(config.get("client_id") or "")
386
- config["client_secret"] = str(config.get("client_secret") or "")
387
- config["redirect_uri"] = str(config.get("redirect_uri") or SSO_REDIRECT_URI)
388
- config["scopes"] = str(config.get("scopes") or "openid email profile")
389
- config["enabled"] = bool(config.get("enabled")) and bool(
390
- config["discovery_url"] and config["client_id"] and config["client_secret"]
391
- )
392
- return config
393
-
394
- def get_sso_settings() -> Dict[str, object]:
395
- return load_sso_config()
396
-
397
- def public_sso_config(config: Optional[Dict[str, object]] = None) -> Dict[str, object]:
398
- cfg = config or get_sso_settings()
399
- return {
400
- "enabled": bool(cfg.get("enabled")),
401
- "provider_name": cfg.get("provider_name") or "",
402
- "discovery_url": cfg.get("discovery_url") or "",
403
- "client_id": cfg.get("client_id") or "",
404
- "redirect_uri": cfg.get("redirect_uri") or SSO_REDIRECT_URI,
405
- "scopes": cfg.get("scopes") or "openid email profile",
406
- "secret_configured": bool(cfg.get("client_secret")),
407
- }
408
-
409
- def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
410
- global _sso_discovery_cache, _sso_discovery_cache_url
411
- current = load_sso_config()
412
- if update.get("client_secret") == "":
413
- update.pop("client_secret", None)
414
- current.update({k: v for k, v in update.items() if v is not None})
415
- current["enabled"] = bool(current.get("enabled")) and bool(
416
- current.get("discovery_url") and current.get("client_id") and current.get("client_secret")
417
- )
418
- SSO_FILE.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
419
- _sso_discovery_cache = None
420
- _sso_discovery_cache_url = ""
421
- return current
422
-
423
- # MCP/skill request models moved to latticeai.api.mcp (v1.3.0).
424
- DEFAULT_VPC_CONFIG = {
425
- "provider": "AWS",
426
- "region": "ap-northeast-2",
427
- "cidr_block": "10.42.0.0/16",
428
- "private_subnets": ["10.42.10.0/24", "10.42.20.0/24"],
429
- "endpoint": "ltcai-private.local",
430
- "vpn_status": "standby",
431
- "peering_status": "not_configured",
432
- "notes": "로컬 MLX 브릿지를 프라이빗 서브넷 또는 VPN 뒤에서 운영할 때 쓰는 네트워크 프로필입니다.",
433
- "updated_at": None,
434
- }
435
-
436
-
437
- def load_users():
438
- if not os.path.exists(USERS_FILE):
439
- return {}
440
- with open(USERS_FILE, "r", encoding="utf-8") as f:
441
- return json.load(f)
442
-
443
- def save_users(users):
444
- with open(USERS_FILE, "w", encoding="utf-8") as f:
445
- json.dump(users, f, ensure_ascii=False, indent=2)
446
-
447
- def load_vpc_config() -> Dict:
448
- if not os.path.exists(VPC_FILE):
449
- return DEFAULT_VPC_CONFIG.copy()
450
- try:
451
- with open(VPC_FILE, "r", encoding="utf-8") as f:
452
- stored = json.load(f)
453
- return {**DEFAULT_VPC_CONFIG, **stored}
454
- except Exception as e:
455
- logging.warning("load_vpc_config failed (using defaults): %s", e)
456
- return DEFAULT_VPC_CONFIG.copy()
457
-
458
- def save_vpc_config(config: Dict):
459
- config["updated_at"] = datetime.now().isoformat()
460
- with open(VPC_FILE, "w", encoding="utf-8") as f:
461
- json.dump(config, f, ensure_ascii=False, indent=2)
462
-
463
- def load_mcp_installs() -> Dict:
464
- if not os.path.exists(MCP_FILE):
465
- return {"installed": {}, "updated_at": None}
466
- try:
467
- with open(MCP_FILE, "r", encoding="utf-8") as f:
468
- data = json.load(f)
469
- if "installed" not in data:
470
- data["installed"] = {}
471
- return data
472
- except Exception as e:
473
- logging.warning("load_mcp_installs failed: %s", e)
474
- return {"installed": {}, "updated_at": None}
475
-
476
- def save_mcp_installs(data: Dict):
477
- data["updated_at"] = datetime.now().isoformat()
478
- with open(MCP_FILE, "w", encoding="utf-8") as f:
479
- json.dump(data, f, ensure_ascii=False, indent=2)
480
-
481
- def mcp_public_item(item: Dict, installed_state: Dict) -> Dict:
482
- state = installed_state.get(item["id"]) or {}
483
- installed = item["install_mode"] in {"builtin", "bundled"} or bool(state.get("installed"))
484
- connector_pending = item["install_mode"] == "connector" and not state.get("authenticated")
485
- authenticated = item["install_mode"] != "connector" or bool(state.get("authenticated"))
486
- return {
487
- "id": item["id"],
488
- "name": item["name"],
489
- "category": item.get("category", ""),
490
- "install_mode": item["install_mode"],
491
- "description": item.get("description", ""),
492
- "capabilities": item.get("capabilities", []),
493
- "connector_url": item.get("connector_url"),
494
- "external_url": item.get("external_url"),
495
- "package": item.get("package"),
496
- "homepage": item.get("homepage"),
497
- "source": item.get("source", "local"),
498
- "installed": installed,
499
- "status": state.get("status") or ("active" if installed and not connector_pending else "needs_auth" if connector_pending else "available"),
500
- "authenticated": authenticated,
501
- "updated_at": state.get("updated_at"),
502
- }
503
-
504
- async def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
505
- text = (query or "").lower()
506
- installed = load_mcp_installs().get("installed", {})
507
- registry = await _get_combined_registry()
508
- scored = []
509
- for item in registry:
510
- score = 0
511
- hits = []
512
- for keyword in item.get("keywords", []):
513
- if keyword.lower() in text:
514
- score += 3 if len(keyword) > 2 else 1
515
- hits.append(keyword)
516
- # description 키워드 매칭 (remote 항목 보완)
517
- if not hits and text:
518
- desc_words = item.get("description", "").lower().split()
519
- for word in text.split():
520
- if len(word) > 2 and word in desc_words:
521
- score += 1
522
- hits.append(word)
523
- if item["id"] == "filesystem" and any(word in text for word in ["만들", "구현", "build", "deploy", "코드", "앱"]):
524
- score += 2
525
- if score:
526
- public = mcp_public_item(item, installed)
527
- public["score"] = score
528
- public["matched_keywords"] = hits[:6]
529
- scored.append(public)
530
- if not scored:
531
- fallback_ids = ["filesystem", "browser", "documents"]
532
- scored = [
533
- {**mcp_public_item(item, installed), "score": 1, "matched_keywords": []}
534
- for item in registry
535
- if item["id"] in fallback_ids
536
- ]
537
- return sorted(scored, key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 24))]
538
-
539
- async def install_mcp(mcp_id: str) -> Dict:
540
- registry = await _get_combined_registry()
541
- item = next((entry for entry in registry if entry["id"] == mcp_id), None)
542
- if not item:
543
- raise HTTPException(status_code=404, detail="MCP를 찾을 수 없습니다.")
544
- data = load_mcp_installs()
545
- state = data.setdefault("installed", {})
546
- status = "active"
547
- message = "MCP가 활성화되었습니다."
548
- if item["install_mode"] == "connector":
549
- status = "needs_auth"
550
- message = "커넥터 인증이 필요합니다. Codex 앱의 connector 설정에서 계정을 연결하면 바로 사용할 수 있습니다."
551
- elif item["install_mode"] == "pip":
552
- packages = item.get("pip_packages") or []
553
- for pkg in packages:
554
- completed = subprocess.run(
555
- [sys.executable, "-m", "pip", "install", "--upgrade", pkg],
556
- capture_output=True, text=True, timeout=900, check=False,
557
- )
558
- if completed.returncode != 0:
559
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
560
- message = f"필수 패키지 설치 완료: {', '.join(packages)}"
561
- elif item["install_mode"] == "pypi":
562
- pkg = item.get("package", "")
563
- version = item.get("package_version")
564
- pkg_str = f"{pkg}=={version}" if version else pkg
565
- completed = subprocess.run(
566
- [sys.executable, "-m", "pip", "install", pkg_str],
567
- capture_output=True, text=True, timeout=300, check=False,
568
- )
569
- if completed.returncode != 0:
570
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
571
- message = f"pip 패키지 설치 완료: {pkg_str}"
572
- elif item["install_mode"] == "npm":
573
- pkg = item.get("package", "")
574
- version = item.get("package_version")
575
- pkg_str = f"{pkg}@{version}" if version else pkg
576
- completed = subprocess.run(
577
- ["npm", "install", "-g", pkg_str],
578
- capture_output=True, text=True, timeout=300, check=False,
579
- )
580
- if completed.returncode != 0:
581
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
582
- message = f"npm 패키지 설치 완료: {pkg_str}"
583
- state[mcp_id] = {
584
- "installed": True,
585
- "status": status,
586
- "authenticated": item["install_mode"] != "connector",
587
- "updated_at": datetime.now().isoformat(),
588
- }
589
- save_mcp_installs(data)
590
- public = mcp_public_item(item, state)
591
- public["message"] = message
592
- return public
593
-
594
- _history_lock = threading.Lock()
595
-
596
- def get_audit_log() -> List[Dict]:
597
- return _get_audit_log(AUDIT_FILE)
598
-
599
- def append_audit_event(event_type: str, **payload) -> None:
600
- _append_audit_event(AUDIT_FILE, event_type, **payload)
601
-
602
- def save_to_history(
603
- role: str,
604
- message: str,
605
- user_email: Optional[str] = None,
606
- user_nickname: Optional[str] = None,
607
- source: Optional[str] = None,
608
- conversation_id: Optional[str] = None,
609
- ):
610
- try:
611
- message = redact_secret_text(message)
612
- if role == "assistant":
613
- message = normalize_branding(message)
614
- item = {"role": role, "content": message, "timestamp": datetime.now().isoformat()}
615
- if user_email:
616
- item["user_email"] = user_email
617
- if user_nickname:
618
- item["user_nickname"] = user_nickname
619
- if source:
620
- item["source"] = source
621
- if conversation_id:
622
- item["conversation_id"] = conversation_id
623
- sensitive = classify_sensitive_message(item, -1)
624
- append_audit_event(
625
- "chat_message",
626
- role=role,
627
- user_email=user_email,
628
- user_nickname=user_nickname,
629
- source=source,
630
- conversation_id=conversation_id,
631
- content_preview=sensitive.get("preview"),
632
- content_chars=len(message or ""),
633
- sensitivity=sensitive.get("sensitivity"),
634
- sensitive_labels=sensitive.get("labels") or [],
635
- )
636
- with _history_lock:
637
- history = []
638
- if os.path.exists(HISTORY_FILE):
639
- with open(HISTORY_FILE, "r", encoding="utf-8") as f:
640
- history = json.load(f)
641
- history.append(item)
642
- if len(history) > 50:
643
- history = history[-50:]
644
- tmp_path = str(HISTORY_FILE) + ".tmp"
645
- with open(tmp_path, "w", encoding="utf-8") as f:
646
- json.dump(history, f, ensure_ascii=False, indent=2)
647
- os.replace(tmp_path, HISTORY_FILE)
648
- try:
649
- if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
650
- KNOWLEDGE_GRAPH.ingest_message(
651
- role,
652
- message,
653
- user_email=user_email,
654
- user_nickname=user_nickname,
655
- source=source,
656
- conversation_id=conversation_id,
657
- raw=item,
658
- )
659
- except Exception as graph_error:
660
- logging.warning("knowledge graph message ingest failed: %s", graph_error)
661
- except Exception as e:
662
- logging.warning("save_to_history failed: %s", e)
663
-
664
- def redact_secret_text(text: str) -> str:
665
- return _redact_secret_text(text)
666
-
667
- def get_history():
668
- if not os.path.exists(HISTORY_FILE):
669
- return []
670
- try:
671
- with open(HISTORY_FILE, "r", encoding="utf-8") as f:
672
- return json.load(f)
673
- except Exception as e:
674
- logging.warning("get_history failed: %s", e)
675
- return []
676
-
677
- # Chat service seam: behaviour-preserving façade for history access and
678
- # Workspace-OS answer-trace recording used by the (unchanged) streaming chat path.
679
- CHAT_SERVICE = ChatService(store=WORKSPACE_OS, get_history=get_history)
680
-
681
- def conversation_title(item: Dict) -> str:
682
- content = str(item.get("content") or "").strip()
683
- content = re.sub(r"\s+", " ", content)
684
- return content[:48] or "새 대화"
685
-
686
- def group_history_conversations(history: Optional[List[Dict]] = None) -> List[Dict]:
687
- history = history if history is not None else get_history()
688
- conversations: Dict[str, Dict] = {}
689
- order: List[str] = []
690
-
691
- for index, item in enumerate(history):
692
- conv_id = item.get("conversation_id")
693
- if not conv_id:
694
- conv_id = "legacy-previous-history"
695
-
696
- if conv_id not in conversations:
697
- conversations[conv_id] = {
698
- "id": conv_id,
699
- "title": "이전 대화 기록" if conv_id == "legacy-previous-history" else conversation_title(item),
700
- "created_at": item.get("timestamp"),
701
- "updated_at": item.get("timestamp"),
702
- "message_count": 0,
703
- "last_message": "",
704
- "source": item.get("source"),
705
- }
706
- order.append(conv_id)
707
-
708
- conv = conversations[conv_id]
709
- conv["message_count"] += 1
710
- conv["updated_at"] = item.get("timestamp") or conv.get("updated_at")
711
- conv["last_message"] = conversation_title(item)
712
- if conv_id != "legacy-previous-history" and item.get("role") == "user" and (not conv.get("title") or conv["title"] == "새 대화"):
713
- conv["title"] = conversation_title(item)
714
-
715
- return sorted((conversations[key] for key in order), key=lambda item: item.get("updated_at") or "", reverse=True)
716
-
717
- def get_conversation_messages(conversation_id: str) -> List[Dict]:
718
- history = get_history()
719
- if conversation_id == "legacy-previous-history":
720
- return [item for item in history if not item.get("conversation_id")]
721
- return [item for item in history if item.get("conversation_id") == conversation_id]
722
-
723
- def clear_history(keep_last: int = 0) -> Dict:
724
- keep_last = max(0, min(int(keep_last or 0), 20))
725
- previous = get_history()
726
- kept = previous[-keep_last:] if keep_last else []
727
- with open(HISTORY_FILE, "w", encoding="utf-8") as f:
728
- json.dump(kept, f, ensure_ascii=False, indent=2)
729
- return {"status": "cleared", "removed": max(0, len(previous) - len(kept)), "kept": len(kept)}
730
-
731
- def clear_conversation(conversation_id: str, started_at: Optional[str] = None) -> Dict:
732
- previous = get_history()
733
- kept = []
734
- removed = 0
735
- for item in previous:
736
- item_conversation_id = item.get("conversation_id")
737
- should_remove = item_conversation_id == conversation_id
738
- if conversation_id == "legacy-previous-history":
739
- should_remove = not item_conversation_id
740
- elif started_at and not item_conversation_id:
741
- should_remove = str(item.get("timestamp") or "") >= started_at
742
-
743
- if should_remove:
744
- removed += 1
745
- else:
746
- kept.append(item)
747
-
748
- with open(HISTORY_FILE, "w", encoding="utf-8") as f:
749
- json.dump(kept, f, ensure_ascii=False, indent=2)
750
- return {"status": "cleared", "conversation_id": conversation_id, "removed": removed, "kept": len(kept)}
751
-
752
- def get_user_role(email: str, users: Optional[Dict] = None) -> str:
753
- users = users or load_users()
754
- user = users.get(email) or {}
755
- if user.get("role") in {"admin", "user"}:
756
- return user["role"]
757
- admin_emails = set(CONFIG.admin_emails)
758
- if email.lower() in admin_emails:
759
- return "admin"
760
- first_email = next(iter(users), None)
761
- return "admin" if first_email == email else "user"
762
-
763
- def _extract_bearer_token(request: Request) -> Optional[str]:
764
- auth = request.headers.get("Authorization", "")
765
- if auth.startswith("Bearer "):
766
- return auth[7:].strip()
767
- return request.cookies.get("session_token")
768
-
769
- def get_current_user(request: Request) -> Optional[str]:
770
- token = _extract_bearer_token(request)
771
- if token:
772
- return get_session_email(token)
773
- return None
774
-
775
- def require_user(request: Request) -> str:
776
- email = get_current_user(request)
777
- if REQUIRE_AUTH and not email:
778
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
779
- return email or ""
780
-
781
-
782
- # ── Rate limiting & file validation — delegated to latticeai.core.security ────
783
- _RATE_LIMIT_ENABLED = CONFIG.rate_limit_enabled
784
-
785
- def enforce_rate_limit(email: str, bucket_key: str) -> None:
786
- _enforce_rate_limit(email, bucket_key, enabled=_RATE_LIMIT_ENABLED)
787
11
 
788
- def _bytes_match_extension(data: bytes, ext: str) -> bool:
789
- return _bytes_match_extension_impl(data, ext)
12
+ from __future__ import annotations
790
13
 
791
- _LOCAL_APPROVAL_TTL_SECONDS = 5 * 60
792
- _local_approvals: Dict[str, Dict[str, object]] = {}
14
+ from typing import Any, List
793
15
 
794
16
 
795
- def _normalize_local_path_for_approval(path: str) -> str:
796
- return str(Path(path).expanduser().resolve())
17
+ def _runtime():
18
+ from latticeai.app_factory import get_shared_runtime
797
19
 
20
+ return get_shared_runtime()
798
21
 
799
- def _content_fingerprint(content: str = "") -> str:
800
- return hashlib.sha256(content.encode("utf-8")).hexdigest()
801
22
 
802
-
803
- def _local_permission_response(path: str, action: str, user_email: str, content: str = "") -> dict:
804
- normalized = _normalize_local_path_for_approval(path)
805
- token = secrets.token_urlsafe(24)
806
- record: Dict[str, object] = {
807
- "path": normalized,
808
- "action": action,
809
- "user_email": user_email,
810
- "expires_at": time.time() + _LOCAL_APPROVAL_TTL_SECONDS,
811
- "approved": False,
812
- }
813
- if action == "write":
814
- record["content_hash"] = _content_fingerprint(content)
815
- _local_approvals[token] = record
816
- return {
817
- "permission_required": True,
818
- "path": path,
819
- "action": action,
820
- "approval_token": token,
821
- "expires_in": _LOCAL_APPROVAL_TTL_SECONDS,
822
- }
823
-
824
-
825
- def _require_local_approval(
826
- *,
827
- token: Optional[str],
828
- path: str,
829
- action: str,
830
- user_email: str,
831
- content: str = "",
832
- ) -> None:
833
- if not token:
834
- raise HTTPException(status_code=403, detail="파일 접근 승인 토큰이 필요합니다.")
835
- record = _local_approvals.get(token)
836
- if not record or float(record.get("expires_at", 0)) < time.time():
837
- raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
838
- if not record.get("approved"):
839
- raise HTTPException(status_code=403, detail="파일 접근이 아직 승인되지 않았습니다.")
840
- if record.get("user_email") != user_email:
841
- raise HTTPException(status_code=403, detail="다른 사용자의 파일 접근 승인은 사용할 수 없습니다.")
842
- if record.get("path") != _normalize_local_path_for_approval(path) or record.get("action") != action:
843
- raise HTTPException(status_code=403, detail="파일 접근 승인 범위가 일치하지 않습니다.")
844
- if action == "write" and record.get("content_hash") != _content_fingerprint(content):
845
- raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
846
-
847
-
848
- def require_admin(request: Request) -> tuple[str, Dict]:
849
- users = load_users()
850
- if not REQUIRE_AUTH:
851
- return "", users
852
- token = _extract_bearer_token(request)
853
- if token:
854
- email = get_session_email(token)
855
- if email:
856
- if get_user_role(email, users) == "admin":
857
- return email, users
858
- raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
859
-
860
- def public_user(email: str, user: Dict, users: Dict) -> Dict:
861
- return {
862
- "email": email,
863
- "name": user.get("name", ""),
864
- "nickname": user.get("nickname", ""),
865
- "role": get_user_role(email, users),
866
- "disabled": bool(user.get("disabled", False)),
867
- }
868
-
869
- def get_history_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
870
- if not email:
871
- return {"user_email": None, "user_nickname": nickname or None}
872
- users = load_users()
873
- user = users.get(email, {})
874
- return {
875
- "user_email": email,
876
- "user_nickname": nickname or user.get("nickname") or user.get("name") or email,
877
- }
878
-
879
- def get_user_api_key(email: Optional[str], provider: str) -> Optional[str]:
880
- if not email:
881
- return None
882
- keyring_key = f"{email}:{provider}"
883
- if keyring is not None:
884
- try:
885
- key = keyring.get_password("LatticeAI", keyring_key)
886
- if key:
887
- return key.strip()
888
- except Exception as exc:
889
- logging.warning("keyring read failed for %s: %s", provider, exc)
890
- users = load_users()
891
- user = users.get(email) or {}
892
- api_keys = user.get("api_keys") or {}
893
- key = api_keys.get(provider)
894
- if isinstance(key, str) and key.strip() and ALLOW_PLAINTEXT_API_KEYS:
895
- return key.strip()
896
- return None
897
-
898
- def set_user_api_key(email: str, provider: str, key: str) -> None:
899
- keyring_key = f"{email}:{provider}"
900
- if keyring is not None:
901
- try:
902
- keyring.set_password("LatticeAI", keyring_key, key)
903
- users = load_users()
904
- user = users.get(email)
905
- if user and "api_keys" in user:
906
- user["api_keys"].pop(provider, None)
907
- if not user["api_keys"]:
908
- user.pop("api_keys", None)
909
- save_users(users)
910
- return
911
- except Exception as exc:
912
- logging.warning("keyring write failed for %s: %s", provider, exc)
913
- if not ALLOW_PLAINTEXT_API_KEYS:
914
- raise HTTPException(
915
- status_code=500,
916
- detail="OS keyring에 API 키를 저장하지 못했습니다. keyring 설정을 확인하거나 LATTICEAI_ALLOW_PLAINTEXT_API_KEYS=true를 명시적으로 설정하세요.",
917
- )
918
-
919
- if not ALLOW_PLAINTEXT_API_KEYS:
920
- raise HTTPException(
921
- status_code=500,
922
- detail="keyring 패키지를 사용할 수 없어 API 키를 안전하게 저장할 수 없습니다.",
923
- )
924
-
925
- users = load_users()
926
- user = users.get(email)
927
- if not user:
928
- user = {
929
- "password_hash": "",
930
- "salt": "",
931
- "name": email,
932
- "nickname": email,
933
- "role": "user",
934
- "disabled": False,
935
- }
936
- api_keys = user.get("api_keys") or {}
937
- api_keys[provider] = key
938
- user["api_keys"] = api_keys
939
- users[email] = user
940
- save_users(users)
941
-
942
- # ── Sensitivity analysis — delegated to latticeai.core.audit ──────────────────
943
- def classify_sensitive_message(item: Dict, index: int) -> Dict:
944
- return _classify_sensitive_message(item, index)
945
-
946
- def build_sensitivity_report(history: List[Dict]) -> Dict:
947
- return _build_sensitivity_report(history)
948
-
949
- # ── Admin audit report — delegated to latticeai.core.audit ───────────────────
950
- def build_admin_audit_report(users: Dict) -> Dict:
951
- graph_stats = None
952
- try:
953
- if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
954
- graph_stats = KNOWLEDGE_GRAPH.stats()
955
- except Exception:
956
- pass
957
- return _build_admin_audit_report(
958
- AUDIT_FILE, users,
959
- get_user_role=get_user_role,
960
- graph_stats=graph_stats,
961
- )
962
-
963
- router = LLMRouter()
964
- set_llm_router(router)
965
- configure_tool_dispatch(load_users=load_users, get_user_role=get_user_role)
966
- gardener = PReinforceGardener()
967
-
968
- async def autoload_default_model() -> None:
969
- if not AUTOLOAD_MODELS:
970
- print("⏭️ Model autoload disabled by LATTICEAI_AUTOLOAD_MODELS=false.")
971
- return
972
-
973
- if IS_PUBLIC_MODE:
974
- model_id = PUBLIC_MODEL
975
- provider = model_id.split(":", 1)[0] if ":" in model_id else "openai"
976
- env_by_provider = {
977
- "openai": "OPENAI_API_KEY",
978
- "openrouter": "OPENROUTER_API_KEY",
979
- "groq": "GROQ_API_KEY",
980
- "together": "TOGETHER_API_KEY",
981
- "ollama": "OLLAMA_API_KEY",
982
- }
983
- required_env = env_by_provider.get(provider)
984
- if required_env and not os.getenv(required_env) and provider != "ollama":
985
- print(f"🌐 Public mode ready. Set {required_env} to autoload {model_id}.")
986
- return
987
- print(f"🌐 Public mode autoload: {model_id}")
988
- try:
989
- msg = await router.load_model(model_id)
990
- print(f"✅ {msg}")
991
- except Exception as e:
992
- print(f"⚠️ Public model autoload failed: {e}")
993
- return
994
-
995
- if not ALLOW_LOCAL_MODELS:
996
- print("⏭️ Local model autoload skipped because LATTICEAI_ALLOW_LOCAL_MODELS=false.")
997
- return
998
-
999
- print("⏳ Auto-loading local model stack:")
1000
- print(f" - Target: {LOCAL_MODEL}")
1001
- if LOCAL_DRAFT_MODEL:
1002
- print(f" - Draft: {LOCAL_DRAFT_MODEL}")
1003
- else:
1004
- print(" - Draft: disabled (set LATTICEAI_LOCAL_DRAFT_MODEL to enable)")
1005
- try:
1006
- await router.load_model(LOCAL_MODEL, draft_model_id=LOCAL_DRAFT_MODEL or None)
1007
- except Exception as e:
1008
- print(f"⚠️ Local model autoload failed: {e}")
1009
-
1010
- async def unload_idle_models_loop() -> None:
1011
- if MODEL_IDLE_UNLOAD_SECONDS <= 0:
1012
- print("⏭️ Model idle unload disabled.")
1013
- return
1014
- while True:
1015
- await asyncio.sleep(min(60, MODEL_IDLE_UNLOAD_SECONDS))
1016
- try:
1017
- unloaded = router.unload_idle_models(MODEL_IDLE_UNLOAD_SECONDS)
1018
- if unloaded:
1019
- print(f"🧹 Idle model unload: {', '.join(unloaded)}")
1020
- except Exception as e:
1021
- logging.warning("Idle model unload failed: %s", e)
1022
-
1023
- def _spawn(coro, *, name: str):
1024
- """Fire-and-forget asyncio task that logs exceptions instead of swallowing them."""
1025
- task = asyncio.create_task(coro, name=name)
1026
- def _on_done(t: asyncio.Task) -> None:
1027
- if t.cancelled():
1028
- return
1029
- exc = t.exception()
1030
- if exc is not None:
1031
- logging.warning("background task '%s' failed: %s", name, exc)
1032
- task.add_done_callback(_on_done)
1033
- return task
1034
-
1035
-
1036
- @asynccontextmanager
1037
- async def lifespan(app: FastAPI):
1038
- try:
1039
- print(f"🧭 Lattice AI mode: {APP_MODE}")
1040
- if ENABLE_TELEGRAM:
1041
- from telegram_bot import run_bot
1042
- _spawn(run_bot(), name="telegram_bot")
1043
- print("🚀 Telegram Bot Bridge activated!")
1044
- else:
1045
- print("⏭️ Telegram Bot Bridge disabled for this mode.")
1046
- _spawn(unload_idle_models_loop(), name="unload_idle_models")
1047
- _spawn(autoload_default_model(), name="autoload_default_model")
1048
- if LOCAL_KG_WATCHER:
1049
- restored = LOCAL_KG_WATCHER.restore_enabled_sources()
1050
- if restored.get("restored"):
1051
- print(f"🕸️ Local knowledge watchers restored: {restored['restored']}")
1052
- except Exception as e:
1053
- print(f"⚠️ Startup sequence failed: {e}")
23
+ def __getattr__(name: str) -> Any:
24
+ if name.startswith("__") and name.endswith("__"):
25
+ # Never let dunder probes (importlib, inspect, pickling) trigger the
26
+ # full application construction.
27
+ raise AttributeError(name)
1054
28
  try:
1055
- yield
1056
- finally:
1057
- if LOCAL_KG_WATCHER:
1058
- LOCAL_KG_WATCHER.stop_all()
1059
- router.unload_all()
1060
- for proc in LOCAL_SERVER_PROCESSES.values():
1061
- try:
1062
- if proc.poll() is None:
1063
- proc.terminate()
1064
- proc.wait(timeout=5)
1065
- except Exception:
1066
- pass
1067
-
1068
- app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version=APP_VERSION, lifespan=lifespan)
1069
-
1070
- CORS_ALLOWED_ORIGINS = [
1071
- f"http://localhost:{DEFAULT_PORT}",
1072
- f"http://127.0.0.1:{DEFAULT_PORT}",
1073
- *CORS_EXTRA_ORIGINS,
1074
- ]
1075
- if CORS_ALLOW_NETWORK:
1076
- CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS + [
1077
- f"http://{DEFAULT_HOST}:{DEFAULT_PORT}",
1078
- f"https://{DEFAULT_HOST}:{DEFAULT_PORT}",
1079
- ]
1080
-
1081
- app.add_middleware(
1082
- CORSMiddleware,
1083
- allow_origins=CORS_ALLOWED_ORIGINS,
1084
- allow_methods=["*"],
1085
- allow_headers=["*"],
1086
- allow_credentials=True,
1087
- )
1088
-
1089
- # UI 파일이 담길 static 폴더 연결
1090
- STATIC_DIR.mkdir(parents=True, exist_ok=True)
1091
- app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
1092
- # PWA icons served at /icons/*
1093
- _ICONS_DIR = STATIC_DIR / "icons"
1094
- if _ICONS_DIR.exists():
1095
- app.mount("/icons", StaticFiles(directory=str(_ICONS_DIR)), name="icons")
1096
- ensure_agent_root()
1097
-
1098
- OPEN_REGISTRATION = CONFIG.open_registration
1099
- INVITE_CODE = CONFIG.invite_code
1100
- INVITE_GATE_ENABLED = CONFIG.invite_gate_enabled
1101
- configure_model_runtime(
1102
- router=router,
1103
- APP_MODE=APP_MODE,
1104
- DEFAULT_HOST=DEFAULT_HOST,
1105
- DEFAULT_PORT=DEFAULT_PORT,
1106
- DATA_DIR=DATA_DIR,
1107
- BASE_DIR=BASE_DIR,
1108
- ENABLE_TELEGRAM=ENABLE_TELEGRAM,
1109
- ENABLE_GRAPH=ENABLE_GRAPH,
1110
- AUTOLOAD_MODELS=AUTOLOAD_MODELS,
1111
- MODEL_IDLE_UNLOAD_SECONDS=MODEL_IDLE_UNLOAD_SECONDS,
1112
- ALLOW_LOCAL_MODELS=ALLOW_LOCAL_MODELS,
1113
- REQUIRE_AUTH=REQUIRE_AUTH,
1114
- INVITE_GATE_ENABLED=INVITE_GATE_ENABLED,
1115
- ALLOW_PLAINTEXT_API_KEYS=ALLOW_PLAINTEXT_API_KEYS,
1116
- CORS_ALLOW_NETWORK=CORS_ALLOW_NETWORK,
1117
- PUBLIC_MODEL=PUBLIC_MODEL,
1118
- LOCAL_MODEL=LOCAL_MODEL,
1119
- IS_PUBLIC_MODE=IS_PUBLIC_MODE,
1120
- keyring=keyring,
1121
- get_current_user=get_current_user,
1122
- get_user_api_key=get_user_api_key,
1123
- )
1124
- STATIC_ROUTES = create_static_routes_router(
1125
- static_dir=STATIC_DIR,
1126
- invite_gate_enabled=INVITE_GATE_ENABLED,
1127
- invite_code=INVITE_CODE,
1128
- app_mode=APP_MODE,
1129
- model_router=router,
1130
- require_user=require_user,
1131
- )
1132
- ui_file_response = STATIC_ROUTES.ui_file_response
1133
- local_sysinfo = STATIC_ROUTES.local_sysinfo
1134
- app.include_router(STATIC_ROUTES.router)
1135
-
1136
- # ── Auth & Admin routers (latticeai.api) ─────────────────────────────────────
1137
- app.include_router(create_auth_router(
1138
- load_users=load_users, save_users=save_users,
1139
- hash_password=hash_password, verify_and_migrate=verify_and_migrate_password,
1140
- create_session=create_session, get_session_email=get_session_email,
1141
- invalidate_session=invalidate_session, extract_bearer_token=_extract_bearer_token,
1142
- get_user_role=get_user_role, require_user=require_user,
1143
- check_ip_rate_limit=_check_rate_limit, client_ip=_client_ip,
1144
- get_sso_settings=get_sso_settings, get_sso_discovery=_get_sso_discovery,
1145
- public_sso_config=public_sso_config,
1146
- open_registration=OPEN_REGISTRATION, session_ttl=_SESSION_TTL,
1147
- require_auth=REQUIRE_AUTH,
1148
- ))
1149
-
1150
- def _graph_stats_safe():
1151
- try:
1152
- return KNOWLEDGE_GRAPH.stats() if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else {"disabled": True}
1153
- except Exception as e:
1154
- return {"error": str(e)}
1155
-
1156
- app.include_router(create_admin_router(
1157
- require_admin=require_admin, require_user=require_user,
1158
- load_users=load_users, save_users=save_users,
1159
- get_user_role=get_user_role, get_history=get_history,
1160
- public_user=public_user, load_vpc_config=load_vpc_config,
1161
- save_vpc_config=save_vpc_config,
1162
- build_admin_audit_report=build_admin_audit_report,
1163
- build_sensitivity_report=build_sensitivity_report,
1164
- append_audit_event=append_audit_event,
1165
- public_sso_config=public_sso_config, save_sso_config=save_sso_config,
1166
- get_graph_stats=_graph_stats_safe, enable_graph=ENABLE_GRAPH,
1167
- invite_code=INVITE_CODE, invite_gate_enabled=INVITE_GATE_ENABLED,
1168
- default_port=DEFAULT_PORT,
1169
- ))
1170
-
1171
- # ── Security & Audit Command Center (피드백 #5) ──────────────────────────────
1172
- def _security_audit_events_safe() -> List[Dict]:
1173
- try:
1174
- return _get_audit_log(AUDIT_FILE)
1175
- except Exception as e:
1176
- logging.warning("security audit events load failed: %s", e)
1177
- return []
1178
-
1179
- def _security_list_uploaded_files() -> List[Dict]:
1180
- """Audit log에서 document_upload 이벤트를 가공해서 file 목록으로 노출."""
1181
- files: List[Dict] = []
1182
- for idx, e in enumerate(_security_audit_events_safe()):
1183
- if e.get("event_type") != "document_upload":
1184
- continue
1185
- files.append({
1186
- "file_id": str(e.get("filename") or idx),
1187
- "filename": e.get("filename"),
1188
- "user_email": e.get("user_email"),
1189
- "user_nickname": e.get("user_nickname"),
1190
- "uploaded_at": e.get("timestamp"),
1191
- "ext": e.get("ext"),
1192
- "bytes": e.get("bytes"),
1193
- "sensitivity": e.get("sensitivity") or "none",
1194
- "sensitive_labels": e.get("sensitive_labels") or [],
1195
- "content_preview": e.get("content_preview"),
1196
- })
1197
- return files
29
+ return getattr(_runtime(), name)
30
+ except AttributeError as exc:
31
+ raise AttributeError(
32
+ f"module 'latticeai.server_app' has no attribute '{name}'"
33
+ ) from exc
1198
34
 
1199
- app.include_router(_create_security_router(
1200
- require_admin=require_admin,
1201
- get_history=get_history,
1202
- get_audit_events=_security_audit_events_safe,
1203
- classify_sensitive_message=classify_sensitive_message,
1204
- build_sensitivity_report=build_sensitivity_report,
1205
- list_uploaded_files=_security_list_uploaded_files,
1206
- append_audit_event=append_audit_event,
1207
- ))
1208
35
 
1209
- # ── Static UI/status routes moved to latticeai.api.static_routes ──
36
+ def __dir__() -> List[str]:
37
+ return sorted(set(globals()) | set(vars(_runtime())))
1210
38
 
1211
- # ── Request / Response Models ──────────────────────────────────────────────────
1212
-
1213
- # ── Workspace OS API ──────────────────────────────────────────────────────────
1214
-
1215
- def _workspace_settings_payload() -> Dict:
1216
- return {
1217
- "mode": APP_MODE,
1218
- "host": DEFAULT_HOST,
1219
- "port": DEFAULT_PORT,
1220
- "require_auth": REQUIRE_AUTH,
1221
- "enable_graph": ENABLE_GRAPH,
1222
- "allow_local_models": ALLOW_LOCAL_MODELS,
1223
- "static_dir": str(STATIC_DIR),
1224
- "data_dir": str(DATA_DIR),
1225
- }
1226
-
1227
-
1228
- def _workspace_models_payload() -> Dict:
1229
- return {
1230
- "current_model": router.current_model_id,
1231
- "loaded_models": router.loaded_model_ids,
1232
- "public_model": PUBLIC_MODEL,
1233
- "local_model": LOCAL_MODEL,
1234
- "local_draft_model": LOCAL_DRAFT_MODEL,
1235
- }
1236
-
1237
-
1238
- def _workspace_graph():
1239
- return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
1240
-
1241
-
1242
- SEARCH_SERVICE = SearchService(graph_store=_workspace_graph())
1243
-
1244
-
1245
- # ── Workspace OS + Organization router (latticeai.api.workspace, v1.2.0) ──────
1246
- app.include_router(create_workspace_router(
1247
- service=WORKSPACE_SERVICE,
1248
- require_user=require_user,
1249
- require_admin=require_admin,
1250
- get_current_user=get_current_user,
1251
- append_audit_event=append_audit_event,
1252
- graph_stats=_graph_stats_safe,
1253
- workspace_models=_workspace_models_payload,
1254
- workspace_settings=_workspace_settings_payload,
1255
- get_history=get_history,
1256
- get_audit_log=get_audit_log,
1257
- require_graph=_require_graph,
1258
- workspace_graph=_workspace_graph,
1259
- knowledge_graph=KNOWLEDGE_GRAPH,
1260
- local_kg_watcher=LOCAL_KG_WATCHER,
1261
- load_users=load_users,
1262
- scan_environment=scan_environment,
1263
- local_sysinfo=local_sysinfo,
1264
- get_recommendations=get_recommendations,
1265
- fetch_skills_marketplace=_fetch_skills_marketplace,
1266
- install_skill=install_skill,
1267
- remove_skill_directory=remove_skill_directory,
1268
- redact_secret_text=redact_secret_text,
1269
- skills_dir=SKILLS_DIR,
1270
- capability_registry=capability_registry,
1271
- ui_file_response=ui_file_response,
1272
- static_dir=STATIC_DIR,
1273
- local_model=LOCAL_MODEL,
1274
- public_model=PUBLIC_MODEL,
1275
- ))
1276
-
1277
-
1278
- # ── v2 Agentic Workspace Platform: cross-system wiring ───────────────────────
1279
- # All cross-subsystem closures live in latticeai.services.platform_runtime to
1280
- # keep this assembly file lean; server_app only constructs it and mounts routers.
1281
- PLATFORM = PlatformRuntime(
1282
- store=WORKSPACE_OS,
1283
- workspace_service=WORKSPACE_SERVICE,
1284
- plugin_registry=PLUGIN_REGISTRY,
1285
- get_current_user=get_current_user,
1286
- workspace_graph=_workspace_graph,
1287
- workspace_scope_from_request=_workspace_scope_from_request,
1288
- get_tool_permission=get_tool_permission,
1289
- hooks=HOOKS_REGISTRY,
1290
- )
1291
-
1292
- # Single AgentRuntime boundary over the orchestrator + run store.
1293
- AGENT_RUNTIME = AgentRuntime(
1294
- store=WORKSPACE_OS,
1295
- orchestrator_factory=PLATFORM.build_orchestrator,
1296
- workspace_graph=_workspace_graph,
1297
- append_audit_event=append_audit_event,
1298
- hooks=HOOKS_REGISTRY,
1299
- )
1300
-
1301
- # ── Hooks dispatch: bind real built-in runners ───────────────────────────────
1302
- # The registry lists built-in hooks; binding a runner here makes them *execute*
1303
- # real platform behaviour when fired (not a placeholder). Runners take a
1304
- # HookContext and may mutate its payload, return a status dict, or block.
1305
- # Bind a real runner to every built-in hook so none is a silent no-op.
1306
- register_builtin_hook_runners(
1307
- HOOKS_REGISTRY,
1308
- append_audit_event=append_audit_event,
1309
- get_tool_permission=get_tool_permission,
1310
- classify_sensitive_message=classify_sensitive_message,
1311
- )
1312
-
1313
- app.include_router(create_plugins_router(
1314
- registry=PLUGIN_REGISTRY,
1315
- require_user=require_user,
1316
- require_admin=require_admin,
1317
- append_audit_event=append_audit_event,
1318
- register_skill=PLATFORM.register_plugin_skill,
1319
- plugin_runners_factory=lambda: PLATFORM.plugin_capability_runners(None, None),
1320
- ui_file_response=ui_file_response,
1321
- static_dir=STATIC_DIR,
1322
- ))
1323
-
1324
- app.include_router(create_workflow_designer_router(
1325
- store=WORKSPACE_OS,
1326
- require_user=require_user,
1327
- get_current_user=get_current_user,
1328
- gate_read=PLATFORM.gate_read,
1329
- gate_write=PLATFORM.gate_write,
1330
- workspace_graph=_workspace_graph,
1331
- build_runners=PLATFORM.build_workflow_runners,
1332
- append_audit_event=append_audit_event,
1333
- ui_file_response=ui_file_response,
1334
- static_dir=STATIC_DIR,
1335
- hooks=HOOKS_REGISTRY,
1336
- ))
1337
-
1338
- app.include_router(create_agents_router(
1339
- store=WORKSPACE_OS,
1340
- orchestrator_factory=PLATFORM.build_orchestrator,
1341
- require_user=require_user,
1342
- get_current_user=get_current_user,
1343
- gate_read=PLATFORM.gate_read,
1344
- gate_write=PLATFORM.gate_write,
1345
- workspace_graph=_workspace_graph,
1346
- append_audit_event=append_audit_event,
1347
- ui_file_response=ui_file_response,
1348
- static_dir=STATIC_DIR,
1349
- agent_runtime=AGENT_RUNTIME,
1350
- ))
1351
-
1352
- app.include_router(create_marketplace_router(
1353
- store=WORKSPACE_OS,
1354
- catalog=TEMPLATE_CATALOG,
1355
- require_user=require_user,
1356
- gate_read=PLATFORM.gate_read,
1357
- gate_write=PLATFORM.gate_write,
1358
- workspace_graph=_workspace_graph,
1359
- ))
1360
-
1361
- app.include_router(create_realtime_router(
1362
- bus=REALTIME_BUS,
1363
- require_user=require_user,
1364
- get_current_user=get_current_user,
1365
- allowed_scopes=PLATFORM.allowed_scopes,
1366
- ui_file_response=ui_file_response,
1367
- static_dir=STATIC_DIR,
1368
- ))
1369
-
1370
-
1371
- # ── Health & Info ──────────────────────────────────────────────────────────────
1372
-
1373
- # ── Model runtime/provider helpers moved to latticeai.services.model_runtime ──
1374
- # ── Health / status / engine-summary router (latticeai.api.health, v1.2.0) ───
1375
- # /health, /mode, /runtime_features, /engines(GET) now live in the health router.
1376
- # Heavier engine mutation endpoints remain below in server_app.
1377
- MODEL_SERVICE = ModelService(
1378
- model_router=router,
1379
- runtime_features=runtime_features,
1380
- is_public=IS_PUBLIC_MODE,
1381
- )
1382
- app.include_router(create_health_router(
1383
- model_service=MODEL_SERVICE,
1384
- engine_status=engine_status,
1385
- get_current_user=get_current_user,
1386
- require_auth=REQUIRE_AUTH,
1387
- app_version=APP_VERSION,
1388
- app_mode=APP_MODE,
1389
- ))
1390
-
1391
-
1392
- # ── Model / Engine router (latticeai.api.models, v1.3.0) ─────────────────────
1393
- app.include_router(create_models_router(
1394
- model_router=router,
1395
- require_user=require_user,
1396
- get_current_user=get_current_user,
1397
- load_users=load_users,
1398
- get_user_role=get_user_role,
1399
- install_engine=install_engine,
1400
- verify_cloud_models=verify_cloud_models,
1401
- normalize_local_model_request=normalize_local_model_request,
1402
- download_hf_model=download_hf_model,
1403
- prepare_and_load_model=prepare_and_load_model,
1404
- prepare_and_load_model_stream=prepare_and_load_model_stream,
1405
- sse_event=sse_event,
1406
- ensure_ollama_server=ensure_ollama_server,
1407
- local_binary=local_binary,
1408
- engine_status=engine_status,
1409
- filter_lower_family_versions=filter_lower_family_versions,
1410
- list_compat_profiles=_list_compat_profiles,
1411
- set_user_api_key=set_user_api_key,
1412
- engine_model_catalog=ENGINE_MODEL_CATALOG,
1413
- model_engine_aliases=MODEL_ENGINE_ALIASES,
1414
- cloud_verify_ttl_seconds=CLOUD_VERIFY_TTL_SECONDS,
1415
- is_public_mode=IS_PUBLIC_MODE,
1416
- allow_local_models=ALLOW_LOCAL_MODELS,
1417
- require_auth=REQUIRE_AUTH,
1418
- ))
1419
-
1420
-
1421
- # ── Chat / Completion ──────────────────────────────────────────────────────────
1422
-
1423
- app.include_router(create_chat_router(
1424
- config=CONFIG,
1425
- hooks=HOOKS_REGISTRY,
1426
- model_router=router,
1427
- chat_service=CHAT_SERVICE,
1428
- workspace_store=WORKSPACE_OS,
1429
- workspace_graph=_workspace_graph,
1430
- gardener=gardener,
1431
- require_user=require_user,
1432
- enforce_rate_limit=enforce_rate_limit,
1433
- get_history_user=get_history_user,
1434
- save_to_history=save_to_history,
1435
- append_audit_event=append_audit_event,
1436
- clear_history=clear_history,
1437
- clear_conversation=clear_conversation,
1438
- get_history=get_history,
1439
- group_history_conversations=group_history_conversations,
1440
- get_conversation_messages=get_conversation_messages,
1441
- conversation_title=conversation_title,
1442
- load_users=load_users,
1443
- get_user_role=get_user_role,
1444
- enable_graph=ENABLE_GRAPH,
1445
- knowledge_graph=KNOWLEDGE_GRAPH,
1446
- public_model=PUBLIC_MODEL,
1447
- base_dir=BASE_DIR,
1448
- ))
1449
-
1450
- def _embedding_info() -> dict:
1451
- from latticeai.core.embedding_providers import PROVIDER_TYPES, embedding_provider_profiles
1452
- info = EMBEDDER.as_dict()
1453
- info["available_providers"] = list(PROVIDER_TYPES)
1454
- info["profile"] = CONFIG.embedding_profile or ""
1455
- info["profiles"] = embedding_provider_profiles()
1456
- return info
1457
-
1458
-
1459
- app.include_router(create_search_router(
1460
- service=SEARCH_SERVICE,
1461
- require_user=require_user,
1462
- embedding_info=_embedding_info,
1463
- ))
1464
-
1465
- app.include_router(create_tools_router(
1466
- config=CONFIG,
1467
- data_dir=DATA_DIR,
1468
- static_dir=STATIC_DIR,
1469
- model_router=router,
1470
- require_user=require_user,
1471
- require_admin=require_admin,
1472
- get_current_user=get_current_user,
1473
- clear_history=clear_history,
1474
- append_audit_event=append_audit_event,
1475
- enforce_rate_limit=enforce_rate_limit,
1476
- bytes_match_extension=_bytes_match_extension,
1477
- classify_sensitive_message=classify_sensitive_message,
1478
- save_to_history=save_to_history,
1479
- enable_graph=ENABLE_GRAPH,
1480
- knowledge_graph=KNOWLEDGE_GRAPH,
1481
- require_graph=_require_graph,
1482
- local_kg_watcher=LOCAL_KG_WATCHER,
1483
- load_mcp_installs=load_mcp_installs,
1484
- recommend_mcps=recommend_mcps,
1485
- install_mcp=install_mcp,
1486
- mcp_public_item=mcp_public_item,
1487
- hooks=HOOKS_REGISTRY,
1488
- ))
1489
-
1490
- app.include_router(create_hooks_router(
1491
- registry=HOOKS_REGISTRY,
1492
- require_user=require_user,
1493
- append_audit_event=append_audit_event,
1494
- ))
1495
-
1496
- app.include_router(create_agent_registry_router(
1497
- registry=AGENT_REGISTRY,
1498
- require_user=require_user,
1499
- append_audit_event=append_audit_event,
1500
- ))
1501
-
1502
- app.include_router(create_memory_router(
1503
- service=MEMORY_SERVICE,
1504
- require_user=require_user,
1505
- get_current_user=get_current_user,
1506
- gate_read=PLATFORM.gate_read,
1507
- gate_write=PLATFORM.gate_write,
1508
- append_audit_event=append_audit_event,
1509
- ))
1510
-
1511
- app.include_router(create_garden_router(gardener=gardener, require_user=require_user))
1512
- app.include_router(create_setup_router(model_router=router, require_user=require_user))
1513
-
1514
- # ── Entry Point ────────────────────────────────────────────────────────────────
1515
39
 
1516
40
  def main() -> None:
1517
- print(f"🧠 Lattice AI Server starting in {APP_MODE} mode on http://{DEFAULT_HOST}:{DEFAULT_PORT}")
1518
- uvicorn.run(app, host=DEFAULT_HOST, port=DEFAULT_PORT, log_level="info")
41
+ from latticeai.app_factory import main as _main
42
+
43
+ _main()
1519
44
 
1520
45
 
1521
46
  if __name__ == "__main__":