ltcai 10.10.0 → 11.1.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 +57 -42
- package/docs/CHANGELOG.md +68 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/PERFORMANCE.md +135 -11
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/docs/v11.1.0_PRODUCT_INTELLIGENCE_PLAN.md +313 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/embeddings.py +12 -37
- package/lattice_brain/graph/_kg_contract.py +11 -0
- package/lattice_brain/graph/curator.py +1 -1
- package/lattice_brain/graph/discovery_index.py +30 -32
- package/lattice_brain/graph/fusion.py +184 -3
- package/lattice_brain/graph/image_vectors.py +230 -0
- package/lattice_brain/graph/ingest.py +11 -5
- package/lattice_brain/graph/proactive.py +138 -1
- package/lattice_brain/graph/projection.py +10 -2
- package/lattice_brain/graph/provenance.py +27 -2
- package/lattice_brain/graph/retrieval.py +249 -8
- package/lattice_brain/graph/retrieval_docgen.py +23 -23
- package/lattice_brain/graph/retrieval_policy.py +6 -0
- package/lattice_brain/graph/retrieval_reads.py +188 -1
- package/lattice_brain/graph/retrieval_vector.py +474 -110
- package/lattice_brain/graph/schema.py +134 -2
- package/lattice_brain/graph/vector_index/__init__.py +85 -0
- package/lattice_brain/graph/vector_index/base.py +170 -0
- package/lattice_brain/graph/vector_index/brute_force.py +114 -0
- package/lattice_brain/graph/vector_index/hnsw.py +293 -0
- package/lattice_brain/graph/vector_index/jobs.py +287 -0
- package/lattice_brain/graph/vector_index/quantized.py +151 -0
- package/lattice_brain/graph/vector_index/selector.py +131 -0
- package/lattice_brain/ingestion.py +259 -7
- package/lattice_brain/multimodal.py +738 -0
- package/lattice_brain/portability.py +654 -2
- package/lattice_brain/runtime/agent_runtime.py +1 -1
- package/lattice_brain/runtime/contracts.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/self_model.py +675 -0
- package/lattice_brain/synthesis.py +801 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +83 -1
- package/latticeai/api/chat_stream.py +4 -1
- package/latticeai/api/local_files.py +62 -0
- package/latticeai/api/memory.py +128 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/portability.py +130 -1
- package/latticeai/api/security_dashboard.py +48 -13
- package/latticeai/api/voice_capture.py +4 -1
- package/latticeai/api/workspace.py +11 -5
- package/latticeai/core/agent.py +5 -1
- package/latticeai/core/agent_prompts.py +66 -0
- package/latticeai/core/context_builder.py +92 -8
- package/latticeai/core/embedding_providers.py +548 -1
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +51 -0
- package/latticeai/core/model_compat.py +2 -2
- package/latticeai/core/tool_registry.py +0 -7
- package/latticeai/core/workspace_os.py +43 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_os_utils.py +4 -50
- package/latticeai/core/workspace_reorganization.py +335 -0
- package/latticeai/core/workspace_review_items.py +12 -1
- package/latticeai/integrations/telegram_bot.py +28 -10
- package/latticeai/models/router.py +1 -1
- package/latticeai/runtime/access_runtime.py +1 -1
- package/latticeai/runtime/build_phases.py +5 -2
- package/latticeai/runtime/network_boundary_wiring.py +9 -5
- package/latticeai/runtime/permission_mode_wiring.py +9 -6
- package/latticeai/runtime/persistence_runtime.py +41 -4
- package/latticeai/runtime/router_registration.py +3 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +253 -0
- package/latticeai/services/change_proposals.py +50 -10
- package/latticeai/services/memory_service.py +35 -1
- package/latticeai/services/model_catalog.py +4 -3
- package/latticeai/services/model_engines.py +28 -14
- package/latticeai/services/multimodal_ports.py +87 -0
- package/latticeai/services/obsidian_bridge.py +618 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/self_model_service.py +171 -0
- package/latticeai/services/voice_capture.py +27 -1
- package/latticeai/tools/filesystem.py +4 -1
- package/package.json +1 -1
- package/scripts/bench_vector_index.py +295 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/release_screen_claims.json +26 -0
- 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-CC8SpNRm.js → Act-D0HWqtn0.js} +1 -1
- package/static/app/assets/{AdminConsole-DGIY_Tb_.js → AdminConsole-D-QDW-A4.js} +1 -1
- package/static/app/assets/{Brain-BDSC2Mol.js → Brain-CzCsI1mi.js} +1 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +2 -0
- package/static/app/assets/BrainSignals-2dHQNkns.js +1 -0
- package/static/app/assets/{Capture-ClcKLsmI.js → Capture-CT8v1StE.js} +1 -1
- package/static/app/assets/{CommandPalette-DpwbyF1S.js → CommandPalette-DoLXC2KH.js} +1 -1
- package/static/app/assets/{Library-D8VNtffM.js → Library-DDoxFE5c.js} +1 -1
- package/static/app/assets/{LivingBrain-CeWwc0NN.js → LivingBrain-BXMWIK_2.js} +1 -1
- package/static/app/assets/{ProductFlow-CS5CCM4t.js → ProductFlow-DOYf7JIs.js} +1 -1
- package/static/app/assets/{ReviewCard-Bap4yPfI.js → ReviewCard-COQsqidK.js} +1 -1
- package/static/app/assets/{System-odPxU1YL.js → System-BRllvYXd.js} +1 -1
- package/static/app/assets/arrow-left-DnyMzss-.js +1 -0
- package/static/app/assets/{bot-CmvhqTeF.js → bot-4BvN07ux.js} +1 -1
- package/static/app/assets/brain-uMb_5hnO.js +1 -0
- package/static/app/assets/{button-DyppYhFH.js → button-CDjtnAoU.js} +1 -1
- package/static/app/assets/{circle-pause-Bm5z7EBI.js → circle-pause-D_RMn7tp.js} +1 -1
- package/static/app/assets/{circle-play-GjKdH0Fg.js → circle-play-B5OpB8ae.js} +1 -1
- package/static/app/assets/{cpu-CuVMv-F5.js → cpu-BIlWInHf.js} +1 -1
- package/static/app/assets/{download-C9_946tf.js → download-BtjXfL3z.js} +1 -1
- package/static/app/assets/{folder-open-B3QKdxib.js → folder-open-DefMpxI2.js} +1 -1
- package/static/app/assets/{hard-drive-BwW9Hx4r.js → hard-drive-BQ8NZVkw.js} +1 -1
- package/static/app/assets/{index-BrUQ-bF8.js → index-0AvoEBzJ.js} +3 -3
- package/static/app/assets/index-vtEfYvQY.css +2 -0
- package/static/app/assets/{input-Db2Hw2VH.js → input-B_5ZJ9oy.js} +1 -1
- package/static/app/assets/{permissionCopy-BpbIHRio.js → permissionCopy-BqZ5tsgL.js} +1 -1
- package/static/app/assets/{primitives-4B_Ynf7r.js → primitives-CVwew78r.js} +1 -1
- package/static/app/assets/search-DkhnOKZt.js +1 -0
- package/static/app/assets/{share-2-BgvRTDPJ.js → share-2-D5zg_0fY.js} +1 -1
- package/static/app/assets/{shield-alert-DWFLyykG.js → shield-alert-B5pZzkUb.js} +1 -1
- package/static/app/assets/{textarea-Bk4vEqzT.js → textarea-nEVIweKY.js} +1 -1
- package/static/app/assets/{useFocusTrap-CGRXEg_p.js → useFocusTrap-Cm99AHlz.js} +1 -1
- package/static/app/assets/{useQuery-CdPzp3_U.js → useQuery-Dm__N6bL.js} +1 -1
- package/static/app/assets/{utils-orU8igel.js → utils-DcDMoZIe.js} +1 -1
- package/static/app/assets/{workspace-Brj2Ylb2.js → workspace-LtRRSKTf.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-BYgYp3ON.js +0 -2
- package/static/app/assets/BrainSignals-DYwVFmcO.js +0 -1
- package/static/app/assets/arrow-left-J47Yde1U.js +0 -1
- package/static/app/assets/brain-DLT9uuGq.js +0 -1
- package/static/app/assets/index-CYb18LtP.css +0 -2
- package/static/app/assets/search-YH-7-x5Y.js +0 -1
|
@@ -52,6 +52,12 @@ class KnowledgeGraphProjectionMixin(_Core):
|
|
|
52
52
|
END;
|
|
53
53
|
"""
|
|
54
54
|
|
|
55
|
+
# The temporal columns pass through *raw* (v11.1.0). ``type`` is COALESCEd
|
|
56
|
+
# because ``legacy_type`` carries the label a reader expects; validity is
|
|
57
|
+
# not a label — a COALESCE there would turn "still valid" (NULL) into a
|
|
58
|
+
# value, which is exactly the ``kgv2_edges`` trap noted in the 11.0.1
|
|
59
|
+
# review. NULL in, NULL out; the fallback to ``created_at`` belongs to the
|
|
60
|
+
# read predicate (``schema.TEMPORAL_PREDICATE_SQL``), not to the view.
|
|
55
61
|
_V2_VIEWS_SQL = """
|
|
56
62
|
CREATE VIEW IF NOT EXISTS kgv2_nodes AS
|
|
57
63
|
SELECT id,
|
|
@@ -59,14 +65,16 @@ class KnowledgeGraphProjectionMixin(_Core):
|
|
|
59
65
|
label AS title,
|
|
60
66
|
summary,
|
|
61
67
|
attrs AS metadata_json,
|
|
62
|
-
created_at, updated_at
|
|
68
|
+
created_at, updated_at,
|
|
69
|
+
valid_from, valid_to, superseded_by
|
|
63
70
|
FROM nodes_v2;
|
|
64
71
|
CREATE VIEW IF NOT EXISTS kgv2_edges AS
|
|
65
72
|
SELECT id, source AS from_node, target AS to_node,
|
|
66
73
|
COALESCE(legacy_type, type) AS type,
|
|
67
74
|
weight,
|
|
68
75
|
metadata AS metadata_json,
|
|
69
|
-
created_at
|
|
76
|
+
created_at,
|
|
77
|
+
valid_from, valid_to, superseded_by
|
|
70
78
|
FROM edges_v2;
|
|
71
79
|
"""
|
|
72
80
|
|
|
@@ -37,9 +37,34 @@ class KnowledgeGraphProvenanceMixin(_Core):
|
|
|
37
37
|
permissions: Optional[Dict[str, Any]] = None,
|
|
38
38
|
metadata: Optional[Dict[str, Any]] = None,
|
|
39
39
|
) -> Dict[str, Any]:
|
|
40
|
-
"""
|
|
40
|
+
"""Record where an ingested node came from (upsert by origin).
|
|
41
|
+
|
|
42
|
+
Row identity is ``(node, content, source_type, source_uri, pipeline)``
|
|
43
|
+
— deliberately *not* the wall clock. Through 11.0.x the basis included
|
|
44
|
+
a second-resolution timestamp, which made the record's identity depend
|
|
45
|
+
on when it happened: re-ingesting unchanged content twice inside the
|
|
46
|
+
same second collapsed onto one row, and one second later appended a
|
|
47
|
+
duplicate. That is not an audit trail, it is a race — the same class of
|
|
48
|
+
defect as the 11.0.0 review-item ids — and it grew this table (and the
|
|
49
|
+
"recent ingestions" list built from it) without bound on every re-scan
|
|
50
|
+
of an unchanged folder or vault.
|
|
51
|
+
|
|
52
|
+
With the clock out of the basis, re-ingesting the same content from the
|
|
53
|
+
same origin *updates* one record (``created_at`` moves to the latest
|
|
54
|
+
sighting, so "recently seen" stays true), while genuinely new content or
|
|
55
|
+
a genuinely different origin — another source URI, another pipeline —
|
|
56
|
+
still appends its own record. The timestamp is data on the row, never
|
|
57
|
+
part of its identity. Every individual ingest event remains visible in
|
|
58
|
+
the audit log (``kg_ingest``), which is where per-event history belongs.
|
|
59
|
+
"""
|
|
41
60
|
now = _now()
|
|
42
|
-
prov_basis =
|
|
61
|
+
prov_basis = "|".join([
|
|
62
|
+
node_id,
|
|
63
|
+
content_hash or "",
|
|
64
|
+
source_type,
|
|
65
|
+
source_uri or "",
|
|
66
|
+
pipeline,
|
|
67
|
+
])
|
|
43
68
|
prov_id = f"prov:{_sha256_text(prov_basis)[:24]}"
|
|
44
69
|
with self._connect() as conn:
|
|
45
70
|
conn.execute(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING
|
|
3
|
+
from typing import TYPE_CHECKING, Sequence
|
|
4
4
|
|
|
5
5
|
# ruff: noqa: F403,F405
|
|
6
6
|
from ._kg_common import * # noqa: F403,F401
|
|
@@ -20,14 +20,47 @@ else:
|
|
|
20
20
|
# traverse / stats) moved byte-identically to .retrieval_reads as
|
|
21
21
|
# KnowledgeGraphReadsMixin. Re-exported here so any legacy
|
|
22
22
|
# ``from lattice_brain.graph.retrieval import ...`` site keeps resolving.
|
|
23
|
+
from .fusion import (
|
|
24
|
+
DEFAULT_EXPANSION_CAP,
|
|
25
|
+
DEFAULT_EXPANSION_SEEDS,
|
|
26
|
+
expand_with_neighbors,
|
|
27
|
+
graph_expansion_enabled,
|
|
28
|
+
rrf_fuse,
|
|
29
|
+
)
|
|
23
30
|
from .retrieval_reads import KnowledgeGraphReadsMixin # noqa: F401
|
|
24
31
|
|
|
32
|
+
#: Node types that are a *thing you can look at or listen to*, not prose. A
|
|
33
|
+
#: match of one of these means the answer rests on more than text.
|
|
34
|
+
MULTIMODAL_NODE_TYPES = ("Image", "ImageText")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def multimodal_signal(matches: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
38
|
+
"""``{"images": n, "types": [...]}`` when a result set includes pictures.
|
|
39
|
+
|
|
40
|
+
``None`` when it does not: the context-quality contract stays four keys
|
|
41
|
+
wide for the ordinary all-text case, and a caller that sees the key knows
|
|
42
|
+
it means something rather than having to compare a zero.
|
|
43
|
+
"""
|
|
44
|
+
images = 0
|
|
45
|
+
seen: List[str] = []
|
|
46
|
+
for match in matches:
|
|
47
|
+
node_type = str(match.get("type") or "")
|
|
48
|
+
if node_type in MULTIMODAL_NODE_TYPES:
|
|
49
|
+
images += 1
|
|
50
|
+
if node_type not in seen:
|
|
51
|
+
seen.append(node_type)
|
|
52
|
+
if not images:
|
|
53
|
+
return None
|
|
54
|
+
return {"images": images, "types": seen}
|
|
55
|
+
|
|
25
56
|
|
|
26
57
|
def context_quality_signal(
|
|
27
58
|
mode: str,
|
|
28
59
|
nodes: int,
|
|
29
60
|
*,
|
|
30
61
|
reason: Optional[str] = None,
|
|
62
|
+
vector: Optional[Dict[str, Any]] = None,
|
|
63
|
+
multimodal: Optional[Dict[str, Any]] = None,
|
|
31
64
|
) -> Dict[str, Any]:
|
|
32
65
|
"""Honest RAG context-quality signal (v9.8.0, additive contract).
|
|
33
66
|
|
|
@@ -37,6 +70,20 @@ def context_quality_signal(
|
|
|
37
70
|
``"none"``; ``limited`` is true whenever the context is thin (0–1 nodes)
|
|
38
71
|
or the vector side fell back to lexical-only retrieval. ``reason`` is a
|
|
39
72
|
short human-readable Korean phrase, only present when limited.
|
|
73
|
+
|
|
74
|
+
``vector`` (v11.1.0) carries the vector channel's own honesty block —
|
|
75
|
+
which backend scored, whether it was approximate, whether the candidate
|
|
76
|
+
scan was truncated. "hybrid, 6 nodes" describes two different answers
|
|
77
|
+
depending on those bits, and the caller that has to say "I did not find
|
|
78
|
+
it" deserves to know which one it got. The key is present **only when
|
|
79
|
+
there is a caveat to report**: an exact, complete vector scan is the
|
|
80
|
+
contract's baseline assumption, so annotating it would be noise, and the
|
|
81
|
+
four-key shape stays exactly what existing consumers pin.
|
|
82
|
+
|
|
83
|
+
``multimodal`` (v11.1.0) follows the same present-only-when-true rule and
|
|
84
|
+
says that part of this context is a picture. "6 nodes" reads differently
|
|
85
|
+
when two of them are screenshots whose text came out of OCR, and the
|
|
86
|
+
surface that has to explain the answer deserves to know.
|
|
40
87
|
"""
|
|
41
88
|
nodes = max(0, int(nodes or 0))
|
|
42
89
|
mode = str(mode or "none")
|
|
@@ -54,7 +101,17 @@ def context_quality_signal(
|
|
|
54
101
|
reason = "그래프 기반 컨텍스트가 제한적입니다"
|
|
55
102
|
if not limited:
|
|
56
103
|
reason = None
|
|
57
|
-
|
|
104
|
+
signal: Dict[str, Any] = {
|
|
105
|
+
"mode": mode,
|
|
106
|
+
"nodes": nodes,
|
|
107
|
+
"limited": limited,
|
|
108
|
+
"reason": reason,
|
|
109
|
+
}
|
|
110
|
+
if vector is not None:
|
|
111
|
+
signal["vector"] = dict(vector)
|
|
112
|
+
if multimodal is not None:
|
|
113
|
+
signal["multimodal"] = dict(multimodal)
|
|
114
|
+
return signal
|
|
58
115
|
|
|
59
116
|
|
|
60
117
|
class KnowledgeGraphRetrievalMixin(_Core):
|
|
@@ -70,6 +127,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
70
127
|
"SlideDeck", # 프레젠테이션
|
|
71
128
|
"Image", # 이미지
|
|
72
129
|
"ImageText", # OCR 텍스트
|
|
130
|
+
"Audio", # 녹음 / 음성 메모 (11.1.0)
|
|
73
131
|
"Concept", # 개념 / 아이디어 / 기술 용어
|
|
74
132
|
"Person", # 사람
|
|
75
133
|
"Error", # 오류 / 버그
|
|
@@ -162,7 +220,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
162
220
|
from_node = node_by_id.get(edge["from"])
|
|
163
221
|
to_node = node_by_id.get(edge["to"])
|
|
164
222
|
if not from_node or not to_node:
|
|
165
|
-
continue
|
|
223
|
+
continue # pragma: no cover — unreachable: the edge query selects endpoints from the same node window
|
|
166
224
|
for topic_node, other_node in ((from_node, to_node), (to_node, from_node)):
|
|
167
225
|
if topic_node["type"] != "Topic":
|
|
168
226
|
continue
|
|
@@ -319,6 +377,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
319
377
|
"SlideDeck",
|
|
320
378
|
"Image",
|
|
321
379
|
"ImageText",
|
|
380
|
+
"Audio",
|
|
322
381
|
"Page",
|
|
323
382
|
"Slide",
|
|
324
383
|
}
|
|
@@ -362,6 +421,8 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
362
421
|
lexical_limit: Optional[int] = None,
|
|
363
422
|
vector_limit: Optional[int] = None,
|
|
364
423
|
min_vector_score: float = 0.0,
|
|
424
|
+
image_vector: Optional[Sequence[float]] = None,
|
|
425
|
+
image_fusion_weight: Optional[float] = None,
|
|
365
426
|
) -> Dict[str, Any]:
|
|
366
427
|
"""Unified lexical + vector retrieval with alpha-weighted linear fusion.
|
|
367
428
|
|
|
@@ -393,6 +454,15 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
393
454
|
fused score into the ``[0.5, 1.0]`` band (``scores.age_decay``).
|
|
394
455
|
Passing an explicit ``alpha`` pins it exactly as before and disables
|
|
395
456
|
rewrite + decay.
|
|
457
|
+
|
|
458
|
+
``image_vector`` (v11.1.0) is the *late fusion* seam for the separate
|
|
459
|
+
image space: the caller supplies a query vector from the same vision
|
|
460
|
+
model that embedded the pictures, its own index is ranked
|
|
461
|
+
independently, and only then are the two rankings blended
|
|
462
|
+
(``image_fusion_weight``, default 0.5). A text query never produces
|
|
463
|
+
one — it reaches images through their OCR text and captions — which is
|
|
464
|
+
exactly why the image channel has to enter at the end rather than
|
|
465
|
+
pretending to share the text index.
|
|
396
466
|
"""
|
|
397
467
|
query = str(query or "").strip()
|
|
398
468
|
try:
|
|
@@ -404,6 +474,10 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
404
474
|
search_query = query
|
|
405
475
|
rewrite_rules: List[str] = []
|
|
406
476
|
recency_half_life_days: Optional[float] = None
|
|
477
|
+
# "alpha" is the historical linear fusion; the policy may select RRF
|
|
478
|
+
# per query class. An explicitly pinned ``alpha`` argument means the
|
|
479
|
+
# caller is asking for linear fusion by name, so it stays linear.
|
|
480
|
+
fusion_strategy = "alpha"
|
|
407
481
|
if alpha is None:
|
|
408
482
|
try:
|
|
409
483
|
from .retrieval_policy import resolve_policy
|
|
@@ -411,6 +485,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
411
485
|
policy = resolve_policy(query)
|
|
412
486
|
query_class = policy["query_class"]
|
|
413
487
|
alpha = float(policy["alpha"])
|
|
488
|
+
fusion_strategy = str(policy.get("fusion_strategy") or "alpha")
|
|
414
489
|
rewrite_rules = list(policy.get("rewrite_rules") or [])
|
|
415
490
|
rewritten = str(policy.get("search_query") or "")
|
|
416
491
|
if rewritten and rewritten != query:
|
|
@@ -438,6 +513,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
438
513
|
"sources": {"lexical": 0, "vector": 0},
|
|
439
514
|
"matches": [],
|
|
440
515
|
"policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
|
|
516
|
+
"fusion_strategy": fusion_strategy,
|
|
441
517
|
"detail": None,
|
|
442
518
|
}
|
|
443
519
|
|
|
@@ -455,6 +531,16 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
455
531
|
detail: Optional[str] = None
|
|
456
532
|
vector_matches: List[Dict[str, Any]] = []
|
|
457
533
|
vector_recall: Optional[Dict[str, Any]] = None
|
|
534
|
+
# The vector channel's own honesty block, echoed additively so a
|
|
535
|
+
# caller can tell an exact "not found" from an approximate one.
|
|
536
|
+
vector_meta: Dict[str, Any] = {
|
|
537
|
+
"backend": None,
|
|
538
|
+
"approx": None,
|
|
539
|
+
"exhaustive": None,
|
|
540
|
+
"truncated": None,
|
|
541
|
+
"embedded_rows": None,
|
|
542
|
+
"degraded": None,
|
|
543
|
+
}
|
|
458
544
|
vector_fn = getattr(self, "vector_search", None)
|
|
459
545
|
if not callable(vector_fn):
|
|
460
546
|
mode = "lexical_only"
|
|
@@ -471,8 +557,16 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
471
557
|
# retrieval_vector.vector_search), and a fused answer built on
|
|
472
558
|
# a truncated scan is not the same claim as a complete one.
|
|
473
559
|
recall = vector_payload.get("recall")
|
|
474
|
-
if isinstance(recall, dict)
|
|
475
|
-
|
|
560
|
+
if isinstance(recall, dict):
|
|
561
|
+
vector_meta["backend"] = recall.get("backend")
|
|
562
|
+
vector_meta["truncated"] = bool(recall.get("truncated"))
|
|
563
|
+
vector_meta["embedded_rows"] = recall.get("candidates_total")
|
|
564
|
+
if recall.get("truncated"):
|
|
565
|
+
vector_recall = dict(recall)
|
|
566
|
+
index_block = vector_payload.get("index")
|
|
567
|
+
if isinstance(index_block, dict):
|
|
568
|
+
vector_meta["approx"] = bool(index_block.get("approx"))
|
|
569
|
+
vector_meta["exhaustive"] = bool(index_block.get("exhaustive"))
|
|
476
570
|
except Exception as exc: # noqa: BLE001 — degrade, never fail the search
|
|
477
571
|
mode = "lexical_only"
|
|
478
572
|
detail = f"vector index unavailable: {exc}"
|
|
@@ -525,6 +619,11 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
525
619
|
entries[node_id] = entry
|
|
526
620
|
return entry
|
|
527
621
|
|
|
622
|
+
# Per-channel id order (best first) — the only input RRF needs, and
|
|
623
|
+
# the one thing a normalized score cannot reconstruct.
|
|
624
|
+
lexical_order: List[str] = []
|
|
625
|
+
vector_order: List[str] = []
|
|
626
|
+
|
|
528
627
|
for rank, match in enumerate(lexical_matches, start=1):
|
|
529
628
|
node_id = _parent_node_id(match)
|
|
530
629
|
if not node_id:
|
|
@@ -534,6 +633,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
534
633
|
entry["scores"]["lexical"], round(1.0 / rank, 6)
|
|
535
634
|
)
|
|
536
635
|
entry["_lexical"] = True
|
|
636
|
+
lexical_order.append(node_id)
|
|
537
637
|
|
|
538
638
|
# Max-normalize cosine scores into [0, 1] (guard the score-0 falsy trap
|
|
539
639
|
# by comparing explicitly, never with truthiness).
|
|
@@ -551,27 +651,86 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
551
651
|
entry = _entry_for(node_id, match)
|
|
552
652
|
entry["scores"]["vector"] = max(entry["scores"]["vector"], round(vec_norm, 6))
|
|
553
653
|
entry["_vector"] = True
|
|
654
|
+
vector_order.append(node_id)
|
|
554
655
|
# Prefer a real snippet when the lexical row had no summary.
|
|
555
656
|
if not entry.get("summary") and match.get("summary"):
|
|
556
657
|
entry["summary"] = match.get("summary")
|
|
557
658
|
|
|
659
|
+
# Graph traversal candidate expansion (opt-in, capped, counted): pull
|
|
660
|
+
# the one-hop neighbours of the strongest hits into the candidate pool
|
|
661
|
+
# so an answer that is adjacent to the match — not in it — is
|
|
662
|
+
# reachable at all. Off by default; see fusion.GRAPH_EXPANSION_ENV.
|
|
663
|
+
expansion_report: Dict[str, Any] = {
|
|
664
|
+
"enabled": False,
|
|
665
|
+
"seeds": 0,
|
|
666
|
+
"added": 0,
|
|
667
|
+
"cap": DEFAULT_EXPANSION_CAP,
|
|
668
|
+
"truncated": False,
|
|
669
|
+
"failed_seeds": 0,
|
|
670
|
+
}
|
|
671
|
+
if entries and graph_expansion_enabled():
|
|
672
|
+
seeds = sorted(
|
|
673
|
+
(
|
|
674
|
+
(node_id, float(entry["scores"]["vector"]))
|
|
675
|
+
for node_id, entry in entries.items()
|
|
676
|
+
),
|
|
677
|
+
key=lambda pair: -pair[1],
|
|
678
|
+
)[:DEFAULT_EXPANSION_SEEDS]
|
|
679
|
+
expanded, expansion_report = expand_with_neighbors(
|
|
680
|
+
seeds,
|
|
681
|
+
self.neighbors,
|
|
682
|
+
exclude=list(entries),
|
|
683
|
+
cap=DEFAULT_EXPANSION_CAP,
|
|
684
|
+
)
|
|
685
|
+
for candidate in expanded:
|
|
686
|
+
node = candidate["node"]
|
|
687
|
+
entry = _entry_for(str(node.get("id")), dict(node))
|
|
688
|
+
entry["scores"]["graph"] = candidate["score"]
|
|
689
|
+
entry["metadata"] = {
|
|
690
|
+
**(entry.get("metadata") or {}),
|
|
691
|
+
"expanded_from": candidate["seed"],
|
|
692
|
+
}
|
|
693
|
+
entry["_graph"] = True
|
|
694
|
+
|
|
695
|
+
rrf_normalized: Dict[str, float] = {}
|
|
696
|
+
if fusion_strategy == "rrf":
|
|
697
|
+
raw_rrf = rrf_fuse(
|
|
698
|
+
{
|
|
699
|
+
"lexical": list(dict.fromkeys(lexical_order)),
|
|
700
|
+
"vector": list(dict.fromkeys(vector_order)),
|
|
701
|
+
}
|
|
702
|
+
)
|
|
703
|
+
peak = max(raw_rrf.values(), default=0.0)
|
|
704
|
+
if peak > 0:
|
|
705
|
+
# Rescale to [0, 1] so the score column keeps the same meaning
|
|
706
|
+
# across strategies; RRF's raw values live around 1/60.
|
|
707
|
+
rrf_normalized = {key: value / peak for key, value in raw_rrf.items()}
|
|
708
|
+
|
|
558
709
|
matches: List[Dict[str, Any]] = []
|
|
559
710
|
for entry in entries.values():
|
|
560
711
|
lex_score = float(entry["scores"]["lexical"])
|
|
561
712
|
vec_score = float(entry["scores"]["vector"])
|
|
562
713
|
if mode == "lexical_only":
|
|
563
714
|
fused = lex_score
|
|
715
|
+
elif fusion_strategy == "rrf":
|
|
716
|
+
fused = float(rrf_normalized.get(entry["node_id"], 0.0))
|
|
717
|
+
entry["scores"]["rrf"] = round(fused, 6)
|
|
564
718
|
else:
|
|
565
719
|
fused = alpha * vec_score + (1.0 - alpha) * lex_score
|
|
566
|
-
entry["score"] = round(fused, 6)
|
|
567
720
|
from_lexical = bool(entry.pop("_lexical", False))
|
|
568
721
|
from_vector = bool(entry.pop("_vector", False))
|
|
569
|
-
if
|
|
722
|
+
if entry.pop("_graph", False):
|
|
723
|
+
# A one-hop neighbour of a hit: related to the answer, never
|
|
724
|
+
# itself a match, so it carries only its damped seed score.
|
|
725
|
+
fused = float(entry["scores"]["graph"])
|
|
726
|
+
entry["fusion"] = "graph"
|
|
727
|
+
elif from_lexical and from_vector:
|
|
570
728
|
entry["fusion"] = "both"
|
|
571
729
|
elif from_vector:
|
|
572
730
|
entry["fusion"] = "vector"
|
|
573
731
|
else:
|
|
574
732
|
entry["fusion"] = "lexical"
|
|
733
|
+
entry["score"] = round(fused, 6)
|
|
575
734
|
matches.append(entry)
|
|
576
735
|
|
|
577
736
|
# Recency-class age decay (retrieval_policy): dampen each fused score
|
|
@@ -591,6 +750,16 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
591
750
|
match["scores"]["age_decay"] = round(multiplier, 6)
|
|
592
751
|
match["score"] = round(float(match["score"]) * multiplier, 6)
|
|
593
752
|
|
|
753
|
+
# Late fusion of the image space (v11.1.0). Runs after the text
|
|
754
|
+
# channels have produced a ranking and before the cut, so image
|
|
755
|
+
# evidence can lift a picture into the answer without ever having been
|
|
756
|
+
# compared against a text vector.
|
|
757
|
+
image_fusion: Optional[Dict[str, Any]] = None
|
|
758
|
+
if image_vector is not None:
|
|
759
|
+
image_fusion = self._fuse_image_channel(
|
|
760
|
+
matches, image_vector, top_k=top_k, weight=image_fusion_weight
|
|
761
|
+
)
|
|
762
|
+
|
|
594
763
|
matches.sort(key=lambda item: (-item["score"], item["node_id"]))
|
|
595
764
|
# Optional cross-encoder rerank (v9.9.5). Off by default; when the
|
|
596
765
|
# env kill-switch is set and the model loads, pair scores reorder the
|
|
@@ -622,6 +791,8 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
622
791
|
"sources": {"lexical": len(lexical_matches), "vector": len(vector_matches)},
|
|
623
792
|
"matches": matches,
|
|
624
793
|
"policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
|
|
794
|
+
"fusion_strategy": fusion_strategy,
|
|
795
|
+
"graph_expansion": expansion_report,
|
|
625
796
|
"rerank": rerank_meta,
|
|
626
797
|
"detail": detail,
|
|
627
798
|
}
|
|
@@ -631,8 +802,59 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
631
802
|
result["vector_recall"] = vector_recall
|
|
632
803
|
if vector_degraded is None:
|
|
633
804
|
result["vector_degraded"] = "partial_recall"
|
|
805
|
+
vector_meta["degraded"] = result.get("vector_degraded")
|
|
806
|
+
result["vector"] = vector_meta
|
|
807
|
+
multimodal = multimodal_signal(matches)
|
|
808
|
+
if multimodal is not None or image_fusion is not None:
|
|
809
|
+
result["multimodal"] = {
|
|
810
|
+
**(multimodal or {"images": 0, "types": []}),
|
|
811
|
+
**({"image_fusion": image_fusion} if image_fusion is not None else {}),
|
|
812
|
+
}
|
|
634
813
|
return result
|
|
635
814
|
|
|
815
|
+
def _fuse_image_channel(
|
|
816
|
+
self,
|
|
817
|
+
matches: List[Dict[str, Any]],
|
|
818
|
+
image_vector: Sequence[float],
|
|
819
|
+
*,
|
|
820
|
+
top_k: int,
|
|
821
|
+
weight: Optional[float],
|
|
822
|
+
) -> Dict[str, Any]:
|
|
823
|
+
"""Rank the image index separately, then blend it into ``matches``.
|
|
824
|
+
|
|
825
|
+
Any failure degrades to "the image channel contributed nothing" with
|
|
826
|
+
the reason attached — an image index that cannot be read is not a
|
|
827
|
+
reason to lose the text answer.
|
|
828
|
+
"""
|
|
829
|
+
from .image_vectors import (
|
|
830
|
+
DEFAULT_IMAGE_FUSION_WEIGHT,
|
|
831
|
+
fuse_image_scores,
|
|
832
|
+
image_similarity_search,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
share = DEFAULT_IMAGE_FUSION_WEIGHT if weight is None else float(weight)
|
|
836
|
+
report: Dict[str, Any] = {
|
|
837
|
+
"weight": round(max(0.0, min(1.0, share)), 4),
|
|
838
|
+
"candidates": 0,
|
|
839
|
+
"fused": 0,
|
|
840
|
+
"detail": None,
|
|
841
|
+
}
|
|
842
|
+
try:
|
|
843
|
+
found = image_similarity_search(
|
|
844
|
+
self, image_vector, top_k=max(1, int(top_k) * 2)
|
|
845
|
+
)
|
|
846
|
+
except Exception as exc: # noqa: BLE001 — never fail the text answer
|
|
847
|
+
report["detail"] = f"image index unavailable: {exc}"
|
|
848
|
+
return report
|
|
849
|
+
report["candidates"] = int(found.get("candidates") or 0)
|
|
850
|
+
report["detail"] = found.get("detail")
|
|
851
|
+
scores = {
|
|
852
|
+
str(row.get("node_id")): float(row.get("score") or 0.0)
|
|
853
|
+
for row in found.get("matches") or []
|
|
854
|
+
}
|
|
855
|
+
report["fused"] = fuse_image_scores(matches, scores, weight=share)
|
|
856
|
+
return report
|
|
857
|
+
|
|
636
858
|
def context_for_query(
|
|
637
859
|
self,
|
|
638
860
|
query: str,
|
|
@@ -669,6 +891,8 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
669
891
|
return ""
|
|
670
892
|
matches: List[Dict[str, Any]] = []
|
|
671
893
|
retrieval_mode = "none"
|
|
894
|
+
vector_meta: Optional[Dict[str, Any]] = None
|
|
895
|
+
multimodal_meta: Optional[Dict[str, Any]] = None
|
|
672
896
|
if use_hybrid:
|
|
673
897
|
try:
|
|
674
898
|
hybrid = self.hybrid_search(
|
|
@@ -678,6 +902,15 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
678
902
|
include_legacy_global=include_legacy_global,
|
|
679
903
|
)
|
|
680
904
|
matches = hybrid.get("matches", [])
|
|
905
|
+
vector_block = hybrid.get("vector") or {}
|
|
906
|
+
# Only a caveat is worth carrying: approximate scoring, a
|
|
907
|
+
# truncated candidate scan, or an already-flagged degradation.
|
|
908
|
+
if (
|
|
909
|
+
vector_block.get("approx")
|
|
910
|
+
or vector_block.get("truncated")
|
|
911
|
+
or vector_block.get("degraded")
|
|
912
|
+
):
|
|
913
|
+
vector_meta = dict(vector_block)
|
|
681
914
|
if matches:
|
|
682
915
|
retrieval_mode = str(hybrid.get("mode") or "hybrid")
|
|
683
916
|
except Exception: # noqa: BLE001 — context building must never fail
|
|
@@ -752,9 +985,17 @@ class KnowledgeGraphRetrievalMixin(_Core):
|
|
|
752
985
|
context = "\n".join(lines)
|
|
753
986
|
if not with_meta:
|
|
754
987
|
return context
|
|
988
|
+
# Only the context that actually reached the model counts as
|
|
989
|
+
# multimodal — matches trimmed by ``limit`` are not in the answer.
|
|
990
|
+
multimodal_meta = multimodal_signal(matches[:limit])
|
|
755
991
|
return {
|
|
756
992
|
"context": context,
|
|
757
|
-
"quality": context_quality_signal(
|
|
993
|
+
"quality": context_quality_signal(
|
|
994
|
+
retrieval_mode,
|
|
995
|
+
len(matches[:limit]),
|
|
996
|
+
vector=vector_meta,
|
|
997
|
+
multimodal=multimodal_meta,
|
|
998
|
+
),
|
|
758
999
|
}
|
|
759
1000
|
|
|
760
1001
|
def context_for_query_with_meta(
|
|
@@ -46,26 +46,26 @@ class KnowledgeGraphDocGenMixin(_Core):
|
|
|
46
46
|
candidate_rows = []
|
|
47
47
|
seen_ids = set()
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
49
|
+
# `query` is non-empty here — the early return above took the blank case.
|
|
50
|
+
q = f"%{query}%"
|
|
51
|
+
rows = conn.execute(
|
|
52
|
+
f"""
|
|
53
|
+
SELECT id, type, title, summary, metadata_json, updated_at
|
|
54
|
+
FROM {nt}
|
|
55
|
+
WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
|
|
56
|
+
AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
|
|
57
|
+
'Spreadsheet', 'Image', 'ImageText', 'Audio',
|
|
58
|
+
'Chat', 'Decision', 'Task', 'Concept',
|
|
59
|
+
'Feature', 'Page', 'Slide')
|
|
60
|
+
ORDER BY updated_at DESC, id ASC
|
|
61
|
+
LIMIT ?
|
|
62
|
+
""",
|
|
63
|
+
(q, q, q, limit * 5),
|
|
64
|
+
).fetchall()
|
|
65
|
+
for row in rows:
|
|
66
|
+
if row["id"] not in seen_ids:
|
|
67
|
+
seen_ids.add(row["id"])
|
|
68
|
+
candidate_rows.append(row)
|
|
69
69
|
|
|
70
70
|
for term in terms:
|
|
71
71
|
t = f"%{term}%"
|
|
@@ -75,9 +75,9 @@ class KnowledgeGraphDocGenMixin(_Core):
|
|
|
75
75
|
FROM {nt}
|
|
76
76
|
WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
|
|
77
77
|
AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
|
|
78
|
-
'Spreadsheet', 'Image', 'ImageText', '
|
|
79
|
-
'
|
|
80
|
-
'Page', 'Slide')
|
|
78
|
+
'Spreadsheet', 'Image', 'ImageText', 'Audio',
|
|
79
|
+
'Chat', 'Decision', 'Task', 'Concept',
|
|
80
|
+
'Feature', 'Page', 'Slide')
|
|
81
81
|
ORDER BY updated_at DESC, id ASC
|
|
82
82
|
LIMIT ?
|
|
83
83
|
""",
|
|
@@ -139,12 +139,17 @@ def resolve_policy(
|
|
|
139
139
|
"query_class": "fact" | "code" | "person" | "recency",
|
|
140
140
|
"weights": {"keyword", "vector", "graph"}, # service fusion
|
|
141
141
|
"alpha": float, # graph-layer fusion
|
|
142
|
+
"fusion_strategy": "alpha" | "rrf", # how they combine
|
|
142
143
|
"original_query": str,
|
|
143
144
|
"search_query": str, # the rewritten form to search with
|
|
144
145
|
"rewrite_rules": [str],
|
|
145
146
|
"recency_half_life_days": float | None, # 14.0 only for recency
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
``fusion_strategy`` is ``"alpha"`` for every class unless
|
|
150
|
+
``LATTICEAI_FUSION_STRATEGY`` says otherwise, so the default policy is
|
|
151
|
+
byte-identical to the pre-11.1.0 one.
|
|
152
|
+
|
|
148
153
|
``recency_half_life_days`` is non-``None`` only for the ``recency``
|
|
149
154
|
class — the honest contract that age decay applies exactly where the
|
|
150
155
|
fusion layers wire it, and nowhere else.
|
|
@@ -157,6 +162,7 @@ def resolve_policy(
|
|
|
157
162
|
"query_class": query_class,
|
|
158
163
|
"weights": dict(profile["weights"]),
|
|
159
164
|
"alpha": float(profile["alpha"]),
|
|
165
|
+
"fusion_strategy": str(profile["strategy"]),
|
|
160
166
|
"original_query": rewrite["original"],
|
|
161
167
|
"search_query": search_query,
|
|
162
168
|
"rewrite_rules": list(rewrite["rules"]),
|