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.
- package/README.md +67 -43
- package/auto_setup.py +11 -1
- package/docs/CHANGELOG.md +86 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
- 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/PERFORMANCE.md +106 -0
- 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/kg_schema.py +11 -1
- package/knowledge_graph.py +12 -2
- package/knowledge_graph_api.py +11 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +583 -0
- package/lattice_brain/graph/retrieval.py +211 -7
- package/lattice_brain/graph/retrieval_vector.py +102 -0
- package/lattice_brain/ingestion.py +360 -4
- package/lattice_brain/quality.py +44 -9
- package/lattice_brain/runtime/multi_agent.py +38 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +27 -2
- package/latticeai/api/change_proposals.py +89 -0
- package/latticeai/api/chat_agent_http.py +2 -0
- package/latticeai/api/review_queue.py +66 -2
- package/latticeai/app_factory.py +23 -0
- package/latticeai/cli/entrypoint.py +3 -1
- package/latticeai/core/agent.py +273 -101
- package/latticeai/core/agent_eval.py +411 -0
- package/latticeai/core/agent_trace.py +104 -0
- package/latticeai/core/legacy_compatibility.py +15 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/tool_governor.py +101 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/router_registration.py +2 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +97 -1
- package/latticeai/services/change_proposals.py +322 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/review_queue.py +47 -3
- package/latticeai/tools/__init__.py +6 -1
- package/llm_router.py +10 -1
- package/local_knowledge_api.py +10 -1
- package/ltcai_cli.py +11 -1
- package/mcp_registry.py +10 -1
- package/p_reinforce.py +10 -1
- package/package.json +1 -1
- package/scripts/agent_eval.py +46 -0
- package/scripts/brain_quality_eval.py +1 -1
- package/scripts/check_current_release_docs.mjs +2 -1
- package/scripts/profile_kg.py +360 -0
- package/setup_wizard.py +10 -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-Cdfqx4wN.js → Act-B6c39ays.js} +2 -1
- package/static/app/assets/{Brain-w_tAuyg6.js → Brain-D7Qg4k6M.js} +1 -1
- package/static/app/assets/{Capture-iJwi9LmS.js → Capture-VF_di68r.js} +1 -1
- package/static/app/assets/{Library-Do-jjhzi.js → Library-D_Gis2PA.js} +1 -1
- package/static/app/assets/{System-DFg_q3E6.js → System-C5s5H2ov.js} +1 -1
- package/static/app/assets/{index-BN6HIWVC.css → index-85wQvEie.css} +1 -1
- package/static/app/assets/index-DJC_2oub.js +18 -0
- package/static/app/assets/{primitives-ZTUlU7pR.js → primitives-DL4Nip8C.js} +1 -1
- package/static/app/assets/{textarea-CMfhqPhE.js → textarea-woZfCXHy.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- 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
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*,
|