ltcai 11.7.0 → 11.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 (136) hide show
  1. package/README.md +73 -70
  2. package/docs/BENCHMARKS.md +9 -2
  3. package/docs/CHANGELOG.md +157 -0
  4. package/docs/CI_AND_RELEASE_GATES.md +126 -41
  5. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  6. package/docs/DEVELOPMENT.md +34 -14
  7. package/docs/LEGACY_COMPATIBILITY.md +10 -6
  8. package/docs/ONBOARDING.md +5 -3
  9. package/docs/OPERATIONS.md +5 -1
  10. package/docs/PERMISSION_MODE.md +14 -9
  11. package/docs/TRUST_MODEL.md +13 -6
  12. package/docs/USABILITY_AUDIT.md +5 -0
  13. package/docs/WHY_LATTICE.md +6 -4
  14. package/docs/kg-schema.md +7 -3
  15. package/docs/mcp-tools.md +73 -79
  16. package/docs/security-model.md +6 -3
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/graph/_kg_common/__init__.py +1 -54
  19. package/lattice_brain/graph/_kg_common/text.py +14 -450
  20. package/lattice_brain/ingestion/__init__.py +6 -3
  21. package/lattice_brain/multimodal/__init__.py +9 -3
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/agent_worker_seam.py +12 -1
  24. package/latticeai/api/models.py +18 -110
  25. package/latticeai/api/search.py +7 -30
  26. package/latticeai/api/worker_compute.py +53 -107
  27. package/latticeai/api/worker_seams.py +17 -2
  28. package/latticeai/core/http_origin.py +3 -3
  29. package/latticeai/core/messages.py +0 -5
  30. package/latticeai/core/policy.py +1 -6
  31. package/latticeai/core/quiet.py +1 -20
  32. package/latticeai/core/security.py +29 -83
  33. package/latticeai/core/sessions.py +95 -4
  34. package/latticeai/core/users.py +0 -38
  35. package/latticeai/models/router/catalog.py +2 -2
  36. package/latticeai/models/router/generation.py +81 -14
  37. package/latticeai/models/router/loading.py +41 -5
  38. package/latticeai/runtime/access_runtime.py +7 -4
  39. package/latticeai/runtime/build_phases/features.py +8 -31
  40. package/latticeai/runtime/build_phases/foundation.py +7 -16
  41. package/latticeai/runtime/build_phases/web.py +3 -3
  42. package/latticeai/runtime/build_phases/worker_profile.py +21 -28
  43. package/latticeai/runtime/runtime_context.py +0 -2
  44. package/latticeai/services/architecture_readiness.py +18 -19
  45. package/latticeai/services/process_audit.py +1 -22
  46. package/latticeai/services/product_readiness.py +33 -11
  47. package/latticeai/services/voice_capture.py +8 -28
  48. package/latticeai/tools/__init__.py +6 -46
  49. package/latticeai/tools/commands.py +9 -15
  50. package/latticeai/tools/knowledge.py +0 -6
  51. package/package.json +3 -5
  52. package/requirements.txt +0 -1
  53. package/scripts/check_current_release_docs.mjs +1 -1
  54. package/scripts/check_openapi_drift.mjs +3 -2
  55. package/scripts/check_server_i18n.mjs +5 -4
  56. package/scripts/compose_openapi.py +2 -1
  57. package/scripts/export_openapi.py +5 -4
  58. package/scripts/gen_worker_allowlist_fixture.py +2 -2
  59. package/scripts/openapi_route_families.json +14 -73
  60. package/scripts/release_screen_claims.json +131 -27
  61. package/src-tauri/Cargo.lock +11 -10
  62. package/src-tauri/Cargo.toml +1 -1
  63. package/src-tauri/tauri.conf.json +1 -1
  64. package/static/app/asset-manifest.json +41 -41
  65. package/static/app/assets/{Act-BPcVAbOL.js → Act-B4WT81kh.js} +1 -1
  66. package/static/app/assets/AdminConsole--Wf71m-o.js +1 -0
  67. package/static/app/assets/{Brain-CT92Kos0.js → Brain-CtYaa26c.js} +2 -2
  68. package/static/app/assets/BrainHome-Be2VPJEc.js +2 -0
  69. package/static/app/assets/BrainSignals-C0__xgpG.js +1 -0
  70. package/static/app/assets/Capture-DdNi5Peb.js +1 -0
  71. package/static/app/assets/Chronicle-B3hNveeI.js +1 -0
  72. package/static/app/assets/CommandPalette-CIsnSsFL.js +1 -0
  73. package/static/app/assets/Library-Bl5XClFV.js +1 -0
  74. package/static/app/assets/LivingBrain-DjkTB_gh.js +1 -0
  75. package/static/app/assets/{ProductFlow-DXBC6brE.js → ProductFlow-DCUNRNHs.js} +1 -1
  76. package/static/app/assets/{ReviewCard-HXRle3qq.js → ReviewCard-CD3yWvUB.js} +2 -2
  77. package/static/app/assets/System-BJ6jQ_SL.js +1 -0
  78. package/static/app/assets/arrow-left-6_28Z0qH.js +1 -0
  79. package/static/app/assets/{bot-Cn8bWRuq.js → bot-Bc3Q27YR.js} +1 -1
  80. package/static/app/assets/brain-B9BDrMTe.js +1 -0
  81. package/static/app/assets/{button-Ct9f2_oT.js → button-D6JcpYcf.js} +1 -1
  82. package/static/app/assets/circle-check-BFu9lD-3.js +1 -0
  83. package/static/app/assets/{circle-pause-CmzC_apg.js → circle-pause-BGMiV8UU.js} +1 -1
  84. package/static/app/assets/{circle-play-D8mW2aQ7.js → circle-play-DoanLHnd.js} +1 -1
  85. package/static/app/assets/{cpu-DZcdd0PZ.js → cpu-DwzNf82m.js} +1 -1
  86. package/static/app/assets/{download-bv1KEPGQ.js → download-Ddw49yCV.js} +1 -1
  87. package/static/app/assets/{folder-open-d-Pip5gr.js → folder-open-Brd6Kvto.js} +1 -1
  88. package/static/app/assets/{hard-drive-D20iavUb.js → hard-drive-Bu-DTJdB.js} +1 -1
  89. package/static/app/assets/index-CGdg_aq9.css +2 -0
  90. package/static/app/assets/{index-Do83hDzJ.js → index-CWKRRsLW.js} +4 -4
  91. package/static/app/assets/input-CEqsxtil.js +1 -0
  92. package/static/app/assets/{link-2-BPJOFlAy.js → link-2-DZ4OA5tJ.js} +1 -1
  93. package/static/app/assets/{permissionCopy-ChdJd493.js → permissionCopy-D9TR0F8b.js} +1 -1
  94. package/static/app/assets/primitives-DORg7Z_7.js +1 -0
  95. package/static/app/assets/search-0NQ21wXe.js +1 -0
  96. package/static/app/assets/{share-2-YNX_NtMU.js → share-2-BC5FirFv.js} +1 -1
  97. package/static/app/assets/{shield-alert-DuQ3zrVL.js → shield-alert-DUbR2W2s.js} +1 -1
  98. package/static/app/assets/textarea-jtQcRSXo.js +1 -0
  99. package/static/app/assets/{useFocusTrap-ZVI98jaW.js → useFocusTrap-HRemcWId.js} +1 -1
  100. package/static/app/assets/{useMutation-CVC4qv_D.js → useMutation-DqlFE-Bw.js} +1 -1
  101. package/static/app/assets/{useQuery-C7BeG4HU.js → useQuery-BizqBNGw.js} +1 -1
  102. package/static/app/assets/utils-WgW4V69R.js +4 -0
  103. package/static/app/assets/workspace-DSek3jCY.js +1 -0
  104. package/static/app/index.html +4 -4
  105. package/static/sw.js +1 -1
  106. package/lattice_brain/ingestion/pipeline.py +0 -108
  107. package/latticeai/api/local_files.py +0 -44
  108. package/latticeai/api/tools.py +0 -126
  109. package/latticeai/api/voice_capture.py +0 -32
  110. package/latticeai/core/agent_permission.py +0 -85
  111. package/scripts/agent_eval.py +0 -34
  112. package/scripts/brain_quality_eval.py +0 -37
  113. package/scripts/check_legacy_debt.mjs +0 -91
  114. package/scripts/check_python.py +0 -100
  115. package/scripts/chunking_parity_corpus.py +0 -449
  116. package/scripts/generate_agent_parity_fixtures.py +0 -771
  117. package/scripts/generate_chunking_parity_fixtures.py +0 -259
  118. package/static/app/assets/AdminConsole-Bw1ATQL0.js +0 -1
  119. package/static/app/assets/BrainHome-CFBkt1K_.js +0 -2
  120. package/static/app/assets/BrainSignals-ReLWF2H8.js +0 -1
  121. package/static/app/assets/Capture-BsTokYkk.js +0 -1
  122. package/static/app/assets/Chronicle-B6f0T9id.js +0 -1
  123. package/static/app/assets/CommandPalette-CuvjTv1u.js +0 -1
  124. package/static/app/assets/Library-BGJbG9Hd.js +0 -1
  125. package/static/app/assets/LivingBrain-DGYK_Jsa.js +0 -1
  126. package/static/app/assets/System-CMHSO9qM.js +0 -1
  127. package/static/app/assets/arrow-left-BfmkskWx.js +0 -1
  128. package/static/app/assets/brain-CQJberbE.js +0 -1
  129. package/static/app/assets/circle-check-DruOxB-4.js +0 -1
  130. package/static/app/assets/index-D9x-kSNy.css +0 -2
  131. package/static/app/assets/input-BLXVNmj1.js +0 -1
  132. package/static/app/assets/primitives-Cv5tbZBY.js +0 -1
  133. package/static/app/assets/search-CT9aho2j.js +0 -1
  134. package/static/app/assets/textarea-DqwLnli4.js +0 -1
  135. package/static/app/assets/utils-CiFtIdZq.js +0 -4
  136. package/static/app/assets/workspace-DQz9vIId.js +0 -1
