ltcai 9.5.0 → 9.7.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 (71) hide show
  1. package/README.md +67 -43
  2. package/auto_setup.py +11 -1
  3. package/docs/CHANGELOG.md +86 -0
  4. package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
  5. package/docs/DEVELOPMENT.md +8 -8
  6. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  7. package/docs/ONBOARDING.md +2 -2
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/PERFORMANCE.md +106 -0
  10. package/docs/TRUST_MODEL.md +1 -1
  11. package/docs/WHY_LATTICE.md +1 -1
  12. package/docs/kg-schema.md +1 -1
  13. package/docs/mcp-tools.md +1 -1
  14. package/kg_schema.py +11 -1
  15. package/knowledge_graph.py +12 -2
  16. package/knowledge_graph_api.py +11 -1
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/graph/proactive.py +583 -0
  19. package/lattice_brain/graph/retrieval.py +211 -7
  20. package/lattice_brain/graph/retrieval_vector.py +102 -0
  21. package/lattice_brain/ingestion.py +360 -4
  22. package/lattice_brain/quality.py +44 -9
  23. package/lattice_brain/runtime/multi_agent.py +38 -2
  24. package/latticeai/__init__.py +1 -1
  25. package/latticeai/api/brain_intelligence.py +27 -2
  26. package/latticeai/api/change_proposals.py +89 -0
  27. package/latticeai/api/chat_agent_http.py +2 -0
  28. package/latticeai/api/review_queue.py +66 -2
  29. package/latticeai/app_factory.py +23 -0
  30. package/latticeai/cli/entrypoint.py +3 -1
  31. package/latticeai/core/agent.py +273 -101
  32. package/latticeai/core/agent_eval.py +411 -0
  33. package/latticeai/core/agent_trace.py +104 -0
  34. package/latticeai/core/legacy_compatibility.py +15 -1
  35. package/latticeai/core/marketplace.py +1 -1
  36. package/latticeai/core/tool_governor.py +101 -0
  37. package/latticeai/core/workspace_os.py +1 -1
  38. package/latticeai/runtime/router_registration.py +2 -0
  39. package/latticeai/services/architecture_readiness.py +1 -1
  40. package/latticeai/services/brain_intelligence.py +97 -1
  41. package/latticeai/services/change_proposals.py +322 -0
  42. package/latticeai/services/product_readiness.py +1 -1
  43. package/latticeai/services/review_queue.py +47 -3
  44. package/latticeai/tools/__init__.py +6 -1
  45. package/llm_router.py +10 -1
  46. package/local_knowledge_api.py +10 -1
  47. package/ltcai_cli.py +11 -1
  48. package/mcp_registry.py +10 -1
  49. package/p_reinforce.py +10 -1
  50. package/package.json +1 -1
  51. package/scripts/agent_eval.py +46 -0
  52. package/scripts/brain_quality_eval.py +1 -1
  53. package/scripts/check_current_release_docs.mjs +2 -1
  54. package/scripts/profile_kg.py +360 -0
  55. package/setup_wizard.py +10 -1
  56. package/src-tauri/Cargo.lock +1 -1
  57. package/src-tauri/Cargo.toml +1 -1
  58. package/src-tauri/tauri.conf.json +1 -1
  59. package/static/app/asset-manifest.json +11 -11
  60. package/static/app/assets/{Act-Cdfqx4wN.js → Act-B6c39ays.js} +2 -1
  61. package/static/app/assets/{Brain-w_tAuyg6.js → Brain-D7Qg4k6M.js} +1 -1
  62. package/static/app/assets/{Capture-iJwi9LmS.js → Capture-VF_di68r.js} +1 -1
  63. package/static/app/assets/{Library-Do-jjhzi.js → Library-D_Gis2PA.js} +1 -1
  64. package/static/app/assets/{System-DFg_q3E6.js → System-C5s5H2ov.js} +1 -1
  65. package/static/app/assets/{index-BN6HIWVC.css → index-85wQvEie.css} +1 -1
  66. package/static/app/assets/index-DJC_2oub.js +18 -0
  67. package/static/app/assets/{primitives-ZTUlU7pR.js → primitives-DL4Nip8C.js} +1 -1
  68. package/static/app/assets/{textarea-CMfhqPhE.js → textarea-woZfCXHy.js} +1 -1
  69. package/static/app/index.html +2 -2
  70. package/static/sw.js +1 -1
  71. package/static/app/assets/index-aJuRi-Xo.js +0 -17
@@ -411,24 +411,228 @@ class KnowledgeGraphRetrievalMixin:
411
411
  )
412
412
  return {"query": query, "matches": matches}
413
413
 
