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
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
이 문서의 모든 파일 경로와 줄 번호는 실제로 열어서 확인한 것이다.
|
|
6
6
|
추측한 경로는 한 줄도 없다.
|
|
7
7
|
|
|
8
|
+
> **증거 기준선 안내 (v11.2.0 기준).** 이 설계서가 인용하는
|
|
9
|
+
> `output/release/v10.6.3/screenshots/` 는 **더 이상 리포에 없다** — 릴리스 증거는
|
|
10
|
+
> v11.0.0 부터만 보관한다(`output/release/v11.0.0` · `v11.0.1` · `v11.1.0`).
|
|
11
|
+
> 따라서 아래의 "이전 캡처와 해시 대조" 절차는 그대로는 재현할 수 없다.
|
|
12
|
+
> 문서는 당시 측정값을 기록한 사료로 남기고, 지금 다시 채점한다면 기준선은
|
|
13
|
+
> **가장 최근 릴리스의 `output/release/<버전>/screenshots/`** 로 바꿔서 읽는다.
|
|
14
|
+
|
|
8
15
|
---
|
|
9
16
|
|
|
10
17
|
## 0. 이 재구성이 존재하는 이유
|
|
@@ -706,7 +713,8 @@ npm run frontend:openapi:check # OpenAPI drift 게이트
|
|
|
706
713
|
|
|
707
714
|
### 7.1 릴리스 캡처 12개 화면 전부 변경 — **24점** (화면당 2점)
|
|
708
715
|
|
|
709
|
-
**확인 명령:**
|
|
716
|
+
**확인 명령:** (기준선 경로는 문서 상단 안내대로 최신 릴리스 디렉터리로 바꿔 읽는다 —
|
|
717
|
+
`output/release/v10.6.3/` 는 리포에서 사라졌다.)
|
|
710
718
|
```
|
|
711
719
|
npm run build:assets
|
|
712
720
|
LTCAI_RELEASE_EVIDENCE_DIR=/tmp/lattice-after npm run release:evidence
|
package/docs/ONBOARDING.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Lattice AI Onboarding
|
|
2
2
|
|
|
3
|
-
Current release: **11.
|
|
3
|
+
Current release: **11.2.0 — All Systems On**.
|
|
4
4
|
|
|
5
5
|
The first-run goal is a five-minute path from "I opened the app" to "my Brain
|
|
6
6
|
has a source, a question, and proof." This page is the product contract behind
|
package/docs/OPERATIONS.md
CHANGED
package/docs/TRUST_MODEL.md
CHANGED
package/docs/WHY_LATTICE.md
CHANGED
package/docs/architecture.md
CHANGED
|
@@ -130,8 +130,12 @@ hardware scan
|
|
|
130
130
|
-> download/install/load/verify
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
The current default recommendation family is Gemma 4. Qwen3
|
|
134
|
-
|
|
133
|
+
The current default recommendation family is Gemma 4. Qwen3.6 and Qwen3.5 are
|
|
134
|
+
the current multimodal alternatives; GPT-OSS 20B and LFM2.5 2.6B fill the
|
|
135
|
+
general-purpose and ultralight tiers as text-only models. Superseded families
|
|
136
|
+
(Qwen3-VL, Llama 4, Qwen2.5-VL, Llama 3.2 Vision) stay in the capability
|
|
137
|
+
registry as *recognised* entries so already-downloaded weights keep working,
|
|
138
|
+
but they never appear in the catalog or a download path. See MODEL_POLICY.md.
|
|
135
139
|
|
|
136
140
|
## Modes
|
|
137
141
|
|
package/docs/kg-schema.md
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Opt-in gates that can still be answered at runtime (v11.2.0).
|
|
2
|
+
|
|
3
|
+
Every opt-in feature in this product used to decide once, in a constructor:
|
|
4
|
+
``self._on = os.getenv("LATTICEAI_…") in {"1", …}``. That is correct for a
|
|
5
|
+
process that reads its environment at boot and never changes its mind, and it
|
|
6
|
+
is a dead end for the settings screen that is coming — a UI toggle cannot move
|
|
7
|
+
a boolean that was already copied into ``self``.
|
|
8
|
+
|
|
9
|
+
:class:`FeatureGate` is the seam that keeps both true at once. It answers at
|
|
10
|
+
*call* time in a fixed order:
|
|
11
|
+
|
|
12
|
+
1. a **bound resolver** — a callable the app layer supplies (the future
|
|
13
|
+
settings surface, a per-workspace policy, a test double);
|
|
14
|
+
2. an explicit **override** set through :meth:`FeatureGate.set`;
|
|
15
|
+
3. the **environment variable**, parsed exactly the way the hand-written
|
|
16
|
+
``os.getenv`` checks it replaces did;
|
|
17
|
+
4. the declared **default**.
|
|
18
|
+
|
|
19
|
+
An untouched gate therefore behaves identically to the frozen read it replaced
|
|
20
|
+
— same env var, same truthy words, same default — while a bound resolver wins
|
|
21
|
+
without a single change at any construction site. Brain Core owns this because
|
|
22
|
+
Brain Core owns the gates that matter most (multi-modal routing, sharing), and
|
|
23
|
+
it may not import ``latticeai``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from typing import Callable, Dict, Optional
|
|
30
|
+
|
|
31
|
+
#: The words this product has always accepted for "on" / "off".
|
|
32
|
+
TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
33
|
+
FALSY = frozenset({"0", "false", "no", "off"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FeatureGate:
|
|
37
|
+
"""One opt-in switch, resolved when it is asked rather than when it is built."""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("env_var", "default", "name", "detail", "_override", "_resolver")
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
env_var: str,
|
|
44
|
+
*,
|
|
45
|
+
default: bool = False,
|
|
46
|
+
name: str = "",
|
|
47
|
+
detail: str = "",
|
|
48
|
+
) -> None:
|
|
49
|
+
self.env_var = env_var
|
|
50
|
+
self.default = bool(default)
|
|
51
|
+
self.name = name or env_var
|
|
52
|
+
#: Plain sentence for a surface that has to explain why a feature is off.
|
|
53
|
+
self.detail = detail
|
|
54
|
+
self._override: Optional[bool] = None
|
|
55
|
+
self._resolver: Optional[Callable[[], bool]] = None
|
|
56
|
+
|
|
57
|
+
# ── resolution ───────────────────────────────────────────────────────────
|
|
58
|
+
def __call__(self) -> bool:
|
|
59
|
+
return self.enabled()
|
|
60
|
+
|
|
61
|
+
def enabled(self) -> bool:
|
|
62
|
+
"""The gate's answer *now* (resolver → override → env → default)."""
|
|
63
|
+
if self._resolver is not None:
|
|
64
|
+
return bool(self._resolver())
|
|
65
|
+
return self.local()
|
|
66
|
+
|
|
67
|
+
def local(self) -> bool:
|
|
68
|
+
"""This gate's own answer, ignoring any bound resolver.
|
|
69
|
+
|
|
70
|
+
The lower three layers on their own (override → env → default). A
|
|
71
|
+
resolver that only has an opinion *sometimes* — a settings service that
|
|
72
|
+
speaks for the features a person actually touched, and stays quiet about
|
|
73
|
+
the rest — hands the question back here, so an operator's environment
|
|
74
|
+
variable keeps working for everything nobody has decided.
|
|
75
|
+
"""
|
|
76
|
+
if self._override is not None:
|
|
77
|
+
return self._override
|
|
78
|
+
return self.from_env()
|
|
79
|
+
|
|
80
|
+
def from_env(self) -> bool:
|
|
81
|
+
"""The environment's answer alone, ignoring resolver and override."""
|
|
82
|
+
raw = os.getenv(self.env_var, "").strip().lower()
|
|
83
|
+
if raw in TRUTHY:
|
|
84
|
+
return True
|
|
85
|
+
if raw in FALSY:
|
|
86
|
+
return False
|
|
87
|
+
return self.default
|
|
88
|
+
|
|
89
|
+
def source(self) -> str:
|
|
90
|
+
"""Which of the four layers produced the current answer."""
|
|
91
|
+
if self._resolver is not None:
|
|
92
|
+
return "resolver"
|
|
93
|
+
if self._override is not None:
|
|
94
|
+
return "override"
|
|
95
|
+
if os.getenv(self.env_var, "").strip().lower() in (TRUTHY | FALSY):
|
|
96
|
+
return "env"
|
|
97
|
+
return "default"
|
|
98
|
+
|
|
99
|
+
# ── injection ────────────────────────────────────────────────────────────
|
|
100
|
+
def set(self, value: Optional[bool]) -> None:
|
|
101
|
+
"""Explicit runtime override. ``None`` hands the answer back to the env."""
|
|
102
|
+
self._override = None if value is None else bool(value)
|
|
103
|
+
|
|
104
|
+
def bind(self, resolver: Optional[Callable[[], bool]]) -> None:
|
|
105
|
+
"""Delegate the answer to a caller-supplied callable (``None`` unbinds)."""
|
|
106
|
+
self._resolver = resolver
|
|
107
|
+
|
|
108
|
+
def reset(self) -> None:
|
|
109
|
+
"""Forget both injections — the gate is env-driven again."""
|
|
110
|
+
self._override = None
|
|
111
|
+
self._resolver = None
|
|
112
|
+
|
|
113
|
+
def describe(self) -> Dict[str, object]:
|
|
114
|
+
"""Honest read for a status surface: state *and* where it came from."""
|
|
115
|
+
return {
|
|
116
|
+
"name": self.name,
|
|
117
|
+
"flag": self.env_var,
|
|
118
|
+
"enabled": self.enabled(),
|
|
119
|
+
"default": self.default,
|
|
120
|
+
"source": self.source(),
|
|
121
|
+
"detail": self.detail,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["FALSY", "TRUTHY", "FeatureGate"]
|
|
@@ -30,6 +30,7 @@ import os
|
|
|
30
30
|
import re
|
|
31
31
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
32
32
|
|
|
33
|
+
from ..gates import FeatureGate
|
|
33
34
|
from ..quiet import quiet
|
|
34
35
|
|
|
35
36
|
QUERY_CLASSES = ("fact", "code", "person", "recency")
|
|
@@ -50,8 +51,20 @@ FUSION_WEIGHTS_ENV = "LATTICEAI_FUSION_WEIGHTS"
|
|
|
50
51
|
# get shipped. Turn it on with LATTICEAI_FUSION_STRATEGY=rrf (all classes) or
|
|
51
52
|
# a JSON object like {"code": "rrf"} (per class).
|
|
52
53
|
FUSION_STRATEGY_ENV = "LATTICEAI_FUSION_STRATEGY"
|
|
54
|
+
#: The one-switch form of the per-class table above, for the settings panel:
|
|
55
|
+
#: "combine by rank instead of score", everywhere. It is applied as the *base*
|
|
56
|
+
#: of the table, so the per-class ``LATTICEAI_FUSION_STRATEGY`` config — the
|
|
57
|
+
#: more specific statement — still wins over it, and an install that touched
|
|
58
|
+
#: neither is byte-identical to what it was.
|
|
59
|
+
FUSION_RRF_ENV = "LATTICEAI_FUSION_RRF"
|
|
53
60
|
FUSION_STRATEGIES = ("alpha", "rrf")
|
|
54
61
|
DEFAULT_FUSION_STRATEGY: Dict[str, str] = dict.fromkeys(QUERY_CLASSES, "alpha")
|
|
62
|
+
FUSION_RRF_GATE = FeatureGate(
|
|
63
|
+
FUSION_RRF_ENV,
|
|
64
|
+
default=False,
|
|
65
|
+
name="fusion_rrf",
|
|
66
|
+
detail="Search channels are combined by rank rather than by score.",
|
|
67
|
+
)
|
|
55
68
|
#: The smoothing constant from the original RRF paper (Cormack et al., 2009).
|
|
56
69
|
#: Larger k flattens the curve, so rank 1 wins by less.
|
|
57
70
|
DEFAULT_RRF_K = 60
|
|
@@ -195,8 +208,16 @@ def _env_strategy_overrides() -> Dict[str, str]:
|
|
|
195
208
|
def fusion_strategy_table(
|
|
196
209
|
overrides: Optional[Mapping[str, str]] = None,
|
|
197
210
|
) -> Dict[str, str]:
|
|
198
|
-
"""Full per-class
|
|
211
|
+
"""Full per-class table: defaults ← simple switch ← env override ← caller.
|
|
212
|
+
|
|
213
|
+
The simple switch (:data:`FUSION_RRF_GATE`, the settings panel's "combine by
|
|
214
|
+
rank") sits *under* the per-class env config on purpose: a person who wrote
|
|
215
|
+
``{"code": "alpha"}`` said something specific, and a single global flag must
|
|
216
|
+
not overrule it.
|
|
217
|
+
"""
|
|
199
218
|
table = dict(DEFAULT_FUSION_STRATEGY)
|
|
219
|
+
if FUSION_RRF_GATE.enabled():
|
|
220
|
+
table = dict.fromkeys(QUERY_CLASSES, "rrf")
|
|
200
221
|
for source in (_env_strategy_overrides(), overrides or {}):
|
|
201
222
|
for cls, value in source.items():
|
|
202
223
|
if cls in table and str(value).lower() in FUSION_STRATEGIES:
|
|
@@ -243,6 +264,14 @@ def rrf_fuse(
|
|
|
243
264
|
# failure mode is dilution: an unbounded expansion turns a precise answer into
|
|
244
265
|
# a tour of the graph.
|
|
245
266
|
GRAPH_EXPANSION_ENV = "LATTICEAI_GRAPH_EXPANSION"
|
|
267
|
+
#: Resolved when asked so the settings panel can move it without a restart; the
|
|
268
|
+
#: env parsing is the same set of words the hand-written check used.
|
|
269
|
+
GRAPH_EXPANSION_GATE = FeatureGate(
|
|
270
|
+
GRAPH_EXPANSION_ENV,
|
|
271
|
+
default=False,
|
|
272
|
+
name="graph_expansion",
|
|
273
|
+
detail="Memories one link away from a hit are offered as extra candidates.",
|
|
274
|
+
)
|
|
246
275
|
DEFAULT_EXPANSION_SEEDS = 3
|
|
247
276
|
DEFAULT_EXPANSION_CAP = 5
|
|
248
277
|
#: Expanded candidates inherit a damped share of their seed's score: they are
|
|
@@ -251,9 +280,8 @@ EXPANSION_DECAY = 0.5
|
|
|
251
280
|
|
|
252
281
|
|
|
253
282
|
def graph_expansion_enabled() -> bool:
|
|
254
|
-
"""True when
|
|
255
|
-
|
|
256
|
-
return raw in {"1", "true", "yes", "on"}
|
|
283
|
+
"""True when the expansion gate opts in (default: off)."""
|
|
284
|
+
return GRAPH_EXPANSION_GATE.enabled()
|
|
257
285
|
|
|
258
286
|
|
|
259
287
|
def expand_with_neighbors(
|
|
@@ -348,10 +376,13 @@ __all__ = [
|
|
|
348
376
|
"DEFAULT_FUSION_WEIGHTS",
|
|
349
377
|
"DEFAULT_RRF_K",
|
|
350
378
|
"EXPANSION_DECAY",
|
|
379
|
+
"FUSION_RRF_ENV",
|
|
380
|
+
"FUSION_RRF_GATE",
|
|
351
381
|
"FUSION_STRATEGIES",
|
|
352
382
|
"FUSION_STRATEGY_ENV",
|
|
353
383
|
"FUSION_WEIGHTS_ENV",
|
|
354
384
|
"GRAPH_EXPANSION_ENV",
|
|
385
|
+
"GRAPH_EXPANSION_GATE",
|
|
355
386
|
"QUERY_CLASSES",
|
|
356
387
|
"classify_query",
|
|
357
388
|
"expand_with_neighbors",
|
|
@@ -52,16 +52,29 @@ class KnowledgeGraphProjectionMixin(_Core):
|
|
|
52
52
|
END;
|
|
53
53
|
"""
|
|
54
54
|
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
55
|
+
# ``type`` is reconstructed from ``legacy_type`` when there is one, because
|
|
56
|
+
# that column carries the label a reader expects. The trap the 11.0.1
|
|
57
|
+
# review recorded — and which 11.2.0 fixes — is that ``edges_v2.legacy_type``
|
|
58
|
+
# is ``NOT NULL DEFAULT ''``: a natively-canonical edge is written with
|
|
59
|
+
# ``legacy_type=''`` so its identity is effectively (source, target, type),
|
|
60
|
+
# and ``COALESCE('', type)`` returns ``''`` because COALESCE only skips
|
|
61
|
+
# NULL. Every canonical edge therefore read back with an **empty type**.
|
|
62
|
+
# ``NULLIF(legacy_type, '')`` is the fix: empty means "no legacy label",
|
|
63
|
+
# which is precisely what the write side meant by it.
|
|
64
|
+
#
|
|
65
|
+
# The empty string stays the write-side sentinel on purpose. SQLite treats
|
|
66
|
+
# NULLs as distinct in a UNIQUE index, so moving to NULL would silently
|
|
67
|
+
# disable the (source, target, type, legacy_type) dedupe and let the same
|
|
68
|
+
# relation land twice.
|
|
69
|
+
#
|
|
70
|
+
# The temporal columns pass through *raw* (v11.1.0): validity is not a
|
|
71
|
+
# label, and a COALESCE there would turn "still valid" (NULL) into a value.
|
|
72
|
+
# NULL in, NULL out; the fallback to ``created_at`` belongs to the read
|
|
73
|
+
# predicate (``schema.TEMPORAL_PREDICATE_SQL``), not to the view.
|
|
61
74
|
_V2_VIEWS_SQL = """
|
|
62
75
|
CREATE VIEW IF NOT EXISTS kgv2_nodes AS
|
|
63
76
|
SELECT id,
|
|
64
|
-
COALESCE(legacy_type, type) AS type,
|
|
77
|
+
COALESCE(NULLIF(legacy_type, ''), type) AS type,
|
|
65
78
|
label AS title,
|
|
66
79
|
summary,
|
|
67
80
|
attrs AS metadata_json,
|
|
@@ -70,7 +83,7 @@ class KnowledgeGraphProjectionMixin(_Core):
|
|
|
70
83
|
FROM nodes_v2;
|
|
71
84
|
CREATE VIEW IF NOT EXISTS kgv2_edges AS
|
|
72
85
|
SELECT id, source AS from_node, target AS to_node,
|
|
73
|
-
COALESCE(legacy_type, type) AS type,
|
|
86
|
+
COALESCE(NULLIF(legacy_type, ''), type) AS type,
|
|
74
87
|
weight,
|
|
75
88
|
metadata AS metadata_json,
|
|
76
89
|
created_at,
|
|
@@ -156,6 +169,7 @@ class KnowledgeGraphProjectionMixin(_Core):
|
|
|
156
169
|
KGStoreV2(self.db_path).init_schema(conn=conn)
|
|
157
170
|
_exec_script(conn, self._V2_VIEWS_SQL)
|
|
158
171
|
self._backfill_v2_on(conn, force=stale)
|
|
172
|
+
self._normalize_v2_legacy_types(conn)
|
|
159
173
|
# version stamp commits together with the backfill — never stranded
|
|
160
174
|
conn.execute(
|
|
161
175
|
"INSERT OR REPLACE INTO kg_meta(key, value) VALUES ('projection_version', ?)",
|
|
@@ -177,6 +191,50 @@ class KnowledgeGraphProjectionMixin(_Core):
|
|
|
177
191
|
except Exception as e:
|
|
178
192
|
logging.warning("knowledge_graph: v2 schema init/backfill skipped: %s", e)
|
|
179
193
|
|
|
194
|
+
def _normalize_v2_legacy_types(self, conn: sqlite3.Connection) -> Dict[str, int]:
|
|
195
|
+
"""Bring pre-11.2.0 rows onto the current ``legacy_type`` convention.
|
|
196
|
+
|
|
197
|
+
The convention is: ``legacy_type`` holds the *raw* label only when it
|
|
198
|
+
differs from the canonical type, and ``''`` (edges) / ``NULL`` (nodes)
|
|
199
|
+
otherwise. Rows written by older builds could carry the canonical value
|
|
200
|
+
in both columns, which is redundant and — because the dedupe key
|
|
201
|
+
includes ``legacy_type`` — splits one relation across two rows.
|
|
202
|
+
|
|
203
|
+
Idempotent by construction: a second run matches nothing. ``UPDATE OR
|
|
204
|
+
IGNORE`` is used because collapsing a redundant row can collide with
|
|
205
|
+
the canonical one that already exists; those survivors are counted and
|
|
206
|
+
reported rather than deleted, since the fixed view reads both
|
|
207
|
+
identically and deleting an edge is not a migration's business.
|
|
208
|
+
"""
|
|
209
|
+
report = {"edges": 0, "nodes": 0, "collisions": 0}
|
|
210
|
+
try:
|
|
211
|
+
before = int(
|
|
212
|
+
conn.execute(
|
|
213
|
+
"SELECT COUNT(*) FROM edges_v2 WHERE legacy_type = type"
|
|
214
|
+
).fetchone()[0]
|
|
215
|
+
or 0
|
|
216
|
+
)
|
|
217
|
+
conn.execute(
|
|
218
|
+
"UPDATE OR IGNORE edges_v2 SET legacy_type='' WHERE legacy_type = type"
|
|
219
|
+
)
|
|
220
|
+
after = int(
|
|
221
|
+
conn.execute(
|
|
222
|
+
"SELECT COUNT(*) FROM edges_v2 WHERE legacy_type = type"
|
|
223
|
+
).fetchone()[0]
|
|
224
|
+
or 0
|
|
225
|
+
)
|
|
226
|
+
report["edges"] = before - after
|
|
227
|
+
report["collisions"] = after
|
|
228
|
+
cursor = conn.execute(
|
|
229
|
+
"UPDATE nodes_v2 SET legacy_type=NULL WHERE legacy_type = ''"
|
|
230
|
+
)
|
|
231
|
+
report["nodes"] = int(cursor.rowcount or 0)
|
|
232
|
+
except sqlite3.Error as exc:
|
|
233
|
+
# The projection is derived; a migration that cannot run leaves the
|
|
234
|
+
# data exactly as it was and the fixed view still reads it right.
|
|
235
|
+
logging.debug("knowledge_graph: legacy_type normalization skipped: %s", exc)
|
|
236
|
+
return report
|
|
237
|
+
|
|
180
238
|
def _backup_before_v2_flip(self) -> Optional[str]:
|
|
181
239
|
"""Create one local SQLite backup before the v2 write-master flip."""
|
|
182
240
|
if not self.db_path.exists() or self.db_path.stat().st_size == 0:
|
|
@@ -134,6 +134,11 @@ class NodeType(str, Enum):
|
|
|
134
134
|
# exists whether or not anyone could hear it. IMAGE has been first-class
|
|
135
135
|
# since 3.6.0 for the same reason, and AUDIO now sits beside it.
|
|
136
136
|
AUDIO = "AUDIO" # 녹음 / 음성 메모
|
|
137
|
+
# v11.2.0 — a video is its own noun for the same reason a recording is:
|
|
138
|
+
# keyframes and subtitles are *derived* from it (IMAGE children, text
|
|
139
|
+
# chunks), and calling the thing they came from a DOCUMENT would make the
|
|
140
|
+
# source of a memory indistinguishable from one of its pieces.
|
|
141
|
+
VIDEO = "VIDEO" # 영상 / 화면 녹화
|
|
137
142
|
|
|
138
143
|
@classmethod
|
|
139
144
|
def from_legacy(cls, label: str) -> "NodeType":
|
|
@@ -285,6 +290,10 @@ _LEGACY_NODE_MAP: Dict[str, NodeType] = {
|
|
|
285
290
|
# v11.1.0 Multi-modal — recordings as a first-class noun.
|
|
286
291
|
"audio": NodeType.AUDIO,
|
|
287
292
|
"오디오": NodeType.AUDIO,
|
|
293
|
+
# v11.2.0 — videos likewise.
|
|
294
|
+
"video": NodeType.VIDEO,
|
|
295
|
+
"영상": NodeType.VIDEO,
|
|
296
|
+
"동영상": NodeType.VIDEO,
|
|
288
297
|
}
|
|
289
298
|
|
|
290
299
|
_LEGACY_EDGE_MAP: Dict[str, EdgeType] = {
|
|
@@ -241,6 +241,15 @@ class KnowledgeGraphStore(
|
|
|
241
241
|
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_type ON vector_embeddings(item_type);
|
|
242
242
|
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_source ON vector_embeddings(source_node);
|
|
243
243
|
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_model ON vector_embeddings(embedding_model);
|
|
244
|
+
-- v11.2.0: the ANN sidecar's freshness fingerprint is
|
|
245
|
+
-- COUNT(*) + MAX(indexed_at) filtered by (embedding_model,
|
|
246
|
+
-- embedding_dim), and it runs on *every* approximate
|
|
247
|
+
-- search. Against the single-column model index that was a
|
|
248
|
+
-- table scan of every row for the current model; this
|
|
249
|
+
-- covering index answers both aggregates from the index
|
|
250
|
+
-- alone. Additive and idempotent — no data is rewritten.
|
|
251
|
+
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_model_dim_indexed
|
|
252
|
+
ON vector_embeddings(embedding_model, embedding_dim, indexed_at);
|
|
244
253
|
CREATE INDEX IF NOT EXISTS idx_vector_index_operations_requested ON vector_index_operations(requested_at);
|
|
245
254
|
CREATE INDEX IF NOT EXISTS idx_provenance_node ON ingestion_provenance(node_id);
|
|
246
255
|
CREATE INDEX IF NOT EXISTS idx_provenance_source_type ON ingestion_provenance(source_type);
|
|
@@ -11,13 +11,21 @@ exactly like "search is a bit worse today":
|
|
|
11
11
|
|
|
12
12
|
Both resolve to the exact brute-force scan and carry a ``detail`` string
|
|
13
13
|
naming the cause, which ``index_status()`` and the search result surface.
|
|
14
|
+
|
|
15
|
+
The backend is a *choice*, not a switch, so it cannot ride a
|
|
16
|
+
:class:`~lattice_brain.gates.FeatureGate` (which answers booleans). It gets the
|
|
17
|
+
same shape of seam instead: :func:`bind_vector_index_resolver` installs a
|
|
18
|
+
caller-supplied resolver that is consulted ahead of the environment, which is
|
|
19
|
+
what lets the settings panel change the backend without a restart. With nothing
|
|
20
|
+
bound — the default — the environment variable is still the whole control
|
|
21
|
+
surface, exactly as before.
|
|
14
22
|
"""
|
|
15
23
|
|
|
16
24
|
from __future__ import annotations
|
|
17
25
|
|
|
18
26
|
import os
|
|
19
27
|
from dataclasses import dataclass
|
|
20
|
-
from typing import Any, Dict, Optional
|
|
28
|
+
from typing import Any, Callable, Dict, Optional
|
|
21
29
|
|
|
22
30
|
from .base import Similarity, VectorIndex
|
|
23
31
|
from .brute_force import BRUTE_FORCE_BACKEND, BruteForceIndex
|
|
@@ -37,6 +45,23 @@ _BACKEND_LABELS = {
|
|
|
37
45
|
_APPROX = {"brute": False, "quantized": True, "hnsw": True}
|
|
38
46
|
_EXHAUSTIVE = {"brute": True, "quantized": True, "hnsw": False}
|
|
39
47
|
|
|
48
|
+
#: App-layer resolver, consulted before the environment. ``None`` (the default)
|
|
49
|
+
#: leaves this module reading exactly the env var it always read.
|
|
50
|
+
_RESOLVER: Optional[Callable[[], Optional[str]]] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def bind_vector_index_resolver(
|
|
54
|
+
resolver: Optional[Callable[[], Optional[str]]],
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Delegate backend selection to a callable (``None`` hands it back to env).
|
|
57
|
+
|
|
58
|
+
A resolver that returns ``None`` also falls through to the environment, so
|
|
59
|
+
"the settings service has no opinion" and "there is no settings service"
|
|
60
|
+
reach the same answer instead of two.
|
|
61
|
+
"""
|
|
62
|
+
global _RESOLVER
|
|
63
|
+
_RESOLVER = resolver
|
|
64
|
+
|
|
40
65
|
|
|
41
66
|
@dataclass(frozen=True)
|
|
42
67
|
class BackendSelection:
|
|
@@ -79,7 +104,11 @@ def _selection(name: str, *, requested: str, detail: Optional[str]) -> BackendSe
|
|
|
79
104
|
|
|
80
105
|
def resolve_vector_index(requested: Optional[str] = None) -> BackendSelection:
|
|
81
106
|
"""Resolve the configured backend (never raises, always falls back safe)."""
|
|
82
|
-
raw = requested
|
|
107
|
+
raw = requested
|
|
108
|
+
if raw is None and _RESOLVER is not None:
|
|
109
|
+
raw = _RESOLVER()
|
|
110
|
+
if raw is None:
|
|
111
|
+
raw = os.getenv(VECTOR_INDEX_ENV, "")
|
|
83
112
|
name = str(raw or "").strip().lower() or DEFAULT_VECTOR_INDEX
|
|
84
113
|
if name not in VECTOR_INDEX_CHOICES:
|
|
85
114
|
return _selection(
|
|
@@ -126,6 +155,7 @@ __all__ = [
|
|
|
126
155
|
"VECTOR_INDEX_CHOICES",
|
|
127
156
|
"VECTOR_INDEX_ENV",
|
|
128
157
|
"BackendSelection",
|
|
158
|
+
"bind_vector_index_resolver",
|
|
129
159
|
"build_index",
|
|
130
160
|
"resolve_vector_index",
|
|
131
161
|
]
|