@@ -2,15 +2,24 @@
2
2
 
3
3
  Extracted from ``server_app.py`` in v1.3.0 with the whole ``/models*`` +
4
4
  ``/engines*`` + ``/setup/set-api-key`` surface. v11.6.0 kept the eight routes
5
- that are *this interpreter's* business — loading, switching and unloading MLX
6
- models, and the resolve → download-if-consented → load → smoke prepare flow —
7
- and moved the rest to ``lattice-host``: engine installation and cloud-key
8
- verification are host operations, ``/setup/set-api-key`` is account state, and
9
- the two catalogue reads (``/models/compat-profiles``, ``/models/recommendations``)
10
- are product views.
5
+ that are *this interpreter's* business and moved the rest to ``lattice-host``:
6
+ engine installation and cloud-key verification are host operations,
7
+ ``/setup/set-api-key`` is account state, and the two catalogue reads
8
+ (``/models/compat-profiles``, ``/models/recommendations``) are product views.
11
9
 
12
- The download itself stays because it is ``huggingface_hub`` / ``ollama``
13
- running here (v11.6.0 gateway integration §4a).
10
+ v11.8.0 took three of those eight away, because nothing called them — not the
11
+ Rust surface, not the SPA client, not either extension:
12
+
13
+ * ``POST /engines/pull-model`` — every caller reaches a download through
14
+ ``/engines/prepare-model``, which resolves, downloads on consent, loads and
15
+ smoke-tests in one step. The bare pull was the older door, and with it went
16
+ this module's only use of ``huggingface_hub`` / ``ollama`` (both still run
17
+ here, under ``services/model_loading.py``, for the prepare flow).
18
+ * ``POST /models/switch/{model_id:path}`` — ``/models/load`` is what every
19
+ surface sends, and it switches as part of loading.
20
+ * ``DELETE /models/unload-all`` — unloading is per-model everywhere.
21
+
22
+ What is left is list, load, unload-one and the two prepare flows.
14
23
 