414
- def context_for_query(
414
+ def hybrid_search(
415
415
  self,
416
416
  query: str,
417
- limit: int = 6,
418
417
  *,
418
+ top_k: int = 20,
419
+ alpha: float = 0.6,
420
+ workspace_id: Optional[str] = None,
419
421
  allowed_workspaces=None,
420
422
  include_legacy_global: bool = False,
421
- ) -> str:
422
- """Return compact graph-backed RAG context for chat generation."""
423
+ lexical_limit: Optional[int] = None,
424
+ vector_limit: Optional[int] = None,
425
+ min_vector_score: float = 0.0,
426
+ ) -> Dict[str, Any]:
427
+ """Unified lexical + vector retrieval with alpha-weighted linear fusion.
428
+
429
+ Runs the SQLite lexical :meth:`search` and the embedding-backed
430
+ ``vector_search`` (sibling mixin via the store MRO), normalizes both
431
+ score spaces to ``[0, 1]``, fuses them as
432
+ ``alpha * vector + (1 - alpha) * lexical`` (the same shape as
433
+ ``lattice_brain.quality.HybridFusion`` — reimplemented here without
434
+ importing that module), and dedupes by ``node_id`` (chunk hits roll up
435
+ to their parent node).
436
+
437
+ Degrades gracefully: when the vector side is unavailable (mixin not
438
+ composed, embedder/index failure) the result falls back to
439
+ lexical-only ranking and reports ``mode: "lexical_only"`` with a
440
+ ``detail`` explaining why. Each match carries per-source ``scores``
441
+ and a ``fusion`` field (``lexical`` / ``vector`` / ``both``).
442
+
443
+ ``workspace_id`` is a convenience for single-workspace callers; the
444
+ richer ``allowed_workspaces`` set wins when both are provided.
445
+ """
423
446
  query = str(query or "").strip()
447
+ try:
448
+ top_k = int(top_k)
449
+ except (TypeError, ValueError):
450
+ top_k = 20
451
+ top_k = max(1, min(top_k, 100))
452
+ try:
453
+ alpha = float(alpha)
454
+ except (TypeError, ValueError):
455
+ alpha = 0.6
456
+ alpha = max(0.0, min(alpha, 1.0))
457
+ if allowed_workspaces is None and workspace_id:
458
+ allowed_workspaces = {str(workspace_id)}
459
+
424
460
  if not query:
