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.
Files changed (97) hide show
  1. package/README.md +55 -40
  2. package/docs/CHANGELOG.md +68 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/retrieval_vector.py +69 -33
  12. package/lattice_brain/runtime/multi_agent.py +96 -15
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +9 -6
  15. package/latticeai/api/auth.py +37 -38
  16. package/latticeai/api/browser.py +7 -6
  17. package/latticeai/api/chat.py +9 -6
  18. package/latticeai/api/chat_agent_http.py +2 -1
  19. package/latticeai/api/chat_history.py +4 -2
  20. package/latticeai/api/chat_intents.py +14 -14
  21. package/latticeai/api/knowledge_graph.py +5 -4
  22. package/latticeai/api/local_files.py +25 -15
  23. package/latticeai/api/mcp.py +15 -11
  24. package/latticeai/api/memory.py +2 -1
  25. package/latticeai/api/models.py +39 -24
  26. package/latticeai/api/network_boundary.py +9 -4
  27. package/latticeai/api/portability.py +27 -19
  28. package/latticeai/api/project_sessions.py +7 -5
  29. package/latticeai/api/review_queue.py +12 -7
  30. package/latticeai/api/setup.py +10 -11
  31. package/latticeai/api/static_routes.py +62 -46
  32. package/latticeai/api/tools.py +7 -6
  33. package/latticeai/core/file_generation.py +141 -8
  34. package/latticeai/core/legacy_compatibility.py +1 -1
  35. package/latticeai/core/marketplace.py +1 -1
  36. package/latticeai/core/mcp_registry.py +20 -4
  37. package/latticeai/core/messages.py +452 -0
  38. package/latticeai/core/workspace_computer_memory.py +84 -0
  39. package/latticeai/core/workspace_indexing.py +102 -0
  40. package/latticeai/core/workspace_onboarding.py +104 -0
  41. package/latticeai/core/workspace_os.py +24 -207
  42. package/latticeai/core/workspace_os_constants.py +1 -1
  43. package/latticeai/core/workspace_relationships.py +99 -0
  44. package/latticeai/integrations/telegram_bot.py +20 -18
  45. package/latticeai/services/architecture_readiness.py +1 -1
  46. package/latticeai/services/model_loading.py +63 -27
  47. package/latticeai/services/product_readiness.py +1 -1
  48. package/package.json +3 -2
  49. package/scripts/check_current_release_docs.mjs +1 -1
  50. package/scripts/check_screenshot_pixel_delta.py +92 -10
  51. package/scripts/check_server_i18n.mjs +96 -0
  52. package/scripts/release_screen_claims.json +46 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +37 -37
  57. package/static/app/assets/{Act-DIbkoJqs.js → Act-CS9IeqUX.js} +1 -1
  58. package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-3UkIEWGA.js} +1 -1
  59. package/static/app/assets/{Brain-BPfuXZ71.js → Brain-B22EmNqS.js} +1 -1
  60. package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-CMDqgJF4.js} +2 -2
  61. package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-BeE8RJo3.js} +1 -1
  62. package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-ZX9bQh68.js} +1 -1
  63. package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-86m4FCcN.js} +1 -1
  64. package/static/app/assets/{Library-BRlJJmwZ.js → Library-Bhz5LUca.js} +1 -1
  65. package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-BSa0wpFG.js} +1 -1
  66. package/static/app/assets/ProductFlow-IP4Q-5aQ.js +1 -0
  67. package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-BepjSDpN.js} +1 -1
  68. package/static/app/assets/{System-Ci0HEtTJ.js → System-Bwx1h_jT.js} +1 -1
  69. package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
  70. package/static/app/assets/{bot-BaPt4PPn.js → bot-DQj0-LkM.js} +1 -1
  71. package/static/app/assets/{brain-BSkWaj3i.js → brain-D8OEwmVj.js} +1 -1
  72. package/static/app/assets/{button-CKwtO1O0.js → button-CmTknyAP.js} +1 -1
  73. package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-yTCWRziJ.js} +1 -1
  74. package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Ccrva84R.js} +1 -1
  75. package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-CbJqWTlS.js} +1 -1
  76. package/static/app/assets/{download-C32oGU-C.js → download-CkSzbzU-.js} +1 -1
  77. package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-CKyjQ4PU.js} +1 -1
  78. package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-DAzk9um0.js} +1 -1
  79. package/static/app/assets/index-BfD-jhA9.css +2 -0
  80. package/static/app/assets/{index-J6h01X78.js → index-CxOcwsHV.js} +3 -3
  81. package/static/app/assets/{input-y6ABdKWY.js → input-DcMETmZ7.js} +1 -1
  82. package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-BVf13_25.js} +1 -1
  83. package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CQV9Q2YM.js} +1 -1
  84. package/static/app/assets/search-CXGASMMH.js +1 -0
  85. package/static/app/assets/{share-2-BgGq2A63.js → share-2-COWCHNZm.js} +1 -1
  86. package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BDrvilyK.js} +1 -1
  87. package/static/app/assets/{textarea-BAcim3fs.js → textarea-rUmsc8cP.js} +1 -1
  88. package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-Bi5UY_8v.js} +1 -1
  89. package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-C-AicB-3.js} +1 -1
  90. package/static/app/assets/{utils-CogdofAA.js → utils-BMwWg78e.js} +1 -1
  91. package/static/app/assets/{workspace-BMvfx9_R.js → workspace-Y93tls8P.js} +1 -1
  92. package/static/app/index.html +4 -4
  93. package/static/sw.js +1 -1
  94. package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
  95. package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
  96. package/static/app/assets/index-BV7Yf6Eq.css +0 -2
  97. package/static/app/assets/search-CaIZKyo_.js +0 -1
