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 +34 -41
- package/docs/CHANGELOG.md +31 -0
- package/lattice_brain/__init__.py +3 -1
- package/lattice_brain/quality.py +409 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/knowledge_graph.py +71 -10
- package/latticeai/api/local_files.py +2 -0
- package/latticeai/api/memory.py +6 -6
- package/latticeai/api/search.py +9 -1
- package/latticeai/api/tools.py +3 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/chat_wiring.py +1 -0
- package/latticeai/services/memory_service.py +64 -21
- package/latticeai/services/router_context.py +4 -0
- package/latticeai/services/search_service.py +61 -10
- package/package.json +1 -1
- 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 +1 -1
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
124
124
|
|
|
125
125
|
Screenshot index and capture notes:
|
|
126
|
-
[output/release/v6.
|
|
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.
|
|
204
|
-
|
|
205
|
-
- `
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
-
|
|
211
|
-
|
|
212
|
-
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
-
|
|
227
|
-
|
|
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.
|
|
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.
|
|
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")
|
package/latticeai/__init__.py
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
140
|
+
kg, allowed = _scoped(request)
|
|
100
141
|
if not q or not q.strip():
|
|
101
142
|
return {"query": q, "matches": []}
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
160
|
+
kg, allowed = _scoped(request)
|
|
112
161
|
if not node_id:
|
|
113
162
|
raise HTTPException(status_code=400, detail="node_id required")
|
|
114
|
-
|
|
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
|
|
package/latticeai/api/memory.py
CHANGED
|
@@ -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)
|
package/latticeai/api/search.py
CHANGED
|
@@ -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 = {
|
|
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
|
package/latticeai/api/tools.py
CHANGED
|
@@ -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,
|
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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=
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED