ltcai 6.3.1 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,42 +69,42 @@ You need Lattice AI when:
69
69
  Choose the owner of the Brain. The profile is not a SaaS account by default; it
70
70
  is the local identity for the knowledge you keep.
71
71
 
72
- ![Login](output/release/v6.3.1/screenshots/01-login.png)
72
+ ![Login](output/release/v6.4.0/screenshots/01-login.png)
73
73
 
74
74
  ### 2. Environment Analysis
75
75
 
76
76
  See what kind of local AI experience this computer can support before choosing a
77
77
  model.
78
78
 
79
- ![Environment Analysis](output/release/v6.3.1/screenshots/02-environment-analysis.png)
79
+ ![Environment Analysis](output/release/v6.4.0/screenshots/02-environment-analysis.png)
80
80
 
81
81
  ### 3. Recommended Models
82
82
 
83
83
  Start with a short list: safest recommendation, faster model, stronger model.
84
84
  Advanced details stay available without overwhelming first-time users.
85
85
 
86
- ![Recommended Models](output/release/v6.3.1/screenshots/03-recommended-models.png)
86
+ ![Recommended Models](output/release/v6.4.0/screenshots/03-recommended-models.png)
87
87
 
88
88
  ### 4. Install And Load
89
89
 
90
90
  Download and load only after consent. Lattice explains model size, local
91
91
  execution, and network use before work starts.
92
92
 
93
- ![Install and Load](output/release/v6.3.1/screenshots/04-install-load-progress.png)
93
+ ![Install and Load](output/release/v6.4.0/screenshots/04-install-load-progress.png)
94
94
 
95
95
  ### 5. Brain Chat
96
96
 
97
97
  Talk normally. Useful decisions and context become memory, then appear later as
98
98
  topics, relationships, and graph structure.
99
99
 
100
- ![Brain Chat Home](output/release/v6.3.1/screenshots/05-brain-chat-home.png)
100
+ ![Brain Chat Home](output/release/v6.4.0/screenshots/05-brain-chat-home.png)
101
101
 
102
102
  ### 6. Review Center
103
103
 
104
104
  Automation results are staged for review before they become durable decisions.
105
105
  Snooze, unsnooze, run now, approve, and dismiss actions stay explicit.
106
106
 
107
- ![Review Center](output/release/v6.3.1/screenshots/13-review-center.png)
107
+ ![Review Center](output/release/v6.4.0/screenshots/13-review-center.png)
108
108
 
109
109
  ## Brain Depths
110
110
 
@@ -120,10 +120,10 @@ The user travels inward from everyday memory to deeper structure:
120
120
 
121
121
  Walkthrough:
122
122
 
123
- ![v6.3.1 Living Brain walkthrough](output/release/v6.3.1/gifs/v6.3.1-living-brain-walkthrough.gif)
123
+ ![v6.4.0 Living Brain walkthrough](output/release/v6.4.0/gifs/v6.4.0-living-brain-walkthrough.gif)
124
124
 
125
125
  Screenshot index and capture notes:
126
- [output/release/v6.3.1/SCREENSHOT_INDEX.md](output/release/v6.3.1/SCREENSHOT_INDEX.md)
126
+ [output/release/v6.4.0/SCREENSHOT_INDEX.md](output/release/v6.4.0/SCREENSHOT_INDEX.md)
127
127
 
128
128
  ## Install
129
129
 
@@ -200,39 +200,31 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for developer workflow details.
200
200
 
201
201
  ## Current Release Preparation
202
202
 
203
- The current development target is **6.3.1 Access Runtime / i18n Follow-up**:
204
-
205
- - `App.tsx` now delegates the main Brain and Admin surfaces to focused feature
206
- modules instead of owning the full product shell.
207
- - `ProductFlow.tsx` is an orchestration layer; onboarding screens live in
208
- dedicated components, use design-system classes, and avoid inline style
209
- layout.
210
- - Model download consent states download size, storage location, external
211
- target, and a "do later" path before any model fetch starts.
212
- - Admin Console and onboarding copy use the shared i18n map for Korean/English
213
- coverage instead of embedding user-facing English in component bodies.
214
- - Historical root modules now shrink into compatibility shims while runtime
215
- code lives under package modules.
216
- - Interaction/tool router assembly uses typed context objects to reduce
217
- app-factory parameter sprawl while preserving route order.
218
- - Release smoke automation now checks wheel install, npm tgz contents, static
219
- assets, and Tauri artifacts after exact-version artifact validation.
220
- - Brain archive, memory provenance, document ingestion, Review Center Run Now,
221
- local model runtime, root-shim smoke, and app-factory review wiring now have
222
- dedicated UX/backend hardening coverage for the 6.3 line.
223
- - Access-control helper closures are extracted from `app_factory.py` into a
224
- focused runtime module with direct unit coverage for role lookup, token
225
- extraction, user enforcement, admin enforcement, and public user projection.
226
- - Capture and Review Center copy now route through the shared Korean/English
227
- i18n map for tabs, placeholders, action labels, empty states, and feedback.
228
-
229
- Expected artifacts for 6.3.1 release must use exact filenames:
230
-
231
- - `dist/ltcai-6.3.1-py3-none-any.whl`
232
- - `dist/ltcai-6.3.1.tar.gz`
233
- - `ltcai-6.3.1.tgz`
234
- - `dist/ltcai-6.3.1.vsix`
235
- - `src-tauri/target/release/bundle/dmg/Lattice AI_6.3.1_aarch64.dmg`
203
+ The current development target is **6.4.0 Digital Brain Quality Hardening**:
204
+
205
+ - `lattice_brain.quality` adds a non-destructive Brain quality layer for
206
+ embedding fallback labelling, drift/re-index planning, BM25 lexical scoring,
207
+ hybrid fusion, reranker fallback contracts, memory candidate quality,
208
+ graph-edge confidence/evidence scoring, structured context guardrails, and
209
+ retrieval benchmark metrics.
210
+ - Graph/search API reads now carry workspace scope through graph, node,
211
+ neighborhood, relationship, keyword, vector, graph, and hybrid search paths.
212
+ - Memory Manager prune, compact, and clear operations are scoped to the
213
+ caller's workspace and owner boundary; unscoped graph clearing from Memory
214
+ Manager is blocked until a workspace-safe graph delete path exists.
215
+ - Digital Brain quality documentation now records the 6.4.0 baseline, risk
216
+ register, validation checklist, and intentionally deferred work.
217
+ - The release remains local-first: no automatic web/email/calendar ingestion,
218
+ no package publishing, no production deployment, and no external reranker or
219
+ embedding API use without explicit opt-in.
220
+
221
+ Expected artifacts for 6.4.0 release must use exact filenames:
222
+
223
+ - `dist/ltcai-6.4.0-py3-none-any.whl`
224
+ - `dist/ltcai-6.4.0.tar.gz`
225
+ - `ltcai-6.4.0.tgz`
226
+ - `dist/ltcai-6.4.0.vsix`
227
+ - `src-tauri/target/release/bundle/dmg/Lattice AI_6.4.0_aarch64.dmg`
236
228
 
