ltcai 11.1.0 → 11.4.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 +47 -54
- package/docs/CHANGELOG.md +94 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
- package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +6 -2
- package/docs/PERMISSION_MODE.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +6 -2
- package/docs/kg-schema.md +2 -2
- package/docs/v11.3.0_PLAN.md +202 -0
- package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/_kg_common/__init__.py +287 -0
- package/lattice_brain/graph/_kg_common/extraction.py +516 -0
- package/lattice_brain/graph/_kg_common/relations.py +161 -0
- package/lattice_brain/graph/_kg_common/text.py +479 -0
- package/lattice_brain/graph/discovery_index/__init__.py +35 -0
- package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
- package/lattice_brain/graph/discovery_index/extract.py +137 -0
- package/lattice_brain/graph/discovery_index/scan.py +411 -0
- package/lattice_brain/graph/discovery_index/upsert.py +495 -0
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/projection/__init__.py +42 -0
- package/lattice_brain/graph/projection/curation.py +500 -0
- package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +81 -485
- package/lattice_brain/graph/retrieval/__init__.py +54 -0
- package/lattice_brain/graph/retrieval/context.py +197 -0
- package/lattice_brain/graph/retrieval/graph_view.py +319 -0
- package/lattice_brain/graph/retrieval/hybrid.py +488 -0
- package/lattice_brain/graph/retrieval/maintenance.py +121 -0
- package/lattice_brain/graph/retrieval/signals.py +95 -0
- package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
- package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
- package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
- package/lattice_brain/graph/retrieval_vector/search.py +560 -0
- package/lattice_brain/graph/retrieval_vector/status.py +374 -0
- package/lattice_brain/graph/schema.py +9 -0
- package/lattice_brain/graph/store.py +9 -0
- package/lattice_brain/graph/vector_index/selector.py +32 -2
- package/lattice_brain/ingestion/__init__.py +130 -0
- package/lattice_brain/ingestion/_contract.py +90 -0
- package/lattice_brain/ingestion/constants.py +127 -0
- package/lattice_brain/ingestion/folder_scan.py +57 -0
- package/lattice_brain/ingestion/folders.py +258 -0
- package/lattice_brain/ingestion/hashing.py +26 -0
- package/lattice_brain/ingestion/jobs_api.py +107 -0
- package/lattice_brain/ingestion/models.py +80 -0
- package/lattice_brain/ingestion/pipeline.py +486 -0
- package/lattice_brain/ingestion/quality.py +209 -0
- package/lattice_brain/ingestion/routing.py +295 -0
- package/lattice_brain/multimodal/__init__.py +164 -0
- package/lattice_brain/multimodal/audio.py +77 -0
- package/lattice_brain/multimodal/common.py +118 -0
- package/lattice_brain/{multimodal.py → multimodal/images.py} +22 -262
- package/lattice_brain/multimodal/ports.py +169 -0
- package/lattice_brain/multimodal/video.py +410 -0
- package/lattice_brain/portability/__init__.py +90 -0
- package/lattice_brain/portability/_contract.py +42 -0
- package/lattice_brain/portability/backups.py +338 -0
- package/lattice_brain/portability/bundles.py +136 -0
- package/lattice_brain/portability/constants.py +93 -0
- package/lattice_brain/portability/fsops.py +138 -0
- package/lattice_brain/portability/service.py +41 -0
- package/lattice_brain/portability/sharing.py +714 -0
- package/lattice_brain/runtime/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +244 -0
- package/lattice_brain/synthesis.py +24 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +4 -0
- package/latticeai/api/chat.py +11 -0
- package/latticeai/api/chat_helpers.py +16 -3
- package/latticeai/api/chat_hybrid.py +32 -1
- package/latticeai/api/chronicle.py +63 -0
- package/latticeai/api/features.py +70 -0
- package/latticeai/api/local_files.py +102 -0
- package/latticeai/api/portability.py +39 -4
- package/latticeai/api/review_queue.py +126 -0
- package/latticeai/api/search.py +16 -2
- package/latticeai/core/agent/__init__.py +93 -0
- package/latticeai/core/agent/_contract.py +79 -0
- package/latticeai/core/agent/context.py +57 -0
- package/latticeai/core/agent/deps.py +125 -0
- package/latticeai/core/agent/execution.py +622 -0
- package/latticeai/core/agent/planning.py +145 -0
- package/latticeai/core/agent/recovery.py +157 -0
- package/latticeai/core/agent/runtime.py +210 -0
- package/latticeai/core/agent/verification.py +231 -0
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +6 -3
- package/latticeai/core/embedding_providers/__init__.py +151 -0
- package/latticeai/core/embedding_providers/base.py +199 -0
- package/latticeai/core/embedding_providers/captions.py +162 -0
- package/latticeai/core/embedding_providers/profiles.py +126 -0
- package/latticeai/core/embedding_providers/text.py +350 -0
- package/latticeai/core/embedding_providers/vision.py +352 -0
- package/latticeai/core/file_generation/__init__.py +115 -0
- package/latticeai/core/file_generation/bundles.py +76 -0
- package/latticeai/core/file_generation/extraction.py +154 -0
- package/latticeai/core/file_generation/inference.py +235 -0
- package/latticeai/core/file_generation/orchestration.py +152 -0
- package/latticeai/core/file_generation/prompting.py +117 -0
- package/latticeai/core/file_generation/repair.py +114 -0
- package/latticeai/core/file_generation/sanitize.py +61 -0
- package/latticeai/core/file_generation/validation.py +201 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +152 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot/__init__.py +123 -0
- package/latticeai/integrations/telegram_bot/__main__.py +17 -0
- package/latticeai/integrations/telegram_bot/config.py +86 -0
- package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
- package/latticeai/integrations/telegram_bot/flows.py +478 -0
- package/latticeai/integrations/telegram_bot/helpers.py +322 -0
- package/latticeai/integrations/telegram_bot/screens.py +394 -0
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/models/router/__init__.py +88 -0
- package/latticeai/models/router/_contract.py +66 -0
- package/latticeai/models/router/branding.py +56 -0
- package/latticeai/models/router/catalog.py +69 -0
- package/latticeai/models/router/documents.py +199 -0
- package/latticeai/models/router/errors.py +37 -0
- package/latticeai/models/router/generation.py +258 -0
- package/latticeai/models/router/loading.py +291 -0
- package/latticeai/models/router/local_models.py +85 -0
- package/latticeai/models/router/registry.py +147 -0
- package/latticeai/runtime/build_phases/__init__.py +82 -0
- package/latticeai/runtime/build_phases/features.py +407 -0
- package/latticeai/runtime/build_phases/foundation.py +555 -0
- package/latticeai/runtime/build_phases/web.py +492 -0
- package/latticeai/runtime/chat_wiring.py +4 -0
- package/latticeai/runtime/feature_toggle_wiring.py +163 -0
- package/latticeai/runtime/router_registration.py +11 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/app_context.py +8 -0
- package/latticeai/services/architecture_readiness.py +48 -19
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence/__init__.py +58 -0
- package/latticeai/services/brain_intelligence/_contract.py +71 -0
- package/latticeai/services/brain_intelligence/consistency.py +193 -0
- package/latticeai/services/brain_intelligence/constants.py +47 -0
- package/latticeai/services/brain_intelligence/digest.py +258 -0
- package/latticeai/services/brain_intelligence/health.py +331 -0
- package/latticeai/services/brain_intelligence/proposals.py +264 -0
- package/latticeai/services/brain_intelligence/sampling.py +84 -0
- package/latticeai/services/brain_intelligence/service.py +48 -0
- package/latticeai/services/chronicle.py +557 -0
- package/latticeai/services/command_center.py +10 -4
- package/latticeai/services/feature_toggles.py +502 -0
- package/latticeai/services/folder_watch.py +122 -1
- package/latticeai/services/hybrid_chat.py +56 -5
- package/latticeai/services/interop_bridges.py +978 -0
- package/latticeai/services/memory_service/__init__.py +52 -0
- package/latticeai/services/memory_service/_contract.py +100 -0
- package/latticeai/services/memory_service/brief.py +431 -0
- package/latticeai/services/memory_service/constants.py +57 -0
- package/latticeai/services/memory_service/maintenance.py +138 -0
- package/latticeai/services/memory_service/manager.py +186 -0
- package/latticeai/services/memory_service/proof.py +136 -0
- package/latticeai/services/memory_service/recall.py +225 -0
- package/latticeai/services/memory_service/service.py +48 -0
- package/latticeai/services/memory_service/stores.py +110 -0
- package/latticeai/services/model_capability_registry.py +434 -261
- package/latticeai/services/model_catalog.py +95 -61
- package/latticeai/services/model_recommendation.py +18 -11
- package/latticeai/services/model_runtime/__init__.py +322 -0
- package/latticeai/services/model_runtime/cloud.py +87 -0
- package/latticeai/services/model_runtime/download.py +282 -0
- package/latticeai/services/model_runtime/engines.py +341 -0
- package/latticeai/services/model_runtime/loading.py +178 -0
- package/latticeai/services/model_runtime/service.py +129 -0
- package/latticeai/services/model_runtime/state.py +131 -0
- package/latticeai/services/model_runtime/status.py +255 -0
- package/latticeai/services/multimodal_ports.py +26 -1
- package/latticeai/services/obsidian_bridge.py +16 -25
- package/latticeai/services/product_readiness.py +15 -7
- package/latticeai/services/search_service.py +149 -2
- package/latticeai/services/tool_dispatch.py +4 -0
- package/latticeai/setup/auto_setup.py +27 -30
- package/latticeai/setup/wizard/__init__.py +126 -0
- package/latticeai/setup/wizard/catalog.py +172 -0
- package/latticeai/setup/wizard/detect.py +323 -0
- package/latticeai/setup/wizard/install.py +348 -0
- package/latticeai/setup/wizard/paths.py +168 -0
- package/latticeai/setup/wizard/plans.py +74 -0
- package/latticeai/setup/wizard/recommend.py +320 -0
- package/package.json +6 -2
- package/scripts/bump_version.py +14 -0
- package/scripts/capture_release_evidence.mjs +33 -21
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +41 -4
- package/scripts/check_max_file_lines.mjs +102 -0
- package/scripts/check_release_evidence_bound.mjs +30 -15
- package/scripts/check_screenshot_pixel_delta.py +34 -4
- package/scripts/check_server_i18n.mjs +2 -0
- package/scripts/generate_rust_parity_fixtures.py +562 -0
- package/scripts/lib/mock_server_fingerprint.mjs +94 -0
- package/scripts/release_screen_claims.json +44 -2
- package/scripts/verify_hf_model_registry.py +253 -218
- package/src-tauri/Cargo.lock +361 -3
- package/src-tauri/Cargo.toml +6 -1
- package/src-tauri/src/backend.rs +349 -0
- package/src-tauri/src/folder.rs +33 -0
- package/src-tauri/src/main.rs +97 -399
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -37
- package/static/app/assets/Act-yYpYnn0v.js +1 -0
- package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
- package/static/app/assets/{Brain-CzCsI1mi.js → Brain-C1HBN0Wf.js} +2 -2
- package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
- package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
- package/static/app/assets/Capture-CFIRsFNE.js +1 -0
- package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
- package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
- package/static/app/assets/Library-DwO3yZST.js +1 -0
- package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-Jn1GK0-S.js} +1 -1
- package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
- package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
- package/static/app/assets/System-DW8F-2xL.js +1 -0
- package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
- package/static/app/assets/{bot-4BvN07ux.js → bot-IM_E_Y12.js} +1 -1
- package/static/app/assets/brain-Ci1CkWjM.js +1 -0
- package/static/app/assets/{button-CDjtnAoU.js → button-COwyqfHM.js} +1 -1
- package/static/app/assets/circle-check-DfInj-qD.js +1 -0
- package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-DEM4A1Y5.js} +1 -1
- package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-C9djDuLd.js} +1 -1
- package/static/app/assets/{cpu-BIlWInHf.js → cpu-DFdo1gw-.js} +1 -1
- package/static/app/assets/{download-BtjXfL3z.js → download-SnJL6oqk.js} +1 -1
- package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-CqZeDkjE.js} +1 -1
- package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-j1jJXYYf.js} +1 -1
- package/static/app/assets/{index-vtEfYvQY.css → index-BLPb5lmE.css} +1 -1
- package/static/app/assets/index-_u5iUHDr.js +10 -0
- package/static/app/assets/input-B0lPdRQZ.js +1 -0
- package/static/app/assets/link-2-CoFbooHS.js +1 -0
- package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-BsyLxtao.js} +1 -1
- package/static/app/assets/primitives-DEbN-d6p.js +1 -0
- package/static/app/assets/search-BybIWPNd.js +1 -0
- package/static/app/assets/{share-2-D5zg_0fY.js → share-2-CVtZ_ewX.js} +1 -1
- package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-CBi2GNWM.js} +1 -1
- package/static/app/assets/{textarea-nEVIweKY.js → textarea-DNMpB5ih.js} +1 -1
- package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-C83t3GXF.js} +1 -1
- package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
- package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-Dcp1OChy.js} +1 -1
- package/static/app/assets/utils-BlZr7Pd4.js +4 -0
- package/static/app/assets/workspace-jJY4RuAV.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/graph/_kg_common.py +0 -1331
- package/lattice_brain/graph/discovery_index.py +0 -1141
- package/lattice_brain/graph/retrieval.py +0 -1120
- package/lattice_brain/graph/retrieval_vector.py +0 -1293
- package/lattice_brain/ingestion.py +0 -1377
- package/lattice_brain/portability.py +0 -1210
- package/latticeai/core/agent.py +0 -1412
- package/latticeai/core/embedding_providers.py +0 -1196
- package/latticeai/core/file_generation.py +0 -1047
- package/latticeai/integrations/telegram_bot.py +0 -1390
- package/latticeai/models/router.py +0 -1007
- package/latticeai/runtime/build_phases.py +0 -1422
- package/latticeai/services/brain_intelligence.py +0 -967
- package/latticeai/services/memory_service.py +0 -1177
- package/latticeai/services/model_runtime.py +0 -1281
- package/latticeai/setup/wizard.py +0 -1277
- package/static/app/assets/Act-D0HWqtn0.js +0 -1
- package/static/app/assets/AdminConsole-D-QDW-A4.js +0 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
- package/static/app/assets/BrainSignals-2dHQNkns.js +0 -1
- package/static/app/assets/Capture-CT8v1StE.js +0 -1
- package/static/app/assets/CommandPalette-DoLXC2KH.js +0 -1
- package/static/app/assets/Library-DDoxFE5c.js +0 -1
- package/static/app/assets/ProductFlow-DOYf7JIs.js +0 -1
- package/static/app/assets/ReviewCard-COQsqidK.js +0 -3
- package/static/app/assets/System-BRllvYXd.js +0 -1
- package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
- package/static/app/assets/brain-uMb_5hnO.js +0 -1
- package/static/app/assets/index-0AvoEBzJ.js +0 -10
- package/static/app/assets/input-B_5ZJ9oy.js +0 -1
- package/static/app/assets/primitives-CVwew78r.js +0 -1
- package/static/app/assets/search-DkhnOKZt.js +0 -1
- package/static/app/assets/utils-DcDMoZIe.js +0 -4
- package/static/app/assets/workspace-LtRRSKTf.js +0 -1
|
@@ -44,7 +44,7 @@ orchestrator, and the agent runtime service. Lazy-loaded so importing
|
|
|
44
44
|
/agents/api/runtime/* , POST /agents (start via runtime.start or executor)
|
|
45
45
|
api/chat.py
|
|
46
46
|
from latticeai.services.tool_dispatch import build_agent_runtime
|
|
47
|
-
-> latticeai/core/agent
|
|
47
|
+
-> latticeai/core/agent/:SingleAgentRuntime (별도 state/plan/transcript 머신, single-agent /agent 경로. dispatch_tool 만 공유)
|
|
48
48
|
latticeai/services/tool_dispatch.py:14
|
|
49
49
|
from latticeai.core.agent import SingleAgentRuntime
|
|
50
50
|
(tool governance + core single-agent용)
|
|
@@ -49,7 +49,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
49
49
|
from ..utils import now_iso as _now
|
|
50
50
|
from .contracts import multi_agent_contract
|
|
51
51
|
|
|
52
|
-
MULTI_AGENT_VERSION = "11.
|
|
52
|
+
MULTI_AGENT_VERSION = "11.4.0"
|
|
53
53
|
|
|
54
54
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
55
55
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Sealing a bundle to a recipient's public key (v11.2.0).
|
|
2
|
+
|
|
3
|
+
Through 11.1.0 a shared subgraph was encrypted with a passphrase, which means
|
|
4
|
+
the sender and the receiver must first agree on a secret over some *other*
|
|
5
|
+
channel — and whatever that channel is, the passphrase is now in it. This
|
|
6
|
+
module removes that step: the receiver publishes an X25519 public key, the
|
|
7
|
+
sender seals a bundle to it, and only the holder of the matching private key
|
|
8
|
+
can open it. Nothing secret ever travels.
|
|
9
|
+
|
|
10
|
+
The construction is the standard sealed box, assembled from primitives
|
|
11
|
+
``cryptography`` already ships (it is a core dependency; nothing new is added):
|
|
12
|
+
|
|
13
|
+
1. the sender generates a **single-use ephemeral X25519 keypair**;
|
|
14
|
+
2. ``ephemeral_private × recipient_public`` yields a shared secret only the two
|
|
15
|
+
of them can compute;
|
|
16
|
+
3. **HKDF-SHA256** stretches that secret into a 256-bit key, with both public
|
|
17
|
+
keys mixed into ``info`` so a key derived for one recipient can never be
|
|
18
|
+
reused for another;
|
|
19
|
+
4. **AES-256-GCM** encrypts the payload under it.
|
|
20
|
+
|
|
21
|
+
Forward secrecy comes free: the ephemeral private key is discarded the moment
|
|
22
|
+
the box is sealed, so a later compromise of the recipient's long-term key still
|
|
23
|
+
does not decrypt an already-sent bundle — nothing else in the envelope can
|
|
24
|
+
reconstruct the shared secret.
|
|
25
|
+
|
|
26
|
+
The sender's Ed25519 signature over the bundle header is **untouched** by all
|
|
27
|
+
of this. Signing says who wrote it; sealing says who may read it. Collapsing
|
|
28
|
+
the two into one keypair is the usual way to end up with neither.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import base64
|
|
34
|
+
import hashlib
|
|
35
|
+
import os
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Dict, Optional
|
|
38
|
+
|
|
39
|
+
from cryptography.exceptions import InvalidTag
|
|
40
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
41
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import (
|
|
42
|
+
X25519PrivateKey,
|
|
43
|
+
X25519PublicKey,
|
|
44
|
+
)
|
|
45
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
46
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
47
|
+
|
|
48
|
+
#: Named in the envelope so a future construction can be told apart from this
|
|
49
|
+
#: one instead of being silently mis-decrypted.
|
|
50
|
+
SEALED_BOX_ALGORITHM = "x25519-hkdf-sha256-aes256gcm"
|
|
51
|
+
#: Domain separation for the KDF — this key schedule is for subgraph bundles
|
|
52
|
+
#: and nothing else.
|
|
53
|
+
HKDF_INFO_PREFIX = b"latticeai.subgraph.sealed-box.v1"
|
|
54
|
+
KEY_BYTES = 32
|
|
55
|
+
NONCE_BYTES = 12
|
|
56
|
+
SALT_BYTES = 16
|
|
57
|
+
#: Filename of the recipient key under the data directory (0600, like the
|
|
58
|
+
#: device identity's key file).
|
|
59
|
+
RECIPIENT_KEY_FILENAME = "subgraph_recipient.key"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _b64(data: bytes) -> str:
|
|
63
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _unb64(text: str) -> bytes:
|
|
67
|
+
padded = str(text) + "=" * (-len(str(text)) % 4)
|
|
68
|
+
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _raw_public(key: X25519PublicKey) -> bytes:
|
|
72
|
+
return key.public_bytes(
|
|
73
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def public_key_fingerprint(public_key_b64: str) -> str:
|
|
78
|
+
"""Human-comparable fingerprint of an X25519 public key.
|
|
79
|
+
|
|
80
|
+
Raises ``ValueError`` when the input is not a usable key — callers use this
|
|
81
|
+
as the validation gate before sealing anything to a pasted string.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
raw = _unb64(public_key_b64)
|
|
85
|
+
X25519PublicKey.from_public_bytes(raw)
|
|
86
|
+
except Exception as exc: # noqa: BLE001 — any parse failure is one answer
|
|
87
|
+
raise ValueError(f"not a valid X25519 public key: {exc}") from exc
|
|
88
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
89
|
+
return ":".join(digest[i : i + 4] for i in range(0, 16, 4))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _derive(shared: bytes, salt: bytes, ephemeral: bytes, recipient: bytes) -> bytes:
|
|
93
|
+
return HKDF(
|
|
94
|
+
algorithm=hashes.SHA256(),
|
|
95
|
+
length=KEY_BYTES,
|
|
96
|
+
salt=salt,
|
|
97
|
+
info=HKDF_INFO_PREFIX + b"|" + ephemeral + b"|" + recipient,
|
|
98
|
+
).derive(shared)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def seal(plaintext: bytes, *, recipient_public_key: str) -> Dict[str, Any]:
|
|
102
|
+
"""Encrypt ``plaintext`` so only the recipient's private key can open it.
|
|
103
|
+
|
|
104
|
+
Returns the JSON-safe crypto block that goes in the bundle envelope. The
|
|
105
|
+
recipient's own public key is echoed back into it so a receiver holding
|
|
106
|
+
several keys can tell at a glance whether this box was meant for them —
|
|
107
|
+
without trying to decrypt it, and without the sender having to be asked.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
recipient_raw = _unb64(recipient_public_key)
|
|
111
|
+
peer = X25519PublicKey.from_public_bytes(recipient_raw)
|
|
112
|
+
except Exception as exc: # noqa: BLE001 — a bad key is a caller error, said plainly
|
|
113
|
+
raise ValueError(f"not a valid X25519 public key: {exc}") from exc
|
|
114
|
+
ephemeral = X25519PrivateKey.generate()
|
|
115
|
+
ephemeral_raw = _raw_public(ephemeral.public_key())
|
|
116
|
+
salt = os.urandom(SALT_BYTES)
|
|
117
|
+
nonce = os.urandom(NONCE_BYTES)
|
|
118
|
+
key = _derive(ephemeral.exchange(peer), salt, ephemeral_raw, recipient_raw)
|
|
119
|
+
ciphertext = AESGCM(key).encrypt(nonce, plaintext, None)
|
|
120
|
+
return {
|
|
121
|
+
"algorithm": SEALED_BOX_ALGORITHM,
|
|
122
|
+
"ephemeral_public_key": _b64(ephemeral_raw),
|
|
123
|
+
"recipient_public_key": _b64(recipient_raw),
|
|
124
|
+
"recipient_fingerprint": public_key_fingerprint(recipient_public_key),
|
|
125
|
+
"salt": _b64(salt),
|
|
126
|
+
"nonce": _b64(nonce),
|
|
127
|
+
"ciphertext": _b64(ciphertext),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def unseal(block: Dict[str, Any], private_key: X25519PrivateKey) -> bytes:
|
|
132
|
+
"""Open a sealed box with the recipient's private key.
|
|
133
|
+
|
|
134
|
+
Fail-closed and specific: an unknown algorithm, a missing field, a box
|
|
135
|
+
addressed to a different key, and a failed tag check each say which one
|
|
136
|
+
happened rather than collapsing into "decryption failed".
|
|
137
|
+
"""
|
|
138
|
+
algorithm = str((block or {}).get("algorithm") or "")
|
|
139
|
+
if algorithm != SEALED_BOX_ALGORITHM:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"unsupported sealed-box algorithm: {algorithm or 'missing'}"
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
ephemeral_raw = _unb64(block["ephemeral_public_key"])
|
|
145
|
+
salt = _unb64(block["salt"])
|
|
146
|
+
nonce = _unb64(block["nonce"])
|
|
147
|
+
ciphertext = _unb64(block["ciphertext"])
|
|
148
|
+
peer = X25519PublicKey.from_public_bytes(ephemeral_raw)
|
|
149
|
+
except Exception as exc: # noqa: BLE001 — a malformed envelope is one answer
|
|
150
|
+
raise ValueError(f"sealed bundle is missing encryption metadata: {exc}") from exc
|
|
151
|
+
recipient_raw = _raw_public(private_key.public_key())
|
|
152
|
+
addressed = str(block.get("recipient_public_key") or "")
|
|
153
|
+
if addressed and _unb64(addressed) != recipient_raw:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"this bundle was sealed to a different recipient key "
|
|
156
|
+
f"({block.get('recipient_fingerprint') or 'unknown fingerprint'})."
|
|
157
|
+
)
|
|
158
|
+
key = _derive(private_key.exchange(peer), salt, ephemeral_raw, recipient_raw)
|
|
159
|
+
try:
|
|
160
|
+
return bytes(AESGCM(key).decrypt(nonce, ciphertext, None))
|
|
161
|
+
except InvalidTag as exc:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"Sealed bundle decryption failed; the recipient key or the bundle is invalid."
|
|
164
|
+
) from exc
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class RecipientIdentity:
|
|
168
|
+
"""This Brain's X25519 receiving keypair — loads or creates, file-backed.
|
|
169
|
+
|
|
170
|
+
Deliberately *not* the device's Ed25519 identity. That key signs, and a
|
|
171
|
+
signing key that also decrypts is a key whose compromise costs twice. The
|
|
172
|
+
private half lives in a 0600 file under the data directory and never leaves
|
|
173
|
+
the machine; :meth:`describe` reports exactly that rather than implying a
|
|
174
|
+
keychain this class does not use.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, data_dir: Any) -> None:
|
|
178
|
+
self._data_dir = Path(data_dir)
|
|
179
|
+
self._key_file = self._data_dir / RECIPIENT_KEY_FILENAME
|
|
180
|
+
self._private = self._load_or_create()
|
|
181
|
+
|
|
182
|
+
def _load_or_create(self) -> X25519PrivateKey:
|
|
183
|
+
if self._key_file.exists():
|
|
184
|
+
return X25519PrivateKey.from_private_bytes(
|
|
185
|
+
_unb64(self._key_file.read_text(encoding="utf-8").strip())
|
|
186
|
+
)
|
|
187
|
+
key = X25519PrivateKey.generate()
|
|
188
|
+
raw = key.private_bytes(
|
|
189
|
+
serialization.Encoding.Raw,
|
|
190
|
+
serialization.PrivateFormat.Raw,
|
|
191
|
+
serialization.NoEncryption(),
|
|
192
|
+
)
|
|
193
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
self._key_file.write_text(_b64(raw), encoding="utf-8")
|
|
195
|
+
os.chmod(self._key_file, 0o600)
|
|
196
|
+
return key
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def public_key_b64(self) -> str:
|
|
200
|
+
return _b64(_raw_public(self._private.public_key()))
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def fingerprint(self) -> str:
|
|
204
|
+
return public_key_fingerprint(self.public_key_b64)
|
|
205
|
+
|
|
206
|
+
def describe(self) -> Dict[str, Any]:
|
|
207
|
+
return {
|
|
208
|
+
"public_key": self.public_key_b64,
|
|
209
|
+
"fingerprint": self.fingerprint,
|
|
210
|
+
"algorithm": SEALED_BOX_ALGORITHM,
|
|
211
|
+
"storage": "file",
|
|
212
|
+
"path": str(self._key_file),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def seal_to_self(self, plaintext: bytes) -> Dict[str, Any]:
|
|
216
|
+
"""Seal to this Brain's own key — the honest way to test a round trip."""
|
|
217
|
+
return seal(plaintext, recipient_public_key=self.public_key_b64)
|
|
218
|
+
|
|
219
|
+
def unseal(self, block: Dict[str, Any]) -> bytes:
|
|
220
|
+
return unseal(block, self._private)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_recipient_identity(data_dir: Any) -> Optional[RecipientIdentity]:
|
|
224
|
+
"""Best-effort recipient key, or ``None`` when the directory is unusable.
|
|
225
|
+
|
|
226
|
+
Sharing is opt-in and a missing key is a *state*, not a crash: the status
|
|
227
|
+
surface reports "no receiving key" rather than failing a status read.
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
return RecipientIdentity(data_dir)
|
|
231
|
+
except (OSError, ValueError): # noqa: BLE001 — unwritable dir / corrupt key file
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = [
|
|
236
|
+
"HKDF_INFO_PREFIX",
|
|
237
|
+
"RECIPIENT_KEY_FILENAME",
|
|
238
|
+
"SEALED_BOX_ALGORITHM",
|
|
239
|
+
"RecipientIdentity",
|
|
240
|
+
"load_recipient_identity",
|
|
241
|
+
"public_key_fingerprint",
|
|
242
|
+
"seal",
|
|
243
|
+
"unseal",
|
|
244
|
+
]
|
|
@@ -35,6 +35,7 @@ import os
|
|
|
35
35
|
from datetime import datetime, timezone
|
|
36
36
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
|
|
37
37
|
|
|
38
|
+
from .gates import FeatureGate
|
|
38
39
|
from .graph.proactive import ProactiveBrain
|
|
39
40
|
from .graph.schema import KGStoreV2
|
|
40
41
|
from .quality import content_signature
|
|
@@ -60,6 +61,19 @@ CONTRADICTION_RESOLUTIONS = ("keep_old", "replace", "keep_both_temporal")
|
|
|
60
61
|
SYNTHESIS_THRESHOLD_ENV = "LATTICEAI_SYNTHESIS_THRESHOLD"
|
|
61
62
|
DEFAULT_SYNTHESIS_THRESHOLD = 25
|
|
62
63
|
|
|
64
|
+
#: Whether the Brain is allowed to start a pass *by itself*. Default on — this
|
|
65
|
+
#: is the behaviour 11.1.0 shipped — and it governs only the automatic path
|
|
66
|
+
#: (:meth:`BrainSynthesizer.run_if_due`). An explicit run a person asked for is
|
|
67
|
+
#: never gated: turning "tidy up on its own" off means the Brain stops deciding
|
|
68
|
+
#: when, not that the button stops working.
|
|
69
|
+
SYNTHESIS_ENV = "LATTICEAI_SYNTHESIS"
|
|
70
|
+
SYNTHESIS_GATE = FeatureGate(
|
|
71
|
+
SYNTHESIS_ENV,
|
|
72
|
+
default=True,
|
|
73
|
+
name="synthesis",
|
|
74
|
+
detail="The Brain reviews accumulated material on its own and proposes tidy-ups.",
|
|
75
|
+
)
|
|
76
|
+
|
|
63
77
|
_MIN_SHARED_TOKENS = 3
|
|
64
78
|
_MIN_CLUSTER_MEMBERS = 3
|
|
65
79
|
_EDGE_SIMILARITY = 0.35
|
|
@@ -516,7 +530,14 @@ class BrainSynthesizer:
|
|
|
516
530
|
self, result: Any, *, workspace_id: Optional[str] = None,
|
|
517
531
|
user_email: Optional[str] = None,
|
|
518
532
|
) -> Optional[Dict[str, Any]]:
|
|
519
|
-
"""Run synthesis only when this ingest pushed the counter over.
|
|
533
|
+
"""Run synthesis only when this ingest pushed the counter over.
|
|
534
|
+
|
|
535
|
+
The gate is checked *before* the counter moves, so "off" means off: a
|
|
536
|
+
long import while the switch is down does not bank a pass that fires the
|
|
537
|
+
instant someone turns it back on.
|
|
538
|
+
"""
|
|
539
|
+
if not SYNTHESIS_GATE.enabled():
|
|
540
|
+
return None
|
|
520
541
|
if not self.observe_ingest(result):
|
|
521
542
|
return None
|
|
522
543
|
return self.run(workspace_id=workspace_id, user_email=user_email)
|
|
@@ -792,6 +813,8 @@ __all__ = [
|
|
|
792
813
|
"ContradictionResolutionError",
|
|
793
814
|
"EDGE_KIND",
|
|
794
815
|
"ProposalDesk",
|
|
816
|
+
"SYNTHESIS_ENV",
|
|
817
|
+
"SYNTHESIS_GATE",
|
|
795
818
|
"SYNTHESIS_REVIEW_SOURCE",
|
|
796
819
|
"SYNTHESIS_THRESHOLD_ENV",
|
|
797
820
|
"SynthesisTrigger",
|
package/latticeai/__init__.py
CHANGED
|
@@ -85,6 +85,10 @@ def create_brain_intelligence_router(
|
|
|
85
85
|
Fixed contract consumed by the frontend:
|
|
86
86
|
``{"status": "ready"|"pending"|"unavailable", "pending_items": int,
|
|
87
87
|
"total_items": int, "detail": str}``.
|
|
88
|
+
|
|
89
|
+
Additive since v11.2.0: ``breakdown`` splits the backlog into
|
|
90
|
+
``embedded``/``missing``/``stale``/``queued`` when the store can. The
|
|
91
|
+
four keys above are unchanged, so existing readers see no difference.
|
|
88
92
|
"""
|
|
89
93
|
user = require_user(request)
|
|
90
94
|
scope = gate_read(request)
|
package/latticeai/api/chat.py
CHANGED
|
@@ -111,6 +111,16 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
111
111
|
get_history_user=context.require("get_history_user"),
|
|
112
112
|
)
|
|
113
113
|
|
|
114
|
+
def review_sink() -> object:
|
|
115
|
+
"""The Review Center queue, resolved per request (may be absent).
|
|
116
|
+
|
|
117
|
+
Cloud-derived knowledge is staged here rather than written, so the
|
|
118
|
+
hybrid turn needs the live queue — which is wired after this router's
|
|
119
|
+
context is built, hence a provider rather than a captured value.
|
|
120
|
+
"""
|
|
121
|
+
provider = context.review_queue
|
|
122
|
+
return None if provider is None else provider()
|
|
123
|
+
|
|
114
124
|
def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
|
|
115
125
|
"""Mirror persisted web exchanges to an injected bridge, never echoing it."""
|
|
116
126
|
if context.on_chat_message is None or source == "telegram":
|
|
@@ -445,6 +455,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
445
455
|
chat_service=chat_service,
|
|
446
456
|
notify=notify_chat_message,
|
|
447
457
|
model_id=selected_model_id,
|
|
458
|
+
review_queue=review_sink(),
|
|
448
459
|
)
|
|
449
460
|
if hybrid_response is not None:
|
|
450
461
|
return hybrid_response
|
|
@@ -212,8 +212,17 @@ def build_context_quality(
|
|
|
212
212
|
lexical-only, or when retrieval failed entirely — so the frontend can
|
|
213
213
|
tell the user the answer is weakly grounded instead of implying rich
|
|
214
214
|
graph context. Never raises.
|
|
215
|
+
|
|
216
|
+
A fifth key, ``multimodal``, appears only when the matches really include
|
|
217
|
+
picture nodes (v11.2.0). "6 nodes" reads differently when two of them are
|
|
218
|
+
screenshots whose text came out of OCR, and until this call site passed
|
|
219
|
+
``multimodal=`` the shipped chat surface could not tell the difference the
|
|
220
|
+
signal was built to report.
|
|
215
221
|
"""
|
|
216
|
-
from lattice_brain.graph.retrieval import
|
|
222
|
+
from lattice_brain.graph.retrieval import (
|
|
223
|
+
context_quality_signal,
|
|
224
|
+
multimodal_signal,
|
|
225
|
+
)
|
|
217
226
|
|
|
218
227
|
query = str(query or "").strip()
|
|
219
228
|
if knowledge_graph is None or not query:
|
|
@@ -233,7 +242,9 @@ def build_context_quality(
|
|
|
233
242
|
return context_quality_signal("none", 0, reason="그래프 검색에 실패했습니다")
|
|
234
243
|
matches = result.get("matches") or []
|
|
235
244
|
return context_quality_signal(
|
|
236
|
-
str(result.get("mode") or "hybrid"),
|
|
245
|
+
str(result.get("mode") or "hybrid"),
|
|
246
|
+
len(matches),
|
|
247
|
+
multimodal=multimodal_signal(matches),
|
|
237
248
|
)
|
|
238
249
|
# Lexical-only stores without the hybrid mixin.
|
|
239
250
|
try:
|
|
@@ -242,7 +253,9 @@ def build_context_quality(
|
|
|
242
253
|
).get("matches") or []
|
|
243
254
|
except Exception: # noqa: BLE001 — signal building must never fail chat
|
|
244
255
|
return context_quality_signal("none", 0, reason="그래프 검색에 실패했습니다")
|
|
245
|
-
return context_quality_signal(
|
|
256
|
+
return context_quality_signal(
|
|
257
|
+
"lexical_only", len(matches), multimodal=multimodal_signal(matches)
|
|
258
|
+
)
|
|
246
259
|
|
|
247
260
|
|
|
248
261
|
# ── answer-citation binding (backlog #11, review §7.2 E #4) ──────────────────
|
|
@@ -14,7 +14,10 @@ from latticeai.core.network_boundary import (
|
|
|
14
14
|
NetworkBoundaryMode,
|
|
15
15
|
normalize_network_mode,
|
|
16
16
|
)
|
|
17
|
-
from latticeai.runtime.network_boundary_wiring import
|
|
17
|
+
from latticeai.runtime.network_boundary_wiring import (
|
|
18
|
+
get_hybrid_policy_service,
|
|
19
|
+
resolve_active_network_mode,
|
|
20
|
+
)
|
|
18
21
|
from latticeai.services.hybrid_chat import stream_hybrid_cloud_turn
|
|
19
22
|
|
|
20
23
|
|
|
@@ -32,6 +35,28 @@ def resolve_request_network_mode(
|
|
|
32
35
|
)
|
|
33
36
|
|
|
34
37
|
|
|
38
|
+
def resolve_hybrid_auto_commit(
|
|
39
|
+
*,
|
|
40
|
+
user_email: Optional[str],
|
|
41
|
+
workspace_id: Optional[str],
|
|
42
|
+
) -> bool:
|
|
43
|
+
"""The scoped hybrid policy's ``auto_commit`` decision for this turn.
|
|
44
|
+
|
|
45
|
+
Sibling of :func:`resolve_request_network_mode`: the dial says whether the
|
|
46
|
+
turn may reach the cloud, this says what may happen to what comes back.
|
|
47
|
+
Default is ``False`` — cloud-derived knowledge waits in the Review Center
|
|
48
|
+
— and a policy that cannot be read is treated as the default rather than
|
|
49
|
+
as permission.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
policy = get_hybrid_policy_service().resolve(
|
|
53
|
+
user_email=user_email, workspace_id=workspace_id
|
|
54
|
+
)
|
|
55
|
+
except Exception: # noqa: BLE001 — an unreadable policy never grants a write
|
|
56
|
+
return False
|
|
57
|
+
return bool(policy.get("auto_commit", False))
|
|
58
|
+
|
|
59
|
+
|
|
35
60
|
def maybe_hybrid_stream_response(
|
|
36
61
|
*,
|
|
37
62
|
req: Any,
|
|
@@ -45,6 +70,7 @@ def maybe_hybrid_stream_response(
|
|
|
45
70
|
chat_service: Any,
|
|
46
71
|
notify: Any,
|
|
47
72
|
model_id: Optional[str],
|
|
73
|
+
review_queue: Any = None,
|
|
48
74
|
) -> Optional[StreamingResponse]:
|
|
49
75
|
"""Return a StreamingResponse when cloud path should run; else None."""
|
|
50
76
|
if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
|
|
@@ -66,6 +92,10 @@ def maybe_hybrid_stream_response(
|
|
|
66
92
|
history_user=history_user,
|
|
67
93
|
notify=notify,
|
|
68
94
|
source=req.source,
|
|
95
|
+
review_queue=review_queue,
|
|
96
|
+
auto_commit=resolve_hybrid_auto_commit(
|
|
97
|
+
user_email=effective_email, workspace_id=workspace_id
|
|
98
|
+
),
|
|
69
99
|
),
|
|
70
100
|
media_type="text/event-stream",
|
|
71
101
|
headers={
|
|
@@ -78,5 +108,6 @@ def maybe_hybrid_stream_response(
|
|
|
78
108
|
|
|
79
109
|
__all__ = [
|
|
80
110
|
"resolve_request_network_mode",
|
|
111
|
+
"resolve_hybrid_auto_commit",
|
|
81
112
|
"maybe_hybrid_stream_response",
|
|
82
113
|
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Brain Chronicle API router (v11.3.0, track B).
|
|
2
|
+
|
|
3
|
+
Read-only surface over :class:`~latticeai.services.chronicle.ChronicleService`
|
|
4
|
+
— the first HTTP exposure of the bitemporal data v11.1.0 started recording:
|
|
5
|
+
|
|
6
|
+
* ``GET /api/chronicle/overview`` — the growth curve: totals, the first and
|
|
7
|
+
last thing the Brain ever saw, and one bucket per day.
|
|
8
|
+
* ``GET /api/chronicle/day/{date}`` — one day's story, grouped into what came
|
|
9
|
+
in, what was learned, what was talked about, and what changed.
|
|
10
|
+
* ``GET /api/chronicle/as-of?ts=…`` — the Brain as it stood at an instant.
|
|
11
|
+
|
|
12
|
+
All three follow the same require_user/gate_read contract as the command
|
|
13
|
+
centre, so a workspace member never reads another workspace's timeline. A
|
|
14
|
+
malformed date or timestamp answers 422 through the message catalog rather
|
|
15
|
+
than a 500 or a silently empty day.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Callable, Optional
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, Query, Request
|
|
23
|
+
|
|
24
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
25
|
+
from latticeai.services.chronicle import ChronicleService
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_chronicle_router(
|
|
29
|
+
*,
|
|
30
|
+
service: ChronicleService,
|
|
31
|
+
require_user: Callable[[Request], str],
|
|
32
|
+
gate_read: Callable[[Request], Optional[str]],
|
|
33
|
+
) -> APIRouter:
|
|
34
|
+
router = APIRouter()
|
|
35
|
+
|
|
36
|
+
@router.get("/api/chronicle/overview")
|
|
37
|
+
async def chronicle_overview(request: Request):
|
|
38
|
+
user = require_user(request)
|
|
39
|
+
scope = gate_read(request)
|
|
40
|
+
return service.overview(user_email=user, workspace_id=scope)
|
|
41
|
+
|
|
42
|
+
@router.get("/api/chronicle/day/{date}")
|
|
43
|
+
async def chronicle_day(date: str, request: Request):
|
|
44
|
+
user = require_user(request)
|
|
45
|
+
scope = gate_read(request)
|
|
46
|
+
try:
|
|
47
|
+
return service.day(date, user_email=user, workspace_id=scope)
|
|
48
|
+
except ValueError:
|
|
49
|
+
raise http_error(422, "chronicle.bad_date", resolve_language(request))
|
|
50
|
+
|
|
51
|
+
@router.get("/api/chronicle/as-of")
|
|
52
|
+
async def chronicle_as_of(request: Request, ts: str = Query(..., max_length=64)):
|
|
53
|
+
require_user(request)
|
|
54
|
+
scope = gate_read(request)
|
|
55
|
+
try:
|
|
56
|
+
return service.as_of(ts, workspace_id=scope)
|
|
57
|
+
except ValueError:
|
|
58
|
+
raise http_error(422, "chronicle.bad_timestamp", resolve_language(request))
|
|
59
|
+
|
|
60
|
+
return router
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["create_chronicle_router"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Feature switchboard API — read the catalog, move one switch (v11.2.0).
|
|
2
|
+
|
|
3
|
+
Two routes and no more. The catalog is rendered by the service (labels,
|
|
4
|
+
explanations, defaults, which options are even installable), so this router
|
|
5
|
+
carries no product knowledge at all: adding a feature is a line in
|
|
6
|
+
``latticeai/services/feature_toggles.CATALOG``, not a change here or in the
|
|
7
|
+
client.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, Union
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
18
|
+
from latticeai.services.feature_toggles import (
|
|
19
|
+
FeatureToggleService,
|
|
20
|
+
InvalidFeatureValue,
|
|
21
|
+
UnknownFeature,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SetFeatureRequest(BaseModel):
|
|
26
|
+
"""One switch move. ``value`` is a bool for a toggle, a string for a choice."""
|
|
27
|
+
|
|
28
|
+
value: Union[bool, str] = Field(
|
|
29
|
+
..., description="true/false for a toggle, or the option id for a choice"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_features_router(
|
|
34
|
+
*,
|
|
35
|
+
service: FeatureToggleService,
|
|
36
|
+
require_user: Callable[..., str],
|
|
37
|
+
) -> APIRouter:
|
|
38
|
+
router = APIRouter(tags=["features"])
|
|
39
|
+
|
|
40
|
+
@router.get("/api/features")
|
|
41
|
+
async def list_features(request: Request) -> Any:
|
|
42
|
+
"""Every opt-in feature, its live value, and where that value came from."""
|
|
43
|
+
require_user(request)
|
|
44
|
+
return service.catalog(resolve_language(request))
|
|
45
|
+
|
|
46
|
+
@router.post("/api/features/{feature_id}")
|
|
47
|
+
async def set_feature(
|
|
48
|
+
feature_id: str, body: SetFeatureRequest, request: Request
|
|
49
|
+
) -> Any:
|
|
50
|
+
"""Persist one person's choice; the answer is the feature as it now reads."""
|
|
51
|
+
user = require_user(request)
|
|
52
|
+
language = resolve_language(request)
|
|
53
|
+
try:
|
|
54
|
+
return service.set(
|
|
55
|
+
feature_id, body.value, language=language, user_email=user
|
|
56
|
+
)
|
|
57
|
+
except UnknownFeature as exc:
|
|
58
|
+
raise http_error(
|
|
59
|
+
400, "features.unknown", language, feature=feature_id
|
|
60
|
+
) from exc
|
|
61
|
+
except InvalidFeatureValue as exc:
|
|
62
|
+
# Pass-through: the service already localized the reason through the
|
|
63
|
+
# same catalog (wrong type, or an option whose optional dependency
|
|
64
|
+
# is not installed), and re-wording it here would only lose it.
|
|
65
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
66
|
+
|
|
67
|
+
return router
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["SetFeatureRequest", "create_features_router"]
|