@@ -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 HTTPException(status_code=403, detail=f"workspace '{workspace_id}' is not readable")
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 HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
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
- with open(settings_path, "r", encoding="utf-8") as f:
233
- settings = json.load(f)
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 HTTPException(status_code=400, detail="name은 필수입니다.")
279
+ raise http_error(400, "mcp.name_required", resolve_language(request))
276
280
  if not req.package.strip():
277
- raise HTTPException(status_code=400, detail="package는 필수입니다.")
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 HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
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 HTTPException(status_code=403, detail="user_email must match the authenticated user.")
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 HTTPException(status_code=403, detail="user_email must match the authenticated user.")
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")
@@ -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 HTTPException(status_code=404, detail=f"Unknown memory source: {source}") from exc
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):
@@ -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 HTTPException(status_code=403, detail="다른 사용자의 모델 자격 증명을 사용할 수 없습니다.")
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 install_engine(req.engine, confirmation_token=req.confirmation_token)
310
- return install_engine(req.engine)
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="Model downloads require explicit user consent (allow_download=true).",
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 HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
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 HTTPException(status_code=400, detail="모델 이름이 비어 있습니다.")
349
+ raise http_error(400, "models.name_empty", resolve_language(request))
344
350
 
345
351
  if provider == "ollama":
346
352
  try:
347
- ensure_ollama_server()
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 HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
360
+ raise http_error(400, "models.ollama_missing", resolve_language(request))
353
361
  try:
354
- completed = subprocess.run(
355
- [ollama, "pull", model_name],
356
- capture_output=True, text=True, timeout=900, check=False,
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 HTTPException(status_code=408, detail="모델 다운로드 시간이 초과되었습니다.")
372
+ raise http_error(408, "models.download_timeout", resolve_language(request))
360
373
  if completed.returncode != 0:
361
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "pull 실패")
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
- result = download_hf_model(model_name, download_provider)
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 HTTPException(status_code=400, detail=f"{provider} 엔진 모델 다운로드는 아직 자동화되지 않았습니다.")
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 HTTPException(status_code=400, detail="알 수 없는 프로바이더입니다.")
460
+ raise http_error(400, "models.unknown_provider", resolve_language(request))
443
461
  if not req.key.strip():
444
- raise HTTPException(status_code=400, detail="API 키가 비어있습니다.")
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 HTTPException(status_code=403, detail="다른 사용자의 API 키를 설정할 권한이 없습니다.")
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 HTTPException(status_code=400, detail="사용자 식별이 필요합니다. 로그인 후 다시 시도하세요.")
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 HTTPException(
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 HTTPException(status_code=404, detail=f"Model '{model_id}' not loaded. Call /models/load first.")
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 HTTPException(status_code=501, detail="knowledge graph not available")
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(status_code=404, detail=result.get("reason") or "node not found")
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 HTTPException(status_code=501, detail="hybrid policy service not configured")
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 HTTPException(status_code=501, detail="hybrid policy service not configured")
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 HTTPException(status_code=503, detail="Knowledge Graph is disabled.")
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(status_code=400, detail="; ".join(result.get("errors") or ["Archive verification failed."]))
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, HTTPException, Request
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 HTTPException(status_code=404, detail="project not found")
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 HTTPException(status_code=404, detail="project not found")
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 HTTPException(status_code=404, detail="project not found")
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 HTTPException(status_code=404, detail="project not found")
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 HTTPException(status_code=404, detail="review item not found") from exc
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 HTTPException(status_code=404, detail="review item not found") from exc
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=f"cannot 'approve' a review item in status {stored.get('status')!r}",
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 HTTPException(status_code=404, detail="review item not found") from exc
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 HTTPException(status_code=404, detail="review item not found") from exc
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 HTTPException(status_code=404, detail="review item not found") from exc
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 HTTPException(status_code=404, detail="review item not found") from exc
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)