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