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.
- package/README.md +39 -31
- package/docs/CHANGELOG.md +64 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +51 -53
- package/docs/spec-vs-impl.md +10 -10
- package/kg_schema.py +2 -520
- package/knowledge_graph.py +37 -4629
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +16 -17
- package/latticeai/api/agents.py +20 -7
- package/latticeai/api/auth.py +46 -15
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +139 -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/plugins.py +3 -6
- package/latticeai/api/realtime.py +5 -8
- 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 +11 -16
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +85 -6
- package/latticeai/api/workspace.py +93 -57
- package/latticeai/app_factory.py +1781 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -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/invitations.py +131 -0
- 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/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +66 -10
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +477 -29
- 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 +243 -4
- 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/run_executor.py +328 -0
- 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 +55 -16
- 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 +10 -20
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +105 -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/sw.js +81 -52
- package/static/v3/asset-manifest.json +33 -25
- 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.c5c80c46.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
- package/static/v3/js/core/api.js +57 -0
- package/static/v3/js/core/i18n.880e1fec.js +575 -0
- package/static/v3/js/core/i18n.js +575 -0
- package/static/v3/js/core/routes.37522821.js +101 -0
- package/static/v3/js/core/routes.js +71 -63
- package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
- package/static/v3/js/core/shell.js +66 -37
- package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
- package/static/v3/js/core/store.js +11 -1
- package/static/v3/js/views/account.eff40715.js +143 -0
- package/static/v3/js/views/account.js +143 -0
- package/static/v3/js/views/activity.0d271ef9.js +67 -0
- package/static/v3/js/views/activity.js +67 -0
- package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
- package/static/v3/js/views/admin-users.js +4 -6
- package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
- package/static/v3/js/views/agents.js +35 -12
- package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
- package/static/v3/js/views/chat.js +23 -0
- 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.4d09c537.js} +60 -44
- package/static/v3/js/views/knowledge-graph.js +60 -44
- package/static/v3/js/views/network.52a4f181.js +97 -0
- package/static/v3/js/views/network.js +97 -0
- package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
- package/static/v3/js/views/planning.js +26 -5
- package/static/v3/js/views/runs.b63b2afa.js +144 -0
- package/static/v3/js/views/runs.js +144 -0
- package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
- package/static/v3/js/views/settings.js +7 -8
- package/static/v3/js/views/snapshots.6f5db095.js +135 -0
- package/static/v3/js/views/snapshots.js +135 -0
- package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
- package/static/v3/js/views/workflows.js +87 -2
- package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
- package/static/v3/js/views/workspace-admin.js +156 -0
- 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/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
- package/static/account.html +0 -115
- package/static/activity.html +0 -73
- package/static/admin.html +0 -488
- package/static/agents.html +0 -139
- package/static/chat.html +0 -844
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -124
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/js/core/routes.2ce3815a.js +0 -93
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
package/latticeai/api/chat.py
CHANGED
|
@@ -25,9 +25,8 @@ from latticeai.core.agent import AgentRunContext, AgentState
|
|
|
25
25
|
from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
|
|
26
26
|
from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
|
|
27
27
|
from latticeai.core.hooks import dispatch_tool
|
|
28
|
-
from latticeai.services.
|
|
28
|
+
from latticeai.services.app_context import AppContext
|
|
29
29
|
from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
|
|
30
|
-
from telegram_bot import broadcast_web_chat
|
|
31
30
|
from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, local_read, network_status
|
|
32
31
|
|
|
33
32
|
class ChatRequest(BaseModel):
|
|
@@ -71,6 +70,28 @@ class AgentEvalRequest(BaseModel):
|
|
|
71
70
|
skill: str
|
|
72
71
|
case_id: Optional[str] = None
|
|
73
72
|
|
|
73
|
+
def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
|
|
74
|
+
"""Restrict history to one user's exchange.
|
|
75
|
+
|
|
76
|
+
Keeps the user's own messages plus assistant replies that directly follow
|
|
77
|
+
them. A bare role=="assistant" pass would leak every other user's replies
|
|
78
|
+
into this user's prompt context.
|
|
79
|
+
"""
|
|
80
|
+
paired: List[Dict] = []
|
|
81
|
+
include_next_assistant = False
|
|
82
|
+
for item in history:
|
|
83
|
+
if item.get("role") == "assistant":
|
|
84
|
+
if include_next_assistant:
|
|
85
|
+
paired.append(item)
|
|
86
|
+
include_next_assistant = False
|
|
87
|
+
elif item.get("user_email") == user_email:
|
|
88
|
+
paired.append(item)
|
|
89
|
+
include_next_assistant = True
|
|
90
|
+
else:
|
|
91
|
+
include_next_assistant = False
|
|
92
|
+
return paired
|
|
93
|
+
|
|
94
|
+
|
|
74
95
|
def detect_language(text: str) -> str:
|
|
75
96
|
"""Detect language: 'ko' (Korean) or 'en' (English)."""
|
|
76
97
|
total = max(len(text), 1)
|
|
@@ -120,46 +141,57 @@ async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[
|
|
|
120
141
|
yield "data: [DONE]\n\n"
|
|
121
142
|
|
|
122
143
|
|
|
123
|
-
def create_chat_router(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
gardener,
|
|
131
|
-
require_user,
|
|
132
|
-
enforce_rate_limit,
|
|
133
|
-
get_history_user,
|
|
134
|
-
save_to_history,
|
|
135
|
-
append_audit_event,
|
|
136
|
-
clear_history,
|
|
137
|
-
clear_conversation,
|
|
138
|
-
get_history,
|
|
139
|
-
group_history_conversations,
|
|
140
|
-
get_conversation_messages,
|
|
141
|
-
conversation_title,
|
|
142
|
-
load_users,
|
|
143
|
-
get_user_role,
|
|
144
|
-
enable_graph: bool,
|
|
145
|
-
knowledge_graph,
|
|
146
|
-
public_model: str,
|
|
147
|
-
base_dir: Path,
|
|
148
|
-
hooks=None,
|
|
149
|
-
) -> APIRouter:
|
|
144
|
+
def create_chat_router(context: AppContext) -> APIRouter:
|
|
145
|
+
"""Build the chat/history/agent router from the typed application context.
|
|
146
|
+
|
|
147
|
+
Replaces the historical ~25-kwarg factory signature: ``context``
|
|
148
|
+
(:class:`latticeai.services.app_context.AppContext`) carries the same
|
|
149
|
+
dependencies as typed fields.
|
|
150
|
+
"""
|
|
150
151
|
api_router = APIRouter()
|
|
151
|
-
router = model_router
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
router = context.model_router
|
|
153
|
+
hooks = context.hooks
|
|
154
|
+
workspace_graph = context.workspace_graph
|
|
155
|
+
context_assembler = context.context_assembler
|
|
156
|
+
require_user = context.require_user
|
|
157
|
+
enforce_rate_limit = context.enforce_rate_limit
|
|
158
|
+
get_history_user = context.get_history_user
|
|
159
|
+
save_to_history = context.save_to_history
|
|
160
|
+
append_audit_event = context.append_audit_event
|
|
161
|
+
clear_history = context.clear_history
|
|
162
|
+
clear_conversation = context.clear_conversation
|
|
163
|
+
get_history = context.get_history
|
|
164
|
+
group_history_conversations = context.group_history_conversations
|
|
165
|
+
get_conversation_messages = context.get_conversation_messages
|
|
166
|
+
conversation_title = context.conversation_title
|
|
167
|
+
|
|
168
|
+
CONFIG = context.config
|
|
169
|
+
CHAT_SERVICE = context.chat_service
|
|
170
|
+
WORKSPACE_OS = context.workspace_store
|
|
171
|
+
ENABLE_GRAPH = context.enable_graph
|
|
172
|
+
KNOWLEDGE_GRAPH = context.knowledge_graph
|
|
173
|
+
PUBLIC_MODEL = context.public_model
|
|
174
|
+
BASE_DIR = context.base_dir
|
|
159
175
|
_doc_gen_sessions: dict = {}
|
|
160
176
|
_pending_agents: dict[str, tuple] = {}
|
|
161
177
|
_pending_agents_lock = threading.Lock()
|
|
162
178
|
|
|
179
|
+
on_chat_message = context.on_chat_message
|
|
180
|
+
|
|
181
|
+
def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
|
|
182
|
+
"""Mirror a chat exchange to the injected external bridge, if any.
|
|
183
|
+
|
|
184
|
+
``on_chat_message`` is registered by ``create_app`` only when
|
|
185
|
+
ENABLE_TELEGRAM is truthy; exchanges that originated *from* telegram
|
|
186
|
+
are never echoed back. No bridge registered → no-op.
|
|
187
|
+
"""
|
|
188
|
+
if on_chat_message is None or source == "telegram":
|
|
189
|
+
return
|
|
190
|
+
try:
|
|
191
|
+
on_chat_message(role, text, source)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
logging.warning("chat message bridge failed: %s", exc)
|
|
194
|
+
|
|
163
195
|
def build_recent_chat_context(
|
|
164
196
|
limit: int = 10,
|
|
165
197
|
include_image_missing_replies: bool = True,
|
|
@@ -170,7 +202,7 @@ def create_chat_router(
|
|
|
170
202
|
if conversation_id:
|
|
171
203
|
history = [item for item in history if item.get("conversation_id") == conversation_id]
|
|
172
204
|
if user_email:
|
|
173
|
-
history =
|
|
205
|
+
history = pair_user_history(history, user_email)
|
|
174
206
|
history = history[-limit:]
|
|
175
207
|
lines = []
|
|
176
208
|
for item in history:
|
|
@@ -250,6 +282,7 @@ def create_chat_router(
|
|
|
250
282
|
knowledge_save=knowledge_save,
|
|
251
283
|
audit=append_audit_event,
|
|
252
284
|
hooks=hooks,
|
|
285
|
+
brain_memory=context.brain_memory,
|
|
253
286
|
)
|
|
254
287
|
|
|
255
288
|
@api_router.post("/chat")
|
|
@@ -272,9 +305,8 @@ def create_chat_router(
|
|
|
272
305
|
except ToolError as exc:
|
|
273
306
|
answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
|
|
274
307
|
save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
asyncio.create_task(broadcast_web_chat("assistant", answer))
|
|
308
|
+
notify_chat_message("user", req.message, req.source)
|
|
309
|
+
notify_chat_message("assistant", answer, req.source)
|
|
278
310
|
if req.stream:
|
|
279
311
|
return StreamingResponse(
|
|
280
312
|
single_text_stream(answer),
|
|
@@ -332,9 +364,8 @@ def create_chat_router(
|
|
|
332
364
|
answer = f"현재 페이지 URL: {req.client_url}"
|
|
333
365
|
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
334
366
|
save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
asyncio.create_task(broadcast_web_chat("assistant", answer))
|
|
367
|
+
notify_chat_message("user", req.message, req.source)
|
|
368
|
+
notify_chat_message("assistant", answer, req.source)
|
|
338
369
|
if req.stream:
|
|
339
370
|
return StreamingResponse(
|
|
340
371
|
single_text_stream(answer),
|
|
@@ -364,32 +395,38 @@ def create_chat_router(
|
|
|
364
395
|
|
|
365
396
|
lang = detect_language(req.message)
|
|
366
397
|
context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
|
|
398
|
+
# v4 Context System: one budgeted, provenance-carrying assembly
|
|
399
|
+
# (workspace memories + hybrid search + garden notes) replaces the
|
|
400
|
+
# ad-hoc vault-scan + LIKE-search concatenation. The trace records
|
|
401
|
+
# why each section is in the prompt.
|
|
402
|
+
context_trace = None
|
|
367
403
|
try:
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
404
|
+
if context_assembler is not None:
|
|
405
|
+
assembled = context_assembler.assemble(
|
|
406
|
+
req.message,
|
|
407
|
+
user_email=effective_email,
|
|
408
|
+
conversation_id=req.conversation_id,
|
|
409
|
+
budget=2000,
|
|
410
|
+
)
|
|
411
|
+
context_trace = assembled.trace()
|
|
412
|
+
if assembled.text:
|
|
413
|
+
context += "\n\n" + assembled.text
|
|
372
414
|
except Exception as e:
|
|
373
|
-
logging.warning("
|
|
374
|
-
|
|
415
|
+
logging.warning("Context assembly skipped: %s", e)
|
|
416
|
+
|
|
375
417
|
is_doc_gen = detect_document_intent(req.message)
|
|
376
418
|
doc_gen_context_result = None
|
|
377
|
-
|
|
419
|
+
|
|
378
420
|
try:
|
|
379
|
-
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
else:
|
|
389
|
-
graph_context = KNOWLEDGE_GRAPH.context_for_query(req.message)
|
|
390
|
-
if graph_context:
|
|
391
|
-
context += f"\n\n[KNOWLEDGE GRAPH]\n{graph_context}"
|
|
392
|
-
print("🕸️ Context reinforced with knowledge graph.")
|
|
421
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH and is_doc_gen:
|
|
422
|
+
# Specialized multi-hop retrieval for document generation.
|
|
423
|
+
doc_gen_context_result = retrieve_context_for_generation(
|
|
424
|
+
KNOWLEDGE_GRAPH, req.message, max_results=10, max_hops=2,
|
|
425
|
+
)
|
|
426
|
+
graph_md = doc_gen_context_result.get("context_markdown", "")
|
|
427
|
+
if graph_md:
|
|
428
|
+
context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
|
|
429
|
+
print("📝 Document generation context retrieved from knowledge graph.")
|
|
393
430
|
except Exception as e:
|
|
394
431
|
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
395
432
|
|
|
@@ -416,11 +453,14 @@ def create_chat_router(
|
|
|
416
453
|
KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
|
|
417
454
|
context,
|
|
418
455
|
)
|
|
456
|
+
if context_trace is not None and isinstance(trace_seed, dict):
|
|
457
|
+
# Persisted with the answer trace: 'why is this in my context?'
|
|
458
|
+
# is answerable from the stored record (UI surface lands in T9b).
|
|
459
|
+
trace_seed["context_assembly"] = context_trace
|
|
419
460
|
|
|
420
461
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
421
462
|
save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
422
|
-
|
|
423
|
-
asyncio.create_task(broadcast_web_chat("user", req.message))
|
|
463
|
+
notify_chat_message("user", req.message, req.source)
|
|
424
464
|
|
|
425
465
|
if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
426
466
|
conv_key = req.conversation_id or "default"
|
|
@@ -456,8 +496,7 @@ def create_chat_router(
|
|
|
456
496
|
user_email=effective_email,
|
|
457
497
|
trace=trace_seed,
|
|
458
498
|
)
|
|
459
|
-
|
|
460
|
-
asyncio.create_task(broadcast_web_chat("assistant", full_text))
|
|
499
|
+
notify_chat_message("assistant", full_text, req.source)
|
|
461
500
|
yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
|
|
462
501
|
yield "data: [DONE]\n\n"
|
|
463
502
|
return StreamingResponse(
|
|
@@ -482,8 +521,7 @@ def create_chat_router(
|
|
|
482
521
|
user_email=effective_email,
|
|
483
522
|
trace=trace_seed,
|
|
484
523
|
)
|
|
485
|
-
|
|
486
|
-
asyncio.create_task(broadcast_web_chat("assistant", str(result)))
|
|
524
|
+
notify_chat_message("assistant", str(result), req.source)
|
|
487
525
|
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
488
526
|
|
|
489
527
|
if req.stream:
|
|
@@ -519,8 +557,7 @@ def create_chat_router(
|
|
|
519
557
|
user_email=effective_email,
|
|
520
558
|
trace=trace_seed,
|
|
521
559
|
)
|
|
522
|
-
|
|
523
|
-
asyncio.create_task(broadcast_web_chat("assistant", str(result)))
|
|
560
|
+
notify_chat_message("assistant", str(result), req.source)
|
|
524
561
|
|
|
525
562
|
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
526
563
|
|
|
@@ -627,8 +664,7 @@ def create_chat_router(
|
|
|
627
664
|
context,
|
|
628
665
|
),
|
|
629
666
|
)
|
|
630
|
-
|
|
631
|
-
asyncio.create_task(broadcast_web_chat("assistant", full_response))
|
|
667
|
+
notify_chat_message("assistant", full_response, req.source)
|
|
632
668
|
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
|
|
633
669
|
yield "data: [DONE]\n\n"
|
|
634
670
|
|
|
@@ -751,7 +787,7 @@ def create_chat_router(
|
|
|
751
787
|
user_email=current_user or None,
|
|
752
788
|
timeline=ctx.transcript,
|
|
753
789
|
relationships=["agent:planner", "agent:reviewer"],
|
|
754
|
-
graph=
|
|
790
|
+
graph=workspace_graph(),
|
|
755
791
|
)
|
|
756
792
|
except Exception as exc:
|
|
757
793
|
logging.warning("workspace agent run record failed: %s", exc)
|
package/latticeai/api/health.py
CHANGED
|
@@ -8,7 +8,7 @@ endpoints (install / verify-cloud / pull-model) stay in server_app for now.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
|
-
from typing import
|
|
11
|
+
from typing import Callable, List, Optional
|
|
12
12
|
|
|
13
13
|
from fastapi import APIRouter, Request
|
|
14
14
|
|
package/latticeai/api/hooks.py
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Invitation API: create, list, and accept workspace invitations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvitationCreateRequest(BaseModel):
|
|
12
|
+
email: Optional[str] = None
|
|
13
|
+
workspace_id: Optional[str] = None
|
|
14
|
+
role: str = "member"
|
|
15
|
+
expires_hours: int = 168
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_invitations_router(
|
|
19
|
+
*,
|
|
20
|
+
invitation_store,
|
|
21
|
+
workspace_service,
|
|
22
|
+
require_admin: Callable,
|
|
23
|
+
require_user: Callable[[Request], str],
|
|
24
|
+
user_id_for_email: Callable[[Optional[str]], Optional[str]],
|
|
25
|
+
append_audit_event: Callable[..., None],
|
|
26
|
+
) -> APIRouter:
|
|
27
|
+
router = APIRouter()
|
|
28
|
+
|
|
29
|
+
@router.get("/invitations")
|
|
30
|
+
async def list_invitations(request: Request):
|
|
31
|
+
require_admin(request)
|
|
32
|
+
return {"invitations": invitation_store.list()}
|
|
33
|
+
|
|
34
|
+
@router.post("/invitations")
|
|
35
|
+
async def create_invitation(req: InvitationCreateRequest, request: Request):
|
|
36
|
+
admin_email, _ = require_admin(request)
|
|
37
|
+
actor_id = user_id_for_email(admin_email)
|
|
38
|
+
if req.workspace_id:
|
|
39
|
+
try:
|
|
40
|
+
workspace_service.store._require_permission(
|
|
41
|
+
workspace_service.store._load_org(workspace_service.store.load_state(), req.workspace_id),
|
|
42
|
+
actor_id,
|
|
43
|
+
"manage_members",
|
|
44
|
+
)
|
|
45
|
+
except FileNotFoundError as exc:
|
|
46
|
+
raise HTTPException(status_code=404, detail=f"Workspace not found: {req.workspace_id}") from exc
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
49
|
+
except PermissionError as exc:
|
|
50
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
51
|
+
if req.role not in {"owner", "admin", "member", "viewer"}:
|
|
52
|
+
raise HTTPException(status_code=400, detail="unknown invitation role")
|
|
53
|
+
invitation = invitation_store.create(
|
|
54
|
+
email=req.email,
|
|
55
|
+
workspace_id=req.workspace_id,
|
|
56
|
+
role=req.role,
|
|
57
|
+
created_by=actor_id,
|
|
58
|
+
expires_hours=req.expires_hours,
|
|
59
|
+
)
|
|
60
|
+
append_audit_event(
|
|
61
|
+
"invitation_created",
|
|
62
|
+
user_email=admin_email,
|
|
63
|
+
invitation_id=invitation.get("id"),
|
|
64
|
+
workspace_id=req.workspace_id,
|
|
65
|
+
role=req.role,
|
|
66
|
+
)
|
|
67
|
+
return {"invitation": invitation}
|
|
68
|
+
|
|
69
|
+
@router.post("/invitations/{token}/accept")
|
|
70
|
+
async def accept_invitation(token: str, request: Request):
|
|
71
|
+
email = require_user(request)
|
|
72
|
+
user_id = user_id_for_email(email)
|
|
73
|
+
if not user_id:
|
|
74
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
75
|
+
try:
|
|
76
|
+
invitation = invitation_store.accept(token, accepted_by=user_id, email=email or None)
|
|
77
|
+
except FileNotFoundError as exc:
|
|
78
|
+
raise HTTPException(status_code=404, detail="Invitation not found") from exc
|
|
79
|
+
except PermissionError as exc:
|
|
80
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
81
|
+
workspace_id = invitation.get("workspace_id")
|
|
82
|
+
if workspace_id:
|
|
83
|
+
try:
|
|
84
|
+
workspace_service.add_member(
|
|
85
|
+
workspace_id,
|
|
86
|
+
user_id=user_id,
|
|
87
|
+
role=invitation.get("role") or "member",
|
|
88
|
+
actor=invitation.get("created_by"),
|
|
89
|
+
)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
92
|
+
append_audit_event(
|
|
93
|
+
"invitation_accepted",
|
|
94
|
+
user_email=email,
|
|
95
|
+
invitation_id=invitation.get("id"),
|
|
96
|
+
workspace_id=workspace_id,
|
|
97
|
+
)
|
|
98
|
+
return {"invitation": invitation}
|
|
99
|
+
|
|
100
|
+
return router
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Knowledge graph page and API routes.
|
|
2
|
+
|
|
3
|
+
Relocated from the root ``knowledge_graph_api.py`` in v4 (T2); the root module
|
|
4
|
+
remains as a deprecation shim. Route paths and response shapes are frozen by
|
|
5
|
+
``tests/unit/test_knowledge_graph_router_parity.py`` — the ``/knowledge-graph/*``
|
|
6
|
+
data endpoints back the v3 SPA (Files, Hybrid Search, graph explorer).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from latticeai.api.ui_redirects import app_redirect
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KnowledgeGraphIngestRequest(BaseModel):
|
|
19
|
+
type: str
|
|
20
|
+
content: str = ""
|
|
21
|
+
role: Optional[str] = None
|
|
22
|
+
title: Optional[str] = None
|
|
23
|
+
source: Optional[str] = None
|
|
24
|
+
conversation_id: Optional[str] = None
|
|
25
|
+
user_email: Optional[str] = None
|
|
26
|
+
user_nickname: Optional[str] = None
|
|
27
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_knowledge_graph_router(
|
|
31
|
+
*,
|
|
32
|
+
get_graph: Callable[[], Any],
|
|
33
|
+
require_graph: Callable[[], None],
|
|
34
|
+
require_user: Callable[[Request], str],
|
|
35
|
+
static_dir: Path,
|
|
36
|
+
) -> APIRouter:
|
|
37
|
+
router = APIRouter()
|
|
38
|
+
|
|
39
|
+
def graph():
|
|
40
|
+
require_graph()
|
|
41
|
+
return get_graph()
|
|
42
|
+
|
|
43
|
+
@router.get("/graph")
|
|
44
|
+
async def knowledge_graph_page(request: Request):
|
|
45
|
+
"""Serve the interactive knowledge graph canvas UI."""
|
|
46
|
+
graph()
|
|
47
|
+
require_user(request)
|
|
48
|
+
return app_redirect("knowledge-graph", request)
|
|
49
|
+
|
|
50
|
+
@router.get("/knowledge-graph")
|
|
51
|
+
async def knowledge_graph_legacy_page(request: Request):
|
|
52
|
+
"""Backward-compatible route for the graph page."""
|
|
53
|
+
graph()
|
|
54
|
+
require_user(request)
|
|
55
|
+
return app_redirect("knowledge-graph", request)
|
|
56
|
+
|
|
57
|
+
@router.post("/knowledge-graph/curate")
|
|
58
|
+
async def knowledge_graph_curate(request: Request):
|
|
59
|
+
require_user(request)
|
|
60
|
+
return graph().curate()
|
|
61
|
+
|
|
62
|
+
@router.get("/knowledge-graph/provenance/coverage")
|
|
63
|
+
async def knowledge_graph_provenance_coverage(request: Request):
|
|
64
|
+
require_user(request)
|
|
65
|
+
return graph().provenance_coverage()
|
|
66
|
+
|
|
67
|
+
@router.get("/knowledge-graph/stats")
|
|
68
|
+
async def knowledge_graph_stats(request: Request):
|
|
69
|
+
require_user(request)
|
|
70
|
+
return graph().stats()
|
|
71
|
+
|
|
72
|
+
@router.get("/knowledge-graph/schema")
|
|
73
|
+
async def knowledge_graph_schema(request: Request):
|
|
74
|
+
require_user(request)
|
|
75
|
+
stats = graph().stats()
|
|
76
|
+
return {
|
|
77
|
+
"legacy_schema_version": stats.get("schema_version"),
|
|
78
|
+
"v2_schema_available": stats.get("v2_schema_available"),
|
|
79
|
+
"v2": stats.get("v2"),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@router.get("/knowledge-graph/graph")
|
|
83
|
+
async def knowledge_graph_data(request: Request, limit: int = 300):
|
|
84
|
+
require_user(request)
|
|
85
|
+
return graph().graph(limit)
|
|
86
|
+
|
|
87
|
+
@router.get("/knowledge-graph/documents")
|
|
88
|
+
async def knowledge_graph_documents(request: Request, limit: int = 200):
|
|
89
|
+
"""Ingested documents (uploads + indexed local docs) with index state.
|
|
90
|
+
|
|
91
|
+
Backs the Files view so uploaded content is visible end-to-end:
|
|
92
|
+
upload → Files → Knowledge Graph → Hybrid Search → Chat.
|
|
93
|
+
"""
|
|
94
|
+
require_user(request)
|
|
95
|
+
return graph().list_documents(limit)
|
|
96
|
+
|
|
97
|
+
@router.get("/knowledge-graph/search")
|
|
98
|
+
async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
|
|
99
|
+
require_user(request)
|
|
100
|
+
if not q or not q.strip():
|
|
101
|
+
return {"query": q, "matches": []}
|
|
102
|
+
return graph().search(q, limit)
|
|
103
|
+
|
|
104
|
+
@router.get("/knowledge-graph/context")
|
|
105
|
+
async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
|
|
106
|
+
require_user(request)
|
|
107
|
+
return {"query": q, "context": graph().context_for_query(q, limit)}
|
|
108
|
+
|
|
109
|
+
@router.get("/knowledge-graph/neighbors/{node_id:path}")
|
|
110
|
+
async def knowledge_graph_neighbors(node_id: str, request: Request):
|
|
111
|
+
require_user(request)
|
|
112
|
+
if not node_id:
|
|
113
|
+
raise HTTPException(status_code=400, detail="node_id required")
|
|
114
|
+
return graph().neighbors(node_id)
|
|
115
|
+
|
|
116
|
+
@router.post("/knowledge-graph/ingest")
|
|
117
|
+
async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
|
|
118
|
+
current_user = require_user(request)
|
|
119
|
+
kg = graph()
|
|
120
|
+
event_type = (req.type or "").strip().lower()
|
|
121
|
+
if event_type not in {"message", "ai_response", "note"}:
|
|
122
|
+
raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
|
|
123
|
+
role = req.role or ("assistant" if event_type == "ai_response" else "user")
|
|
124
|
+
return kg.ingest_message(
|
|
125
|
+
role,
|
|
126
|
+
req.content,
|
|
127
|
+
user_email=req.user_email or current_user,
|
|
128
|
+
user_nickname=req.user_nickname,
|
|
129
|
+
source=req.source or "mcp",
|
|
130
|
+
conversation_id=req.conversation_id,
|
|
131
|
+
raw={
|
|
132
|
+
"type": req.type,
|
|
133
|
+
"title": req.title,
|
|
134
|
+
"content": req.content,
|
|
135
|
+
"metadata": req.metadata or {},
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return router
|
|
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
14
14
|
from fastapi.responses import FileResponse
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
|
-
from
|
|
17
|
+
from latticeai.api.knowledge_graph import create_knowledge_graph_router
|
|
18
18
|
from local_knowledge_api import create_local_knowledge_router
|
|
19
19
|
from tools import local_list, local_read, local_write
|
|
20
20
|
|
package/latticeai/api/mcp.py
CHANGED
|
@@ -17,11 +17,12 @@ from datetime import datetime
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any, Callable, Dict, List, Optional
|
|
19
19
|
|
|
20
|
+
from latticeai.services.ingestion import IngestionItem
|
|
20
21
|
from fastapi import APIRouter, HTTPException, Request
|
|
21
22
|
from pydantic import BaseModel
|
|
22
23
|
|
|
23
|
-
import mcp_registry
|
|
24
|
-
from mcp_registry import (
|
|
24
|
+
import latticeai.core.mcp_registry as mcp_registry
|
|
25
|
+
from latticeai.core.mcp_registry import (
|
|
25
26
|
_get_combined_registry,
|
|
26
27
|
_fetch_skills_marketplace,
|
|
27
28
|
_fetch_plugin_directory,
|
|
@@ -76,6 +77,7 @@ def create_mcp_router(
|
|
|
76
77
|
tool_response: Callable[..., Any],
|
|
77
78
|
require_graph: Callable[[], Any],
|
|
78
79
|
knowledge_graph: Any,
|
|
80
|
+
ingestion_pipeline: Any,
|
|
79
81
|
data_dir: Path,
|
|
80
82
|
) -> APIRouter:
|
|
81
83
|
router = APIRouter()
|
|
@@ -290,7 +292,7 @@ def create_mcp_router(
|
|
|
290
292
|
if not skill_md.exists():
|
|
291
293
|
continue
|
|
292
294
|
lines = skill_md.read_text(encoding="utf-8").splitlines()
|
|
293
|
-
desc = next((
|
|
295
|
+
desc = next((ln.split(":", 1)[1].strip() for ln in lines if ln.startswith("description:")), "")
|
|
294
296
|
comment = lines[0] if lines else ""
|
|
295
297
|
if "anthropics/claude-plugins-official" in comment:
|
|
296
298
|
source = "anthropic"
|
|
@@ -357,15 +359,25 @@ def create_mcp_router(
|
|
|
357
359
|
args = req.args or {}
|
|
358
360
|
if req.action == "knowledge_graph_ingest":
|
|
359
361
|
_require_graph()
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
362
|
+
# v4: MCP messages enter the brain through the unified ingestion
|
|
363
|
+
# pipeline (provenance + hook lifecycle), not a direct store call.
|
|
364
|
+
owner = args.get("user_email") or current_user
|
|
365
|
+
result = ingestion_pipeline.ingest(
|
|
366
|
+
IngestionItem(
|
|
367
|
+
source_type="mcp_message",
|
|
368
|
+
text=args.get("content") or "",
|
|
369
|
+
owner=owner,
|
|
370
|
+
conversation_id=args.get("conversation_id"),
|
|
371
|
+
metadata={
|
|
372
|
+
"role": args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
|
|
373
|
+
"user_nickname": args.get("user_nickname"),
|
|
374
|
+
"source": args.get("source") or "mcp",
|
|
375
|
+
"raw": args,
|
|
376
|
+
},
|
|
377
|
+
),
|
|
378
|
+
user_email=owner,
|
|
368
379
|
)
|
|
380
|
+
return result.as_dict()
|
|
369
381
|
if req.action == "knowledge_graph_search":
|
|
370
382
|
_require_graph()
|
|
371
383
|
return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
|
package/latticeai/api/memory.py
CHANGED
|
@@ -7,7 +7,7 @@ operations (prune / compact / rebuild / clear). Full paths in decorators.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from typing import
|
|
10
|
+
from typing import Callable, List, Optional
|
|
11
11
|
|
|
12
12
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
13
|
from pydantic import BaseModel
|
package/latticeai/api/models.py
CHANGED
|
@@ -264,7 +264,7 @@ def create_models_router(
|
|
|
264
264
|
|
|
265
265
|
@router.post("/setup/set-api-key")
|
|
266
266
|
async def set_api_key(req: SetApiKeyRequest, request: Request):
|
|
267
|
-
from
|
|
267
|
+
from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS
|
|
268
268
|
config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
|
|
269
269
|
if not config:
|
|
270
270
|
raise HTTPException(status_code=400, detail="알 수 없는 프로바이더입니다.")
|