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.
Files changed (49) hide show
  1. package/README.md +34 -22
  2. package/docs/CHANGELOG.md +49 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
  4. package/docs/DEVELOPMENT.md +8 -8
  5. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  6. package/docs/ONBOARDING.md +2 -2
  7. package/docs/OPERATIONS.md +1 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/docs/mcp-tools.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/runtime/multi_agent.py +1 -1
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/automation_intelligence.py +129 -0
  16. package/latticeai/api/brain_intelligence.py +68 -0
  17. package/latticeai/app_factory.py +17 -0
  18. package/latticeai/core/legacy_compatibility.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/workspace_os.py +1 -1
  21. package/latticeai/runtime/chat_wiring.py +2 -0
  22. package/latticeai/runtime/persistence_runtime.py +15 -0
  23. package/latticeai/runtime/router_registration.py +16 -0
  24. package/latticeai/services/architecture_readiness.py +1 -1
  25. package/latticeai/services/automation_intelligence.py +450 -0
  26. package/latticeai/services/brain_intelligence.py +439 -0
  27. package/latticeai/services/memory_service.py +65 -5
  28. package/latticeai/services/product_readiness.py +1 -1
  29. package/latticeai/services/router_context.py +1 -0
  30. package/package.json +1 -1
  31. package/scripts/check_current_release_docs.mjs +1 -1
  32. package/src-tauri/Cargo.lock +1 -1
  33. package/src-tauri/Cargo.toml +1 -1
  34. package/src-tauri/tauri.conf.json +1 -1
  35. package/static/app/asset-manifest.json +11 -11
  36. package/static/app/assets/Act-t9oveJO7.js +1 -0
  37. package/static/app/assets/{Brain-DBYgdcjt.js → Brain-DP-gpcEJ.js} +1 -1
  38. package/static/app/assets/{Capture-Cf3hqRtN.js → Capture-DYknDKy8.js} +1 -1
  39. package/static/app/assets/{Library-CFfkNn3s.js → Library-DLyc_g8c.js} +1 -1
  40. package/static/app/assets/{System-BOurbT-v.js → System-BZgJ7tGu.js} +1 -1
  41. package/static/app/assets/index-BeQ77vPs.js +17 -0
  42. package/static/app/assets/index-Cl4S_9Id.css +2 -0
  43. package/static/app/assets/{primitives-DcUUmhdC.js → primitives-DawfkPR4.js} +1 -1
  44. package/static/app/assets/{textarea-BklR6zN4.js → textarea-a4Ir3SZS.js} +1 -1
  45. package/static/app/index.html +2 -2
  46. package/static/sw.js +1 -1
  47. package/static/app/assets/Act-C3dBrWE-.js +0 -1
  48. package/static/app/assets/index-A3M9sElj.js +0 -17
  49. 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
- # Quality gate: when at least one result carries real lexical evidence,
905
- # zero-score rows are noise relative to it and are dropped. When nothing
906
- # scores (e.g. tokenization mismatch), everything is kept so the tiers'
907
- # own search filters still decide the gate never empties a recall.
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
 
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "9.2.0"
21
+ PRODUCT_VERSION_TARGET = "9.4.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -56,3 +56,4 @@ class InteractionRouterContext:
56
56
  memory_service: Any
57
57
  platform: Any
58
58
  active_model_getter: Any = None
59
+ brain_intelligence: Any = None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "9.2.0",
3
+ "version": "9.4.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -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 = "Model-Agnostic File Generation";
9
+ const releaseTheme = "Question-Driven Everyday Automation";
10
10
  const title = `${version} — ${releaseTheme}`;
11
11
  const escapedVersion = version.replaceAll(".", "\\.");
12
12
 
@@ -1584,7 +1584,7 @@ dependencies = [
1584
1584
 
1585
1585
  [[package]]
1586
1586
  name = "lattice-ai-desktop"
1587
- version = "9.2.0"
1587
+ version = "9.4.0"
1588
1588
  dependencies = [
1589
1589
  "plist",
1590
1590
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "9.2.0"
3
+ version = "9.4.0"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "9.2.0",
4
+ "version": "9.4.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",
@@ -1,19 +1,19 @@
1
1
  {
2
- "version": "9.2.0",
2
+ "version": "9.4.0",
3
3
  "generated_at": "vite",
4
4
  "entrypoints": {
5
- "app": "/static/app/assets/index-A3M9sElj.js"
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-DcUUmhdC.js": "/static/app/assets/primitives-DcUUmhdC.js",
10
- "_textarea-BklR6zN4.js": "/static/app/assets/textarea-BklR6zN4.js",
11
- "index.html": "/static/app/assets/index-A3M9sElj.js",
12
- "assets/index-Bmx9rzTc.css": "/static/app/assets/index-Bmx9rzTc.css",
13
- "src/pages/Act.tsx": "/static/app/assets/Act-C3dBrWE-.js",
14
- "src/pages/Brain.tsx": "/static/app/assets/Brain-DBYgdcjt.js",
15
- "src/pages/Capture.tsx": "/static/app/assets/Capture-Cf3hqRtN.js",
16
- "src/pages/Library.tsx": "/static/app/assets/Library-CFfkNn3s.js",
17
- "src/pages/System.tsx": "/static/app/assets/System-BOurbT-v.js"
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
  }