ltcai 6.3.1 → 6.5.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 +35 -41
- package/docs/CHANGELOG.md +59 -0
- package/frontend/src/api/client.ts +1 -0
- package/frontend/src/features/brain/BrainConversation.tsx +12 -3
- package/frontend/src/features/brain/BrainHome.tsx +21 -1
- package/frontend/src/features/brain/BrainMemoryLayer.tsx +8 -1
- package/frontend/src/features/brain/BrainOverviewPanel.tsx +15 -1
- package/frontend/src/features/brain/brainData.ts +72 -1
- package/frontend/src/features/brain/types.ts +15 -0
- package/frontend/src/i18n.ts +22 -0
- package/frontend/src/styles.css +128 -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 +12 -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 +130 -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 +5 -5
- package/static/app/assets/index-C1J_1441.js +16 -0
- package/static/app/assets/index-C1J_1441.js.map +1 -0
- package/static/app/assets/{index-Div5vMlq.css → index-CdCVz_i4.css} +1 -1
- package/static/app/index.html +2 -2
- package/static/app/assets/index-t1jx1BR9.js +0 -16
- package/static/app/assets/index-t1jx1BR9.js.map +0 -1
package/frontend/src/styles.css
CHANGED
|
@@ -1421,7 +1421,65 @@ body {
|
|
|
1421
1421
|
line-height: 1.35;
|
|
1422
1422
|
}
|
|
1423
1423
|
|
|
1424
|
+
.brain-readiness-strip {
|
|
1425
|
+
display: grid;
|
|
1426
|
+
grid-template-columns: minmax(0, 1fr) minmax(5.6rem, 0.6fr) auto;
|
|
1427
|
+
align-items: center;
|
|
1428
|
+
gap: 0.7rem;
|
|
1429
|
+
margin-top: 0.75rem;
|
|
1430
|
+
border: 1px solid hsl(var(--brain-core) / 0.28);
|
|
1431
|
+
border-radius: 8px;
|
|
1432
|
+
background: hsl(var(--brain-core) / 0.06);
|
|
1433
|
+
padding: 0.66rem;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
.brain-readiness-strip span {
|
|
1437
|
+
display: block;
|
|
1438
|
+
color: hsl(var(--fg-muted));
|
|
1439
|
+
font-size: 0.64rem;
|
|
1440
|
+
font-weight: 820;
|
|
1441
|
+
text-transform: uppercase;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
.brain-readiness-strip strong {
|
|
1445
|
+
display: block;
|
|
1446
|
+
margin-top: 0.12rem;
|
|
1447
|
+
font-size: 0.82rem;
|
|
1448
|
+
line-height: 1.2;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
.brain-readiness-strip button {
|
|
1452
|
+
min-height: 2rem;
|
|
1453
|
+
border: 1px solid hsl(var(--brain-core) / 0.34);
|
|
1454
|
+
border-radius: 999px;
|
|
1455
|
+
background: hsl(var(--surface) / 0.78);
|
|
1456
|
+
color: hsl(var(--fg));
|
|
1457
|
+
padding: 0 0.72rem;
|
|
1458
|
+
font-size: 0.76rem;
|
|
1459
|
+
font-weight: 760;
|
|
1460
|
+
white-space: nowrap;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
.brain-readiness-meter {
|
|
1464
|
+
height: 0.42rem;
|
|
1465
|
+
overflow: hidden;
|
|
1466
|
+
border-radius: 999px;
|
|
1467
|
+
background: hsl(var(--bg) / 0.55);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
.brain-readiness-meter i {
|
|
1471
|
+
display: block;
|
|
1472
|
+
height: 100%;
|
|
1473
|
+
border-radius: inherit;
|
|
1474
|
+
background: linear-gradient(90deg, hsl(var(--memory)), hsl(var(--knowledge)), hsl(var(--brain-core)));
|
|
1475
|
+
transition: width 260ms ease;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1424
1478
|
.brain-save-feedback {
|
|
1479
|
+
display: grid;
|
|
1480
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
1481
|
+
align-items: center;
|
|
1482
|
+
gap: 0.22rem 0.48rem;
|
|
1425
1483
|
border: 1px solid hsl(var(--memory) / 0.38);
|
|
1426
1484
|
border-radius: 8px;
|
|
1427
1485
|
background: hsl(var(--memory) / 0.08);
|
|
@@ -1431,6 +1489,13 @@ body {
|
|
|
1431
1489
|
font-weight: 720;
|
|
1432
1490
|
}
|
|
1433
1491
|
|
|
1492
|
+
.brain-save-feedback small {
|
|
1493
|
+
grid-column: 2;
|
|
1494
|
+
color: hsl(var(--fg-muted));
|
|
1495
|
+
font-size: 0.72rem;
|
|
1496
|
+
font-weight: 560;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1434
1499
|
.brain-message {
|
|
1435
1500
|
max-width: 78%;
|
|
1436
1501
|
margin-bottom: 1.1rem;
|
|
@@ -1911,6 +1976,59 @@ body {
|
|
|
1911
1976
|
outline: none;
|
|
1912
1977
|
}
|
|
1913
1978
|
|
|
1979
|
+
.brain-depth-rail {
|
|
1980
|
+
position: absolute;
|
|
1981
|
+
left: 50%;
|
|
1982
|
+
top: 0.85rem;
|
|
1983
|
+
z-index: 6;
|
|
1984
|
+
display: flex;
|
|
1985
|
+
width: min(92vw, 35rem);
|
|
1986
|
+
transform: translateX(-50%);
|
|
1987
|
+
gap: 0.28rem;
|
|
1988
|
+
border: 1px solid hsl(var(--border) / 0.48);
|
|
1989
|
+
border-radius: 999px;
|
|
1990
|
+
background: hsl(var(--surface-glass));
|
|
1991
|
+
padding: 0.32rem;
|
|
1992
|
+
box-shadow: 0 12px 30px hsl(200 30% 3% / 0.18);
|
|
1993
|
+
backdrop-filter: blur(18px);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
.brain-depth-rail button {
|
|
1997
|
+
display: inline-grid;
|
|
1998
|
+
min-width: 0;
|
|
1999
|
+
flex: 1 1 0;
|
|
2000
|
+
align-items: center;
|
|
2001
|
+
gap: 0.1rem;
|
|
2002
|
+
border: 0;
|
|
2003
|
+
border-radius: 999px;
|
|
2004
|
+
background: transparent;
|
|
2005
|
+
color: hsl(var(--fg-muted));
|
|
2006
|
+
padding: 0.36rem 0.42rem;
|
|
2007
|
+
text-align: center;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
.brain-depth-rail span {
|
|
2011
|
+
font-size: 0.58rem;
|
|
2012
|
+
font-weight: 840;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
.brain-depth-rail strong {
|
|
2016
|
+
overflow: hidden;
|
|
2017
|
+
text-overflow: ellipsis;
|
|
2018
|
+
white-space: nowrap;
|
|
2019
|
+
font-size: 0.66rem;
|
|
2020
|
+
line-height: 1.05;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
.brain-depth-rail button.is-revealed {
|
|
2024
|
+
background: hsl(var(--brain-core) / 0.1);
|
|
2025
|
+
color: hsl(var(--fg));
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
.brain-depth-rail button[aria-current="step"] {
|
|
2029
|
+
box-shadow: 0 0 0 1px hsl(var(--brain-core) / 0.38) inset;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1914
2032
|
.memory-fragment,
|
|
1915
2033
|
.concept-signal,
|
|
1916
2034
|
.graph-node {
|
|
@@ -2833,6 +2951,16 @@ body {
|
|
|
2833
2951
|
.brain-depth-actions button {
|
|
2834
2952
|
flex: 0 0 auto;
|
|
2835
2953
|
}
|
|
2954
|
+
.brain-depth-rail {
|
|
2955
|
+
top: 0.35rem;
|
|
2956
|
+
width: min(100vw - 1rem, 25rem);
|
|
2957
|
+
}
|
|
2958
|
+
.brain-depth-rail strong {
|
|
2959
|
+
display: none;
|
|
2960
|
+
}
|
|
2961
|
+
.brain-readiness-strip {
|
|
2962
|
+
grid-template-columns: 1fr;
|
|
2963
|
+
}
|
|
2836
2964
|
.brain-overview-grid {
|
|
2837
2965
|
grid-template-columns: 1fr;
|
|
2838
2966
|
}
|
|
@@ -26,7 +26,7 @@ from .storage import (
|
|
|
26
26
|
storage_from_env,
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
-
__version__ = "6.
|
|
29
|
+
__version__ = "6.5.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.5.0"
|
|
23
23
|
|
|
24
24
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
25
25
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED