ltcai 3.6.0 → 4.0.1

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