237
229
  Do not upload `dist/*`. Package registry publishing remains owner-run.
238
230
 
@@ -251,6 +243,7 @@ Do not upload `dist/*`. Package registry publishing remains owner-run.
251
243
 
252
244
  | Version | Theme |
253
245
  | --- | --- |
246
+ | 6.4.0 | Digital Brain Quality Hardening: workspace-scoped graph/search/memory reads and mutations, Brain quality primitives, structured context guardrails, and retrieval benchmark coverage |
254
247
  | 6.3.1 | Access Runtime / i18n Follow-up: app-factory access-control extraction, focused access runtime tests, and Capture/Review Center i18n coverage |
255
248
  | 6.3.0 | Product Hardening Completion: Brain archive/provenance/ingestion UX polish, Review Center Run Now contract hardening, local model runtime status, app-factory review wiring, shim smoke, i18n guard, and exact release artifacts |
256
249
  | 6.2.0 | Product Decomposition / Release Smoke Automation: App and ProductFlow feature extraction, legacy root shim shrink, model download consent UX, typed router contexts, localized smoke tests, and wheel/npm/static/Tauri release smoke |
package/docs/CHANGELOG.md CHANGED
@@ -3,6 +3,37 @@
3
3
  The top entry is the current release-preparation target. Older entries are
4
4
  historical and may describe behavior as it existed at that release.
5
5
 
6
+ ## [6.4.0] - 2026-06-17
7
+
8
+ > Digital Brain Quality Hardening. Tightens workspace-scoped Brain retrieval
9
+ > and memory mutation boundaries while adding non-destructive quality primitives
10
+ > for embeddings, retrieval, memory, graph validation, context assembly, and
11
+ > benchmarks.
12
+
13
+ ### Added
14
+ - `lattice_brain.quality` with embedding fallback labelling, drift/re-index
15
+ planning, BM25 lexical scoring, hybrid fusion, reranker fallback contracts,
16
+ memory candidate scoring/deduplication/conflict/retention helpers, graph edge
17
+ confidence/evidence metrics, structured context guardrails, and retrieval
18
+ benchmark metric calculation.
19
+ - `docs/v6.4/BRAIN_QUALITY_BASELINE.md` documenting the 6.4.0 Digital Brain
20
+ quality baseline, risk register, validation items, and deferred work.
21
+ - Refreshed README release evidence screenshots and walkthrough GIF under
22
+ `output/release/v6.4.0/`.
23
+ - Unit coverage for Brain quality primitives and workspace-scoped graph/search
24
+ and memory-manager mutation boundaries.
25
+
26
+ ### Changed
27
+ - `/api/graph*`, `/knowledge-graph/*`, and hybrid-search service paths now
28
+ preserve workspace scope across graph, node, neighborhood, relationship,
29
+ keyword, vector, graph, and hybrid retrieval.
30
+ - Memory Manager prune, compact, and clear operations now intersect requested
31
+ ids/kinds with the caller's scoped memory set.
32
+ - Memory Manager graph clear is blocked because the existing graph clear path
33
+ is not workspace-scoped.
34
+ - Package/runtime/static metadata is synchronized to 6.4.0; package publish and
35
+ deployment remain owner-run only.
36
+
6
37
  ## [6.3.1] - 2026-06-17
7
38
 
8
39
  > Access Runtime / i18n Follow-up. Closes the next app-factory decomposition
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "6.3.1"
29
+ __version__ = "6.4.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -45,6 +45,7 @@ __all__ = [
45
45
  "IngestionPipeline",
46
46
  "KGPortabilityService",
47
47
  "KnowledgeGraphStore",
48
+ "LatticeBrainQuality",
48
49
  "MultiAgentOrchestrator",
49
50
  "PostgresConfig",
50
51
  "PostgresEngine",
