ltcai 11.1.0 → 11.2.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 +53 -53
- package/docs/CHANGELOG.md +33 -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/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +6 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/projection.py +66 -8
- 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.py +175 -27
- package/lattice_brain/multimodal.py +525 -5
- package/lattice_brain/portability.py +169 -32
- 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/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.py +55 -2
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +6 -3
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +143 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/runtime/build_phases.py +28 -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/services/app_context.py +8 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence.py +123 -7
- 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/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.py +1 -1
- package/latticeai/services/multimodal_ports.py +26 -1
- package/latticeai/services/obsidian_bridge.py +16 -25
- package/latticeai/services/product_readiness.py +1 -1
- 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.py +77 -44
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/release_screen_claims.json +13 -0
- package/scripts/verify_hf_model_registry.py +253 -218
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-D0HWqtn0.js → Act-AWf0SAKp.js} +1 -1
- package/static/app/assets/{AdminConsole-D-QDW-A4.js → AdminConsole-D0u8Tiyj.js} +1 -1
- package/static/app/assets/{Brain-CzCsI1mi.js → Brain-tuhI4sOC.js} +1 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
- package/static/app/assets/{BrainSignals-2dHQNkns.js → BrainSignals-jMYgQ2Ar.js} +1 -1
- package/static/app/assets/{Capture-CT8v1StE.js → Capture-CqOSzyPr.js} +1 -1
- package/static/app/assets/{CommandPalette-DoLXC2KH.js → CommandPalette-DC0Bzh-I.js} +1 -1
- package/static/app/assets/{Library-DDoxFE5c.js → Library-CX-bbhmK.js} +1 -1
- package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-DBwhto14.js} +1 -1
- package/static/app/assets/{ProductFlow-DOYf7JIs.js → ProductFlow-BHA2cfKI.js} +1 -1
- package/static/app/assets/{ReviewCard-COQsqidK.js → ReviewCard-BUhCKRNM.js} +1 -1
- package/static/app/assets/{System-BRllvYXd.js → System-Bu2t5hn1.js} +1 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
- package/static/app/assets/{bot-4BvN07ux.js → bot-Cia42c2h.js} +1 -1
- package/static/app/assets/brain-DJMoqrwx.js +1 -0
- package/static/app/assets/{button-CDjtnAoU.js → button-2j2Ijzgq.js} +1 -1
- package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-BEFeWpVW.js} +1 -1
- package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-ujXMcHxl.js} +1 -1
- package/static/app/assets/{cpu-BIlWInHf.js → cpu-k4awryFq.js} +1 -1
- package/static/app/assets/{download-BtjXfL3z.js → download-DFbLJ_ig.js} +1 -1
- package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-7y_b6xkM.js} +1 -1
- package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-Bidh02Kr.js} +1 -1
- package/static/app/assets/{index-0AvoEBzJ.js → index-BpYkzcVm.js} +3 -3
- package/static/app/assets/{index-vtEfYvQY.css → index-DwDl9-8Y.css} +1 -1
- package/static/app/assets/{input-B_5ZJ9oy.js → input-DSlJJxRs.js} +1 -1
- package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-Bpb83Hx9.js} +1 -1
- package/static/app/assets/{primitives-CVwew78r.js → primitives-BCx6TvfG.js} +1 -1
- package/static/app/assets/search-Cgy8cCFJ.js +1 -0
- package/static/app/assets/{share-2-D5zg_0fY.js → share-2-BH1M-WNi.js} +1 -1
- package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-BlKdBXcG.js} +1 -1
- package/static/app/assets/{textarea-nEVIweKY.js → textarea-CCWbUfFB.js} +1 -1
- package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
- package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-CXQiwbVT.js} +1 -1
- package/static/app/assets/{utils-DcDMoZIe.js → utils-zqPZJxdx.js} +2 -2
- package/static/app/assets/{workspace-LtRRSKTf.js → workspace-DXTihhfU.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
- package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
- package/static/app/assets/brain-uMb_5hnO.js +0 -1
- package/static/app/assets/search-DkhnOKZt.js +0 -1
|
@@ -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,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"]
|
|
@@ -60,12 +60,36 @@ class FolderWatchEnableRequest(BaseModel):
|
|
|
60
60
|
path: str
|
|
61
61
|
recursive: bool = True
|
|
62
62
|
workspace_id: Optional[str] = None
|
|
63
|
+
#: ``folder`` (default, unchanged) or ``vault`` — a watched external
|
|
64
|
+
#: Obsidian vault, which re-runs the whole bridge sync when it changes and
|
|
65
|
+
#: carries its own opt-in gate on top of this approval.
|
|
66
|
+
kind: str = "folder"
|
|
63
67
|
# Watching continuously reads local disk → same approval dance as
|
|
64
68
|
# /api/ingestion/folder. This is the explicit opt-in the review requires.
|
|
65
69
|
approved: bool = False
|
|
66
70
|
approval_token: Optional[str] = None
|
|
67
71
|
|
|
68
72
|
|
|
73
|
+
class InteropIngestRequest(BaseModel):
|
|
74
|
+
"""Read one local interop source through the one ingestion gate (v11.2.0).
|
|
75
|
+
|
|
76
|
+
``source`` picks the bridge — ``notion`` (an export directory or ``.zip``),
|
|
77
|
+
``git`` (a local repository), ``mail`` (``.eml``/``.ics`` files or a folder
|
|
78
|
+
of them). Every one of them reads a path the user approves through the same
|
|
79
|
+
local-read dance as ``/api/ingestion/folder``, and ``dry_run`` reports what
|
|
80
|
+
a real run would touch without writing anything.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
source: str
|
|
84
|
+
path: str
|
|
85
|
+
workspace_id: Optional[str] = None
|
|
86
|
+
dry_run: bool = False
|
|
87
|
+
#: Git only: how far back to read. Ignored by the other bridges.
|
|
88
|
+
max_commits: Optional[int] = None
|
|
89
|
+
approved: bool = False
|
|
90
|
+
approval_token: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
|
|
69
93
|
class ObsidianSyncRequest(BaseModel):
|
|
70
94
|
"""One-shot sync of an *external* Obsidian vault (v11.1.0).
|
|
71
95
|
|
|
@@ -107,10 +131,18 @@ def create_local_files_router(
|
|
|
107
131
|
if folder_watch is None and ingestion_pipeline is not None and data_dir is not None:
|
|
108
132
|
try:
|
|
109
133
|
from latticeai.services.folder_watch import FolderWatchService
|
|
134
|
+
from latticeai.services.obsidian_bridge import ObsidianVaultBridge
|
|
110
135
|
|
|
111
136
|
folder_watch = FolderWatchService(
|
|
112
137
|
pipeline=ingestion_pipeline,
|
|
113
138
|
config_path=Path(data_dir) / "folder_watch.json",
|
|
139
|
+
# A watched vault re-runs the *whole* bridge sync, which is what
|
|
140
|
+
# makes its link edges real; the bridge is handed in rather than
|
|
141
|
+
# imported inside the poller so the watch service keeps knowing
|
|
142
|
+
# nothing about how a vault is parsed.
|
|
143
|
+
vault_bridge=ObsidianVaultBridge(
|
|
144
|
+
pipeline=ingestion_pipeline, knowledge_graph=knowledge_graph,
|
|
145
|
+
),
|
|
114
146
|
)
|
|
115
147
|
folder_watch.restore()
|
|
116
148
|
except Exception: # noqa: BLE001 — watch mode is optional, never blocks routes
|
|
@@ -405,6 +437,66 @@ def create_local_files_router(
|
|
|
405
437
|
dry_run=req.dry_run,
|
|
406
438
|
)
|
|
407
439
|
|
|
440
|
+
# ── interop bridges: Notion export / git history / mail + calendar ───────
|
|
441
|
+
@router.get("/api/ingestion/interop")
|
|
442
|
+
async def interop_status(request: Request):
|
|
443
|
+
"""Which interop bridges this machine can actually run, and why not."""
|
|
444
|
+
require_user(request)
|
|
445
|
+
from latticeai.services.interop_bridges import bridge_status
|
|
446
|
+
|
|
447
|
+
return bridge_status()
|
|
448
|
+
|
|
449
|
+
@router.post("/api/ingestion/interop")
|
|
450
|
+
async def interop_ingest(req: InteropIngestRequest, request: Request):
|
|
451
|
+
"""Read an approved local interop source through the one gate.
|
|
452
|
+
|
|
453
|
+
Same approval dance and the same ``IngestionPipeline`` door as the
|
|
454
|
+
vault bridge: no vendor API, no credentials, nothing leaves the
|
|
455
|
+
machine. ``dry_run`` writes nothing and reports what a real run would.
|
|
456
|
+
"""
|
|
457
|
+
current_user = permission_gateway.require_local_user(request)
|
|
458
|
+
_require_pipeline()
|
|
459
|
+
workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
|
|
460
|
+
path = (req.path or "").strip()
|
|
461
|
+
if not path:
|
|
462
|
+
raise http_error(400, "ingestion.interop_path_required", resolve_language(request))
|
|
463
|
+
from latticeai.services.interop_bridges import build_bridge
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
bridge = build_bridge(
|
|
467
|
+
req.source, pipeline=ingestion_pipeline, knowledge_graph=knowledge_graph,
|
|
468
|
+
)
|
|
469
|
+
except ValueError:
|
|
470
|
+
raise http_error(
|
|
471
|
+
400,
|
|
472
|
+
"ingestion.interop_unknown_source",
|
|
473
|
+
resolve_language(request),
|
|
474
|
+
source=req.source,
|
|
475
|
+
)
|
|
476
|
+
if not req.approved:
|
|
477
|
+
return permission_gateway.local_permission_response(path, "read", current_user)
|
|
478
|
+
permission_gateway.require_local_approval(
|
|
479
|
+
token=req.approval_token,
|
|
480
|
+
path=path,
|
|
481
|
+
action="read",
|
|
482
|
+
user_email=current_user,
|
|
483
|
+
)
|
|
484
|
+
options: Dict[str, Any] = {}
|
|
485
|
+
if req.max_commits is not None:
|
|
486
|
+
options["max_commits"] = req.max_commits
|
|
487
|
+
# Walking an export and embedding its pages is blocking work; the server
|
|
488
|
+
# owns one event loop and it may not sit here (10.9.0 ASYNC gate).
|
|
489
|
+
return await asyncio.to_thread(
|
|
490
|
+
lambda: bridge.sync(
|
|
491
|
+
path,
|
|
492
|
+
owner=current_user or None,
|
|
493
|
+
workspace_id=workspace_id,
|
|
494
|
+
user_email=current_user or None,
|
|
495
|
+
dry_run=req.dry_run,
|
|
496
|
+
**options,
|
|
497
|
+
)
|
|
498
|
+
)
|
|
499
|
+
|
|
408
500
|
# ── folder watch mode: opt-in, off by default (backlog #8) ────────────────
|
|
409
501
|
def _require_folder_watch(request: Request):
|
|
410
502
|
_require_pipeline()
|
|
@@ -446,7 +538,16 @@ def create_local_files_router(
|
|
|
446
538
|
owner=current_user or None,
|
|
447
539
|
workspace_id=workspace_id,
|
|
448
540
|
recursive=req.recursive,
|
|
541
|
+
kind=req.kind,
|
|
449
542
|
)
|
|
543
|
+
if result.get("status") == "disabled":
|
|
544
|
+
# Turned off rather than broken: 403 with the reason, the same shape
|
|
545
|
+
# every other opt-in gate in this product answers with.
|
|
546
|
+
raise HTTPException(
|
|
547
|
+
status_code=403,
|
|
548
|
+
detail=result.get("detail")
|
|
549
|
+
or translate("ingestion.vault_watch_disabled", resolve_language(request)),
|
|
550
|
+
)
|
|
450
551
|
if result.get("status") != "ok":
|
|
451
552
|
raise HTTPException(
|
|
452
553
|
status_code=400,
|
|
@@ -518,6 +619,7 @@ def create_local_files_router(
|
|
|
518
619
|
__all__ = [
|
|
519
620
|
"FolderIngestRequest",
|
|
520
621
|
"FolderWatchEnableRequest",
|
|
622
|
+
"InteropIngestRequest",
|
|
521
623
|
"LocalAccessRequest",
|
|
522
624
|
"LocalWriteRequest",
|
|
523
625
|
"ObsidianSyncRequest",
|
|
@@ -87,8 +87,16 @@ class SubgraphSelection(BaseModel):
|
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
class SubgraphArchiveRequest(SubgraphSelection):
|
|
90
|
+
"""Exactly one recipient mechanism — a passphrase, or a public key.
|
|
91
|
+
|
|
92
|
+
Both optional at the schema level and validated in the service, so the
|
|
93
|
+
error a caller gets names the rule ("choose one") instead of a field.
|
|
94
|
+
"""
|
|
95
|
+
|
|
90
96
|
path: Optional[str] = None
|
|
91
|
-
passphrase: str
|
|
97
|
+
passphrase: Optional[str] = None
|
|
98
|
+
#: The recipient's X25519 public key, from their ``/share/recipient-key``.
|
|
99
|
+
recipient_public_key: Optional[str] = None
|
|
92
100
|
|
|
93
101
|
|
|
94
102
|
class SubgraphImportRequest(BaseModel):
|
|
@@ -269,6 +277,22 @@ def create_portability_router(
|
|
|
269
277
|
except (ValueError, RuntimeError) as exc:
|
|
270
278
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
271
279
|
|
|
280
|
+
@router.get("/api/knowledge-graph/share/recipient-key")
|
|
281
|
+
async def share_recipient_key(request: Request):
|
|
282
|
+
"""This Brain's X25519 receiving key, for a sender to seal a bundle to.
|
|
283
|
+
|
|
284
|
+
Public by construction — a public key encrypts, it does not decrypt —
|
|
285
|
+
so handing it out is the whole point. Still admin-gated and still
|
|
286
|
+
behind the share flag, because minting the key is a decision to
|
|
287
|
+
participate in sharing at all.
|
|
288
|
+
"""
|
|
289
|
+
require_admin(request)
|
|
290
|
+
_require_service(request)
|
|
291
|
+
try:
|
|
292
|
+
return service.recipient_key()
|
|
293
|
+
except PermissionError:
|
|
294
|
+
raise http_error(403, "portability.brain_network_disabled", resolve_language(request))
|
|
295
|
+
|
|
272
296
|
@router.post("/api/knowledge-graph/share/archive")
|
|
273
297
|
async def share_archive(req: SubgraphArchiveRequest, request: Request):
|
|
274
298
|
"""Same bundle, written as an encrypted ``.latticebrain`` file."""
|
|
@@ -277,8 +301,14 @@ def create_portability_router(
|
|
|
277
301
|
body = req.model_dump()
|
|
278
302
|
path = body.pop("path")
|
|
279
303
|
passphrase = body.pop("passphrase")
|
|
304
|
+
recipient_public_key = body.pop("recipient_public_key")
|
|
280
305
|
try:
|
|
281
|
-
return service.export_subgraph_archive(
|
|
306
|
+
return service.export_subgraph_archive(
|
|
307
|
+
path,
|
|
308
|
+
passphrase=passphrase,
|
|
309
|
+
recipient_public_key=recipient_public_key,
|
|
310
|
+
**body,
|
|
311
|
+
)
|
|
282
312
|
except PermissionError:
|
|
283
313
|
raise http_error(403, "portability.brain_network_disabled", resolve_language(request))
|
|
284
314
|
except (ValueError, RuntimeError) as exc:
|
|
@@ -294,8 +324,13 @@ def create_portability_router(
|
|
|
294
324
|
try:
|
|
295
325
|
artifact = req.artifact
|
|
296
326
|
if artifact is None:
|
|
297
|
-
if not req.path
|
|
298
|
-
raise ValueError(
|
|
327
|
+
if not req.path:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
"Provide an artifact, or the path of a bundle on disk."
|
|
330
|
+
)
|
|
331
|
+
# A sealed bundle needs no passphrase — the envelope says which
|
|
332
|
+
# mechanism it used and the service refuses if the wrong one is
|
|
333
|
+
# supplied, so the route does not have to guess here.
|
|
299
334
|
artifact = service.read_subgraph_archive(req.path, passphrase=req.passphrase)
|
|
300
335
|
return service.import_subgraph_proposals(
|
|
301
336
|
artifact,
|
|
@@ -70,6 +70,39 @@ class ReviewCounts(BaseModel):
|
|
|
70
70
|
pending_by_source: Dict[str, int] = Field(default_factory=dict)
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
class BulkActionRequest(BaseModel):
|
|
74
|
+
"""Decide several review items at once, named explicitly.
|
|
75
|
+
|
|
76
|
+
``ids`` is required and never defaults to "everything pending": a bulk
|
|
77
|
+
action whose scope is implicit is how an inbox gets emptied by accident.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
ids: List[str] = Field(default_factory=list)
|
|
81
|
+
reason: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class BulkActionOutcome(BaseModel):
|
|
85
|
+
"""Per-item verdict, so a partial success is legible rather than a total."""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
status: str # ok | not_found | conflict | failed
|
|
89
|
+
item_status: Optional[str] = None
|
|
90
|
+
detail: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class BulkActionResult(BaseModel):
|
|
94
|
+
action: str
|
|
95
|
+
requested: int
|
|
96
|
+
succeeded: int
|
|
97
|
+
failed: int
|
|
98
|
+
results: List[BulkActionOutcome] = Field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
#: Ceiling on one bulk call. Large enough for "clear today's inbox", small
|
|
102
|
+
#: enough that one request cannot walk the whole table inside a web worker.
|
|
103
|
+
BULK_ACTION_CAP = 200
|
|
104
|
+
|
|
105
|
+
|
|
73
106
|
def create_review_queue_router(
|
|
74
107
|
*,
|
|
75
108
|
service: ReviewQueueService,
|
|
@@ -123,6 +156,19 @@ def create_review_queue_router(
|
|
|
123
156
|
scope = gate_read(request)
|
|
124
157
|
return service.counts(workspace_id=scope, user_email=user)
|
|
125
158
|
|
|
159
|
+
# NOTE: declared *before* /automation/reviews/{item_id} for the same reason
|
|
160
|
+
# "counts" is — routes match in declaration order, so a later /bulk/... would
|
|
161
|
+
# be swallowed by the {item_id} matcher and answer 404 for a missing item.
|
|
162
|
+
@router.post("/automation/reviews/bulk/approve", response_model=BulkActionResult)
|
|
163
|
+
async def bulk_approve(req: BulkActionRequest, request: Request):
|
|
164
|
+
"""Approve several items, reporting each one's outcome individually."""
|
|
165
|
+
return _bulk(request, req, "approve")
|
|
166
|
+
|
|
167
|
+
@router.post("/automation/reviews/bulk/dismiss", response_model=BulkActionResult)
|
|
168
|
+
async def bulk_dismiss(req: BulkActionRequest, request: Request):
|
|
169
|
+
"""Dismiss several items, reporting each one's outcome individually."""
|
|
170
|
+
return _bulk(request, req, "dismiss")
|
|
171
|
+
|
|
126
172
|
@router.get("/automation/reviews/{item_id}", response_model=ReviewItem)
|
|
127
173
|
async def get_item(item_id: str, request: Request):
|
|
128
174
|
require_user(request)
|
|
@@ -205,6 +251,86 @@ def create_review_queue_router(
|
|
|
205
251
|
async def unsnooze_item(item_id: str, request: Request):
|
|
206
252
|
return _act(request, item_id, "unsnooze")
|
|
207
253
|
|
|
254
|
+
def _bulk(request: Request, req: BulkActionRequest, action: str) -> Dict[str, Any]:
|
|
255
|
+
"""One transition applied N times through the *same* single-item guard.
|
|
256
|
+
|
|
257
|
+
Deliberately not a bulk UPDATE. Every id goes through the ordinary
|
|
258
|
+
service call, so an already-approved item still 409s, a
|
|
259
|
+
``change_proposal`` still applies its staged content, and the audit
|
|
260
|
+
trail records N decisions — because that is what happened. A failure
|
|
261
|
+
on one id never aborts the rest, and the response says exactly which
|
|
262
|
+
ones did not land instead of returning a single number the caller has
|
|
263
|
+
to interpret.
|
|
264
|
+
"""
|
|
265
|
+
user = require_user(request)
|
|
266
|
+
scope = gate_write(request)
|
|
267
|
+
language = resolve_language(request)
|
|
268
|
+
wanted = [str(item).strip() for item in req.ids if str(item).strip()]
|
|
269
|
+
if not wanted:
|
|
270
|
+
raise http_error(422, "review.bulk_ids_required", language)
|
|
271
|
+
if len(wanted) > BULK_ACTION_CAP:
|
|
272
|
+
raise http_error(422, "review.bulk_too_many", language, cap=BULK_ACTION_CAP)
|
|
273
|
+
results: List[Dict[str, Any]] = []
|
|
274
|
+
succeeded = 0
|
|
275
|
+
for item_id in wanted:
|
|
276
|
+
outcome = _bulk_one(request, item_id, action, user=user, scope=scope, reason=req.reason)
|
|
277
|
+
if outcome["status"] == "ok":
|
|
278
|
+
succeeded += 1
|
|
279
|
+
results.append(outcome)
|
|
280
|
+
return {
|
|
281
|
+
"action": action,
|
|
282
|
+
"requested": len(wanted),
|
|
283
|
+
"succeeded": succeeded,
|
|
284
|
+
"failed": len(wanted) - succeeded,
|
|
285
|
+
"results": results,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
def _bulk_one(
|
|
289
|
+
request: Request,
|
|
290
|
+
item_id: str,
|
|
291
|
+
action: str,
|
|
292
|
+
*,
|
|
293
|
+
user: str,
|
|
294
|
+
scope: Optional[str],
|
|
295
|
+
reason: str,
|
|
296
|
+
) -> Dict[str, Any]:
|
|
297
|
+
try:
|
|
298
|
+
if action == "dismiss":
|
|
299
|
+
item = service.dismiss(item_id, workspace_id=scope, reason=reason or None)
|
|
300
|
+
else:
|
|
301
|
+
item = _approve_one(request, item_id, user=user, scope=scope)
|
|
302
|
+
except (FileNotFoundError, KeyError):
|
|
303
|
+
return {"id": item_id, "status": "not_found", "detail": None}
|
|
304
|
+
except InvalidReviewTransition as exc:
|
|
305
|
+
return {"id": item_id, "status": "conflict", "detail": str(exc)}
|
|
306
|
+
except ProposalConflictError as exc:
|
|
307
|
+
return {"id": item_id, "status": "conflict", "detail": str(exc)}
|
|
308
|
+
except HTTPException as exc:
|
|
309
|
+
return {
|
|
310
|
+
"id": item_id,
|
|
311
|
+
"status": "conflict" if exc.status_code == 409 else "failed",
|
|
312
|
+
"detail": str(exc.detail),
|
|
313
|
+
}
|
|
314
|
+
except ValueError as exc:
|
|
315
|
+
return {"id": item_id, "status": "failed", "detail": str(exc)}
|
|
316
|
+
append_audit_event(f"review_item_{action}", user_email=user, item_id=item_id)
|
|
317
|
+
return {"id": item_id, "status": "ok", "item_status": item.get("status"), "detail": None}
|
|
318
|
+
|
|
319
|
+
def _approve_one(
|
|
320
|
+
request: Request, item_id: str, *, user: str, scope: Optional[str]
|
|
321
|
+
) -> Dict[str, Any]:
|
|
322
|
+
"""Approve exactly the way the single-item route does, staged content included."""
|
|
323
|
+
if change_proposals is not None:
|
|
324
|
+
stored = service.get(item_id, workspace_id=scope)
|
|
325
|
+
if stored.get("source") == "change_proposal":
|
|
326
|
+
if stored.get("effective_status") not in ("pending", "snoozed"):
|
|
327
|
+
raise InvalidReviewTransition("approve", str(stored.get("status")))
|
|
328
|
+
applied = change_proposals.approve_and_apply(
|
|
329
|
+
item_id, user_email=user, workspace_id=scope
|
|
330
|
+
)
|
|
331
|
+
return dict(applied["item"])
|
|
332
|
+
return service.approve(item_id, workspace_id=scope)
|
|
333
|
+
|
|
208
334
|
@router.post("/automation/reviews/{item_id}/run_now", response_model=ReviewItem)
|
|
209
335
|
async def run_now_item(item_id: str, request: Request):
|
|
210
336
|
user = require_user(request)
|
package/latticeai/api/search.py
CHANGED
|
@@ -25,6 +25,11 @@ class HybridSearchRequest(SearchRequest):
|
|
|
25
25
|
vector_limit: int = 30
|
|
26
26
|
graph_limit: int = 30
|
|
27
27
|
weights: Optional[Dict[str, float]] = None
|
|
28
|
+
#: Opt in to scoring this text query against the *image* index too
|
|
29
|
+
#: (v11.2.0). Only possible when a shared-space vision model is
|
|
30
|
+
#: configured; when it is not, the response says so instead of quietly
|
|
31
|
+
#: returning the text-only ranking as if fusion had happened.
|
|
32
|
+
image_fusion: bool = False
|
|
28
33
|
|
|
29
34
|
|
|
30
35
|
class GraphNodeRequest(BaseModel):
|
|
@@ -106,17 +111,26 @@ def create_search_router(
|
|
|
106
111
|
vector_limit=req.vector_limit,
|
|
107
112
|
graph_limit=req.graph_limit,
|
|
108
113
|
weights=req.weights or DEFAULT_HYBRID_WEIGHTS,
|
|
114
|
+
image_fusion=req.image_fusion,
|
|
109
115
|
)
|
|
110
116
|
except ValueError as exc:
|
|
111
117
|
_raise_graph_error(exc)
|
|
112
118
|
|
|
113
119
|
@router.get("/api/search/hybrid")
|
|
114
|
-
async def hybrid_search_get(
|
|
120
|
+
async def hybrid_search_get(
|
|
121
|
+
q: str, request: Request, limit: int = 30, image_fusion: bool = False,
|
|
122
|
+
) -> Dict[str, Any]:
|
|
115
123
|
try:
|
|
116
|
-
return _guarded(request).hybrid_search(q, limit=limit)
|
|
124
|
+
return _guarded(request).hybrid_search(q, limit=limit, image_fusion=image_fusion)
|
|
117
125
|
except ValueError as exc:
|
|
118
126
|
_raise_graph_error(exc)
|
|
119
127
|
|
|
128
|
+
@router.get("/api/search/image-query")
|
|
129
|
+
async def image_query_status(request: Request) -> Dict[str, Any]:
|
|
130
|
+
"""Whether a typed question can reach the image index on this install."""
|
|
131
|
+
require_user(request)
|
|
132
|
+
return service.image_query_status()
|
|
133
|
+
|
|
120
134
|
@router.post("/api/search/keyword")
|
|
121
135
|
async def keyword_search(req: SearchRequest, request: Request) -> Dict[str, Any]:
|
|
122
136
|
try:
|
package/latticeai/core/agent.py
CHANGED
|
@@ -114,7 +114,8 @@ class AgentRunContext:
|
|
|
114
114
|
__slots__ = ("state", "plan", "transcript", "retry_count",
|
|
115
115
|
"state_history", "corrections", "final_message", "rollback_log",
|
|
116
116
|
"executing_model", "reviewing_model", "approved_by_human", "trace",
|
|
117
|
-
"on_step", "project_context", "permission_mode"
|
|
117
|
+
"on_step", "project_context", "permission_mode",
|
|
118
|
+
"self_model_summary")
|
|
118
119
|
|
|
119
120
|
def __init__(self) -> None:
|
|
120
121
|
self.state: AgentState = AgentState.IDLE
|
|
@@ -143,6 +144,12 @@ class AgentRunContext:
|
|
|
143
144
|
# per-tool gate in the same run agree; ``None`` falls back to the
|
|
144
145
|
# process-wide resolver on ``deps``.
|
|
145
146
|
self.permission_mode: Optional[str] = None
|
|
147
|
+
# Self-Model summary resolved once per run (v11.2.0). The executor
|
|
148
|
+
# prompt is rebuilt on every turn of the loop; reading the profile
|
|
149
|
+
# once and reusing it keeps a graph query off that hot path — and
|
|
150
|
+
# keeps every turn of one run describing the same person. ``None``
|
|
151
|
+
# means "not resolved yet"; ``""`` is a resolved, empty profile.
|
|
152
|
+
self.self_model_summary: Optional[str] = None
|
|
146
153
|
|
|
147
154
|
|
|
148
155
|
@dataclass
|
|
@@ -242,6 +249,17 @@ class AgentDeps:
|
|
|
242
249
|
# which is exactly the pre-9.9.8 behaviour.
|
|
243
250
|
permission_mode: Any = None
|
|
244
251
|
|
|
252
|
+
# ── Self-Model port (optional, v11.2.0) ──────────────────────────
|
|
253
|
+
# What the Brain has learned about its owner, injected into the executor
|
|
254
|
+
# prompt. Either a fixed string or a resolver callable taking
|
|
255
|
+
# ``user_email``/``workspace_id`` scope kwargs (or none at all). 11.1.0
|
|
256
|
+
# built ``executor_prompt_for(self_model_summary=…)`` and then had nothing
|
|
257
|
+
# to pass it, because the runtime held its executor prompt as a fixed
|
|
258
|
+
# string; this is the port that was missing. ``None`` — and an empty
|
|
259
|
+
# summary — produce exactly the prompt bytes the loop produced before it
|
|
260
|
+
# existed.
|
|
261
|
+
self_model_summary: Any = None
|
|
262
|
+
|
|
245
263
|
|
|
246
264
|
class SingleAgentRuntime:
|
|
247
265
|
"""Drives the agent state machine over injected :class:`AgentDeps`."""
|
|
@@ -608,6 +626,41 @@ class SingleAgentRuntime:
|
|
|
608
626
|
|
|
609
627
|
ctx.state = AgentState.VERIFYING
|
|
610
628
|
|
|
629
|
+
def _self_model_summary(
|
|
630
|
+
self, ctx: AgentRunContext, current_user: str, request_workspace: Optional[str]
|
|
631
|
+
) -> str:
|
|
632
|
+
"""What this Brain knows about its owner, resolved once per run.
|
|
633
|
+
|
|
634
|
+
The port may be a plain string or a resolver taking scope kwargs — the
|
|
635
|
+
same two shapes ``permission_mode`` accepts, so there is one convention
|
|
636
|
+
for "a value or a way to get one". Anything that goes wrong yields an
|
|
637
|
+
empty summary: prompt assembly must not fail because a profile could
|
|
638
|
+
not be read, and an empty summary is byte-identical to having no port
|
|
639
|
+
at all.
|
|
640
|
+
"""
|
|
641
|
+
if ctx.self_model_summary is not None:
|
|
642
|
+
return ctx.self_model_summary
|
|
643
|
+
source = self.deps.self_model_summary
|
|
644
|
+
summary = ""
|
|
645
|
+
if source is not None:
|
|
646
|
+
try:
|
|
647
|
+
if callable(source):
|
|
648
|
+
try:
|
|
649
|
+
summary = source(
|
|
650
|
+
user_email=current_user or None,
|
|
651
|
+
workspace_id=request_workspace,
|
|
652
|
+
)
|
|
653
|
+
except TypeError:
|
|
654
|
+
# A resolver that takes no scope arguments is allowed.
|
|
655
|
+
summary = source()
|
|
656
|
+
else:
|
|
657
|
+
summary = source
|
|
658
|
+
except Exception: # noqa: BLE001 — a profile is never worth a failed run
|
|
659
|
+
logging.debug("agent: self-model summary unavailable", exc_info=True)
|
|
660
|
+
summary = ""
|
|
661
|
+
ctx.self_model_summary = str(summary or "").strip()
|
|
662
|
+
return ctx.self_model_summary
|
|
663
|
+
|
|
611
664
|
def _executor_context(
|
|
612
665
|
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
613
666
|
current_user: str, request_workspace: Optional[str],
|
|
@@ -650,7 +703,7 @@ class SingleAgentRuntime:
|
|
|
650
703
|
# v11.1.0: the executor prompt carries profile-aware file-writing
|
|
651
704
|
# hints, because "wrote nothing at all" was the weak-model failure
|
|
652
705
|
# mode the loop could not repair after the fact.
|
|
653
|
-
f"{executor_prompt_for(d.executor_prompt, profile=profile)}\n\n"
|
|
706
|
+
f"{executor_prompt_for(d.executor_prompt, profile=profile, self_model_summary=self._self_model_summary(ctx, current_user, request_workspace))}\n\n"
|
|
654
707
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
655
708
|
f"Workspace root: {d.agent_root}{self._project_block(ctx)}\n\n"
|
|
656
709
|
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
|