425
- return ""
426
- matches = self.search(
461
+ return {
462
+ "query": query,
463
+ "mode": "hybrid",
464
+ "alpha": alpha,
465
+ "top_k": top_k,
466
+ "sources": {"lexical": 0, "vector": 0},
467
+ "matches": [],
468
+ "detail": None,
469
+ }
470
+
471
+ lex_fetch = max(1, min(int(lexical_limit or max(top_k * 2, 20)), 100))
472
+ vec_fetch = max(1, min(int(vector_limit or max(top_k * 2, 20)), 100))
473
+
474
+ lexical_matches = self.search(
427
475
  query,
428
- limit,
476
+ lex_fetch,
429
477
  allowed_workspaces=allowed_workspaces,
430
478
  include_legacy_global=include_legacy_global,
431
479
  ).get("matches", [])
480
+
481
+ mode = "hybrid"
482
+ detail: Optional[str] = None
483
+ vector_matches: List[Dict[str, Any]] = []
484
+ vector_fn = getattr(self, "vector_search", None)
485
+ if not callable(vector_fn):
486
+ mode = "lexical_only"
487
+ detail = "vector search is not available on this store"
488
+ else:
489
+ try:
490
+ vector_matches = list(
491
+ (vector_fn(query, limit=vec_fetch, min_score=min_vector_score) or {}).get(
492
+ "matches", []
493
+ )
494
+ )
495
+ except Exception as exc: # noqa: BLE001 — degrade, never fail the search
496
+ mode = "lexical_only"
497
+ detail = f"vector index unavailable: {exc}"
498
+ vector_matches = []
499
+ if vector_matches and allowed_workspaces is not None:
500
+ vector_matches = self.filter_scoped_nodes(
501
+ vector_matches,
502
+ allowed_workspaces,
503
+ id_key="node_id",
504
+ include_legacy_global=include_legacy_global,
505
+ )
506
+
507
+ def _parent_node_id(match: Dict[str, Any]) -> str:
508
+ # Chunk-level hits dedupe to their parent content node.
509
+ if match.get("type") == "Chunk":
510
+ meta = match.get("metadata") or {}
511
+ parent = meta.get("source_node") or meta.get("parent_source_node")
512
+ if parent:
513
+ return str(parent)
514
+ return str(match.get("node_id") or match.get("id") or "")
515
+
516
+ entries: Dict[str, Dict[str, Any]] = {}
517
+
518
+ def _entry_for(node_id: str, match: Dict[str, Any]) -> Dict[str, Any]:
519
+ entry = entries.get(node_id)
520
+ if entry is None:
521
+ entry = {
522
+ "node_id": node_id,
523
+ "id": match.get("id") or node_id,
524
+ "type": match.get("type"),
525
+ "title": match.get("title"),
526
+ "summary": match.get("summary"),
527
+ "metadata": match.get("metadata") or {},
528
+ "updated_at": match.get("updated_at"),
529
+ "scores": {"lexical": 0.0, "vector": 0.0},
530
+ "_lexical": False,
531
+ "_vector": False,
532
+ }
533
+ entries[node_id] = entry
534
+ return entry
535
+
536
+ for rank, match in enumerate(lexical_matches, start=1):
537
+ node_id = _parent_node_id(match)
538
+ if not node_id:
539
+ continue
540
+ entry = _entry_for(node_id, match)
541
+ entry["scores"]["lexical"] = max(
542
+ entry["scores"]["lexical"], round(1.0 / rank, 6)
543
+ )
544
+ entry["_lexical"] = True
545
+
546
+ # Max-normalize cosine scores into [0, 1] (guard the score-0 falsy trap
547
+ # by comparing explicitly, never with truthiness).
548
+ max_vec = 0.0
549
+ for match in vector_matches:
550
+ raw = match.get("score")
551
+ if raw is not None and float(raw) > max_vec:
552
+ max_vec = float(raw)
553
+ for match in vector_matches:
554
+ node_id = _parent_node_id(match)
555
+ if not node_id:
556
+ continue
557
+ raw = float(match.get("score") or 0.0)
558
+ vec_norm = max(0.0, raw) / max_vec if max_vec > 0 else 0.0
559
+ entry = _entry_for(node_id, match)
560
+ entry["scores"]["vector"] = max(entry["scores"]["vector"], round(vec_norm, 6))
561
+ entry["_vector"] = True
562
+ # Prefer a real snippet when the lexical row had no summary.
563
+ if not entry.get("summary") and match.get("summary"):
564
+ entry["summary"] = match.get("summary")
565
+
566
+ matches: List[Dict[str, Any]] = []
567
+ for entry in entries.values():
568
+ lex_score = float(entry["scores"]["lexical"])
569
+ vec_score = float(entry["scores"]["vector"])
570
+ if mode == "lexical_only":
571
+ fused = lex_score
572
+ else:
573
+ fused = alpha * vec_score + (1.0 - alpha) * lex_score
574
+ entry["score"] = round(fused, 6)
575
+ from_lexical = bool(entry.pop("_lexical", False))
576
+ from_vector = bool(entry.pop("_vector", False))
577
+ if from_lexical and from_vector:
578
+ entry["fusion"] = "both"
579
+ elif from_vector:
580
+ entry["fusion"] = "vector"
581
+ else:
582
+ entry["fusion"] = "lexical"
583
+ matches.append(entry)
584
+
585
+ matches.sort(key=lambda item: (-item["score"], item["node_id"]))
586
+ matches = matches[:top_k]
587
+ for rank, match in enumerate(matches, start=1):
588
+ match["rank"] = rank
589
+ return {
590
+ "query": query,
591
+ "mode": mode,
592
+ "alpha": alpha,
593
+ "top_k": top_k,
594
+ "sources": {"lexical": len(lexical_matches), "vector": len(vector_matches)},
595
+ "matches": matches,
596
+ "detail": detail,
597
+ }
598
+
599
+ def context_for_query(
600
+ self,
601
+ query: str,
602
+ limit: int = 6,
603
+ *,
604
+ allowed_workspaces=None,
605
+ include_legacy_global: bool = False,
606
+ use_hybrid: bool = False,
607
+ ) -> str:
608
+ """Return compact graph-backed RAG context for chat generation.
609
+
610
+ ``use_hybrid=True`` sources the matches from :meth:`hybrid_search`
611
+ (lexical + vector fusion) instead of the lexical-only :meth:`search`.
612
+ Default behavior is unchanged, and any hybrid failure silently falls
613
+ back to the legacy lexical path.
614
+ """
615
+ query = str(query or "").strip()
616
+ if not query:
617
+ return ""
618
+ matches: List[Dict[str, Any]] = []
619
+ if use_hybrid:
620
+ try:
621
+ matches = self.hybrid_search(
622
+ query,
623
+ top_k=limit,
624
+ allowed_workspaces=allowed_workspaces,
625
+ include_legacy_global=include_legacy_global,
626
+ ).get("matches", [])
627
+ except Exception: # noqa: BLE001 — context building must never fail
628
+ matches = []
629
+ if not matches:
630
+ matches = self.search(
631
+ query,
632
+ limit,
633
+ allowed_workspaces=allowed_workspaces,
634
+ include_legacy_global=include_legacy_global,
635
+ ).get("matches", [])
432
636
  if not matches:
433
637
  topics = _topic_candidates(query, limit=4)
434
638
  if topics:
@@ -72,6 +72,108 @@ class KnowledgeGraphVectorMixin:
72
72
  )