15
24
  Mirrors the established router-factory convention: the heavy provider/runtime
16
25
  helpers are injected as bound service callables. This module owns the sole
@@ -21,14 +30,13 @@ from __future__ import annotations
21
30
 
22
31
  import asyncio
23
32
  import logging
24
- import subprocess
25
33
  from typing import Any, Callable, Dict, List, NoReturn, Optional
26
34
 
27
35
  from fastapi import APIRouter, HTTPException, Request
28
36
  from fastapi.responses import StreamingResponse
29
37
  from pydantic import BaseModel
30
38
 
31
- from latticeai.core.messages import http_error, resolve_language, translate
39
+ from latticeai.core.messages import http_error, resolve_language
32
40
  from latticeai.services.model_errors import ModelRuntimeError
33
41
 
34
42
 
@@ -76,11 +84,6 @@ class LoadModelRequest(BaseModel):
76
84
 
77
85
 
78
86
 
79
- class PullModelRequest(BaseModel):
80
- model: str
81
- allow_download: bool = False
82
-
83
-
84
87
  class PrepareModelRequest(BaseModel):
85
88
  model: str
86
89
  engine: Optional[str] = None
@@ -94,13 +97,9 @@ def create_models_router(
94
97
  model_router: Any,
95
98
  require_user: Callable[[Request], str],
96
99
  require_admin: Callable[[Request], tuple],
97
- normalize_local_model_request: Callable[..., str],
98
- download_hf_model: Callable[..., Dict],
99
100
  prepare_and_load_model: Callable[..., Any],
100
101
  prepare_and_load_model_stream: Callable[..., Any],
101
102
  sse_event: Callable[[str, Dict], str],
102
- ensure_ollama_server: Callable[[], None],
103
- local_binary: Callable[[str], Optional[str]],
104
103
  engine_status: Callable[[], List[Dict]],
105
104
  filter_lower_family_versions: Callable[[List[Dict]], List[Dict]],
106
105
  list_compat_profiles: Callable[[], Any],
@@ -289,80 +288,6 @@ def create_models_router(
289
288
 
290
289
  # ── Engines ───────────────────────────────────────────────────────────
291
290
 
292
-
293
- @router.post("/engines/pull-model")
294
- async def pull_ollama_model(req: PullModelRequest, request: Request):
295
- _authorize_model_admin(request)
296
- if not req.allow_download:
297
- raise HTTPException(
298
- status_code=403,
299
- detail=translate("models.download_consent_required", resolve_language(request)),
300
- )
301
- model_ref = normalize_local_model_request(req.model, None)
302
- if not model_ref:
303
- raise http_error(400, "models.identifier_empty", resolve_language(request))
304
-
305
- if ":" in model_ref and model_ref.split(":", 1)[0].strip().lower() in {"ollama", "vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
306
- provider, model_name = model_ref.split(":", 1)
307
- provider = provider.strip().lower()
308
- model_name = model_name.strip()
309
- else:
310
- provider, model_name = "local_mlx", model_ref
311
-
312
- if not model_name:
313
- raise http_error(400, "models.name_empty", resolve_language(request))
314
-
315
- if provider == "ollama":
316
- try:
317
- # Starts the daemon and waits for it — blocking, like the pull
318
- # below, and for the same reason it may not run on the loop.
319
- await asyncio.to_thread(ensure_ollama_server)
320
- except ModelRuntimeError as exc:
321
- _raise_model_http(exc)
322
- ollama = local_binary("ollama")
323
- if not ollama:
324
- raise http_error(400, "models.ollama_missing", resolve_language(request))
325
- try:
326
- # A model pull is minutes of network I/O. Held on the event loop
327
- # it stalled every other request — including the health check the
328
- # UI uses to decide the server is alive — until the pull finished.
329
- completed = await asyncio.to_thread(
330
- lambda: subprocess.run(
331
- [ollama, "pull", model_name],
332
- capture_output=True, text=True, timeout=900, check=False,
333
- )
334
- )
335
- except subprocess.TimeoutExpired:
336
- raise http_error(408, "models.download_timeout", resolve_language(request))
337
- if completed.returncode != 0:
338
- raise HTTPException(
339
- status_code=500,
340
- detail=completed.stderr[-2000:]
341
- or translate("models.pull_failed", resolve_language(request)),
342
- )
343
- return {"provider": provider, "model": model_name, "returncode": completed.returncode}
344
-
345
- if provider == "lmstudio":
346
- raise HTTPException(
347
- status_code=400,
348
- detail=(
349
- "LM Studio 모델은 Lattice에서 Hugging Face로 pull하지 않습니다. "
350
- "LM Studio 앱에서 모델을 다운로드하고 Local Server를 켠 뒤 모델을 로드하세요. "
351
- "그러면 모델 선택창에 실제 /v1/models 항목이 표시됩니다."
352
- ),
353
- )
354
-
355
- if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
356
- download_provider = "local_mlx" if provider == "mlx" else provider
357
- try:
358
- # Multi-gigabyte Hugging Face download; same rule as the pull above.
359
- result = await asyncio.to_thread(download_hf_model, model_name, download_provider)
360
- except ModelRuntimeError as exc:
361
- _raise_model_http(exc)
362
- return {"provider": provider, "model": model_name, "returncode": 0, **result}
363
-
364
- raise http_error(400, "models.download_not_automated", resolve_language(request), provider=provider) # pragma: no cover — every prefix the check above accepts is handled, and the fallback is local_mlx
365
-
366
291
  @router.post("/engines/prepare-model")
367
292
  async def engines_prepare_model(req: PrepareModelRequest, request: Request):
368
293
  current_user = _authorize_model_admin(request, req.user_email)
@@ -484,27 +409,10 @@ def create_models_router(
484
409
  detail=friendly_model_runtime_error(e, model_id=req.model_id, engine=req.engine),
485
410
  )
486
411
 
487
- @router.post("/models/switch/{model_id:path}")
488
- async def switch_model(model_id: str, request: Request):
489
- _authorize_model_admin(request)
490
- try:
491
- _router.switch_model(model_id)
492
- return {"status": "ok", "current": _router.current_model_id}
493
- except KeyError:
494
- raise http_error(404, "chat.model_not_loaded", resolve_language(request), model=model_id)
495
-
496
412
  @router.delete("/models/unload/{model_id:path}")
497
413
  async def unload_model(model_id: str, request: Request):
498
414
  _authorize_model_admin(request)
499
415
  _router.unload_model(model_id)
500
416
  return {"status": "ok", "unloaded": model_id}
501
417
 
502
- @router.delete("/models/unload-all")
503
- async def unload_all_models(request: Request):
504
- _authorize_model_admin(request)
505
- unloaded = _router.loaded_model_ids
506
- _router.unload_all()
507
- return {"status": "ok", "unloaded": unloaded}
508
-
509
-
510
418
  return router
@@ -10,6 +10,11 @@ it is the one that was asked for.
10
10
  ``GET /api/embeddings/status`` therefore reports the **embedder**, not the
11
11
  index. Index completeness is a native jobs route now, and reporting it from
12
12
  here would have meant re-opening a store the worker no longer holds.
13
+
14
+ It is now the module's only route. ``GET /api/embeddings/providers`` — the
15
+ static catalogue of provider ids and the env vars each needs — was removed in
16
+ v11.8.0: no surface in the tree asked for it, and a catalogue nothing reads is
17
+ a second place for the provider list to go stale.
13
18
  """
14
19
 
15
20
  from __future__ import annotations
@@ -18,7 +23,6 @@ from typing import Any, Callable, Dict, NoReturn, Optional
18
23
 
19
24
  from fastapi import APIRouter, HTTPException, Request
20
25
 
21
- from latticeai.core.embedding_providers import embedding_provider_profiles
22
26
  from latticeai.services.search_service import SearchService
23
27
 
24
28
 
@@ -31,8 +35,8 @@ def create_search_router(
31
35
  router = APIRouter()
32
36
 
33
37
  def _raise_embedder_error(exc: Exception) -> NoReturn:
34
- # NoReturn, not None: both handlers end with this in their except
35
- # branch, and without it each one reads as a missing return.
38
+ # NoReturn, not None: the handler ends with this in its except branch,
39
+ # and without it the function reads as a missing return.
36
40
  raise HTTPException(status_code=404, detail=str(exc)) from exc
37
41
 
38
42
  @router.get("/api/embeddings/status")
@@ -44,31 +48,4 @@ def create_search_router(
44
48
  except ValueError as exc:
45
49
  _raise_embedder_error(exc)
46
50
 
47
- @router.get("/api/embeddings/providers")
48
- async def embeddings_providers(request: Request) -> Dict[str, Any]:
49
- require_user(request)
50
- resolved = embedding_info() if embedding_info else {}
51
- profiles = resolved.get("profiles") or embedding_provider_profiles()
52
- return {
53
- "active": resolved.get("active_provider"),
54
- "requested": resolved.get("requested_provider"),
55
- "profile": resolved.get("profile") or "",
56
- "profiles": profiles,
57
- "providers": [
58
- {"id": "hash", "label": "Local hash (fallback)", "grade": "fallback",
59
- "requires": [], "detail": "Deterministic offline vectors — always available."},
60
- {"id": "mlx", "label": "MLX (Apple Silicon)", "grade": "production",
61
- "requires": ["LATTICEAI_EMBEDDING_MODEL"], "detail": "Local embedding model via MLX."},
62
- {"id": "ollama", "label": "Ollama", "grade": "production",
63
- "requires": ["LATTICEAI_EMBEDDING_MODEL", "LATTICEAI_EMBEDDING_BASE_URL"],
64
- "detail": "Local/remote Ollama embedding server."},
65
- {"id": "openai", "label": "OpenAI-compatible", "grade": "production",
66
- "requires": ["LATTICEAI_EMBEDDING_MODEL", "LATTICEAI_EMBEDDING_BASE_URL", "LATTICEAI_EMBEDDING_API_KEY"],
67
- "detail": "Any /v1/embeddings endpoint (OpenAI, LM Studio, vLLM, …)."},
68
- {"id": "custom", "label": "Custom callable", "grade": "production",
69
- "requires": ["LATTICEAI_EMBEDDING_CUSTOM_TARGET"],
70
- "detail": "User-supplied module:callable returning vectors."},
71
- ],
72
- }
73
-
74
51
  return router
@@ -3,8 +3,14 @@
3
3
  Plan §설계 결정 2 (revised) hands **every write** to Rust — platform state and
4
4
  the knowledge graph both. What is left for Python is the part Rust cannot do
5
5
  without shipping a model runtime and half of PyPI: turning text into vectors,
6
- turning a document into text, turning a spec into document bytes, turning audio
7
- into a transcript, and turning a picture into facts about it.
6
+ turning a document into text, turning a spec into document bytes, and turning
7
+ audio into a transcript.
8
+
9
+ A ninth seam, ``POST /worker/multimodal/describe``, was here until v11.8.0. It
10
+ wrapped :func:`lattice_brain.multimodal.extract_image_facts` for a native image
11
+ ingest that was never built, so nothing in the tree ever called it — not
12
+ ``lattice-ingest``, not the gateway. The Brain Core functions behind it are
13
+ untouched; what went is the door nobody opened.
8
14
 
9
15
  ``worker_seams.py`` holds the *state* seams (``/worker/chat/record-turn``,
10
16
  ``/worker/graph/mutate``) that Wave 2 codes against and Wave 2.5 §W3 retires.
@@ -13,7 +19,7 @@ here opens a database, writes a file, or reaches a store. Each handler is a
13
19
  function of its request body, so a caller can retry it, cache it, or run two of
14
20
  them at once without asking who else is writing.
15
21
 
16
- The five seams, and what each was extracted from:
22
+ The eight seams, and what each was extracted from:
17
23
 
18
24
  ``POST /worker/embed``
19
25
  ``EmbeddingProvider.embed_batch`` on the *resolved* provider — the same
@@ -30,22 +36,15 @@ The five seams, and what each was extracted from:
30
36
 
31
37
  ``POST /worker/render/{docx,xlsx,pptx,pdf}``
32
38
  The *building* half of ``tools.documents.create_*`` — same libraries, same
33
- layout decisions, same filename sanitising — with ``save(path)`` replaced by
34
- ``save(BytesIO)``. Rust places the file; this seam never learns where.
39
+ layout decisions — with ``save(path)`` replaced by ``save(BytesIO)``. Rust
40
+ places the file, sanitises the name and resolves the target; this seam
41
+ never learns where and answers with bytes only.
35
42
 
36
43
  ``POST /worker/asr``
37
44
  ``VoiceCaptureService._transcribe``'s contract without the ingest: the same
38
45
  injected transcriber port, the same container whitelist and size ceiling,
39
46
  and the same refusal to call an empty transcript "text".
40
47
 
41
- ``POST /worker/multimodal/describe``
42
- :func:`lattice_brain.multimodal.extract_image_facts` plus
43
- :func:`~lattice_brain.multimodal.image_quality_score` — exactly what
44
- ``IngestionRoutingMixin._ingest_image`` computes before it writes a node.
45
- The describe step *is* separable here because Brain Core already split it:
46
- observing an image and writing it are two functions, and only the first one
47
- is compute.
48
-
49
48
  ``POST /worker/extract``
50
49
  :func:`~lattice_brain.graph._kg_common.extraction._extract_concepts` (LLM-first),
51
50
  :func:`~lattice_brain.graph._kg_common.extraction._extract_triples` and
@@ -67,8 +66,10 @@ import asyncio
67
66
  import base64
68
67
  import binascii
69
68
  import contextlib
69
+ import importlib.util
70
70
  import io
71
71
  import logging
72
+ import sys
72
73
  import tempfile
73
74
  from pathlib import Path
74
75
  from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
@@ -85,7 +86,7 @@ from latticeai.services.voice_capture import (
85
86
  SUPPORTED_AUDIO_EXTENSIONS,
86
87
  )
87
88
  from latticeai.tools import _CJK_FONT_CANDIDATES, ToolError
88
- from latticeai.tools.documents import _body_to_str, _safe_filename, read_document
89
+ from latticeai.tools.documents import _body_to_str, read_document
89
90
 
90
91
  logger = logging.getLogger(__name__)
91
92
 
@@ -107,16 +108,6 @@ EXTRACT_LIMITS: Dict[str, int] = {"message": 12, "document": 15}
107
108
  #: write-side module would tie this compute seam to a module W1 is replacing.
108
109
  PASSAGE_MAX_CHARS = 50_000
109
110
 
110
- #: Render kind → the suffix ``_safe_filename`` enforces. Same table as
111
- #: ``documents._DOCUMENT_TOOL_TARGETS``, minus the output directory: where the
112
- #: file lands is Rust's decision now, so this seam does not carry it.
113
- RENDER_SUFFIXES: Dict[str, str] = {
114
- "docx": ".docx",
115
- "xlsx": ".xlsx",
116
- "pptx": ".pptx",
117
- "pdf": ".pdf",
118
- }
119
-
120
111
  #: The CJK-capable fonts ``create_pdf`` looks for, as a module attribute so a
121
112
  #: test can point the probe at a font that exists (and at one that does not)
122
113
  #: instead of asserting whatever the host machine happens to have installed.
@@ -166,6 +157,29 @@ WORKER_COMPUTE_MESSAGES: Dict[str, Dict[str, str]] = {
166
157
  }
167
158
 
168
159
 
160
+ def pointer_tools_available() -> bool:
161
+ """Whether this interpreter can import ``pyautogui``.
162
+
163
+ Cheap and side-effect free: ``find_spec`` does not load the module, so a
164
+ headless worker without a display is not punished for answering the
165
+ question. The platform computer-use status route reads this through
166
+ ``GET /worker/sysinfo`` rather than guessing from its own process.
167
+ """
168
+ return importlib.util.find_spec("pyautogui") is not None
169
+
170
+
171
+ def sysinfo_payload_extras() -> Dict[str, Any]:
172
+ """Additive fields for ``GET /worker/sysinfo``.
173
+
174
+ Existing GPU keys stay owned by ``worker_seams.probe_gpu_memory``. This
175
+ dict is merged in; it must never reuse those names.
176
+ """
177
+ return {
178
+ "capabilities": {"pointer_tools": pointer_tools_available()},
179
+ "python_version": "{}.{}.{}".format(*sys.version_info[:3]),
180
+ }
181
+
182
+
169
183
  def register_worker_compute_messages() -> None:
170
184
  """Publish this module's messages into the one shared catalog."""
171
185
  for key, entry in WORKER_COMPUTE_MESSAGES.items():
@@ -415,16 +429,6 @@ class AsrRequest(BaseModel):
415
429
  filename: Optional[str] = None
416
430
 
417
431
 
418
- class DescribeRequest(BaseModel):
419
- """One picture, with the two observation switches ``extract_image_facts`` takes."""
420
-
421
- content_b64: str
422
- mime: Optional[str] = None
423
- filename: Optional[str] = None
424
- ocr: bool = True
425
- thumbnail: bool = True
426
-
427
-
428
432
  class ExtractRequest(BaseModel):
429
433
  """The text one ingest door would hand ``_extract_concepts``."""
430
434
 
@@ -484,18 +488,16 @@ def create_worker_compute_router(
484
488
  *,
485
489
  embedder: Any,
486
490
  transcriber: Optional[Callable[[str], str]] = None,
487
- multimodal_ports: Any = None,
488
491
  require_user: Callable[[Request], Any],
489
492
  enforce_rate_limit: Callable[[str, str], None],
490
493
  ) -> APIRouter:
491
- """The nine compute seams, wired to what this worker actually resolved.
494
+ """The eight compute seams, wired to what this worker actually resolved.
492
495
 
493
496
  ``embedder`` is the :class:`~latticeai.core.embedding_providers.text.ResolvedEmbedder`
494
497
  ``phase_brain`` built (``None`` ⇒ 503, because a worker with no embedder is
495
- a configuration rather than a crash). ``transcriber`` and
496
- ``multimodal_ports`` are the same injected ports the voice and ingestion
497
- paths hold; absent, the relevant seam reports the absence instead of
498
- inventing a transcript or a caption.
498
+ a configuration rather than a crash). ``transcriber`` is the injected port
499
+ the voice path holds; absent, ``/worker/asr`` reports the absence instead of
500
+ inventing a transcript.
499
501
  """
500
502
  router = APIRouter()
501
503
 
@@ -522,11 +524,18 @@ def create_worker_compute_router(
522
524
  async def _render(
523
525
  kind: str,
524
526
  builder: Callable[[], bytes],
525
- filename: str,
526
527
  language: str,
527
528
  **extra: Any,
528
529
  ) -> Dict[str, Any]:
529
- """Build one document off the event loop and hand back its bytes."""
530
+ """Build one document off the event loop and hand back its bytes.
531
+
532
+ The reply is the bytes and what they cost, and nothing about *where*
533
+ they go: ``lattice-agent``'s ``documents::document_output_target`` runs
534
+ its own ``safe_filename`` and ``Workspace::resolve`` over the request's
535
+ ``filename`` **before** it calls here, and writes to the target it
536
+ resolved. A second sanitisation on this side produced a name no caller
537
+ ever read — two spellings of one rule, one of them invisible.
538
+ """
530
539
  try:
531
540
  payload = await asyncio.to_thread(builder)
532
541
  except ToolError as exc:
@@ -541,7 +550,6 @@ def create_worker_compute_router(
541
550
  500, "worker_compute.render_failed", language, kind=kind, reason=str(exc)
542
551
  ) from exc
543
552
  return {
544
- "filename": _safe_filename(filename, RENDER_SUFFIXES[kind]),
545
553
  "content_b64": base64.b64encode(payload).decode("ascii"),
546
554
  "bytes": len(payload),
547
555
  **extra,
@@ -616,7 +624,6 @@ def create_worker_compute_router(
616
624
  return await _render(
617
625
  "docx",
618
626
  lambda: build_docx_bytes(req.title, req.body),
619
- req.filename,
620
627
  resolve_language(request),
621
628
  )
622
629
 
@@ -628,7 +635,6 @@ def create_worker_compute_router(
628
635
  return await _render(
629
636
  "xlsx",
630
637
  lambda: build_xlsx_bytes(req.rows, req.sheet_name),
631
- req.filename,
632
638
  resolve_language(request),
633
639
  rows=len(req.rows),
634
640
  )
@@ -641,7 +647,6 @@ def create_worker_compute_router(
641
647
  return await _render(
642
648
  "pptx",
643
649
  lambda: build_pptx_bytes(req.title, req.slides),
644
- req.filename,
645
650
  resolve_language(request),
646
651
  slides=len(req.slides) + 1,
647
652
  )
@@ -654,7 +659,6 @@ def create_worker_compute_router(
654
659
  return await _render(
655
660
  "pdf",
656
661
  lambda: build_pdf_bytes(req.title, req.body),
657
- req.filename,
658
662
  resolve_language(request),
659
663
  )
660
664
 
@@ -731,64 +735,6 @@ def create_worker_compute_router(
731
735
  "detail": "",
732
736
  }
733
737
 
734
- @router.post("/worker/multimodal/describe")
735
- async def worker_multimodal_describe(req: DescribeRequest, request: Request):
736
- """Everything ``_ingest_image`` knows before it writes a node.
737
-
738
- ``metadata`` is ``ImageFacts.as_metadata()`` — the exact dict
739
- ``write_image_memory`` merges onto the node — and ``index_text`` is the
740
- exact text it chunks. ``embedding`` is present only when a vision model
741
- produced one, which is the one property an image vector must have.
742
- """
743
- _require_seam(request)
744
- _admit(request)
745
- language = resolve_language(request)
746
- data = _decode(req.content_b64, language)
747
- suffix = _suffix_for(req.filename, req.mime, ".png")
748
-
749
- from lattice_brain.ingestion import _quality_level
750
- from lattice_brain.multimodal import (
751
- MODALITY_IMAGE,
752
- MultimodalPorts,
753
- extract_image_facts,
754
- image_quality_score,
755
- )
756
-
757
- ports = multimodal_ports or MultimodalPorts()
758
- with _temp_payload(data, suffix) as tmp_path:
759
- facts = await asyncio.to_thread(
760
- lambda: extract_image_facts(
761
- tmp_path, ports=ports, ocr=req.ocr, thumbnail=req.thumbnail
762
- )
763
- )
764
- quality = image_quality_score(facts)
765
- return {
766
- "modality": MODALITY_IMAGE,
767
- "readable": facts.readable,
768
- "error": facts.error,
769
- "width": facts.width,
770
- "height": facts.height,
771
- "format": facts.image_format,
772
- "mode": facts.mode,
773
- "ocr_status": facts.ocr_status,
774
- "ocr_text": facts.ocr_text,
775
- "ocr_detail": facts.ocr_detail,
776
- "caption_status": facts.caption_status,
777
- "caption": facts.caption,
778
- "embedding_status": facts.embedding_status,
779
- "embedding": facts.embedding,
780
- "embedding_detail": facts.embedding_detail,
781
- "thumbnail": facts.thumbnail,
782
- "index_text": facts.index_text(),
783
- "metadata": facts.as_metadata(),
784
- "quality": {
785
- "score": quality["score"],
786
- "level": _quality_level(quality["score"]),
787
- "reasons": quality["reasons"],
788
- },
789
- "ports": ports.describe(),
790
- }
791
-
792
738
  @router.post("/worker/extract")
793
739
  async def worker_extract(req: ExtractRequest, request: Request):
794
740
  """Concepts, triples and Task/Decision items for this text.
@@ -821,10 +767,8 @@ __all__ = [
821
767
  "EXTRACT_KINDS",
822
768
  "EXTRACT_LIMITS",
823
769
  "PASSAGE_MAX_CHARS",
824
- "RENDER_SUFFIXES",
825
770
  "WORKER_COMPUTE_MESSAGES",
826
771
  "AsrRequest",
827
- "DescribeRequest",
828
772
  "EmbedRequest",
829
773
  "ExtractRequest",
830
774
  "ParseRequest",
@@ -838,5 +782,7 @@ __all__ = [
838
782
  "build_pptx_bytes",
839
783
  "build_xlsx_bytes",
840
784
  "create_worker_compute_router",
785
+ "pointer_tools_available",
841
786
  "register_worker_compute_messages",
787
+ "sysinfo_payload_extras",
842
788
  ]
@@ -85,21 +85,36 @@ def probe_gpu_memory() -> Dict[str, Any]:
85
85
  total = _total_memory_bytes()
86
86
  except Exception as exc: # noqa: BLE001 — an absent GPU is not a failure
87
87
  quiet("MLX unified-memory probe")
88
- return {
88
+ payload = {
89
89
  "mlx_available": False,
90
90
  "gpu_mem_gb": 0.0,
91
91
  "gpu_mem_pct": 0.0,
92
92
  "total_bytes": 0,
93
93
  "detail": str(exc),
94
94
  }
95
+ payload.update(_sysinfo_extras())
96
+ return payload
95
97
  used = active + cached
96
- return {
98
+ payload = {
97
99
  "mlx_available": True,
98
100
  "gpu_mem_gb": round(used / (1024 ** 3), 2),
99
101
  "gpu_mem_pct": round(used / total * 100, 1) if total else 0.0,
100
102
  "total_bytes": total,
101
103
  "detail": None,
102
104
  }
105
+ payload.update(_sysinfo_extras())
106
+ return payload
107
+
108
+
109
+ def _sysinfo_extras() -> Dict[str, Any]:
110
+ """Additive capability / interpreter facts. Fail closed to empty extras."""
111
+ try:
112
+ from latticeai.api.worker_compute import sysinfo_payload_extras
113
+
114
+ extras = sysinfo_payload_extras()
115
+ except Exception: # noqa: BLE001 — a missing extra must not hide the GPU reading
116
+ return {}
117
+ return extras if isinstance(extras, dict) else {}
103
118
 
104
119
 
105
120
  # ── request bodies ──────────────────────────────────────────────────────────
@@ -18,9 +18,9 @@ Threat model, so the rule is checkable rather than felt:
18
18
  :mod:`latticeai.core.csrf` already trusts it. Honouring a forwarded host from
19
19
  the same caller widens nothing that was not already open.
20
20
  * **Anyone else is not trusted.** Off-loopback the header is honoured only from
21
- a peer the operator listed in ``LATTICEAI_TRUSTED_PROXIES`` — the same
22
- allowlist ``latticeai.core.security.client_ip`` uses for the same reason, and
23
- empty by default.
21
+ a peer the operator listed in ``LATTICEAI_TRUSTED_PROXIES`` — the allowlist
22
+ ``latticeai.core.security.configure_trusted_proxies`` holds, mirrored by
23
+ ``lattice-auth`` at the front door for the same reason, and empty by default.
24
24
 
25
25
  The functions are pure: the caller supplies the headers and the peer address, so
26
26
  the policy is testable without an ASGI app or a socket.
@@ -181,11 +181,6 @@ MESSAGES: Dict[str, Dict[str, str]] = {
181
181
  }
182
182
 
183
183
 
184
- def bilingual(ko: str, en: str) -> Dict[str, str]:
185
- """One Korean/English phrase pair, in the shape every caller renders."""
186
- return {"ko": ko, "en": en}
187
-
188
-
189
184
  def resolve_language(request: Any, default: str = DEFAULT_LANGUAGE) -> str:
190
185
  """Language for this request: the product's choice, then the browser's.
191
186
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Dict, Iterable, List, Set
5
+ from typing import Dict, List, Set
6
6
 
7
7
  ROLE_CAPABILITIES: Dict[str, Set[str]] = {
8
8
  "owner": {"all"},
@@ -46,8 +46,3 @@ def role_has_capability(role: str, capability: str) -> bool:
46
46
  def require_capability(role: str, capability: str) -> None:
47
47
  if not role_has_capability(role, capability):
48
48
  raise PermissionError(f"role '{normalize_role(role)}' lacks capability '{capability}'")
49
-
50
-
51
- def policy_matrix(roles: Iterable[str] | None = None) -> List[Dict[str, object]]:
52
- selected = list(roles or ROLE_CAPABILITIES.keys())
53
- return [{"role": normalize_role(role), "caps": capabilities_for_role(role)} for role in selected]
@@ -25,7 +25,6 @@ from __future__ import annotations
25
25
 
26
26
  import logging
27
27
  import sys
28
- import traceback
29
28
  from typing import Optional
30
29
 
31
30
  logger = logging.getLogger("latticeai.suppressed")
@@ -63,22 +62,4 @@ def quiet(reason: Optional[str] = None, *, level: int = logging.DEBUG) -> None:
63
62
  )
64
63
 
65
64
 
66
- def quiet_summary(reason: Optional[str] = None) -> str:
67
- """One-line description of the live exception, for callers that report it."""
68
- exc_type, exc, _ = sys.exc_info()
69
- if exc is None:
70
- return ""
71
- name = getattr(exc_type, "__name__", "Exception")
72
- text = str(exc).strip() or name
73
- return f"{reason}: {text}" if reason else text
74
-
75
-
76
- def format_suppressed() -> str:
77
- """Full traceback of the live exception as text (for diagnostics payloads)."""
78
- exc_type, exc, tb = sys.exc_info()
79
- if exc is None:
80
- return ""
81
- return "".join(traceback.format_exception(exc_type, exc, tb))
82
-
83
-
84
- __all__ = ["quiet", "quiet_summary", "format_suppressed"]
65
+ __all__ = ["quiet"]