ltcai 9.2.0 → 9.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 -22
- package/docs/CHANGELOG.md +49 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/docs/mcp-tools.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/automation_intelligence.py +129 -0
- package/latticeai/api/brain_intelligence.py +68 -0
- package/latticeai/app_factory.py +17 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/persistence_runtime.py +15 -0
- package/latticeai/runtime/router_registration.py +16 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +450 -0
- package/latticeai/services/brain_intelligence.py +439 -0
- package/latticeai/services/memory_service.py +65 -5
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/router_context.py +1 -0
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +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 +11 -11
- package/static/app/assets/Act-t9oveJO7.js +1 -0
- package/static/app/assets/{Brain-DBYgdcjt.js → Brain-DP-gpcEJ.js} +1 -1
- package/static/app/assets/{Capture-Cf3hqRtN.js → Capture-DYknDKy8.js} +1 -1
- package/static/app/assets/{Library-CFfkNn3s.js → Library-DLyc_g8c.js} +1 -1
- package/static/app/assets/{System-BOurbT-v.js → System-BZgJ7tGu.js} +1 -1
- package/static/app/assets/index-BeQ77vPs.js +17 -0
- package/static/app/assets/index-Cl4S_9Id.css +2 -0
- package/static/app/assets/{primitives-DcUUmhdC.js → primitives-DawfkPR4.js} +1 -1
- package/static/app/assets/{textarea-BklR6zN4.js → textarea-a4Ir3SZS.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/Act-C3dBrWE-.js +0 -1
- package/static/app/assets/index-A3M9sElj.js +0 -17
- package/static/app/assets/index-Bmx9rzTc.css +0 -2
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Proactive Brain Intelligence service (v9.3.0).
|
|
2
|
+
|
|
3
|
+
The Brain graduates from a passive store to an active steward of its own
|
|
4
|
+
knowledge. This service wires the previously dormant quality layer
|
|
5
|
+
(:mod:`lattice_brain.quality` — dedupe, merge, conflict and temporal
|
|
6
|
+
contradiction detection, retention) into router-facing capabilities:
|
|
7
|
+
|
|
8
|
+
* **health_report** — scored diagnosis of the Brain across freshness,
|
|
9
|
+
connectivity, embedding coverage, and contradiction pressure, with
|
|
10
|
+
recommended next actions. Every number is read from the live stores;
|
|
11
|
+
a missing store degrades the dimension to ``unavailable``, never a guess.
|
|
12
|
+
* **insights** — a proactive digest: recent knowledge growth, most active
|
|
13
|
+
types, stale knowledge, orphan (disconnected) nodes, and suggested
|
|
14
|
+
questions grounded in real node titles.
|
|
15
|
+
* **contradictions** — surfaced conflicts across workspace memories
|
|
16
|
+
(negation/preference conflicts, temporal contradictions) plus explicit
|
|
17
|
+
CONTRADICTS edges already recorded in the graph.
|
|
18
|
+
* **consolidate** — duplicate-memory and duplicate-edge detection. Dry-run
|
|
19
|
+
by default (consent-first, like every Brain automation); ``apply=True``
|
|
20
|
+
prunes only exact duplicate workspace memories through the audited
|
|
21
|
+
MemoryService path and never touches graph content.
|
|
22
|
+
|
|
23
|
+
Pure service: no FastAPI, no globals. Collaborators are injected.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from datetime import datetime, timedelta, timezone
|
|
30
|
+
from typing import Any, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
from lattice_brain.quality import GraphEdgeQualityManager, MemoryQualityManager
|
|
33
|
+
from latticeai.core.timeutil import now_iso as _now
|
|
34
|
+
|
|
35
|
+
LOGGER = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_STALE_DAYS = 45
|
|
38
|
+
_RECENT_DAYS = 7
|
|
39
|
+
_GRAPH_SAMPLE_LIMIT = 800
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_ts(value: Any) -> Optional[datetime]:
|
|
43
|
+
text = str(value or "").strip()
|
|
44
|
+
if not text:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
if parsed.tzinfo is None:
|
|
51
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
52
|
+
return parsed
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BrainIntelligenceService:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
knowledge_graph: Any = None,
|
|
60
|
+
memory_service: Any = None,
|
|
61
|
+
enable_graph: bool = True,
|
|
62
|
+
) -> None:
|
|
63
|
+
self._kg = knowledge_graph
|
|
64
|
+
self._memory = memory_service
|
|
65
|
+
self._enable_graph = bool(enable_graph and knowledge_graph is not None)
|
|
66
|
+
self._memory_quality = MemoryQualityManager()
|
|
67
|
+
self._edge_quality = GraphEdgeQualityManager()
|
|
68
|
+
|
|
69
|
+
# ── shared graph sampling ─────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def _graph_sample(self, *, workspace_id: Optional[str]) -> Dict[str, Any]:
|
|
72
|
+
"""Recent graph slice with scoping applied. Empty when graph is off."""
|
|
73
|
+
if not self._enable_graph:
|
|
74
|
+
return {"nodes": [], "edges": [], "available": False}
|
|
75
|
+
try:
|
|
76
|
+
kwargs: Dict[str, Any] = {}
|
|
77
|
+
if workspace_id is not None:
|
|
78
|
+
kwargs["allowed_workspaces"] = {workspace_id}
|
|
79
|
+
data = self._kg.graph(_GRAPH_SAMPLE_LIMIT, **kwargs)
|
|
80
|
+
edges = []
|
|
81
|
+
for edge in data.get("edges") or []:
|
|
82
|
+
# Store emits "from"/"to"; the quality layer expects
|
|
83
|
+
# "source"/"target". Normalize once here.
|
|
84
|
+
normalized = dict(edge)
|
|
85
|
+
normalized.setdefault("source", edge.get("from"))
|
|
86
|
+
normalized.setdefault("target", edge.get("to"))
|
|
87
|
+
edges.append(normalized)
|
|
88
|
+
return {
|
|
89
|
+
"nodes": list(data.get("nodes") or []),
|
|
90
|
+
"edges": edges,
|
|
91
|
+
"available": True,
|
|
92
|
+
}
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
LOGGER.exception("brain intelligence graph sample failed")
|
|
95
|
+
return {"nodes": [], "edges": [], "available": False, "error": str(exc)}
|
|
96
|
+
|
|
97
|
+
def _workspace_memories(
|
|
98
|
+
self, *, user_email: Optional[str], workspace_id: Optional[str]
|
|
99
|
+
) -> List[Dict[str, Any]]:
|
|
100
|
+
if self._memory is None:
|
|
101
|
+
return []
|
|
102
|
+
try:
|
|
103
|
+
inspected = self._memory.inspect(
|
|
104
|
+
"workspace",
|
|
105
|
+
user_email=user_email,
|
|
106
|
+
workspace_id=workspace_id or "personal",
|
|
107
|
+
limit=500,
|
|
108
|
+
)
|
|
109
|
+
return list(inspected.get("items") or [])
|
|
110
|
+
except Exception:
|
|
111
|
+
LOGGER.exception("brain intelligence memory read failed")
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
# ── health report ─────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def health_report(
|
|
117
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
118
|
+
) -> Dict[str, Any]:
|
|
119
|
+
sample = self._graph_sample(workspace_id=workspace_id)
|
|
120
|
+
nodes, edges = sample["nodes"], sample["edges"]
|
|
121
|
+
now = datetime.now(timezone.utc)
|
|
122
|
+
dimensions: Dict[str, Dict[str, Any]] = {}
|
|
123
|
+
|
|
124
|
+
# Freshness — how much of the sampled knowledge saw recent updates.
|
|
125
|
+
if sample["available"] and nodes:
|
|
126
|
+
stale_cutoff = now - timedelta(days=_STALE_DAYS)
|
|
127
|
+
dated = [(_parse_ts(n.get("updated_at")), n) for n in nodes]
|
|
128
|
+
known = [pair for pair in dated if pair[0] is not None]
|
|
129
|
+
stale = [n for ts, n in known if ts < stale_cutoff]
|
|
130
|
+
fresh_ratio = 1.0 - (len(stale) / len(known)) if known else 0.0
|
|
131
|
+
dimensions["freshness"] = {
|
|
132
|
+
"status": "ok",
|
|
133
|
+
"score": round(fresh_ratio * 100),
|
|
134
|
+
"sampled": len(nodes),
|
|
135
|
+
"stale_nodes": len(stale),
|
|
136
|
+
"stale_threshold_days": _STALE_DAYS,
|
|
137
|
+
}
|
|
138
|
+
else:
|
|
139
|
+
dimensions["freshness"] = {"status": "unavailable", "score": None}
|
|
140
|
+
|
|
141
|
+
# Connectivity — orphan nodes are knowledge the Brain cannot reason
|
|
142
|
+
# across; a well-tended graph keeps them rare.
|
|
143
|
+
if sample["available"] and nodes:
|
|
144
|
+
connected = set()
|
|
145
|
+
for edge in edges:
|
|
146
|
+
connected.add(str(edge.get("source") or edge.get("from_node") or ""))
|
|
147
|
+
connected.add(str(edge.get("target") or edge.get("to_node") or ""))
|
|
148
|
+
orphans = [n for n in nodes if str(n.get("id")) not in connected]
|
|
149
|
+
ratio = 1.0 - (len(orphans) / len(nodes))
|
|
150
|
+
dimensions["connectivity"] = {
|
|
151
|
+
"status": "ok",
|
|
152
|
+
"score": round(ratio * 100),
|
|
153
|
+
"sampled": len(nodes),
|
|
154
|
+
"orphan_nodes": len(orphans),
|
|
155
|
+
"edges": len(edges),
|
|
156
|
+
}
|
|
157
|
+
else:
|
|
158
|
+
dimensions["connectivity"] = {"status": "unavailable", "score": None}
|
|
159
|
+
|
|
160
|
+
# Embedding coverage — semantic recall only works for indexed items.
|
|
161
|
+
index_status: Dict[str, Any] = {}
|
|
162
|
+
if self._enable_graph and hasattr(self._kg, "index_status"):
|
|
163
|
+
try:
|
|
164
|
+
index_status = self._kg.index_status()
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
LOGGER.exception("brain intelligence index status failed")
|
|
167
|
+
index_status = {"error": str(exc)}
|
|
168
|
+
scale = index_status.get("scale") or {}
|
|
169
|
+
if "coverage_ratio" in scale:
|
|
170
|
+
dimensions["embedding_coverage"] = {
|
|
171
|
+
"status": "ok",
|
|
172
|
+
"score": round(float(scale["coverage_ratio"]) * 100),
|
|
173
|
+
"ready_items": scale.get("ready_items"),
|
|
174
|
+
"pending_items": scale.get("pending_items"),
|
|
175
|
+
"needs_reindex": index_status.get("status") == "needs_reindex",
|
|
176
|
+
}
|
|
177
|
+
else:
|
|
178
|
+
dimensions["embedding_coverage"] = {"status": "unavailable", "score": None}
|
|
179
|
+
|
|
180
|
+
# Edge quality + contradiction pressure — reuses the quality layer.
|
|
181
|
+
if sample["available"] and edges:
|
|
182
|
+
metrics = self._edge_quality.compute_quality_metrics(edges)
|
|
183
|
+
contradiction_edges = [
|
|
184
|
+
e for e in edges if "CONTRADICT" in str(e.get("type") or "").upper()
|
|
185
|
+
]
|
|
186
|
+
pressure = min(1.0, metrics.get("dup_rate", 0.0) + len(contradiction_edges) / max(len(edges), 1))
|
|
187
|
+
dimensions["consistency"] = {
|
|
188
|
+
"status": "ok",
|
|
189
|
+
"score": round((1.0 - pressure) * 100),
|
|
190
|
+
"edge_metrics": metrics,
|
|
191
|
+
"contradiction_edges": len(contradiction_edges),
|
|
192
|
+
}
|
|
193
|
+
else:
|
|
194
|
+
dimensions["consistency"] = {"status": "unavailable", "score": None}
|
|
195
|
+
|
|
196
|
+
scores = [d["score"] for d in dimensions.values() if d.get("score") is not None]
|
|
197
|
+
overall = round(sum(scores) / len(scores)) if scores else None
|
|
198
|
+
grade = (
|
|
199
|
+
None if overall is None
|
|
200
|
+
else "excellent" if overall >= 85
|
|
201
|
+
else "good" if overall >= 70
|
|
202
|
+
else "attention" if overall >= 50
|
|
203
|
+
else "critical"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
actions: List[Dict[str, str]] = []
|
|
207
|
+
emb = dimensions["embedding_coverage"]
|
|
208
|
+
if emb.get("needs_reindex"):
|
|
209
|
+
actions.append({
|
|
210
|
+
"id": "rebuild_vector_index",
|
|
211
|
+
"reason": f"{emb.get('pending_items', 0)} items are missing or stale in the vector index.",
|
|
212
|
+
})
|
|
213
|
+
conn_dim = dimensions["connectivity"]
|
|
214
|
+
conn_score = conn_dim.get("score")
|
|
215
|
+
if conn_score is not None and conn_score < 70:
|
|
216
|
+
actions.append({
|
|
217
|
+
"id": "review_orphans",
|
|
218
|
+
"reason": f"{conn_dim.get('orphan_nodes', 0)} nodes have no relationships.",
|
|
219
|
+
})
|
|
220
|
+
fresh_dim = dimensions["freshness"]
|
|
221
|
+
fresh_score = fresh_dim.get("score")
|
|
222
|
+
if fresh_score is not None and fresh_score < 60:
|
|
223
|
+
actions.append({
|
|
224
|
+
"id": "refresh_stale_knowledge",
|
|
225
|
+
"reason": f"{fresh_dim.get('stale_nodes', 0)} nodes untouched for over {_STALE_DAYS} days.",
|
|
226
|
+
})
|
|
227
|
+
cons_dim = dimensions["consistency"]
|
|
228
|
+
if cons_dim.get("contradiction_edges"):
|
|
229
|
+
actions.append({
|
|
230
|
+
"id": "resolve_contradictions",
|
|
231
|
+
"reason": f"{cons_dim['contradiction_edges']} contradiction edges recorded in the graph.",
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"overall_score": overall,
|
|
236
|
+
"grade": grade,
|
|
237
|
+
"dimensions": dimensions,
|
|
238
|
+
"recommended_actions": actions,
|
|
239
|
+
"graph_available": sample["available"],
|
|
240
|
+
"generated_at": _now(),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# ── insights digest ──────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
def insights(
|
|
246
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
247
|
+
) -> Dict[str, Any]:
|
|
248
|
+
sample = self._graph_sample(workspace_id=workspace_id)
|
|
249
|
+
nodes, edges = sample["nodes"], sample["edges"]
|
|
250
|
+
now = datetime.now(timezone.utc)
|
|
251
|
+
recent_cutoff = now - timedelta(days=_RECENT_DAYS)
|
|
252
|
+
stale_cutoff = now - timedelta(days=_STALE_DAYS)
|
|
253
|
+
|
|
254
|
+
recent_nodes: List[Dict[str, Any]] = []
|
|
255
|
+
stale_nodes: List[Dict[str, Any]] = []
|
|
256
|
+
type_counts: Dict[str, int] = {}
|
|
257
|
+
for node in nodes:
|
|
258
|
+
node_type = str(node.get("type") or "node")
|
|
259
|
+
ts = _parse_ts(node.get("updated_at"))
|
|
260
|
+
if ts is not None and ts >= recent_cutoff:
|
|
261
|
+
recent_nodes.append(node)
|
|
262
|
+
type_counts[node_type] = type_counts.get(node_type, 0) + 1
|
|
263
|
+
elif ts is not None and ts < stale_cutoff:
|
|
264
|
+
stale_nodes.append(node)
|
|
265
|
+
|
|
266
|
+
connected = set()
|
|
267
|
+
for edge in edges:
|
|
268
|
+
connected.add(str(edge.get("source") or edge.get("from_node") or ""))
|
|
269
|
+
connected.add(str(edge.get("target") or edge.get("to_node") or ""))
|
|
270
|
+
orphans = [n for n in nodes if str(n.get("id")) not in connected]
|
|
271
|
+
|
|
272
|
+
def _slim(node: Dict[str, Any]) -> Dict[str, Any]:
|
|
273
|
+
return {
|
|
274
|
+
"id": node.get("id"),
|
|
275
|
+
"type": node.get("type"),
|
|
276
|
+
"title": str(node.get("title") or "")[:120],
|
|
277
|
+
"updated_at": node.get("updated_at"),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
trending = sorted(type_counts.items(), key=lambda kv: kv[1], reverse=True)[:5]
|
|
281
|
+
suggested_questions = [
|
|
282
|
+
f"{str(node.get('title') or '').strip()[:60]}에 대해 지금까지 알고 있는 것을 정리해줘"
|
|
283
|
+
for node in recent_nodes[:3]
|
|
284
|
+
if str(node.get("title") or "").strip()
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
"window_days": _RECENT_DAYS,
|
|
289
|
+
"activity": {
|
|
290
|
+
"recent_nodes": len(recent_nodes),
|
|
291
|
+
"recent_samples": [_slim(n) for n in recent_nodes[:8]],
|
|
292
|
+
"trending_types": [{"type": t, "count": c} for t, c in trending],
|
|
293
|
+
},
|
|
294
|
+
"attention": {
|
|
295
|
+
"stale_nodes": len(stale_nodes),
|
|
296
|
+
"stale_samples": [_slim(n) for n in stale_nodes[:8]],
|
|
297
|
+
"orphan_nodes": len(orphans),
|
|
298
|
+
"orphan_samples": [_slim(n) for n in orphans[:8]],
|
|
299
|
+
},
|
|
300
|
+
"suggested_questions": suggested_questions,
|
|
301
|
+
"graph_available": sample["available"],
|
|
302
|
+
"generated_at": _now(),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# ── contradictions ───────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
def contradictions(
|
|
308
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
309
|
+
) -> Dict[str, Any]:
|
|
310
|
+
memories = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
|
|
311
|
+
memory_rows = [
|
|
312
|
+
{
|
|
313
|
+
"id": str(m.get("id") or f"mem-{i}"),
|
|
314
|
+
"content": str(m.get("content") or ""),
|
|
315
|
+
"score": 0.6,
|
|
316
|
+
"source": "workspace",
|
|
317
|
+
"timestamp": m.get("created_at") or m.get("timestamp") or 0,
|
|
318
|
+
}
|
|
319
|
+
for i, m in enumerate(memories)
|
|
320
|
+
if str(m.get("content") or "").strip()
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
conflicts: List[Dict[str, Any]] = []
|
|
324
|
+
candidates = self._memory_quality.extract_candidates(memory_rows)
|
|
325
|
+
for candidate in self._memory_quality.detect_conflicts(candidates):
|
|
326
|
+
pair_conflicts = [c for c in candidate.conflicts if c.startswith("conflict:contradicts:")]
|
|
327
|
+
for marker in pair_conflicts:
|
|
328
|
+
other_id = marker.rsplit(":", 1)[-1]
|
|
329
|
+
if any(
|
|
330
|
+
c["kind"] == "memory_pair"
|
|
331
|
+
and {c["left_id"], c["right_id"]} == {candidate.id, other_id}
|
|
332
|
+
for c in conflicts
|
|
333
|
+
):
|
|
334
|
+
continue
|
|
335
|
+
other = next((r for r in memory_rows if r["id"] == other_id), None)
|
|
336
|
+
conflicts.append({
|
|
337
|
+
"kind": "memory_pair",
|
|
338
|
+
"left_id": candidate.id,
|
|
339
|
+
"left_content": candidate.content[:200],
|
|
340
|
+
"right_id": other_id,
|
|
341
|
+
"right_content": (other or {}).get("content", "")[:200],
|
|
342
|
+
"signal": "preference_negation",
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
temporal = self._memory_quality.detect_temporal_contradictions(memory_rows)
|
|
346
|
+
temporal_items = [
|
|
347
|
+
{
|
|
348
|
+
"kind": "temporal",
|
|
349
|
+
"id": item.get("id"),
|
|
350
|
+
"content": str(item.get("content") or "")[:200],
|
|
351
|
+
"signal": item.get("proactive_flag"),
|
|
352
|
+
}
|
|
353
|
+
for item in temporal
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
edge_items: List[Dict[str, Any]] = []
|
|
357
|
+
sample = self._graph_sample(workspace_id=workspace_id)
|
|
358
|
+
for edge in sample["edges"]:
|
|
359
|
+
if "CONTRADICT" in str(edge.get("type") or "").upper():
|
|
360
|
+
edge_items.append({
|
|
361
|
+
"kind": "graph_edge",
|
|
362
|
+
"id": edge.get("id"),
|
|
363
|
+
"source": edge.get("source") or edge.get("from_node"),
|
|
364
|
+
"target": edge.get("target") or edge.get("to_node"),
|
|
365
|
+
"signal": "contradicts_edge",
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
items = conflicts + temporal_items + edge_items
|
|
369
|
+
return {
|
|
370
|
+
"items": items,
|
|
371
|
+
"count": len(items),
|
|
372
|
+
"sources": {
|
|
373
|
+
"memory_pairs": len(conflicts),
|
|
374
|
+
"temporal": len(temporal_items),
|
|
375
|
+
"graph_edges": len(edge_items),
|
|
376
|
+
},
|
|
377
|
+
"memories_scanned": len(memory_rows),
|
|
378
|
+
"generated_at": _now(),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
# ── consolidation ────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
def consolidate(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
apply: bool = False,
|
|
387
|
+
user_email: Optional[str] = None,
|
|
388
|
+
workspace_id: Optional[str] = None,
|
|
389
|
+
) -> Dict[str, Any]:
|
|
390
|
+
memories = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
|
|
391
|
+
memory_rows = [
|
|
392
|
+
{
|
|
393
|
+
"id": str(m.get("id") or f"mem-{i}"),
|
|
394
|
+
"content": str(m.get("content") or ""),
|
|
395
|
+
"score": 0.6,
|
|
396
|
+
"source": "workspace",
|
|
397
|
+
}
|
|
398
|
+
for i, m in enumerate(memories)
|
|
399
|
+
if str(m.get("content") or "").strip()
|
|
400
|
+
]
|
|
401
|
+
candidates = self._memory_quality.extract_candidates(memory_rows)
|
|
402
|
+
kept = self._memory_quality.dedupe(candidates)
|
|
403
|
+
kept_ids = {c.id for c in kept}
|
|
404
|
+
duplicate_memory_ids = [c.id for c in candidates if c.id not in kept_ids]
|
|
405
|
+
|
|
406
|
+
sample = self._graph_sample(workspace_id=workspace_id)
|
|
407
|
+
duplicate_edge_ids = [
|
|
408
|
+
edge_id
|
|
409
|
+
for edge_id in self._edge_quality.detect_duplicate_edges(sample["edges"])
|
|
410
|
+
if edge_id
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
pruned = 0
|
|
414
|
+
if apply and duplicate_memory_ids and self._memory is not None:
|
|
415
|
+
try:
|
|
416
|
+
result = self._memory.prune(
|
|
417
|
+
ids=duplicate_memory_ids,
|
|
418
|
+
user_email=user_email,
|
|
419
|
+
workspace_id=workspace_id,
|
|
420
|
+
)
|
|
421
|
+
pruned = int(result.get("count") or 0)
|
|
422
|
+
except Exception:
|
|
423
|
+
LOGGER.exception("consolidation prune failed")
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
"mode": "applied" if apply else "dry_run",
|
|
427
|
+
"memories_scanned": len(memory_rows),
|
|
428
|
+
"duplicate_memories": duplicate_memory_ids,
|
|
429
|
+
"duplicate_memory_count": len(duplicate_memory_ids),
|
|
430
|
+
"pruned": pruned,
|
|
431
|
+
# Graph edges are reported for review only; consolidation never
|
|
432
|
+
# mutates graph content directly.
|
|
433
|
+
"duplicate_edges": duplicate_edge_ids[:50],
|
|
434
|
+
"duplicate_edge_count": len(duplicate_edge_ids),
|
|
435
|
+
"generated_at": _now(),
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
__all__ = ["BrainIntelligenceService"]
|
|
@@ -901,15 +901,75 @@ class MemoryService:
|
|
|
901
901
|
"matched_terms": matched,
|
|
902
902
|
})
|
|
903
903
|
|
|
904
|
-
#
|
|
905
|
-
#
|
|
906
|
-
#
|
|
907
|
-
#
|
|
904
|
+
# v9.3.0 hybrid recall: blend semantic similarity from the vector
|
|
905
|
+
# index into the lexical ranking. Vector evidence lets recall find
|
|
906
|
+
# knowledge phrased differently from the query — the main lexical
|
|
907
|
+
# blind spot. The vector tier is optional: any failure degrades recall
|
|
908
|
+
# back to pure lexical instead of breaking it.
|
|
909
|
+
vector_used = False
|
|
910
|
+
if self._enable_graph and q and hasattr(self._kg, "vector_search"):
|
|
911
|
+
try:
|
|
912
|
+
vector_hits = list(
|
|
913
|
+
self._kg.vector_search(q, limit=limit).get("matches", [])
|
|
914
|
+
)
|
|
915
|
+
# Workspace scoping is server-owned: the vector index is
|
|
916
|
+
# global, so scoped calls must filter matches to visible
|
|
917
|
+
# nodes before they can influence results.
|
|
918
|
+
if workspace_id is not None and vector_hits and hasattr(self._kg, "filter_scoped_nodes"):
|
|
919
|
+
vector_hits = self._kg.filter_scoped_nodes(
|
|
920
|
+
vector_hits, {workspace_id}, id_key="node_id"
|
|
921
|
+
)
|
|
922
|
+
vector_used = True
|
|
923
|
+
except Exception as exc:
|
|
924
|
+
LOGGER.exception("vector recall failed; falling back to lexical")
|
|
925
|
+
errors.append({"source": "vector", "detail": str(exc)})
|
|
926
|
+
vector_hits = []
|
|
927
|
+
by_node_id = {str(r.get("id")): r for r in results if r.get("source") == "graph"}
|
|
928
|
+
for hit in vector_hits:
|
|
929
|
+
node_id = str(hit.get("node_id") or hit.get("id") or "")
|
|
930
|
+
similarity = round(float(hit.get("score") or 0.0), 4)
|
|
931
|
+
if similarity <= 0:
|
|
932
|
+
continue
|
|
933
|
+
existing = by_node_id.get(node_id)
|
|
934
|
+
if existing is not None:
|
|
935
|
+
existing["vector_score"] = max(existing.get("vector_score", 0.0), similarity)
|
|
936
|
+
existing["score"] = round(
|
|
937
|
+
max(existing.get("score", 0.0), 0.4 * existing.get("score", 0.0) + 0.6 * similarity),
|
|
938
|
+
4,
|
|
939
|
+
)
|
|
940
|
+
else:
|
|
941
|
+
matched = _matched_terms(hit.get("title"), hit.get("summary"))
|
|
942
|
+
row = {
|
|
943
|
+
"source": "graph",
|
|
944
|
+
"id": node_id or hit.get("id"),
|
|
945
|
+
"title": hit.get("title") or "node",
|
|
946
|
+
"snippet": str(hit.get("summary") or "")[:240],
|
|
947
|
+
"kind": hit.get("type") or "node",
|
|
948
|
+
"score": round(max(_lexical_score(matched), 0.6 * similarity), 4),
|
|
949
|
+
"matched_terms": matched,
|
|
950
|
+
"vector_score": similarity,
|
|
951
|
+
}
|
|
952
|
+
results.append(row)
|
|
953
|
+
if node_id:
|
|
954
|
+
by_node_id[node_id] = row
|
|
955
|
+
|
|
956
|
+
# Quality gate: when at least one result carries real evidence
|
|
957
|
+
# (lexical term hits, or semantic similarity in hybrid mode),
|
|
958
|
+
# zero-score rows are noise relative to it and are dropped. When
|
|
959
|
+
# nothing scores (e.g. tokenization mismatch), everything is kept so
|
|
960
|
+
# the tiers' own search filters still decide — the gate never empties
|
|
961
|
+
# a recall.
|
|
908
962
|
candidates = len(results)
|
|
909
963
|
if query_tokens and any(r.get("score", 0) > 0 for r in results):
|
|
910
964
|
results = [r for r in results if r.get("score", 0) > 0]
|
|
911
965
|
for r in results:
|
|
912
966
|
r["confidence"] = "high" if r.get("score", 0) >= 0.65 else "medium" if r.get("score", 0) >= 0.3 else "low"
|
|
967
|
+
evidence = []
|
|
968
|
+
if r.get("matched_terms"):
|
|
969
|
+
evidence.append("lexical")
|
|
970
|
+
if r.get("vector_score"):
|
|
971
|
+
evidence.append("semantic")
|
|
972
|
+
r["evidence_kinds"] = evidence
|
|
913
973
|
|
|
914
974
|
results.sort(key=lambda r: r.get("score", 0), reverse=True)
|
|
915
975
|
return {
|
|
@@ -923,7 +983,7 @@ class MemoryService:
|
|
|
923
983
|
"candidates": candidates,
|
|
924
984
|
"passed": len(results),
|
|
925
985
|
"filtered": candidates - len(results),
|
|
926
|
-
"gate": "lexical-evidence/v1",
|
|
986
|
+
"gate": "hybrid-evidence/v2" if vector_used else "lexical-evidence/v1",
|
|
927
987
|
},
|
|
928
988
|
}
|
|
929
989
|
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ const root = process.cwd();
|
|
|
6
6
|
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
7
7
|
const version = pkg.version;
|
|
8
8
|
const releaseDir = `output/release/v${version}`;
|
|
9
|
-
const releaseTheme = "
|
|
9
|
+
const releaseTheme = "Question-Driven Everyday Automation";
|
|
10
10
|
const title = `${version} — ${releaseTheme}`;
|
|
11
11
|
const escapedVersion = version.replaceAll(".", "\\.");
|
|
12
12
|
|
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "9.
|
|
2
|
+
"version": "9.4.0",
|
|
3
3
|
"generated_at": "vite",
|
|
4
4
|
"entrypoints": {
|
|
5
|
-
"app": "/static/app/assets/index-
|
|
5
|
+
"app": "/static/app/assets/index-BeQ77vPs.js"
|
|
6
6
|
},
|
|
7
7
|
"assets": {
|
|
8
8
|
"../node_modules/@tauri-apps/api/core.js": "/static/app/assets/core-CwxXejkd.js",
|
|
9
|
-
"_primitives-
|
|
10
|
-
"_textarea-
|
|
11
|
-
"index.html": "/static/app/assets/index-
|
|
12
|
-
"assets/index-
|
|
13
|
-
"src/pages/Act.tsx": "/static/app/assets/Act-
|
|
14
|
-
"src/pages/Brain.tsx": "/static/app/assets/Brain-
|
|
15
|
-
"src/pages/Capture.tsx": "/static/app/assets/Capture-
|
|
16
|
-
"src/pages/Library.tsx": "/static/app/assets/Library-
|
|
17
|
-
"src/pages/System.tsx": "/static/app/assets/System-
|
|
9
|
+
"_primitives-DawfkPR4.js": "/static/app/assets/primitives-DawfkPR4.js",
|
|
10
|
+
"_textarea-a4Ir3SZS.js": "/static/app/assets/textarea-a4Ir3SZS.js",
|
|
11
|
+
"index.html": "/static/app/assets/index-BeQ77vPs.js",
|
|
12
|
+
"assets/index-Cl4S_9Id.css": "/static/app/assets/index-Cl4S_9Id.css",
|
|
13
|
+
"src/pages/Act.tsx": "/static/app/assets/Act-t9oveJO7.js",
|
|
14
|
+
"src/pages/Brain.tsx": "/static/app/assets/Brain-DP-gpcEJ.js",
|
|
15
|
+
"src/pages/Capture.tsx": "/static/app/assets/Capture-DYknDKy8.js",
|
|
16
|
+
"src/pages/Library.tsx": "/static/app/assets/Library-DLyc_g8c.js",
|
|
17
|
+
"src/pages/System.tsx": "/static/app/assets/System-BZgJ7tGu.js"
|
|
18
18
|
}
|
|
19
19
|
}
|