ltcai 11.0.1 → 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 +55 -43
- package/docs/CHANGELOG.md +61 -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/PERFORMANCE.md +71 -18
- 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/embeddings.py +12 -37
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/discovery_index.py +30 -32
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/image_vectors.py +230 -0
- package/lattice_brain/graph/ingest.py +11 -5
- package/lattice_brain/graph/projection.py +66 -8
- package/lattice_brain/graph/provenance.py +27 -2
- package/lattice_brain/graph/retrieval.py +113 -2
- package/lattice_brain/graph/retrieval_docgen.py +6 -6
- package/lattice_brain/graph/schema.py +18 -0
- package/lattice_brain/graph/store.py +9 -0
- package/lattice_brain/graph/vector_index/selector.py +32 -2
- package/lattice_brain/ingestion.py +363 -10
- package/lattice_brain/multimodal.py +1258 -0
- 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/self_model.py +77 -22
- 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/memory.py +128 -1
- 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 +59 -2
- package/latticeai/core/agent_prompts.py +66 -0
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +98 -11
- package/latticeai/core/embedding_providers.py +528 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +180 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os.py +43 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_reorganization.py +335 -0
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/runtime/build_phases.py +33 -2
- package/latticeai/runtime/chat_wiring.py +4 -0
- package/latticeai/runtime/feature_toggle_wiring.py +163 -0
- package/latticeai/runtime/persistence_runtime.py +41 -4
- 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 +1 -1
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence.py +123 -7
- package/latticeai/services/change_proposals.py +50 -10
- 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.py +34 -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 +112 -0
- 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/self_model_service.py +171 -0
- package/latticeai/services/tool_dispatch.py +4 -0
- package/latticeai/services/voice_capture.py +27 -1
- 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 +22 -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-D4zSxFR-.js → Act-AWf0SAKp.js} +1 -1
- package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D0u8Tiyj.js} +1 -1
- package/static/app/assets/{Brain-C2EqQg74.js → Brain-tuhI4sOC.js} +1 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +1 -0
- package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CqOSzyPr.js} +1 -1
- package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DC0Bzh-I.js} +1 -1
- package/static/app/assets/{Library-BN0HYOfc.js → Library-CX-bbhmK.js} +1 -1
- package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-DBwhto14.js} +1 -1
- package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-BHA2cfKI.js} +1 -1
- package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-BUhCKRNM.js} +1 -1
- package/static/app/assets/{System-BElUcSSw.js → System-Bu2t5hn1.js} +1 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
- package/static/app/assets/{bot--qYHMtkP.js → bot-Cia42c2h.js} +1 -1
- package/static/app/assets/brain-DJMoqrwx.js +1 -0
- package/static/app/assets/{button-51Z3rsuv.js → button-2j2Ijzgq.js} +1 -1
- package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-BEFeWpVW.js} +1 -1
- package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-ujXMcHxl.js} +1 -1
- package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-k4awryFq.js} +1 -1
- package/static/app/assets/{download-G-2olkWz.js → download-DFbLJ_ig.js} +1 -1
- package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-7y_b6xkM.js} +1 -1
- package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-Bidh02Kr.js} +1 -1
- package/static/app/assets/{index-D7Rr-J2Y.js → index-BpYkzcVm.js} +3 -3
- package/static/app/assets/index-DwDl9-8Y.css +2 -0
- package/static/app/assets/{input-D4w_BZWl.js → input-DSlJJxRs.js} +1 -1
- package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-Bpb83Hx9.js} +1 -1
- package/static/app/assets/{primitives-d0g9pvzS.js → primitives-BCx6TvfG.js} +1 -1
- package/static/app/assets/search-Cgy8cCFJ.js +1 -0
- package/static/app/assets/{share-2-NmD7e_oV.js → share-2-BH1M-WNi.js} +1 -1
- package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-BlKdBXcG.js} +1 -1
- package/static/app/assets/{textarea-BPAJDc-0.js → textarea-CCWbUfFB.js} +1 -1
- package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
- package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-CXQiwbVT.js} +1 -1
- package/static/app/assets/{utils-DG1_ExrP.js → utils-zqPZJxdx.js} +2 -2
- package/static/app/assets/{workspace-CWVf3gsI.js → workspace-DXTihhfU.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
- package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
- package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
- package/static/app/assets/brain-DDCLjRqO.js +0 -1
- package/static/app/assets/index-CkzokZAj.css +0 -2
- package/static/app/assets/search-BLCYt75v.js +0 -1
|
@@ -37,7 +37,7 @@ import hashlib
|
|
|
37
37
|
import json
|
|
38
38
|
import logging
|
|
39
39
|
import re
|
|
40
|
-
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
40
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
41
41
|
|
|
42
42
|
from .context import approx_tokens
|
|
43
43
|
from .synthesis import ProposalDesk
|
|
@@ -127,7 +127,7 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
|
|
|
127
127
|
(
|
|
128
128
|
"preference",
|
|
129
129
|
re.compile(
|
|
130
|
-
r"(?:나는|저는|내가|제가)\s*(?P<v>[^\n.。]+?)"
|
|
130
|
+
r"(?:나는|저는|내가|제가)\s*(?P<v>[^\n.。]+?)\s*(?:을|를|이|가)?"
|
|
131
131
|
r"\s*(?:좋아|선호|싫어)(?:합니다|한다|해요|해|하고)"
|
|
132
132
|
),
|
|
133
133
|
1,
|
|
@@ -145,11 +145,10 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
|
|
|
145
145
|
"en_first_person_preference",
|
|
146
146
|
),
|
|
147
147
|
(
|
|
148
|
+
# The frequency word stays inside the capture: "회고를 씁니다" is a
|
|
149
|
+
# sentence, "매일 회고를 씁니다" is a habit.
|
|
148
150
|
"habit",
|
|
149
|
-
re.compile(
|
|
150
|
-
r"(?:매일|매주|매달|아침마다|항상|늘)\s*(?P<v>[^\n.。]+?)"
|
|
151
|
-
r"\s*(?:합니다|한다|해요|해)(?:\b|[\s.。!?]|$)"
|
|
152
|
-
),
|
|
151
|
+
re.compile(r"(?P<v>(?:매일|매주|매달|아침마다|항상|늘)\s*[^\n.。!?]+)"),
|
|
153
152
|
1,
|
|
154
153
|
0.65,
|
|
155
154
|
"ko_routine",
|
|
@@ -157,7 +156,8 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
|
|
|
157
156
|
(
|
|
158
157
|
"habit",
|
|
159
158
|
re.compile(
|
|
160
|
-
r"\bI\s+(?:always|usually|every\s+(?:morning|day|week))
|
|
159
|
+
r"\bI\s+(?P<v>(?:always|usually|every\s+(?:morning|day|week))"
|
|
160
|
+
r"\s+[^\n.!?]+)",
|
|
161
161
|
re.IGNORECASE,
|
|
162
162
|
),
|
|
163
163
|
1,
|
|
@@ -205,7 +205,17 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
|
|
|
205
205
|
|
|
206
206
|
|
|
207
207
|
class SelfModelError(ValueError):
|
|
208
|
-
"""Raised when a Self-Model operation cannot be carried out as asked.
|
|
208
|
+
"""Raised when a Self-Model operation cannot be carried out as asked.
|
|
209
|
+
|
|
210
|
+
Carries a machine-readable ``code`` alongside the developer message: Brain
|
|
211
|
+
Core cannot reach the server's message catalog (isolation), so the API
|
|
212
|
+
layer translates the code instead of forwarding an English sentence to a
|
|
213
|
+
Korean user.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(self, message: str, *, code: str = "invalid") -> None:
|
|
217
|
+
self.code = code
|
|
218
|
+
super().__init__(message)
|
|
209
219
|
|
|
210
220
|
|
|
211
221
|
# ── extraction (deterministic) ───────────────────────────────────────────────
|
|
@@ -347,12 +357,18 @@ def apply_self_model_proposal(
|
|
|
347
357
|
"""
|
|
348
358
|
item = review_queue.get(item_id, workspace_id=workspace_id)
|
|
349
359
|
if str(item.get("kind")) != SELF_MODEL_KIND:
|
|
350
|
-
raise SelfModelError(
|
|
360
|
+
raise SelfModelError(
|
|
361
|
+
f"review item {item_id} is not a Self-Model proposal",
|
|
362
|
+
code="not_a_proposal",
|
|
363
|
+
)
|
|
351
364
|
fact = dict((item.get("payload") or {}).get("fact") or {})
|
|
352
365
|
kind = str(fact.get("kind") or "")
|
|
353
366
|
text = _normalize(str(fact.get("text") or ""))
|
|
354
367
|
if kind not in FACT_NODE_TYPES or not text:
|
|
355
|
-
raise SelfModelError(
|
|
368
|
+
raise SelfModelError(
|
|
369
|
+
f"review item {item_id} carries no Self-Model fact",
|
|
370
|
+
code="empty_proposal",
|
|
371
|
+
)
|
|
356
372
|
approved = review_queue.approve(item_id, workspace_id=workspace_id)
|
|
357
373
|
node = _write_fact(
|
|
358
374
|
store,
|
|
@@ -386,9 +402,11 @@ def upsert_self_model_fact(
|
|
|
386
402
|
kind = str(kind or "").strip().lower()
|
|
387
403
|
value = _normalize(text)
|
|
388
404
|
if kind not in FACT_NODE_TYPES:
|
|
389
|
-
raise SelfModelError(
|
|
405
|
+
raise SelfModelError(
|
|
406
|
+
f"kind must be one of {sorted(FACT_NODE_TYPES)}", code="invalid_kind"
|
|
407
|
+
)
|
|
390
408
|
if not value:
|
|
391
|
-
raise SelfModelError("a Self-Model fact needs text")
|
|
409
|
+
raise SelfModelError("a Self-Model fact needs text", code="text_required")
|
|
392
410
|
return _write_fact(
|
|
393
411
|
store,
|
|
394
412
|
kind=kind,
|
|
@@ -405,14 +423,14 @@ def delete_self_model_fact(store: Any, node_id: str) -> Dict[str, Any]:
|
|
|
405
423
|
"""Remove one fact (or the whole root) from the subgraph, permanently."""
|
|
406
424
|
identifier = str(node_id or "").strip()
|
|
407
425
|
if not identifier.startswith(SELF_ID_PREFIX):
|
|
408
|
-
raise SelfModelError(f"not a Self-Model node: {identifier}")
|
|
426
|
+
raise SelfModelError(f"not a Self-Model node: {identifier}", code="not_self_model")
|
|
409
427
|
with store._connect() as conn:
|
|
410
428
|
deleted = conn.execute(
|
|
411
429
|
"DELETE FROM nodes WHERE id=?", (identifier,)
|
|
412
430
|
).rowcount
|
|
413
431
|
conn.execute("DELETE FROM nodes_v2 WHERE id=?", (identifier,))
|
|
414
432
|
if not deleted:
|
|
415
|
-
raise SelfModelError(f"Self-Model node not found: {identifier}")
|
|
433
|
+
raise SelfModelError(f"Self-Model node not found: {identifier}", code="not_found")
|
|
416
434
|
return {"status": "ok", "id": identifier, "deleted_at": now_iso()}
|
|
417
435
|
|
|
418
436
|
|
|
@@ -420,13 +438,18 @@ def delete_self_model_fact(store: Any, node_id: str) -> Dict[str, Any]:
|
|
|
420
438
|
|
|
421
439
|
|
|
422
440
|
def list_self_model(
|
|
423
|
-
store: Any,
|
|
441
|
+
store: Any,
|
|
442
|
+
*,
|
|
443
|
+
workspace_id: Optional[str] = None,
|
|
444
|
+
allowed_workspaces: Optional[Iterable[str]] = None,
|
|
424
445
|
) -> Dict[str, Any]:
|
|
425
446
|
"""Every fact the Brain holds about its owner, grouped for display."""
|
|
426
|
-
facts = _read_facts(
|
|
447
|
+
facts = _read_facts(
|
|
448
|
+
store, workspace_id=workspace_id, allowed_workspaces=allowed_workspaces
|
|
449
|
+
)
|
|
427
450
|
counts: Dict[str, int] = {kind: 0 for kind in KIND_ORDER}
|
|
428
451
|
for fact in facts:
|
|
429
|
-
counts[fact["kind"]]
|
|
452
|
+
counts[fact["kind"]] += 1
|
|
430
453
|
return {
|
|
431
454
|
"available": True,
|
|
432
455
|
"facts": facts,
|
|
@@ -442,6 +465,7 @@ def self_model_summary(
|
|
|
442
465
|
*,
|
|
443
466
|
limit_tokens: int = DEFAULT_SUMMARY_TOKENS,
|
|
444
467
|
workspace_id: Optional[str] = None,
|
|
468
|
+
allowed_workspaces: Optional[Iterable[str]] = None,
|
|
445
469
|
) -> str:
|
|
446
470
|
"""Plain-text Self-Model summary for injection into a model's context.
|
|
447
471
|
|
|
@@ -450,7 +474,9 @@ def self_model_summary(
|
|
|
450
474
|
An empty subgraph returns ``""`` — the caller injects nothing rather than a
|
|
451
475
|
header with nothing under it.
|
|
452
476
|
"""
|
|
453
|
-
facts = _read_facts(
|
|
477
|
+
facts = _read_facts(
|
|
478
|
+
store, workspace_id=workspace_id, allowed_workspaces=allowed_workspaces
|
|
479
|
+
)
|
|
454
480
|
if not facts or limit_tokens <= 0:
|
|
455
481
|
return ""
|
|
456
482
|
header = "사용자에 대해 확인된 사실:"
|
|
@@ -523,8 +549,28 @@ def _write_fact(
|
|
|
523
549
|
}
|
|
524
550
|
|
|
525
551
|
|
|
552
|
+
def _allowed_set(
|
|
553
|
+
workspace_id: Optional[str], allowed_workspaces: Optional[Iterable[str]]
|
|
554
|
+
) -> Optional[set]:
|
|
555
|
+
"""Normalize the two scoping spellings into one allow-set (``None`` = all).
|
|
556
|
+
|
|
557
|
+
``allowed_workspaces`` is the scoping vocabulary the graph reads already
|
|
558
|
+
use (``filter_scoped_nodes``); ``workspace_id`` is the single-scope sugar
|
|
559
|
+
the API surfaces speak. A fact with no workspace is personal-global and
|
|
560
|
+
stays visible in both.
|
|
561
|
+
"""
|
|
562
|
+
if allowed_workspaces is not None:
|
|
563
|
+
return {str(item) for item in allowed_workspaces if item}
|
|
564
|
+
if workspace_id is None:
|
|
565
|
+
return None
|
|
566
|
+
return {str(workspace_id)}
|
|
567
|
+
|
|
568
|
+
|
|
526
569
|
def _read_facts(
|
|
527
|
-
store: Any,
|
|
570
|
+
store: Any,
|
|
571
|
+
*,
|
|
572
|
+
workspace_id: Optional[str] = None,
|
|
573
|
+
allowed_workspaces: Optional[Iterable[str]] = None,
|
|
528
574
|
) -> List[Dict[str, Any]]:
|
|
529
575
|
"""Fact rows for a workspace, ordered deterministically (kind, then text).
|
|
530
576
|
|
|
@@ -533,6 +579,10 @@ def _read_facts(
|
|
|
533
579
|
both read modes. A store that cannot be read yields no facts rather than
|
|
534
580
|
raising into a prompt-assembly path.
|
|
535
581
|
"""
|
|
582
|
+
if not hasattr(store, "_connect"):
|
|
583
|
+
# Not a Brain store (a doc-generation seam may be handed a stand-in).
|
|
584
|
+
# There is no profile to read, and that is not an error worth logging.
|
|
585
|
+
return []
|
|
536
586
|
try:
|
|
537
587
|
with store._connect() as conn:
|
|
538
588
|
rows = conn.execute(
|
|
@@ -544,11 +594,12 @@ def _read_facts(
|
|
|
544
594
|
logger.exception("self-model read failed")
|
|
545
595
|
return []
|
|
546
596
|
facts = [fact for fact in (_row_to_fact(row) for row in rows) if fact]
|
|
547
|
-
|
|
597
|
+
allowed = _allowed_set(workspace_id, allowed_workspaces)
|
|
598
|
+
if allowed is not None:
|
|
548
599
|
facts = [
|
|
549
600
|
fact
|
|
550
601
|
for fact in facts
|
|
551
|
-
if fact["workspace_id"]
|
|
602
|
+
if not fact["workspace_id"] or str(fact["workspace_id"]) in allowed
|
|
552
603
|
]
|
|
553
604
|
return sorted(facts, key=lambda fact: (KIND_ORDER.index(fact["kind"]), fact["text"]))
|
|
554
605
|
|
|
@@ -584,6 +635,7 @@ def summary_for_prompt(
|
|
|
584
635
|
*,
|
|
585
636
|
limit_tokens: int = DEFAULT_SUMMARY_TOKENS,
|
|
586
637
|
workspace_id: Optional[str] = None,
|
|
638
|
+
allowed_workspaces: Optional[Iterable[str]] = None,
|
|
587
639
|
) -> str:
|
|
588
640
|
""":func:`self_model_summary` that never raises — the injection seam.
|
|
589
641
|
|
|
@@ -592,7 +644,10 @@ def summary_for_prompt(
|
|
|
592
644
|
"""
|
|
593
645
|
try:
|
|
594
646
|
return self_model_summary(
|
|
595
|
-
store,
|
|
647
|
+
store,
|
|
648
|
+
limit_tokens=limit_tokens,
|
|
649
|
+
workspace_id=workspace_id,
|
|
650
|
+
allowed_workspaces=allowed_workspaces,
|
|
596
651
|
)
|
|
597
652
|
except Exception: # noqa: BLE001 — see docstring
|
|
598
653
|
logger.exception("self-model summary failed")
|
|
@@ -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,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",
|