ltcai 10.7.0 → 10.9.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 +55 -40
- package/docs/CHANGELOG.md +68 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/retrieval_vector.py +69 -33
- package/lattice_brain/runtime/multi_agent.py +96 -15
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +9 -6
- package/latticeai/api/auth.py +37 -38
- package/latticeai/api/browser.py +7 -6
- package/latticeai/api/chat.py +9 -6
- package/latticeai/api/chat_agent_http.py +2 -1
- package/latticeai/api/chat_history.py +4 -2
- package/latticeai/api/chat_intents.py +14 -14
- package/latticeai/api/knowledge_graph.py +5 -4
- package/latticeai/api/local_files.py +25 -15
- package/latticeai/api/mcp.py +15 -11
- package/latticeai/api/memory.py +2 -1
- package/latticeai/api/models.py +39 -24
- package/latticeai/api/network_boundary.py +9 -4
- package/latticeai/api/portability.py +27 -19
- package/latticeai/api/project_sessions.py +7 -5
- package/latticeai/api/review_queue.py +12 -7
- package/latticeai/api/setup.py +10 -11
- package/latticeai/api/static_routes.py +62 -46
- package/latticeai/api/tools.py +7 -6
- package/latticeai/core/file_generation.py +141 -8
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +20 -4
- package/latticeai/core/messages.py +452 -0
- package/latticeai/core/workspace_computer_memory.py +84 -0
- package/latticeai/core/workspace_indexing.py +102 -0
- package/latticeai/core/workspace_onboarding.py +104 -0
- package/latticeai/core/workspace_os.py +24 -207
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_relationships.py +99 -0
- package/latticeai/integrations/telegram_bot.py +20 -18
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/model_loading.py +63 -27
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +3 -2
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_screenshot_pixel_delta.py +92 -10
- package/scripts/check_server_i18n.mjs +96 -0
- package/scripts/release_screen_claims.json +46 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-DIbkoJqs.js → Act-CS9IeqUX.js} +1 -1
- package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-3UkIEWGA.js} +1 -1
- package/static/app/assets/{Brain-BPfuXZ71.js → Brain-B22EmNqS.js} +1 -1
- package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-CMDqgJF4.js} +2 -2
- package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-BeE8RJo3.js} +1 -1
- package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-ZX9bQh68.js} +1 -1
- package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-86m4FCcN.js} +1 -1
- package/static/app/assets/{Library-BRlJJmwZ.js → Library-Bhz5LUca.js} +1 -1
- package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-BSa0wpFG.js} +1 -1
- package/static/app/assets/ProductFlow-IP4Q-5aQ.js +1 -0
- package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-BepjSDpN.js} +1 -1
- package/static/app/assets/{System-Ci0HEtTJ.js → System-Bwx1h_jT.js} +1 -1
- package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
- package/static/app/assets/{bot-BaPt4PPn.js → bot-DQj0-LkM.js} +1 -1
- package/static/app/assets/{brain-BSkWaj3i.js → brain-D8OEwmVj.js} +1 -1
- package/static/app/assets/{button-CKwtO1O0.js → button-CmTknyAP.js} +1 -1
- package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-yTCWRziJ.js} +1 -1
- package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Ccrva84R.js} +1 -1
- package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-CbJqWTlS.js} +1 -1
- package/static/app/assets/{download-C32oGU-C.js → download-CkSzbzU-.js} +1 -1
- package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-CKyjQ4PU.js} +1 -1
- package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-DAzk9um0.js} +1 -1
- package/static/app/assets/index-BfD-jhA9.css +2 -0
- package/static/app/assets/{index-J6h01X78.js → index-CxOcwsHV.js} +3 -3
- package/static/app/assets/{input-y6ABdKWY.js → input-DcMETmZ7.js} +1 -1
- package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-BVf13_25.js} +1 -1
- package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CQV9Q2YM.js} +1 -1
- package/static/app/assets/search-CXGASMMH.js +1 -0
- package/static/app/assets/{share-2-BgGq2A63.js → share-2-COWCHNZm.js} +1 -1
- package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BDrvilyK.js} +1 -1
- package/static/app/assets/{textarea-BAcim3fs.js → textarea-rUmsc8cP.js} +1 -1
- package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-Bi5UY_8v.js} +1 -1
- package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-C-AicB-3.js} +1 -1
- package/static/app/assets/{utils-CogdofAA.js → utils-BMwWg78e.js} +1 -1
- package/static/app/assets/{workspace-BMvfx9_R.js → workspace-Y93tls8P.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
- package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
- package/static/app/assets/index-BV7Yf6Eq.css +0 -2
- package/static/app/assets/search-CaIZKyo_.js +0 -1
package/latticeai/api/mcp.py
CHANGED
|
@@ -11,6 +11,7 @@ import cycle.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import asyncio
|
|
14
15
|
import json
|
|
15
16
|
import logging
|
|
16
17
|
from datetime import datetime
|
|
@@ -29,6 +30,7 @@ from latticeai.core.mcp_registry import (
|
|
|
29
30
|
_get_combined_registry,
|
|
30
31
|
install_skill,
|
|
31
32
|
)
|
|
33
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
32
34
|
from latticeai.core.tool_registry import (
|
|
33
35
|
KNOWLEDGE_WRITE_TOOLS,
|
|
34
36
|
MCP_TOOL_DESCRIPTIONS,
|
|
@@ -130,7 +132,7 @@ def create_mcp_router(
|
|
|
130
132
|
except PermissionError as exc:
|
|
131
133
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
132
134
|
|
|
133
|
-
def _read_workspace(requested: Optional[str], user: Optional[str]) -> Optional[str]:
|
|
135
|
+
def _read_workspace(request: Request, requested: Optional[str], user: Optional[str]) -> Optional[str]:
|
|
134
136
|
if workspace_service is not None:
|
|
135
137
|
try:
|
|
136
138
|
return workspace_service.resolve_read_scope(requested, user)
|
|
@@ -139,7 +141,7 @@ def create_mcp_router(
|
|
|
139
141
|
workspace_id = requested or "personal"
|
|
140
142
|
allowed = _allowed_graph_workspaces(user)
|
|
141
143
|
if allowed is not None and workspace_id not in set(allowed):
|
|
142
|
-
raise
|
|
144
|
+
raise http_error(403, "common.workspace_unreadable", resolve_language(request), workspace=workspace_id)
|
|
143
145
|
return workspace_id
|
|
144
146
|
|
|
145
147
|
@router.get("/mcp/tools")
|
|
@@ -204,7 +206,7 @@ def create_mcp_router(
|
|
|
204
206
|
registry = await _get_combined_registry()
|
|
205
207
|
item = next((e for e in registry if e["id"] == mcp_id), None)
|
|
206
208
|
if not item or item.get("install_mode") != "connector":
|
|
207
|
-
raise
|
|
209
|
+
raise http_error(404, "mcp.connector_not_found", resolve_language(request))
|
|
208
210
|
installed = load_mcp_installs().get("installed", {})
|
|
209
211
|
public = mcp_public_item(item, installed)
|
|
210
212
|
public["instructions"] = [
|
|
@@ -229,8 +231,10 @@ def create_mcp_router(
|
|
|
229
231
|
if not settings_path.exists():
|
|
230
232
|
return {"servers": []}
|
|
231
233
|
try:
|
|
232
|
-
|
|
233
|
-
|
|
234
|
+
# Home may be a network mount, where a read is not the microsecond
|
|
235
|
+
# a local one is; keep it off the loop like every other file read.
|
|
236
|
+
raw = await asyncio.to_thread(settings_path.read_text, "utf-8")
|
|
237
|
+
settings = json.loads(raw)
|
|
234
238
|
mcp_servers = settings.get("mcpServers", {})
|
|
235
239
|
servers = []
|
|
236
240
|
for name, cfg in mcp_servers.items():
|
|
@@ -272,9 +276,9 @@ def create_mcp_router(
|
|
|
272
276
|
admin_email, _ = require_admin(request)
|
|
273
277
|
append_audit_event("mcp_custom_add", user_email=admin_email, name=req.name, package=req.package)
|
|
274
278
|
if not req.name.strip():
|
|
275
|
-
raise
|
|
279
|
+
raise http_error(400, "mcp.name_required", resolve_language(request))
|
|
276
280
|
if not req.package.strip():
|
|
277
|
-
raise
|
|
281
|
+
raise http_error(400, "mcp.package_required", resolve_language(request))
|
|
278
282
|
items = _load_custom_mcps()
|
|
279
283
|
entry = {
|
|
280
284
|
"id": f"custom:{req.name.strip().lower().replace(' ', '-')}",
|
|
@@ -302,7 +306,7 @@ def create_mcp_router(
|
|
|
302
306
|
before = len(items)
|
|
303
307
|
items = [e for e in items if e["id"] != mcp_id]
|
|
304
308
|
if len(items) == before:
|
|
305
|
-
raise
|
|
309
|
+
raise http_error(404, "mcp.item_not_found", resolve_language(request))
|
|
306
310
|
_save_custom_mcps(items)
|
|
307
311
|
return {"status": "ok"}
|
|
308
312
|
|
|
@@ -415,7 +419,7 @@ def create_mcp_router(
|
|
|
415
419
|
# pipeline (provenance + hook lifecycle), not a direct store call.
|
|
416
420
|
claimed_user = str(args.get("user_email") or "").strip()
|
|
417
421
|
if current_user and claimed_user and claimed_user.lower() != current_user.strip().lower():
|
|
418
|
-
raise
|
|
422
|
+
raise http_error(403, "common.user_mismatch", resolve_language(request))
|
|
419
423
|
owner = current_user or claimed_user or None
|
|
420
424
|
workspace_id = _write_workspace(args.get("workspace_id"), owner)
|
|
421
425
|
raw = dict(args)
|
|
@@ -464,12 +468,12 @@ def create_mcp_router(
|
|
|
464
468
|
if req.action in SCOPED_KNOWLEDGE_TOOLS:
|
|
465
469
|
claimed_user = str(dispatch_args.get("user_email") or "").strip().lower()
|
|
466
470
|
if claimed_user and claimed_user != current_user.strip().lower():
|
|
467
|
-
raise
|
|
471
|
+
raise http_error(403, "common.user_mismatch", resolve_language(request))
|
|
468
472
|
requested_workspace = dispatch_args.get("workspace_id")
|
|
469
473
|
if req.action in KNOWLEDGE_WRITE_TOOLS:
|
|
470
474
|
workspace_id = _write_workspace(requested_workspace, current_user)
|
|
471
475
|
else:
|
|
472
|
-
workspace_id = _read_workspace(requested_workspace, current_user)
|
|
476
|
+
workspace_id = _read_workspace(request, requested_workspace, current_user)
|
|
473
477
|
dispatch_args["workspace_id"] = workspace_id
|
|
474
478
|
dispatch_args["user_email"] = current_user
|
|
475
479
|
enforce_tool_policy(req.action, dispatch_args, current_user=current_user, source="mcp")
|
package/latticeai/api/memory.py
CHANGED
|
@@ -12,6 +12,7 @@ from typing import Callable, List, Optional
|
|
|
12
12
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
15
16
|
from latticeai.services.memory_service import MemoryService
|
|
16
17
|
|
|
17
18
|
|
|
@@ -96,7 +97,7 @@ def create_memory_router(
|
|
|
96
97
|
try:
|
|
97
98
|
return service.inspect(source, user_email=user, workspace_id=scope, limit=limit)
|
|
98
99
|
except KeyError as exc:
|
|
99
|
-
raise
|
|
100
|
+
raise http_error(404, "memory.unknown_source", resolve_language(request), source=source) from exc
|
|
100
101
|
|
|
101
102
|
@router.post("/api/memory/recall")
|
|
102
103
|
async def memory_recall(req: RecallRequest, request: Request):
|
package/latticeai/api/models.py
CHANGED
|
@@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
20
20
|
from fastapi.responses import StreamingResponse
|
|
21
21
|
from pydantic import BaseModel
|
|
22
22
|
|
|
23
|
+
from latticeai.core.messages import http_error, resolve_language, translate
|
|
23
24
|
from latticeai.services.model_errors import ModelRuntimeError
|
|
24
25
|
|
|
25
26
|
|
|
@@ -140,7 +141,7 @@ def create_models_router(
|
|
|
140
141
|
current_user = require_user(request)
|
|
141
142
|
if REQUIRE_AUTH:
|
|
142
143
|
if claimed_email and _normalized_identity(claimed_email) != _normalized_identity(current_user):
|
|
143
|
-
raise
|
|
144
|
+
raise http_error(403, "models.other_user_credentials", resolve_language(request))
|
|
144
145
|
require_admin(request)
|
|
145
146
|
return current_user
|
|
146
147
|
|
|
@@ -305,9 +306,14 @@ def create_models_router(
|
|
|
305
306
|
async def engines_install(req: InstallEngineRequest, request: Request):
|
|
306
307
|
_authorize_model_admin(request)
|
|
307
308
|
try:
|
|
309
|
+
# `install_engine` shells out with a 900s timeout. On the event loop
|
|
310
|
+
# that is a fifteen-minute freeze of the entire server — no chat, no
|
|
311
|
+
# status, no UI — for one admin installing an engine.
|
|
308
312
|
if req.confirmation_token:
|
|
309
|
-
return
|
|
310
|
-
|
|
313
|
+
return await asyncio.to_thread(
|
|
314
|
+
install_engine, req.engine, confirmation_token=req.confirmation_token
|
|
315
|
+
)
|
|
316
|
+
return await asyncio.to_thread(install_engine, req.engine)
|
|
311
317
|
except ModelRuntimeError as exc:
|
|
312
318
|
_raise_model_http(exc)
|
|
313
319
|
|
|
@@ -326,11 +332,11 @@ def create_models_router(
|
|
|
326
332
|
if not req.allow_download:
|
|
327
333
|
raise HTTPException(
|
|
328
334
|
status_code=403,
|
|
329
|
-
detail="
|
|
335
|
+
detail=translate("models.download_consent_required", resolve_language(request)),
|
|
330
336
|
)
|
|
331
337
|
model_ref = normalize_local_model_request(req.model, None)
|
|
332
338
|
if not model_ref:
|
|
333
|
-
raise
|
|
339
|
+
raise http_error(400, "models.identifier_empty", resolve_language(request))
|
|
334
340
|
|
|
335
341
|
if ":" in model_ref and model_ref.split(":", 1)[0].strip().lower() in {"ollama", "vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
|
|
336
342
|
provider, model_name = model_ref.split(":", 1)
|
|
@@ -340,25 +346,36 @@ def create_models_router(
|
|
|
340
346
|
provider, model_name = "local_mlx", model_ref
|
|
341
347
|
|
|
342
348
|
if not model_name:
|
|
343
|
-
raise
|
|
349
|
+
raise http_error(400, "models.name_empty", resolve_language(request))
|
|
344
350
|
|
|
345
351
|
if provider == "ollama":
|
|
346
352
|
try:
|
|
347
|
-
|
|
353
|
+
# Starts the daemon and waits for it — blocking, like the pull
|
|
354
|
+
# below, and for the same reason it may not run on the loop.
|
|
355
|
+
await asyncio.to_thread(ensure_ollama_server)
|
|
348
356
|
except ModelRuntimeError as exc:
|
|
349
357
|
_raise_model_http(exc)
|
|
350
358
|
ollama = local_binary("ollama")
|
|
351
359
|
if not ollama:
|
|
352
|
-
raise
|
|
360
|
+
raise http_error(400, "models.ollama_missing", resolve_language(request))
|
|
353
361
|
try:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
362
|
+
# A model pull is minutes of network I/O. Held on the event loop
|
|
363
|
+
# it stalled every other request — including the health check the
|
|
364
|
+
# UI uses to decide the server is alive — until the pull finished.
|
|
365
|
+
completed = await asyncio.to_thread(
|
|
366
|
+
lambda: subprocess.run(
|
|
367
|
+
[ollama, "pull", model_name],
|
|
368
|
+
capture_output=True, text=True, timeout=900, check=False,
|
|
369
|
+
)
|
|
357
370
|
)
|
|
358
371
|
except subprocess.TimeoutExpired:
|
|
359
|
-
raise
|
|
372
|
+
raise http_error(408, "models.download_timeout", resolve_language(request))
|
|
360
373
|
if completed.returncode != 0:
|
|
361
|
-
raise HTTPException(
|
|
374
|
+
raise HTTPException(
|
|
375
|
+
status_code=500,
|
|
376
|
+
detail=completed.stderr[-2000:]
|
|
377
|
+
or translate("models.pull_failed", resolve_language(request)),
|
|
378
|
+
)
|
|
362
379
|
return {"provider": provider, "model": model_name, "returncode": completed.returncode}
|
|
363
380
|
|
|
364
381
|
if provider == "lmstudio":
|
|
@@ -374,12 +391,13 @@ def create_models_router(
|
|
|
374
391
|
if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
|
|
375
392
|
download_provider = "local_mlx" if provider == "mlx" else provider
|
|
376
393
|
try:
|
|
377
|
-
|
|
394
|
+
# Multi-gigabyte Hugging Face download; same rule as the pull above.
|
|
395
|
+
result = await asyncio.to_thread(download_hf_model, model_name, download_provider)
|
|
378
396
|
except ModelRuntimeError as exc:
|
|
379
397
|
_raise_model_http(exc)
|
|
380
398
|
return {"provider": provider, "model": model_name, "returncode": 0, **result}
|
|
381
399
|
|
|
382
|
-
raise
|
|
400
|
+
raise http_error(400, "models.download_not_automated", resolve_language(request), provider=provider)
|
|
383
401
|
|
|
384
402
|
@router.post("/engines/prepare-model")
|
|
385
403
|
async def engines_prepare_model(req: PrepareModelRequest, request: Request):
|
|
@@ -439,15 +457,15 @@ def create_models_router(
|
|
|
439
457
|
from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS
|
|
440
458
|
config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
|
|
441
459
|
if not config:
|
|
442
|
-
raise
|
|
460
|
+
raise http_error(400, "models.unknown_provider", resolve_language(request))
|
|
443
461
|
if not req.key.strip():
|
|
444
|
-
raise
|
|
462
|
+
raise http_error(400, "models.api_key_empty", resolve_language(request))
|
|
445
463
|
current_user = require_user(request)
|
|
446
464
|
if REQUIRE_AUTH and req.user_email and _normalized_identity(req.user_email) != _normalized_identity(current_user):
|
|
447
|
-
raise
|
|
465
|
+
raise http_error(403, "models.other_user_api_key", resolve_language(request))
|
|
448
466
|
target_email = str(_effective_email(current_user, req.user_email) or "").strip()
|
|
449
467
|
if not target_email:
|
|
450
|
-
raise
|
|
468
|
+
raise http_error(400, "models.sign_in_required", resolve_language(request))
|
|
451
469
|
set_user_api_key(target_email, req.provider, req.key.strip())
|
|
452
470
|
return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
|
|
453
471
|
|
|
@@ -502,10 +520,7 @@ def create_models_router(
|
|
|
502
520
|
model_id = req.model_id
|
|
503
521
|
requested_engine = req.engine or (model_id.split(":", 1)[0] if ":" in model_id else "local_mlx")
|
|
504
522
|
if IS_PUBLIC_MODE and not ALLOW_LOCAL_MODELS and requested_engine in {"local_mlx", "mlx"}:
|
|
505
|
-
raise
|
|
506
|
-
status_code=400,
|
|
507
|
-
detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
|
|
508
|
-
)
|
|
523
|
+
raise http_error(400, "models.public_mode_blocks_local", resolve_language(request))
|
|
509
524
|
compatibility = model_runtime_compatibility(model_id, engine=requested_engine)
|
|
510
525
|
if compatibility.get("supported") is False:
|
|
511
526
|
raise HTTPException(status_code=400, detail=compatibility)
|
|
@@ -532,7 +547,7 @@ def create_models_router(
|
|
|
532
547
|
_router.switch_model(model_id)
|
|
533
548
|
return {"status": "ok", "current": _router.current_model_id}
|
|
534
549
|
except KeyError:
|
|
535
|
-
raise
|
|
550
|
+
raise http_error(404, "chat.model_not_loaded", resolve_language(request), model=model_id)
|
|
536
551
|
|
|
537
552
|
@router.delete("/models/unload/{model_id:path}")
|
|
538
553
|
async def unload_model(model_id: str, request: Request):
|
|
@@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, Optional
|
|
|
7
7
|
from fastapi import APIRouter, HTTPException, Request
|
|
8
8
|
from pydantic import BaseModel, Field
|
|
9
9
|
|
|
10
|
+
from latticeai.core.messages import http_error, resolve_language, translate
|
|
10
11
|
from latticeai.core.network_boundary import network_mode_catalog, normalize_network_mode
|
|
11
12
|
from latticeai.services.cloud_egress_audit import record_cloud_egress
|
|
12
13
|
from latticeai.services.cloud_token_guard import budget_for
|
|
@@ -164,12 +165,16 @@ def create_network_boundary_router(
|
|
|
164
165
|
"""
|
|
165
166
|
user, scope = _scope(request, body.workspace_id)
|
|
166
167
|
if knowledge_graph is None or not hasattr(knowledge_graph, "set_node_sensitivity"):
|
|
167
|
-
raise
|
|
168
|
+
raise http_error(501, "common.graph_unavailable", resolve_language(request))
|
|
168
169
|
result = knowledge_graph.set_node_sensitivity(
|
|
169
170
|
body.node_id, local_only=bool(body.local_only), reason=body.reason
|
|
170
171
|
)
|
|
171
172
|
if not result.get("ok"):
|
|
172
|
-
raise HTTPException(
|
|
173
|
+
raise HTTPException(
|
|
174
|
+
status_code=404,
|
|
175
|
+
detail=result.get("reason")
|
|
176
|
+
or translate("graph.node_not_found", resolve_language(request)),
|
|
177
|
+
)
|
|
173
178
|
record_cloud_egress(
|
|
174
179
|
node_ids=[body.node_id], token_estimate=0, mode="(policy)",
|
|
175
180
|
provider="(local)", user_email=user, workspace_id=scope,
|
|
@@ -184,14 +189,14 @@ def create_network_boundary_router(
|
|
|
184
189
|
workspace_id: Optional[str] = None,
|
|
185
190
|
):
|
|
186
191
|
if policy_service is None:
|
|
187
|
-
raise
|
|
192
|
+
raise http_error(501, "boundary.policy_not_configured", resolve_language(request))
|
|
188
193
|
user, scope = _scope(request, workspace_id)
|
|
189
194
|
return policy_service.resolve(user_email=user, workspace_id=scope)
|
|
190
195
|
|
|
191
196
|
@router.post("/api/network-boundary/policy")
|
|
192
197
|
async def set_hybrid_policy(body: SetHybridPolicyRequest, request: Request):
|
|
193
198
|
if policy_service is None:
|
|
194
|
-
raise
|
|
199
|
+
raise http_error(501, "boundary.policy_not_configured", resolve_language(request))
|
|
195
200
|
user, scope = _scope(request, body.workspace_id)
|
|
196
201
|
patch: Dict[str, Any] = {}
|
|
197
202
|
for key in (
|
|
@@ -12,6 +12,8 @@ from typing import Any, Callable, Optional
|
|
|
12
12
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
|
+
from latticeai.core.messages import http_error, resolve_language, translate
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
class ImportRequest(BaseModel):
|
|
17
19
|
artifact: dict
|
|
@@ -72,52 +74,52 @@ def create_portability_router(
|
|
|
72
74
|
) -> APIRouter:
|
|
73
75
|
router = APIRouter()
|
|
74
76
|
|
|
75
|
-
def _require_service():
|
|
77
|
+
def _require_service(request: Request):
|
|
76
78
|
if service is None or not service.available():
|
|
77
|
-
raise
|
|
79
|
+
raise http_error(503, "common.graph_disabled", resolve_language(request))
|
|
78
80
|
|
|
79
81
|
@router.get("/api/knowledge-graph/portability")
|
|
80
82
|
async def portability_status(request: Request):
|
|
81
83
|
require_user(request)
|
|
82
|
-
_require_service()
|
|
84
|
+
_require_service(request)
|
|
83
85
|
return service.snapshot_metadata()
|
|
84
86
|
|
|
85
87
|
@router.get("/api/brain/storage")
|
|
86
88
|
async def brain_storage_status(request: Request):
|
|
87
89
|
require_user(request)
|
|
88
|
-
_require_service()
|
|
90
|
+
_require_service(request)
|
|
89
91
|
return service.storage_status()
|
|
90
92
|
|
|
91
93
|
@router.get("/api/knowledge-graph/backup-health")
|
|
92
94
|
async def backup_health(request: Request):
|
|
93
95
|
require_user(request)
|
|
94
|
-
_require_service()
|
|
96
|
+
_require_service(request)
|
|
95
97
|
return service.backup_health()
|
|
96
98
|
|
|
97
99
|
@router.get("/api/knowledge-graph/provenance")
|
|
98
100
|
async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
|
|
99
101
|
"""Recent ingestions (provenance trail) for the ingestion-sources UI."""
|
|
100
102
|
require_admin(request)
|
|
101
|
-
_require_service()
|
|
103
|
+
_require_service(request)
|
|
102
104
|
return service.recent_ingestions(limit=limit, source_type=source_type)
|
|
103
105
|
|
|
104
106
|
@router.post("/api/knowledge-graph/export")
|
|
105
107
|
async def export_graph(request: Request):
|
|
106
108
|
"""Logical JSON export of the whole graph (read-only)."""
|
|
107
109
|
require_admin(request)
|
|
108
|
-
_require_service()
|
|
110
|
+
_require_service(request)
|
|
109
111
|
return service.export()
|
|
110
112
|
|
|
111
113
|
@router.post("/api/knowledge-graph/export-file")
|
|
112
114
|
async def export_graph_file(request: Request):
|
|
113
115
|
require_admin(request)
|
|
114
|
-
_require_service()
|
|
116
|
+
_require_service(request)
|
|
115
117
|
return service.export_to_file()
|
|
116
118
|
|
|
117
119
|
@router.post("/api/knowledge-graph/import")
|
|
118
120
|
async def import_graph(req: ImportRequest, request: Request):
|
|
119
121
|
require_admin(request)
|
|
120
|
-
_require_service()
|
|
122
|
+
_require_service(request)
|
|
121
123
|
try:
|
|
122
124
|
return service.import_data(req.artifact, mode=req.mode, dry_run=req.dry_run)
|
|
123
125
|
except ValueError as exc:
|
|
@@ -126,13 +128,13 @@ def create_portability_router(
|
|
|
126
128
|
@router.post("/api/knowledge-graph/backup")
|
|
127
129
|
async def backup_graph(req: BackupRequest, request: Request):
|
|
128
130
|
require_admin(request)
|
|
129
|
-
_require_service()
|
|
131
|
+
_require_service(request)
|
|
130
132
|
return service.backup(req.path)
|
|
131
133
|
|
|
132
134
|
@router.post("/api/knowledge-graph/restore")
|
|
133
135
|
async def restore_graph(req: RestoreRequest, request: Request):
|
|
134
136
|
require_admin(request)
|
|
135
|
-
_require_service()
|
|
137
|
+
_require_service(request)
|
|
136
138
|
try:
|
|
137
139
|
return service.restore(req.path, verify=req.verify, dry_run=req.dry_run, confirm=req.confirm)
|
|
138
140
|
except (ValueError, FileNotFoundError) as exc:
|
|
@@ -141,7 +143,7 @@ def create_portability_router(
|
|
|
141
143
|
@router.post("/api/knowledge-graph/archive")
|
|
142
144
|
async def encrypted_archive(req: EncryptedArchiveRequest, request: Request):
|
|
143
145
|
require_admin(request)
|
|
144
|
-
_require_service()
|
|
146
|
+
_require_service(request)
|
|
145
147
|
try:
|
|
146
148
|
return service.encrypted_archive(req.path, passphrase=req.passphrase)
|
|
147
149
|
except (ValueError, FileNotFoundError) as exc:
|
|
@@ -150,7 +152,7 @@ def create_portability_router(
|
|
|
150
152
|
@router.post("/api/knowledge-graph/archive/inspect")
|
|
151
153
|
async def inspect_encrypted_archive(req: EncryptedInspectRequest, request: Request):
|
|
152
154
|
require_admin(request)
|
|
153
|
-
_require_service()
|
|
155
|
+
_require_service(request)
|
|
154
156
|
try:
|
|
155
157
|
return service.inspect_encrypted_archive(req.path, passphrase=req.passphrase)
|
|
156
158
|
except (ValueError, FileNotFoundError) as exc:
|
|
@@ -159,16 +161,22 @@ def create_portability_router(
|
|
|
159
161
|
@router.post("/api/knowledge-graph/archive/verify")
|
|
160
162
|
async def verify_encrypted_archive(req: EncryptedVerifyRequest, request: Request):
|
|
161
163
|
require_admin(request)
|
|
162
|
-
_require_service()
|
|
164
|
+
_require_service(request)
|
|
163
165
|
result = service.verify_encrypted_archive(req.path, passphrase=req.passphrase)
|
|
164
166
|
if not result.get("ok"):
|
|
165
|
-
raise HTTPException(
|
|
167
|
+
raise HTTPException(
|
|
168
|
+
status_code=400,
|
|
169
|
+
detail="; ".join(
|
|
170
|
+
result.get("errors")
|
|
171
|
+
or [translate("portability.verification_failed", resolve_language(request))]
|
|
172
|
+
),
|
|
173
|
+
)
|
|
166
174
|
return result
|
|
167
175
|
|
|
168
176
|
@router.post("/api/knowledge-graph/archive/import")
|
|
169
177
|
async def import_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
|
|
170
178
|
require_admin(request)
|
|
171
|
-
_require_service()
|
|
179
|
+
_require_service(request)
|
|
172
180
|
try:
|
|
173
181
|
return service.import_encrypted_archive(
|
|
174
182
|
req.path,
|
|
@@ -182,7 +190,7 @@ def create_portability_router(
|
|
|
182
190
|
@router.post("/api/knowledge-graph/archive/restore")
|
|
183
191
|
async def restore_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
|
|
184
192
|
require_admin(request)
|
|
185
|
-
_require_service()
|
|
193
|
+
_require_service(request)
|
|
186
194
|
try:
|
|
187
195
|
return service.restore_encrypted_archive(
|
|
188
196
|
req.path,
|
|
@@ -196,7 +204,7 @@ def create_portability_router(
|
|
|
196
204
|
@router.post("/api/brain/storage/postgres/docker")
|
|
197
205
|
async def setup_postgres_docker(req: DockerPostgresRequest, request: Request):
|
|
198
206
|
require_admin(request)
|
|
199
|
-
_require_service()
|
|
207
|
+
_require_service(request)
|
|
200
208
|
return service.postgres_docker_setup(
|
|
201
209
|
consent=req.consent,
|
|
202
210
|
dry_run=req.dry_run,
|
|
@@ -206,7 +214,7 @@ def create_portability_router(
|
|
|
206
214
|
@router.post("/api/brain/storage/migrate-postgres")
|
|
207
215
|
async def migrate_sqlite_to_postgres(req: SQLiteToPostgresRequest, request: Request):
|
|
208
216
|
require_admin(request)
|
|
209
|
-
_require_service()
|
|
217
|
+
_require_service(request)
|
|
210
218
|
try:
|
|
211
219
|
return service.migrate_sqlite_to_postgres(
|
|
212
220
|
dsn=req.dsn,
|
|
@@ -11,9 +11,11 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
from typing import Any, Callable, List, Optional
|
|
13
13
|
|
|
14
|
-
from fastapi import APIRouter,
|
|
14
|
+
from fastapi import APIRouter, Request
|
|
15
15
|
from pydantic import BaseModel, Field
|
|
16
16
|
|
|
17
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
class CreateProjectRequest(BaseModel):
|
|
19
21
|
title: str = Field(..., min_length=1, max_length=200)
|
|
@@ -65,7 +67,7 @@ def create_project_sessions_router(
|
|
|
65
67
|
user = require_user(request)
|
|
66
68
|
record = store.get(session_id, user_email=user, workspace_id=_scope(request))
|
|
67
69
|
if record is None:
|
|
68
|
-
raise
|
|
70
|
+
raise http_error(404, "project.not_found", resolve_language(request))
|
|
69
71
|
return record
|
|
70
72
|
|
|
71
73
|
@router.patch("/api/projects/{session_id}")
|
|
@@ -80,7 +82,7 @@ def create_project_sessions_router(
|
|
|
80
82
|
workspace_id=_scope(request, write=True),
|
|
81
83
|
)
|
|
82
84
|
if record is None:
|
|
83
|
-
raise
|
|
85
|
+
raise http_error(404, "project.not_found", resolve_language(request))
|
|
84
86
|
return record
|
|
85
87
|
|
|
86
88
|
@router.put("/api/projects/{session_id}/todos")
|
|
@@ -93,7 +95,7 @@ def create_project_sessions_router(
|
|
|
93
95
|
workspace_id=_scope(request, write=True),
|
|
94
96
|
)
|
|
95
97
|
if record is None:
|
|
96
|
-
raise
|
|
98
|
+
raise http_error(404, "project.not_found", resolve_language(request))
|
|
97
99
|
return record
|
|
98
100
|
|
|
99
101
|
@router.delete("/api/projects/{session_id}")
|
|
@@ -103,7 +105,7 @@ def create_project_sessions_router(
|
|
|
103
105
|
session_id, user_email=user, workspace_id=_scope(request, write=True)
|
|
104
106
|
)
|
|
105
107
|
if not removed:
|
|
106
|
-
raise
|
|
108
|
+
raise http_error(404, "project.not_found", resolve_language(request))
|
|
107
109
|
return {"status": "deleted", "id": session_id}
|
|
108
110
|
|
|
109
111
|
return router
|
|
@@ -19,6 +19,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
19
19
|
from fastapi import APIRouter, HTTPException, Request
|
|
20
20
|
from pydantic import BaseModel, Field
|
|
21
21
|
|
|
22
|
+
from latticeai.core.messages import http_error, resolve_language, translate
|
|
22
23
|
from latticeai.services.change_proposals import ProposalConflictError
|
|
23
24
|
from latticeai.services.review_queue import InvalidReviewTransition, ReviewQueueService
|
|
24
25
|
|
|
@@ -129,7 +130,7 @@ def create_review_queue_router(
|
|
|
129
130
|
try:
|
|
130
131
|
return service.get(item_id, workspace_id=scope)
|
|
131
132
|
except FileNotFoundError as exc:
|
|
132
|
-
raise
|
|
133
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
133
134
|
|
|
134
135
|
@router.post("/automation/reviews/{item_id}/approve", response_model=ReviewItem)
|
|
135
136
|
async def approve_item(item_id: str, request: Request):
|
|
@@ -141,12 +142,16 @@ def create_review_queue_router(
|
|
|
141
142
|
try:
|
|
142
143
|
stored = service.get(item_id, workspace_id=scope)
|
|
143
144
|
except FileNotFoundError as exc:
|
|
144
|
-
raise
|
|
145
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
145
146
|
if stored.get("source") == "change_proposal":
|
|
146
147
|
if stored.get("effective_status") not in ("pending", "snoozed"):
|
|
147
148
|
raise HTTPException(
|
|
148
149
|
status_code=409,
|
|
149
|
-
detail=
|
|
150
|
+
detail=translate(
|
|
151
|
+
"review.cannot_approve_in_status",
|
|
152
|
+
resolve_language(request),
|
|
153
|
+
status=stored.get("status"),
|
|
154
|
+
),
|
|
150
155
|
)
|
|
151
156
|
try:
|
|
152
157
|
applied = change_proposals.approve_and_apply(
|
|
@@ -177,7 +182,7 @@ def create_review_queue_router(
|
|
|
177
182
|
item_id, workspace_id=scope, reason=(req.reason if req else None)
|
|
178
183
|
)
|
|
179
184
|
except FileNotFoundError as exc:
|
|
180
|
-
raise
|
|
185
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
181
186
|
except InvalidReviewTransition as exc:
|
|
182
187
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
183
188
|
append_audit_event("review_item_dismiss", user_email=user, item_id=item_id)
|
|
@@ -190,7 +195,7 @@ def create_review_queue_router(
|
|
|
190
195
|
try:
|
|
191
196
|
item = service.snooze(item_id, until=req.until, workspace_id=scope)
|
|
192
197
|
except FileNotFoundError as exc:
|
|
193
|
-
raise
|
|
198
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
194
199
|
except InvalidReviewTransition as exc:
|
|
195
200
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
196
201
|
append_audit_event("review_item_snooze", user_email=user, item_id=item_id)
|
|
@@ -211,7 +216,7 @@ def create_review_queue_router(
|
|
|
211
216
|
workspace_id=scope,
|
|
212
217
|
)
|
|
213
218
|
except FileNotFoundError as exc:
|
|
214
|
-
raise
|
|
219
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
215
220
|
except InvalidReviewTransition as exc:
|
|
216
221
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
217
222
|
append_audit_event("review_item_run_now", user_email=user, item_id=item_id)
|
|
@@ -224,7 +229,7 @@ def create_review_queue_router(
|
|
|
224
229
|
try:
|
|
225
230
|
item = fn(item_id, workspace_id=scope)
|
|
226
231
|
except FileNotFoundError as exc:
|
|
227
|
-
raise
|
|
232
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
228
233
|
except InvalidReviewTransition as exc:
|
|
229
234
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
230
235
|
append_audit_event(f"review_item_{action}", user_email=user, item_id=item_id)
|