73
73
  return items
74
74
 
75
+ def index_node_incremental(self, node_id: str) -> Dict[str, Any]:
76
+ """Embed/index only ``node_id`` and its chunks (incremental sync).
77
+
78
+ The item construction mirrors :meth:`_iter_vector_source_items` exactly
79
+ (same ids, same ``source_node``/``parent_source_node`` semantics), so
80
+ anything this method indexes is indistinguishable from a full
81
+ :meth:`rebuild_vector_index` pass — and anything it *fails* to index
82
+ stays visible as ``missing``/``stale`` backlog in :meth:`index_status`,
83
+ where a later rebuild picks it up.
84
+
85
+ Never raises: embedding-provider or storage failures are reported as
86
+ ``{"status": "failed", ...}`` so ingestion callers can degrade instead
87
+ of losing an already-persisted write.
88
+ """
89
+ node_id = str(node_id or "").strip()
90
+ started = time.perf_counter()
91
+ summary: Dict[str, Any] = {
92
+ "node_id": node_id,
93
+ "items_total": 0,
94
+ "items_indexed": 0,
95
+ "items_skipped": 0,
96
+ }
97
+ if not node_id:
98
+ return {**summary, "status": "skipped", "detail": "node_id required"}
99
+ try:
100
+ with self._connect() as conn:
101
+ row = conn.execute(
102
+ "SELECT id, type, title, summary, metadata_json FROM nodes WHERE id=?",
103
+ (node_id,),
104
+ ).fetchone()
105
+ if row is None:
106
+ return {**summary, "status": "skipped", "detail": "node not found"}
107
+ items: List[Dict[str, Any]] = []
108
+ if row["type"] != "Chunk":
109
+ metadata = _safe_loads(row["metadata_json"])
110
+ text = self._vector_text_for_node(
111
+ title=row["title"],
112
+ summary=row["summary"] or "",
113
+ metadata=metadata,
114
+ )
115
+ if text:
116
+ items.append(
117
+ {
118
+ "item_id": row["id"],
119
+ "item_type": "node",
120
+ "source_node": row["id"],
121
+ "text": text,
122
+ "metadata": {"node_type": row["type"], **metadata},
123
+ }
124
+ )
125
+ for chunk_row in conn.execute(
126
+ """
127
+ SELECT c.id, c.source_node AS parent_source_node, c.text, c.metadata_json
128
+ FROM chunks c
129
+ JOIN nodes n ON n.id=c.id
130
+ WHERE c.source_node=?
131
+ ORDER BY c.created_at ASC, c.id ASC
132
+ """,
133
+ (node_id,),
134
+ ).fetchall():
135
+ metadata = _safe_loads(chunk_row["metadata_json"])
136
+ text = _clean_text(chunk_row["text"] or "")
137
+ if text:
138
+ items.append(
139
+ {
140
+ "item_id": chunk_row["id"],
141
+ "item_type": "chunk",
142
+ "source_node": chunk_row["id"],
143
+ "text": text,
144
+ "metadata": {
145
+ **metadata,
146
+ "parent_source_node": chunk_row["parent_source_node"],
147
+ },
148
+ }
149
+ )
150
+ indexed = skipped = 0
151
+ for item in items:
152
+ if self._upsert_vector_item(conn, **item):
153
+ indexed += 1
154
+ else:
155
+ skipped += 1
156
+ summary.update(
157
+ {
158
+ "items_total": len(items),
159
+ "items_indexed": indexed,
160
+ "items_skipped": skipped,
161
+ }
162
+ )
163
+ return {
164
+ **summary,
165
+ "status": "indexed" if indexed else "noop",
166
+ "duration_ms": round((time.perf_counter() - started) * 1000, 2),
167
+ "embedding_model": self._embedding_model.model_id,
168
+ }
169
+ except Exception as exc: # noqa: BLE001 — incremental sync must never raise
170
+ return {
171
+ **summary,
172
+ "status": "failed",
173
+ "detail": str(exc),
174
+ "duration_ms": round((time.perf_counter() - started) * 1000, 2),
175
+ }
176
+
75
177
  def rebuild_vector_index(
76
178
  self,
77
179
  *,