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.
- package/README.md +11 -7
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +47 -53
- package/kg_schema.py +93 -10
- package/knowledge_graph.py +362 -33
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +7 -1
- package/latticeai/api/auth.py +27 -4
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/knowledge_graph.py +146 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +2 -4
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/workflow_designer.py +46 -0
- package/latticeai/api/workspace.py +71 -49
- package/latticeai/app_factory.py +1710 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +37 -7
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +58 -10
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +1 -0
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +31 -0
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +9 -7
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +82 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/account.html +2 -4
- package/static/admin.html +3 -5
- package/static/chat.html +3 -6
- package/static/graph.html +2 -4
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +20 -19
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
- package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
- package/static/v3/js/core/routes.js +22 -22
- package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
- package/static/v3/js/core/shell.js +1 -1
- package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
- package/static/v3/js/core/store.js +1 -1
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
- package/static/v3/js/views/knowledge-graph.js +33 -37
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/static/workspace.html +2 -2
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- 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()
|