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.
- package/README.md +73 -70
- package/docs/BENCHMARKS.md +9 -2
- package/docs/CHANGELOG.md +157 -0
- package/docs/CI_AND_RELEASE_GATES.md +126 -41
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +34 -14
- package/docs/LEGACY_COMPATIBILITY.md +10 -6
- package/docs/ONBOARDING.md +5 -3
- package/docs/OPERATIONS.md +5 -1
- package/docs/PERMISSION_MODE.md +14 -9
- package/docs/TRUST_MODEL.md +13 -6
- package/docs/USABILITY_AUDIT.md +5 -0
- package/docs/WHY_LATTICE.md +6 -4
- package/docs/kg-schema.md +7 -3
- package/docs/mcp-tools.md +73 -79
- package/docs/security-model.md +6 -3
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +1 -54
- package/lattice_brain/graph/_kg_common/text.py +14 -450
- package/lattice_brain/ingestion/__init__.py +6 -3
- package/lattice_brain/multimodal/__init__.py +9 -3
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agent_worker_seam.py +12 -1
- package/latticeai/api/models.py +18 -110
- package/latticeai/api/search.py +7 -30
- package/latticeai/api/worker_compute.py +53 -107
- package/latticeai/api/worker_seams.py +17 -2
- package/latticeai/core/http_origin.py +3 -3
- package/latticeai/core/messages.py +0 -5
- package/latticeai/core/policy.py +1 -6
- package/latticeai/core/quiet.py +1 -20
- package/latticeai/core/security.py +29 -83
- package/latticeai/core/sessions.py +95 -4
- package/latticeai/core/users.py +0 -38
- package/latticeai/models/router/catalog.py +2 -2
- package/latticeai/models/router/generation.py +81 -14
- package/latticeai/models/router/loading.py +41 -5
- package/latticeai/runtime/access_runtime.py +7 -4
- package/latticeai/runtime/build_phases/features.py +8 -31
- package/latticeai/runtime/build_phases/foundation.py +7 -16
- package/latticeai/runtime/build_phases/web.py +3 -3
- package/latticeai/runtime/build_phases/worker_profile.py +21 -28
- package/latticeai/runtime/runtime_context.py +0 -2
- package/latticeai/services/architecture_readiness.py +18 -19
- package/latticeai/services/process_audit.py +1 -22
- package/latticeai/services/product_readiness.py +33 -11
- package/latticeai/services/voice_capture.py +8 -28
- package/latticeai/tools/__init__.py +6 -46
- package/latticeai/tools/commands.py +9 -15
- package/latticeai/tools/knowledge.py +0 -6
- package/package.json +3 -5
- package/requirements.txt +0 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_openapi_drift.mjs +3 -2
- package/scripts/check_server_i18n.mjs +5 -4
- package/scripts/compose_openapi.py +2 -1
- package/scripts/export_openapi.py +5 -4
- package/scripts/gen_worker_allowlist_fixture.py +2 -2
- package/scripts/openapi_route_families.json +14 -73
- package/scripts/release_screen_claims.json +131 -27
- package/src-tauri/Cargo.lock +11 -10
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -41
- package/static/app/assets/{Act-BPcVAbOL.js → Act-B4WT81kh.js} +1 -1
- package/static/app/assets/AdminConsole--Wf71m-o.js +1 -0
- package/static/app/assets/{Brain-CT92Kos0.js → Brain-CtYaa26c.js} +2 -2
- package/static/app/assets/BrainHome-Be2VPJEc.js +2 -0
- package/static/app/assets/BrainSignals-C0__xgpG.js +1 -0
- package/static/app/assets/Capture-DdNi5Peb.js +1 -0
- package/static/app/assets/Chronicle-B3hNveeI.js +1 -0
- package/static/app/assets/CommandPalette-CIsnSsFL.js +1 -0
- package/static/app/assets/Library-Bl5XClFV.js +1 -0
- package/static/app/assets/LivingBrain-DjkTB_gh.js +1 -0
- package/static/app/assets/{ProductFlow-DXBC6brE.js → ProductFlow-DCUNRNHs.js} +1 -1
- package/static/app/assets/{ReviewCard-HXRle3qq.js → ReviewCard-CD3yWvUB.js} +2 -2
- package/static/app/assets/System-BJ6jQ_SL.js +1 -0
- package/static/app/assets/arrow-left-6_28Z0qH.js +1 -0
- package/static/app/assets/{bot-Cn8bWRuq.js → bot-Bc3Q27YR.js} +1 -1
- package/static/app/assets/brain-B9BDrMTe.js +1 -0
- package/static/app/assets/{button-Ct9f2_oT.js → button-D6JcpYcf.js} +1 -1
- package/static/app/assets/circle-check-BFu9lD-3.js +1 -0
- package/static/app/assets/{circle-pause-CmzC_apg.js → circle-pause-BGMiV8UU.js} +1 -1
- package/static/app/assets/{circle-play-D8mW2aQ7.js → circle-play-DoanLHnd.js} +1 -1
- package/static/app/assets/{cpu-DZcdd0PZ.js → cpu-DwzNf82m.js} +1 -1
- package/static/app/assets/{download-bv1KEPGQ.js → download-Ddw49yCV.js} +1 -1
- package/static/app/assets/{folder-open-d-Pip5gr.js → folder-open-Brd6Kvto.js} +1 -1
- package/static/app/assets/{hard-drive-D20iavUb.js → hard-drive-Bu-DTJdB.js} +1 -1
- package/static/app/assets/index-CGdg_aq9.css +2 -0
- package/static/app/assets/{index-Do83hDzJ.js → index-CWKRRsLW.js} +4 -4
- package/static/app/assets/input-CEqsxtil.js +1 -0
- package/static/app/assets/{link-2-BPJOFlAy.js → link-2-DZ4OA5tJ.js} +1 -1
- package/static/app/assets/{permissionCopy-ChdJd493.js → permissionCopy-D9TR0F8b.js} +1 -1
- package/static/app/assets/primitives-DORg7Z_7.js +1 -0
- package/static/app/assets/search-0NQ21wXe.js +1 -0
- package/static/app/assets/{share-2-YNX_NtMU.js → share-2-BC5FirFv.js} +1 -1
- package/static/app/assets/{shield-alert-DuQ3zrVL.js → shield-alert-DUbR2W2s.js} +1 -1
- package/static/app/assets/textarea-jtQcRSXo.js +1 -0
- package/static/app/assets/{useFocusTrap-ZVI98jaW.js → useFocusTrap-HRemcWId.js} +1 -1
- package/static/app/assets/{useMutation-CVC4qv_D.js → useMutation-DqlFE-Bw.js} +1 -1
- package/static/app/assets/{useQuery-C7BeG4HU.js → useQuery-BizqBNGw.js} +1 -1
- package/static/app/assets/utils-WgW4V69R.js +4 -0
- package/static/app/assets/workspace-DSek3jCY.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/ingestion/pipeline.py +0 -108
- package/latticeai/api/local_files.py +0 -44
- package/latticeai/api/tools.py +0 -126
- package/latticeai/api/voice_capture.py +0 -32
- package/latticeai/core/agent_permission.py +0 -85
- package/scripts/agent_eval.py +0 -34
- package/scripts/brain_quality_eval.py +0 -37
- package/scripts/check_legacy_debt.mjs +0 -91
- package/scripts/check_python.py +0 -100
- package/scripts/chunking_parity_corpus.py +0 -449
- package/scripts/generate_agent_parity_fixtures.py +0 -771
- package/scripts/generate_chunking_parity_fixtures.py +0 -259
- package/static/app/assets/AdminConsole-Bw1ATQL0.js +0 -1
- package/static/app/assets/BrainHome-CFBkt1K_.js +0 -2
- package/static/app/assets/BrainSignals-ReLWF2H8.js +0 -1
- package/static/app/assets/Capture-BsTokYkk.js +0 -1
- package/static/app/assets/Chronicle-B6f0T9id.js +0 -1
- package/static/app/assets/CommandPalette-CuvjTv1u.js +0 -1
- package/static/app/assets/Library-BGJbG9Hd.js +0 -1
- package/static/app/assets/LivingBrain-DGYK_Jsa.js +0 -1
- package/static/app/assets/System-CMHSO9qM.js +0 -1
- package/static/app/assets/arrow-left-BfmkskWx.js +0 -1
- package/static/app/assets/brain-CQJberbE.js +0 -1
- package/static/app/assets/circle-check-DruOxB-4.js +0 -1
- package/static/app/assets/index-D9x-kSNy.css +0 -2
- package/static/app/assets/input-BLXVNmj1.js +0 -1
- package/static/app/assets/primitives-Cv5tbZBY.js +0 -1
- package/static/app/assets/search-CT9aho2j.js +0 -1
- package/static/app/assets/textarea-DqwLnli4.js +0 -1
- package/static/app/assets/utils-CiFtIdZq.js +0 -4
- package/static/app/assets/workspace-DQz9vIId.js +0 -1
package/latticeai/api/models.py
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
package/latticeai/api/search.py
CHANGED
|
@@ -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:
|
|
35
|
-
#
|
|
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
|
|
7
|
-
into a transcript
|
|
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
|
|
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
|
|
34
|
-
|
|
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,
|
|
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
|
|
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``
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
package/latticeai/core/policy.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Dict,
|
|
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]
|
package/latticeai/core/quiet.py
CHANGED
|
@@ -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
|
-
|
|
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"]
|