@@ -65,6 +66,7 @@ _LAZY = {
65
66
  "ConversationStore": ("conversations", "ConversationStore"),
66
67
  "BrainMemory": ("memory", "BrainMemory"),
67
68
  "KnowledgeGraphStore": ("graph.store", "KnowledgeGraphStore"),
69
+ "LatticeBrainQuality": ("quality", "LatticeBrainQuality"),
68
70
  "IngestionItem": ("ingestion", "IngestionItem"),
69
71
  "IngestionPipeline": ("ingestion", "IngestionPipeline"),
70
72
  "KGPortabilityService": ("portability", "KGPortabilityService"),
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ lattice_brain/quality.py
4
+ Pure Python Quality Layer for Lattice Brain (v6.4+ hardening)
5
+ - Does not modify any DB schema or existing APIs
6
+ - Self-contained, dependency-free core (stdlib only)
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import math
11
+ import time
12
+ import hashlib
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, List, Optional
15
+ from collections import defaultdict
16
+ import re
17
+
18
+ # -----------------------------
19
+ # 1. Embedding Fallback Labelling + Drift/Reindex Plan
20
+ # -----------------------------
21
+ @dataclass
22
+ class EmbeddingLabel:
23
+ vector_id: str
24
+ label: str
25
+ confidence: float
26
+ drift_score: float = 0.0
27
+ needs_reindex: bool = False
28
+
29
+ class EmbeddingFallbackLabeller:
30
+ """Fallback labelling when primary embedding unavailable + drift detection"""
31
+
32
+ def __init__(self, drift_threshold: float = 0.15):
33
+ self.drift_threshold = drift_threshold
34
+ self.label_cache: Dict[str, EmbeddingLabel] = {}
35
+ self.vector_cache: Dict[str, List[float]] = {}
36
+
37
+ def label(self, vector_id: str, embedding: Optional[List[float]] = None,
38
+ metadata: Optional[Dict] = None) -> EmbeddingLabel:
39
+ if embedding is None:
40
+ label = "unembedded_fallback"
41
+ conf = 0.3
42
+ else:
43
+ # Simple hash-based pseudo-label for determinism
44
+ h = hashlib.md5(str(embedding[:4]).encode()).hexdigest()[:8]
45
+ label = f"emb_cluster_{h}"
46
+ conf = 0.85
47
+
48
+ drift = self._compute_drift(vector_id, embedding)
49
+ needs_reindex = drift > self.drift_threshold
50
+
51
+ el = EmbeddingLabel(vector_id, label, conf, drift, needs_reindex)
52
+ self.label_cache[vector_id] = el
53
+ if embedding is not None:
54
+ self.vector_cache[vector_id] = [float(value) for value in embedding]
55
+ return el
56
+
57
+ def _compute_drift(self, vector_id: str, new_emb: Optional[List[float]]) -> float:
58
+ old = self.vector_cache.get(vector_id)
59
+ if old is None or new_emb is None:
60
+ return 0.0
61
+ return self._cosine_distance(old, new_emb)
62
+
63
+ def _cosine_distance(self, a: List[float], b: List[float]) -> float:
64
+ if not a or not b or len(a) != len(b):
65
+ return 1.0
66
+ dot = sum(x*y for x,y in zip(a,b))
67
+ na = math.sqrt(sum(x*x for x in a))
68
+ nb = math.sqrt(sum(x*x for x in b))
69
+ return 1.0 - (dot / (na*nb + 1e-9))
70
+
71
+ def generate_reindex_plan(self) -> List[str]:
72
+ return [vid for vid, lab in self.label_cache.items() if lab.needs_reindex]
73
+
74
+ # -----------------------------
75
+ # 2. BM25 Lexical Scoring + Hybrid Fusion + Reranker Interface
76
+ # -----------------------------
77
+ class BM25Scorer:
78
+ def __init__(self, k1: float = 1.5, b: float = 0.75):
79
+ self.k1 = k1
80
+ self.b = b
81
+ self.doc_freqs: Dict[str, int] = defaultdict(int)
82
+ self.doc_lens: Dict[str, int] = {}
83
+ self.corpus_size = 0
84
+ self.avgdl = 0.0
85
+
86
+ def fit(self, corpus: Dict[str, str]):
87
+ self.corpus_size = len(corpus)
88
+ total_len = 0
89
+ for did, text in corpus.items():
90
+ tokens = self._tokenize(text)
91
+ self.doc_lens[did] = len(tokens)
92
+ total_len += len(tokens)
93
+ for t in set(tokens):
94
+ self.doc_freqs[t] += 1
95
+ self.avgdl = total_len / max(1, self.corpus_size)
96
+
97
+ def score(self, query: str, doc_id: str, doc_text: str) -> float:
98
+ tokens = self._tokenize(query)
99
+ doc_tokens = self._tokenize(doc_text)
100
+ score = 0.0
101
+ for t in tokens:
102
+ if t not in doc_tokens:
103
+ continue
104
+ tf = doc_tokens.count(t)
105
+ df = self.doc_freqs.get(t, 0)
106
+ idf = math.log((self.corpus_size - df + 0.5) / (df + 0.5) + 1)
107
+ denom = tf + self.k1 * (1 - self.b + self.b * self.doc_lens.get(doc_id, 0) / self.avgdl)
108
+ score += idf * tf * (self.k1 + 1) / (denom + 1e-9)
109
+ return score
110
+
111
+ def _tokenize(self, text: str) -> List[str]:
112
+ return re.findall(r'\w+', text.lower())
113
+
114
+ class HybridFusion:
115
+ """Hybrid fusion of lexical (BM25) + vector scores"""
116
+ def __init__(self, alpha: float = 0.6):
117
+ self.alpha = alpha # weight for vector score
118
+ self.bm25 = BM25Scorer()
119
+
120
+ def fuse(self, query: str, candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
121
+ # candidates: [{"id": , "text": , "vector_score": }]
122
+ corpus = {c["id"]: c.get("text", "") for c in candidates}
123
+ self.bm25.fit(corpus)
124
+ results = []
125
+ for c in candidates:
126
+ lex = self.bm25.score(query, c["id"], c.get("text", ""))
127
+ vec = c.get("vector_score", 0.5)
128
+ fused = self.alpha * vec + (1 - self.alpha) * (lex / 10.0) # normalize rough
129
+ c["fused_score"] = round(fused, 4)
130
+ results.append(c)
131
+ return sorted(results, key=lambda x: x["fused_score"], reverse=True)
132
+
133
+ class RerankerInterface:
134
+ """Pluggable reranker interface"""
135
+ def rerank(self, query: str, candidates: List[Dict], top_k: int = 5) -> List[Dict]:
136
+ # Default local-safe fallback: preserve fused ordering without making
137
+ # unearned cross-encoder claims.
138
+ for c in candidates:
139
+ c["rerank_score"] = c.get("fused_score", 0.0)
140
+ return sorted(candidates, key=lambda x: x.get("rerank_score", 0), reverse=True)[:top_k]
141
+
142
+ # -----------------------------
143
+ # 3. Memory Candidate Extraction / Scoring / Dedupe / Merge / Conflict / Retention
144
+ # -----------------------------
145
+ @dataclass
146
+ class MemoryCandidate:
147
+ id: str
148
+ content: str
149
+ score: float = 0.0
150
+ source: str = "unknown"
151
+ timestamp: float = field(default_factory=time.time)
152
+ conflicts: List[str] = field(default_factory=list)
153
+
154
+ class MemoryQualityManager:
155
+ def extract_candidates(self, memories: List[Dict]) -> List[MemoryCandidate]:
156
+ return [MemoryCandidate(m["id"], m["content"], m.get("score", 0.6), m.get("source", "mem")) for m in memories]
157
+
158
+ def score_candidates(self, cands: List[MemoryCandidate], query: str) -> List[MemoryCandidate]:
159
+ for c in cands:
160
+ # simple lexical overlap score
161
+ overlap = sum(1 for w in query.lower().split() if w in c.content.lower())
162
+ c.score = min(1.0, 0.4 + overlap * 0.15)
163
+ return sorted(cands, key=lambda x: x.score, reverse=True)
164
+
165
+ def dedupe(self, cands: List[MemoryCandidate], threshold: float = 0.85) -> List[MemoryCandidate]:
166
+ kept = []
167
+ seen = set()
168
+ for c in cands:
169
+ h = hashlib.md5(c.content.encode()).hexdigest()[:16]
170
+ if h not in seen:
171
+ seen.add(h)
172
+ kept.append(c)
173
+ return kept
174
+
175
+ def merge(self, cands: List[MemoryCandidate]) -> List[MemoryCandidate]:
176
+ # naive merge by content prefix
177
+ merged = {}
178
+ for c in cands:
179
+ key = c.content[:30]
180
+ if key not in merged or c.score > merged[key].score:
181
+ merged[key] = c
182
+ return list(merged.values())
183
+
184
+ def detect_conflicts(self, cands: List[MemoryCandidate]) -> List[MemoryCandidate]:
185
+ # Lightweight local heuristic; LLM conflict classifiers can replace it.
186
+ for i, c in enumerate(cands):
187
+ if "not" in c.content.lower() or "반대" in c.content:
188
+ c.conflicts.append("conflict:possible_negation")
189
+ return cands
190
+
191
+ def apply_retention(self, cands: List[MemoryCandidate], max_age_days: int = 90) -> List[MemoryCandidate]:
192
+ now = time.time()
193
+ return [c for c in cands if (now - c.timestamp) < max_age_days * 86400]
194
+
195
+ # -----------------------------
196
+ # 4. Graph Edge Validation / Confidence / Evidence / Duplicate Merge / Quality Metrics
197
+ # -----------------------------
198
+ @dataclass
199
+ class GraphEdgeQuality:
200
+ edge_id: str
201
+ confidence: float
202
+ evidence_count: int
203
+ is_duplicate: bool = False
204
+ quality_score: float = 0.0
205
+
206
+ class GraphEdgeQualityManager:
207
+ def validate_edge(self, edge: Dict[str, Any]) -> GraphEdgeQuality:
208
+ conf = edge.get("confidence", 0.7)
209
+ ev = len(edge.get("evidence", []))
210
+ q = min(1.0, conf * 0.75 + min(ev, 5) / 5 * 0.3)
211
+ return GraphEdgeQuality(edge.get("id", "e0"), conf, ev, quality_score=q)
212
+
213
+ def detect_duplicate_edges(self, edges: List[Dict]) -> List[str]:
214
+ seen = {}
215
+ dups = []
216
+ for e in edges:
217
+ key = (e.get("source"), e.get("target"), e.get("type"))
218
+ if key in seen:
219
+ dups.append(e.get("id"))
220
+ else:
221
+ seen[key] = e.get("id")
222
+ return dups
223
+
224
+ def merge_duplicate_edges(self, edges: List[Dict]) -> List[Dict]:
225
+ # keep highest confidence
226
+ best = {}
227
+ for e in edges:
228
+ key = (e.get("source"), e.get("target"), e.get("type"))
229
+ if key not in best or e.get("confidence", 0) > best[key].get("confidence", 0):
230
+ best[key] = e
231
+ return list(best.values())
232
+
233
+ def compute_quality_metrics(self, edges: List[Dict]) -> Dict[str, float]:
234
+ if not edges:
235
+ return {"avg_conf": 0.0, "avg_evidence": 0.0, "dup_rate": 0.0}
236
+ confs = [e.get("confidence", 0.5) for e in edges]
237
+ evs = [len(e.get("evidence", [])) for e in edges]
238
+ dups = len(self.detect_duplicate_edges(edges))
239
+ return {
240
+ "avg_conf": round(sum(confs)/len(confs), 3),
241
+ "avg_evidence": round(sum(evs)/len(evs), 2),
242
+ "dup_rate": round(dups / len(edges), 3)
243
+ }
244
+
245
+ # -----------------------------
246
+ # 5. Structured Context Assembly + Guardrails
247
+ # -----------------------------
248
+ @dataclass
249
+ class ContextGuardrails:
250
+ known: bool = True
251
+ inferred: bool = False
252
+ stale: bool = False
253
+ unknown: bool = False
254
+ confidence: float = 0.8
255
+ timestamp: float = field(default_factory=time.time)
256
+ attribution: str = "system"
257
+
258
+ class StructuredContextAssembler:
259
+ SECTIONS = ["Facts", "Decisions", "Preferences", "Relationships", "Projects", "Recent Events"]
260
+
261
+ def assemble(self, items: List[Dict[str, Any]]) -> Dict[str, List[Dict]]:
262
+ ctx: Dict[str, List[Dict]] = {s: [] for s in self.SECTIONS}
263
+ for item in items:
264
+ section = item.get("section", "Facts")
265
+ if section not in ctx:
266
+ section = "Facts"
267
+ guard = ContextGuardrails(
268
+ known=item.get("known", True),
269
+ inferred=item.get("inferred", False),
270
+ stale=item.get("stale", False),
271
+ unknown=item.get("unknown", False),
272
+ confidence=item.get("confidence", 0.75),
273
+ attribution=item.get("attribution", "user")
274
+ )
275
+ item["guardrails"] = guard.__dict__
276
+ ctx[section].append(item)
277
+ return ctx
278
+
279
+ def apply_guardrails(self, ctx: Dict[str, List[Dict]]) -> Dict[str, List[Dict]]:
280
+ cleaned = {}
281
+ for sec, items in ctx.items():
282
+ cleaned[sec] = [i for i in items if not i.get("guardrails", {}).get("unknown", False)]
283
+ return cleaned
284
+
285
+ # -----------------------------
286
+ # 6. Retrieval Benchmark Fixture Runner
287
+ # -----------------------------
288
+ class RetrievalBenchmarkRunner:
289
+ def __init__(self):
290
+ self.results: List[Dict] = []
291
+
292
+ def run_fixture(self, fixture_name: str, queries: List[Any], top_k: int = 5) -> Dict[str, Any]:
293
+ start = time.time()
294
+ judged = [q for q in queries if isinstance(q, dict)]
295
+ if judged:
296
+ recalls = []
297
+ precisions = []
298
+ ndcgs = []
299
+ for query in judged:
300
+ relevant = set(query.get("relevant") or [])
301
+ retrieved = list(query.get("retrieved") or [])[:top_k]
302
+ if not relevant:
303
+ continue
304
+ hits = [doc_id for doc_id in retrieved if doc_id in relevant]
305
+ recalls.append(len(hits) / len(relevant))
306
+ precisions.append(len(hits) / max(1, len(retrieved)))
307
+ dcg = sum(
308
+ 1.0 / math.log2(rank + 2)
309
+ for rank, doc_id in enumerate(retrieved)
310
+ if doc_id in relevant
311
+ )
312
+ ideal = sum(1.0 / math.log2(rank + 2) for rank in range(min(len(relevant), top_k)))
313
+ ndcgs.append(dcg / ideal if ideal else 0.0)
314
+ recall = sum(recalls) / len(recalls) if recalls else 0.0
315
+ precision = sum(precisions) / len(precisions) if precisions else 0.0
316
+ ndcg = sum(ndcgs) / len(ndcgs) if ndcgs else 0.0
317
+ else:
318
+ recall = precision = ndcg = 0.0
319
+ metrics = {
320
+ "fixture": fixture_name,
321
+ "queries": len(queries),
322
+ "avg_latency_ms": round((time.time() - start) * 1000 / max(1, len(queries)), 2),
323
+ "recall@5": round(recall, 4),
324
+ "precision@5": round(precision, 4),
325
+ "ndcg@5": round(ndcg, 4),
326
+ "top_k": top_k,
327
+ "judged": len(judged),
328
+ }
329
+ self.results.append(metrics)
330
+ return metrics
331
+
332
+ def summary(self) -> Dict[str, Any]:
333
+ if not self.results:
334
+ return {"status": "no runs"}
335
+ return {
336
+ "total_runs": len(self.results),
337
+ "avg_recall": round(sum(r["recall@5"] for r in self.results) / len(self.results), 3)
338
+ }
339
+
340
+ # -----------------------------
341
+ # Main Quality Layer Facade
342
+ # -----------------------------
343
+ class LatticeBrainQuality:
344
+ """Main entry point - pure Python quality hardening layer"""
345
+ def __init__(self):
346
+ self.embed_labeller = EmbeddingFallbackLabeller()
347
+ self.hybrid = HybridFusion()
348
+ self.reranker = RerankerInterface()
349
+ self.memory_mgr = MemoryQualityManager()
350
+ self.graph_mgr = GraphEdgeQualityManager()
351
+ self.context_assembler = StructuredContextAssembler()
352
+ self.benchmark = RetrievalBenchmarkRunner()
353
+
354
+ def full_quality_pass(self, payload: Dict[str, Any]) -> Dict[str, Any]:
355
+ """End-to-end quality pipeline (non-destructive)"""
356
+ result = {"status": "ok", "timestamp": time.time()}
357
+
358
+ # embedding
359
+ if "embeddings" in payload:
360
+ labels = [self.embed_labeller.label(e["id"], e.get("vector")) for e in payload["embeddings"]]
361
+ result["embedding_labels"] = [label.__dict__ for label in labels]
362
+ result["reindex_plan"] = self.embed_labeller.generate_reindex_plan()
363
+
364
+ # hybrid retrieval
365
+ if "retrieval" in payload:
366
+ fused = self.hybrid.fuse(payload["retrieval"]["query"], payload["retrieval"]["candidates"])
367
+ reranked = self.reranker.rerank(payload["retrieval"]["query"], fused)
368
+ result["retrieval"] = {"fused": fused, "reranked": reranked}
369
+
370
+ # memory
371
+ if "memories" in payload:
372
+ cands = self.memory_mgr.extract_candidates(payload["memories"])
373
+ cands = self.memory_mgr.score_candidates(cands, payload.get("query", ""))
374
+ cands = self.memory_mgr.dedupe(cands)
375
+ cands = self.memory_mgr.merge(cands)
376
+ cands = self.memory_mgr.detect_conflicts(cands)
377
+ cands = self.memory_mgr.apply_retention(cands)
378
+ result["memory_candidates"] = [c.__dict__ for c in cands]
379
+
380
+ # graph
381
+ if "graph_edges" in payload:
382
+ eqs = [self.graph_mgr.validate_edge(e) for e in payload["graph_edges"]]
383
+ result["graph_quality"] = [q.__dict__ for q in eqs]
384
+ result["graph_metrics"] = self.graph_mgr.compute_quality_metrics(payload["graph_edges"])
385
+
386
+ # context
387
+ if "context_items" in payload:
388
+ ctx = self.context_assembler.assemble(payload["context_items"])
389
+ ctx = self.context_assembler.apply_guardrails(ctx)
390
+ result["structured_context"] = ctx
391
+
392
+ return result
393
+
394
+
395
+ __all__ = [
396
+ "BM25Scorer",
397
+ "ContextGuardrails",
398
+ "EmbeddingFallbackLabeller",
399
+ "EmbeddingLabel",
400
+ "GraphEdgeQuality",
401
+ "GraphEdgeQualityManager",
402
+ "HybridFusion",
403
+ "LatticeBrainQuality",
404
+ "MemoryCandidate",
405
+ "MemoryQualityManager",
406
+ "RetrievalBenchmarkRunner",
407
+ "RerankerInterface",
408
+ "StructuredContextAssembler",
409
+ ]
@@ -19,7 +19,7 @@ from datetime import datetime
19
19
  from typing import Any, Callable, Dict, List, Optional
20
20
 
21
21
 
22
- MULTI_AGENT_VERSION = "6.3.1"
22
+ MULTI_AGENT_VERSION = "6.4.0"
23
23
 
24
24
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
25
25
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "6.3.1"
3
+ __version__ = "6.4.0"
@@ -27,12 +27,32 @@ class KnowledgeGraphIngestRequest(BaseModel):
27
27
  metadata: Optional[Dict[str, Any]] = None
28
28
 
29
29
 
30
+ def _format_context(matches: list, limit: int) -> str:
31
+ """Mirror ``KnowledgeGraphRetrievalMixin.context_for_query`` formatting for a
32
+ pre-filtered match list, so scoped callers get identical context lines minus
33
+ the rows they are not allowed to see."""
34
+ lines = []
35
+ for match in matches[:limit]:
36
+ meta = match.get("metadata") or {}
37
+ source = (
38
+ meta.get("relative_path")
39
+ or meta.get("filename")
40
+ or meta.get("conversation_id")
41
+ or meta.get("source")
42
+ or match.get("id")
43
+ )
44
+ summary = " ".join(str(match.get("summary") or "").split())[:700]
45
+ lines.append(f"- [{match.get('type')}] {match.get('title')} | source={source} | {summary}")
46
+ return "\n".join(lines)
47
+
48
+
30
49
  def create_knowledge_graph_router(
31
50
  *,
32
51
  get_graph: Callable[[], Any],
33
52
  require_graph: Callable[[], None],
34
53
  require_user: Callable[[Request], str],
35
54
  static_dir: Path,
55
+ allowed_workspaces_for: Optional[Callable[[Optional[str]], Any]] = None,
36
56
  ) -> APIRouter:
37
57
  router = APIRouter()
38
58
 
@@ -40,6 +60,21 @@ def create_knowledge_graph_router(
40
60
  require_graph()
41
61
  return get_graph()
42
62
 
63
+ def _scoped(request: Request):
64
+ """Authenticate the caller and resolve their allowed workspace set.
65
+
66
+ Returns ``(graph, allowed)``. ``allowed is None`` means no scoping
67
+ (single-user / no-auth mode); otherwise it is the set of workspace ids
68
+ the caller may read. Legacy-global rows (no workspace) stay visible —
69
+ the documented pre-v4 compatibility behavior enforced by
70
+ ``filter_scoped_nodes``.
71
+ """
72
+ user = require_user(request)
73
+ allowed = None
74
+ if allowed_workspaces_for is not None and user:
75
+ allowed = allowed_workspaces_for(user)
76
+ return graph(), allowed
77
+
43
78
  @router.get("/graph")
44
79
  async def knowledge_graph_page(request: Request):
45
80
  """Serve the interactive knowledge graph canvas UI."""
@@ -81,8 +116,10 @@ def create_knowledge_graph_router(
81
116
 
82
117
  @router.get("/knowledge-graph/graph")
83
118
  async def knowledge_graph_data(request: Request, limit: int = 300):
84
- require_user(request)
85
- return graph().graph(limit)
119
+ kg, allowed = _scoped(request)
120
+ if allowed is None:
121
+ return kg.graph(limit)
122
+ return kg.graph(limit, allowed_workspaces=allowed)
86
123
 
87
124
  @router.get("/knowledge-graph/documents")
88
125
  async def knowledge_graph_documents(request: Request, limit: int = 200):
@@ -91,27 +128,51 @@ def create_knowledge_graph_router(
91
128
  Backs the Files view so uploaded content is visible end-to-end:
92
129
  upload → Files → Knowledge Graph → Hybrid Search → Chat.
93
130
  """
94
- require_user(request)
95
- return graph().list_documents(limit)
131
+ kg, allowed = _scoped(request)
132
+ payload = kg.list_documents(limit)
133
+ if allowed is not None:
134
+ documents = kg.filter_scoped_nodes(payload.get("documents", []), allowed)
135
+ payload = {**payload, "documents": documents, "total": len(documents)}
136
+ return payload
96
137
 
97
138
  @router.get("/knowledge-graph/search")
98
139
  async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
99
- require_user(request)
140
+ kg, allowed = _scoped(request)
100
141
  if not q or not q.strip():
101
142
  return {"query": q, "matches": []}
102
- return graph().search(q, limit)
143
+ payload = kg.search(q, limit)
144
+ if allowed is not None:
145
+ payload = {**payload, "matches": kg.filter_scoped_nodes(payload.get("matches", []), allowed)}
146
+ return payload
103
147
 
104
148
  @router.get("/knowledge-graph/context")
105
149
  async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
106
- require_user(request)
107
- return {"query": q, "context": graph().context_for_query(q, limit)}
150
+ kg, allowed = _scoped(request)
151
+ if allowed is None:
152
+ return {"query": q, "context": kg.context_for_query(q, limit)}
153
+ # Scoped mode: derive context from scope-filtered search matches so the
154
+ # RAG context never carries content from workspaces the caller can't read.
155
+ matches = kg.filter_scoped_nodes(kg.search(q, limit).get("matches", []), allowed)
156
+ return {"query": q, "context": _format_context(matches, limit)}
108
157
 
109
158
  @router.get("/knowledge-graph/neighbors/{node_id:path}")
110
159
  async def knowledge_graph_neighbors(node_id: str, request: Request):
111
- require_user(request)
160
+ kg, allowed = _scoped(request)
112
161
  if not node_id:
113
162
  raise HTTPException(status_code=400, detail="node_id required")
114
- return graph().neighbors(node_id)
163
+ if allowed is not None and not kg.filter_scoped_nodes([{"id": node_id}], allowed):
164
+ raise HTTPException(status_code=404, detail="node not found")
165
+ payload = kg.neighbors(node_id)
166
+ if allowed is not None:
167
+ neighbors = kg.filter_scoped_nodes(payload.get("neighbors", []), allowed)
168
+ kept = {n.get("id") for n in neighbors}
169
+ edges = [
170
+ e for e in payload.get("edges", [])
171
+ if (e.get("from") == node_id or e.get("from") in kept)
172
+ and (e.get("to") == node_id or e.get("to") in kept)
173
+ ]
174
+ payload = {**payload, "neighbors": neighbors, "edges": edges}
175
+ return payload
115
176
 
116
177
  @router.post("/knowledge-graph/ingest")
117
178
  async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
@@ -48,6 +48,7 @@ def create_local_files_router(
48
48
  local_kg_watcher,
49
49
  hooks=None,
50
50
  data_dir: Optional[Path] = None,
51
+ allowed_workspaces_for=None,
51
52
  ) -> APIRouter:
52
53
  router = APIRouter()
53
54
 
@@ -210,6 +211,7 @@ def create_local_files_router(
210
211
  require_graph=require_graph,
211
212
  require_user=require_user,
212
213
  static_dir=static_dir,
214
+ allowed_workspaces_for=allowed_workspaces_for,
213
215
  )
214
216
  )
215
217
 
@@ -74,16 +74,16 @@ def create_memory_router(
74
74
  @router.post("/api/memory/prune")
75
75
  async def memory_prune(req: PruneRequest, request: Request):
76
76
  user = require_user(request)
77
- gate_write(request)
78
- result = service.prune(ids=req.ids, kind=req.kind, user_email=user)
77
+ scope = gate_write(request)
78
+ result = service.prune(ids=req.ids, kind=req.kind, user_email=user, workspace_id=scope)
79
79
  append_audit_event("memory_prune", user_email=user, count=result.get("count", 0))
80
80
  return result
81
81
 
82
82
  @router.post("/api/memory/compact")
83
83
  async def memory_compact(request: Request):
84
84
  user = require_user(request)
85
- gate_write(request)
86
- result = service.compact(user_email=user)
85
+ scope = gate_write(request)
86
+ result = service.compact(user_email=user, workspace_id=scope)
87
87
  append_audit_event("memory_compact", user_email=user, compacted=result.get("compacted", 0))
88
88
  return result
89
89
 
@@ -98,9 +98,9 @@ def create_memory_router(
98
98
  @router.post("/api/memory/clear")
99
99
  async def memory_clear(req: ClearRequest, request: Request):
100
100
  user = require_user(request)
101
- gate_write(request)
101
+ scope = gate_write(request)
102
102
  try:
103
- result = service.clear(scope=req.scope, confirm=req.confirm, user_email=user)
103
+ result = service.clear(scope=req.scope, confirm=req.confirm, user_email=user, workspace_id=scope)
104
104
  except ValueError as exc:
105
105
  raise HTTPException(status_code=400, detail=str(exc)) from exc
106
106
  append_audit_event("memory_clear", user_email=user, scope=req.scope)
@@ -51,7 +51,15 @@ class _ScopedSearchService:
51
51
  """Injects the caller's workspace scope into every search call —
52
52
  enforcement lives at this one chokepoint, not in each handler."""
53
53
 
54
- _SCOPED = {"keyword_search", "vector_search", "graph_search", "hybrid_search"}
54
+ _SCOPED = {
55
+ "keyword_search",
56
+ "vector_search",
57
+ "graph_search",
58
+ "hybrid_search",
59
+ "graph",
60
+ "node",
61
+ "relationships",
62
+ }
55
63
 
56
64
  def __init__(self, service: SearchService, allowed):
57
65
  self._service = service
@@ -202,6 +202,7 @@ def create_tools_router(
202
202
  install_mcp=None,
203
203
  mcp_public_item=None,
204
204
  hooks=None,
205
+ allowed_workspaces_for=None,
205
206
  ) -> APIRouter:
206
207
  if tool_context is not None:
207
208
  config = tool_context.config
@@ -227,6 +228,7 @@ def create_tools_router(
227
228
  install_mcp = tool_context.install_mcp
228
229
  mcp_public_item = tool_context.mcp_public_item
229
230
  hooks = tool_context.hooks
231
+ allowed_workspaces_for = tool_context.allowed_workspaces_for
230
232
 
231
233
  api_router = APIRouter()
232
234
  HOOKS = hooks
@@ -486,6 +488,7 @@ def create_tools_router(
486
488
  local_kg_watcher=LOCAL_KG_WATCHER,
487
489
  hooks=HOOKS,
488
490
  data_dir=DATA_DIR,
491
+ allowed_workspaces_for=allowed_workspaces_for,
489
492
  ))
490
493
  api_router.include_router(create_computer_use_router(
491
494
  model_router=router,
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "6.3.1"
14
+ MARKETPLACE_VERSION = "6.4.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
20
 
21
21
 
22
- WORKSPACE_OS_VERSION = "6.3.1"
22
+ WORKSPACE_OS_VERSION = "6.4.0"
23
23
 
24
24
  # Workspace types separate single-user Personal workspaces from shared
25
25
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -88,6 +88,7 @@ def build_interaction_contexts(
88
88
  install_mcp=install_mcp,
89
89
  mcp_public_item=mcp_public_item,
90
90
  hooks=hooks,
91
+ allowed_workspaces_for=allowed_workspaces_for,
91
92
  )
92
93
  interaction_router_context = InteractionRouterContext(
93
94
  chat_context=chat_context,
@@ -133,8 +133,11 @@ class MemoryService:
133
133
 
134
134
  # ── Memory Manager: sources / usage / health ──────────────────────────
135
135
  def manager(self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None) -> Dict[str, Any]:
136
- ws_mem = self._workspace_memories(user_email=user_email, workspace_id="personal")
137
- project_mem = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"]
136
+ ws_mem = self._workspace_memories(user_email=user_email, workspace_id=workspace_id or "personal")
137
+ if workspace_id is None:
138
+ project_mem = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"]
139
+ else:
140
+ project_mem = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
138
141
  snaps = self._snapshots(workspace_id=workspace_id)
139
142
  convs = self._conversations()
140
143
  kg_stats = self._kg_stats()
@@ -268,10 +271,13 @@ class MemoryService:
268
271
  # ── inspect a single tier ─────────────────────────────────────────────
269
272
  def inspect(self, source: str, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
270
273
  if source == "workspace":
271
- items = self._workspace_memories(user_email=user_email, workspace_id="personal")[:limit]
274
+ items = self._workspace_memories(user_email=user_email, workspace_id=workspace_id or "personal")[:limit]
272
275
  return {"source": source, "items": items, "count": len(items)}
273
276
  if source == "project":
274
- items = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"][:limit]
277
+ if workspace_id is None:
278
+ items = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"][:limit]
279
+ else:
280
+ items = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)[:limit]
275
281
  return {"source": source, "items": items, "count": len(items)}
276
282
  if source == "agent":
277
283
  items = self._snapshots(workspace_id=workspace_id)[:limit]
@@ -287,12 +293,39 @@ class MemoryService:
287
293
  raise KeyError(source)
288
294
 
289
295
  # ── mutating operations ───────────────────────────────────────────────
290
- def prune(self, *, ids: Optional[List[str]] = None, kind: Optional[str] = None, user_email: Optional[str] = None) -> Dict[str, Any]:
296
+ def prune(
297
+ self,
298
+ *,
299
+ ids: Optional[List[str]] = None,
300
+ kind: Optional[str] = None,
301
+ user_email: Optional[str] = None,
302
+ workspace_id: Optional[str] = None,
303
+ ) -> Dict[str, Any]:
304
+ # Ownership guard: a caller may only prune memories they own. Both the
305
+ # explicit-id and kind paths are intersected with the caller's own
306
+ # memories, so a forged id for another user's memory is refused, not
307
+ # silently deleted.
308
+ owned_ids = {
309
+ m["id"]
310
+ for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
311
+ if m.get("id")
312
+ }
291
313
  removed: List[str] = []
292
- target_ids = list(ids or [])
314
+ skipped: List[str] = []
315
+ target_ids: List[str] = []
316
+ seen: set = set()
317
+ for mid in (ids or []):
318
+ if mid in seen:
319
+ continue
320
+ seen.add(mid)
321
+ if mid in owned_ids:
322
+ target_ids.append(mid)
323
+ else:
324
+ skipped.append(mid)
293
325
  if kind:
294
- for m in self._workspace_memories(user_email=user_email, workspace_id=None):
295
- if m.get("kind") == kind and m.get("id"):
326
+ for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id):
327
+ if m.get("kind") == kind and m.get("id") and m["id"] not in seen:
328
+ seen.add(m["id"])
296
329
  target_ids.append(m["id"])
297
330
  for mid in target_ids:
298
331
  try:
@@ -300,14 +333,22 @@ class MemoryService:
300
333
  removed.append(mid)
301
334
  except Exception:
302
335
  continue
303
- return {"removed": removed, "count": len(removed)}
336
+ result: Dict[str, Any] = {"removed": removed, "count": len(removed)}
337
+ if skipped:
338
+ result["skipped"] = skipped
339
+ return result
304
340
 
305
- def compact(self, *, user_email: Optional[str] = None) -> Dict[str, Any]:
341
+ def compact(
342
+ self,
343
+ *,
344
+ user_email: Optional[str] = None,
345
+ workspace_id: Optional[str] = None,
346
+ ) -> Dict[str, Any]:
306
347
  """Dedupe workspace memories with identical (kind, content)."""
307
348
  seen: set = set()
308
349
  removed: List[str] = []
309
350
  # Oldest first so the first occurrence (oldest) is kept.
310
- memories = list(reversed(self._workspace_memories(user_email=user_email, workspace_id=None)))
351
+ memories = list(reversed(self._workspace_memories(user_email=user_email, workspace_id=workspace_id)))
311
352
  for m in memories:
312
353
  key = (m.get("kind"), str(m.get("content") or "").strip())
313
354
  if key in seen:
@@ -332,21 +373,23 @@ class MemoryService:
332
373
  return {"status": "error", "detail": str(exc)}
333
374
  return {"status": "error", "detail": f"Unknown rebuild target: {target}"}
334
375
 
335
- def clear(self, *, scope: str, confirm: bool = False, user_email: Optional[str] = None) -> Dict[str, Any]:
376
+ def clear(
377
+ self,
378
+ *,
379
+ scope: str,
380
+ confirm: bool = False,
381
+ user_email: Optional[str] = None,
382
+ workspace_id: Optional[str] = None,
383
+ ) -> Dict[str, Any]:
336
384
  if not confirm:
337
385
  raise ValueError("clear requires confirm=true")
338
386
  if scope in WORKSPACE_KINDS:
339
- result = self.prune(kind=scope, user_email=user_email)
387
+ result = self.prune(kind=scope, user_email=user_email, workspace_id=workspace_id)
340
388
  return {"cleared": scope, **result}
341
389
  if scope == "workspace":
342
- ids = [m["id"] for m in self._workspace_memories(user_email=user_email, workspace_id=None) if m.get("id")]
343
- result = self.prune(ids=ids, user_email=user_email)
390
+ ids = [m["id"] for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id) if m.get("id")]
391
+ result = self.prune(ids=ids, user_email=user_email, workspace_id=workspace_id)
344
392
  return {"cleared": "workspace", **result}
345
393
  if scope == "graph":
346
- if not self._enable_graph:
347
- return {"status": "unavailable", "detail": "Knowledge graph disabled."}
348
- try:
349
- return {"cleared": "graph", "result": self._kg.clear_all()}
350
- except Exception as exc:
351
- return {"status": "error", "detail": str(exc)}
394
+ raise ValueError("graph clear is disabled from Memory Manager because it is not workspace-scoped")
352
395
  raise ValueError(f"unsupported clear scope: {scope}")
@@ -34,6 +34,10 @@ class ToolRouterContext:
34
34
  install_mcp: Any
35
35
  mcp_public_item: Any
36
36
  hooks: Any = None
37
+ # Resolves a caller email to their allowed workspace set (None = no scoping,
38
+ # i.e. single-user / no-auth mode). Threaded to the knowledge-graph router so
39
+ # its read endpoints enforce the same workspace boundary as /api/search.
40
+ allowed_workspaces_for: Any = None
37
41
 
38
42
 
39
43
  @dataclass(frozen=True)
@@ -179,10 +179,12 @@ class SearchService:
179
179
  allowed_workspaces=None,
180
180
  ) -> Dict[str, Any]:
181
181
  weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
182
+ # Scope each channel at the source so out-of-scope rows never enter the
183
+ # fusion set (defense-in-depth — the fused result is re-scoped below too).
182
184
  channels = {
183
- "keyword": self.keyword_search(query, limit=keyword_limit),
184
- "vector": self.vector_search(query, limit=vector_limit),
185
- "graph": self.graph_search(query, limit=graph_limit),
185
+ "keyword": self.keyword_search(query, limit=keyword_limit, allowed_workspaces=allowed_workspaces),
186
+ "vector": self.vector_search(query, limit=vector_limit, allowed_workspaces=allowed_workspaces),
187
+ "graph": self.graph_search(query, limit=graph_limit, allowed_workspaces=allowed_workspaces),
186
188
  }
187
189
  fused: Dict[str, Dict[str, Any]] = {}
188
190
  for source, payload in channels.items():
@@ -234,14 +236,50 @@ class SearchService:
234
236
  "matches": matches,
235
237
  }
236
238
 
237
- def graph(self, *, limit: int = 300) -> Dict[str, Any]:
238
- return self._require_graph().graph(limit=limit)
239
-
240
- def node(self, node_id: str, *, include_neighbors: bool = True, depth: int = 1, limit: int = 100) -> Dict[str, Any]:
239
+ def graph(self, *, limit: int = 300, allowed_workspaces=None) -> Dict[str, Any]:
241
240
  graph = self._require_graph()
242
- payload = {"node": graph.get_node(node_id)}
241
+ try:
242
+ return graph.graph(limit=limit, allowed_workspaces=allowed_workspaces)
243
+ except TypeError:
244
+ payload = graph.graph(limit=limit)
245
+ if allowed_workspaces is not None:
246
+ nodes = graph.filter_scoped_nodes(payload.get("nodes", []), allowed_workspaces)
247
+ kept = {node.get("id") for node in nodes}
248
+ edges = [
249
+ edge for edge in payload.get("edges", [])
250
+ if edge.get("from") in kept and edge.get("to") in kept
251
+ ]
252
+ payload = {**payload, "nodes": nodes, "edges": edges}
253
+ return payload
254
+
255
+ def node(
256
+ self,
257
+ node_id: str,
258
+ *,
259
+ include_neighbors: bool = True,
260
+ depth: int = 1,
261
+ limit: int = 100,
262
+ allowed_workspaces=None,
263
+ ) -> Dict[str, Any]:
264
+ graph = self._require_graph()
265
+ node = graph.get_node(node_id)
266
+ if allowed_workspaces is not None:
267
+ visible = graph.filter_scoped_nodes([node], allowed_workspaces)
268
+ if not visible:
269
+ raise ValueError(f"graph node not found: {node_id}")
270
+ node = visible[0]
271
+ payload = {"node": node}
243
272
  if include_neighbors:
244
- payload["neighborhood"] = graph.traverse(node_id, depth=depth, limit=limit)
273
+ neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
274
+ if allowed_workspaces is not None:
275
+ nodes = graph.filter_scoped_nodes(neighborhood.get("nodes", []), allowed_workspaces)
276
+ kept = {item.get("id") for item in nodes}
277
+ edges = [
278
+ edge for edge in neighborhood.get("edges", [])
279
+ if edge.get("from") in kept and edge.get("to") in kept
280
+ ]
281
+ neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
282
+ payload["neighborhood"] = neighborhood
245
283
  return payload
246
284
 
247
285
  def relationships(
@@ -251,13 +289,26 @@ class SearchService:
251
289
  node_id: str = "",
252
290
  relationship_type: str = "",
253
291
  limit: int = 30,
292
+ allowed_workspaces=None,
254
293
  ) -> Dict[str, Any]:
255
- return self._require_graph().relationship_search(
294
+ graph = self._require_graph()
295
+ payload = graph.relationship_search(
256
296
  query=query,
257
297
  node_id=node_id,
258
298
  relationship_type=relationship_type,
259
299
  limit=limit,
260
300
  )
301
+ if allowed_workspaces is not None:
302
+ kept = []
303
+ for rel in payload.get("relationships", []):
304
+ endpoints = [
305
+ {"id": (rel.get("source") or {}).get("id")},
306
+ {"id": (rel.get("target") or {}).get("id")},
307
+ ]
308
+ if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
309
+ kept.append(rel)
310
+ payload = {**payload, "relationships": kept}
311
+ return payload
261
312
 
262
313
  def index_status(self) -> Dict[str, Any]:
263
314
  return self._require_graph().index_status()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "6.3.1",
3
+ "version": "6.4.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -1654,7 +1654,7 @@ dependencies = [
1654
1654
 
1655
1655
  [[package]]
1656
1656
  name = "lattice-ai-desktop"
1657
- version = "6.3.1"
1657
+ version = "6.4.0"
1658
1658
  dependencies = [
1659
1659
  "plist",
1660
1660
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "6.3.1"
3
+ version = "6.4.0"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "6.3.1",
4
+ "version": "6.4.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "6.3.1",
2
+ "version": "6.4.0",
3
3
  "generated_at": "vite",
4
4
  "entrypoints": {
5
5
  "app": "/static/app/index.html"