ltcai 9.6.0 → 9.8.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 (73) hide show
  1. package/README.md +98 -306
  2. package/auto_setup.py +11 -1
  3. package/docs/CHANGELOG.md +91 -0
  4. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  5. package/docs/DEVELOPMENT.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/PERFORMANCE.md +106 -0
  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/kg_schema.py +11 -1
  12. package/knowledge_graph.py +12 -2
  13. package/knowledge_graph_api.py +11 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/proactive.py +583 -0
  16. package/lattice_brain/graph/retrieval.py +301 -8
  17. package/lattice_brain/graph/retrieval_vector.py +145 -0
  18. package/lattice_brain/ingestion.py +757 -16
  19. package/lattice_brain/quality.py +44 -9
  20. package/lattice_brain/runtime/multi_agent.py +38 -2
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/brain_intelligence.py +39 -2
  23. package/latticeai/api/change_proposals.py +32 -3
  24. package/latticeai/api/chat.py +17 -0
  25. package/latticeai/api/chat_helpers.py +48 -0
  26. package/latticeai/api/chat_stream.py +9 -1
  27. package/latticeai/api/local_files.py +120 -2
  28. package/latticeai/api/review_queue.py +66 -2
  29. package/latticeai/app_factory.py +3 -0
  30. package/latticeai/core/agent.py +193 -135
  31. package/latticeai/core/agent_eval.py +278 -4
  32. package/latticeai/core/legacy_compatibility.py +15 -1
  33. package/latticeai/core/marketplace.py +1 -1
  34. package/latticeai/core/workspace_os.py +1 -1
  35. package/latticeai/runtime/router_registration.py +2 -0
  36. package/latticeai/services/architecture_readiness.py +1 -1
  37. package/latticeai/services/automation_intelligence.py +151 -14
  38. package/latticeai/services/brain_intelligence.py +169 -1
  39. package/latticeai/services/change_proposals.py +56 -4
  40. package/latticeai/services/product_readiness.py +1 -1
  41. package/latticeai/services/review_queue.py +43 -2
  42. package/llm_router.py +10 -1
  43. package/local_knowledge_api.py +10 -1
  44. package/ltcai_cli.py +11 -1
  45. package/mcp_registry.py +10 -1
  46. package/p_reinforce.py +10 -1
  47. package/package.json +1 -1
  48. package/scripts/brain_quality_eval.py +1 -1
  49. package/scripts/check_current_release_docs.mjs +1 -1
  50. package/scripts/profile_kg.py +360 -0
  51. package/setup_wizard.py +10 -1
  52. package/src-tauri/Cargo.lock +1 -1
  53. package/src-tauri/Cargo.toml +1 -1
  54. package/src-tauri/tauri.conf.json +1 -1
  55. package/static/app/asset-manifest.json +11 -11
  56. package/static/app/assets/{Act-BkOEmwBi.js → Act-Dd3z8AzF.js} +2 -1
  57. package/static/app/assets/Brain-BMkgdWnI.js +321 -0
  58. package/static/app/assets/Capture-D2Aw9gkv.js +1 -0
  59. package/static/app/assets/Library-Yreq-KW5.js +1 -0
  60. package/static/app/assets/System-CXNmmtEo.js +1 -0
  61. package/static/app/assets/{index-85wQvEie.css → index-7gY9t9Sd.css} +1 -1
  62. package/static/app/assets/index-CndfILiF.js +18 -0
  63. package/static/app/assets/primitives-DxsIXb6G.js +1 -0
  64. package/static/app/assets/textarea-DH7ne8VI.js +1 -0
  65. package/static/app/index.html +2 -2
  66. package/static/sw.js +1 -1
  67. package/static/app/assets/Brain-C9ITUsQ_.js +0 -321
  68. package/static/app/assets/Capture-C-ppTeud.js +0 -1
  69. package/static/app/assets/Library-CGQbgWTu.js +0 -1
  70. package/static/app/assets/System-djmj0n2_.js +0 -1
  71. package/static/app/assets/index-AF0-4XVv.js +0 -18
  72. package/static/app/assets/primitives-jbb2qv4Q.js +0 -1
  73. package/static/app/assets/textarea-CFoo0OxJ.js +0 -1
@@ -5,6 +5,40 @@ from __future__ import annotations
5
5
  from ._kg_common import * # noqa: F403,F401
6
6
 
7
7
 
8
+ def context_quality_signal(
9
+ mode: str,
10
+ nodes: int,
11
+ *,
12
+ reason: Optional[str] = None,
13
+ ) -> Dict[str, Any]:
14
+ """Honest RAG context-quality signal (v9.8.0, additive contract).
15
+
16
+ Shape consumed by the chat metadata channel:
17
+ ``{"mode": "hybrid"|"lexical_only"|"none", "nodes": int, "limited": bool,
18
+ "reason": str|None}``. ``nodes == 0`` always collapses ``mode`` to
19
+ ``"none"``; ``limited`` is true whenever the context is thin (0–1 nodes)
20
+ or the vector side fell back to lexical-only retrieval. ``reason`` is a
21
+ short human-readable Korean phrase, only present when limited.
22
+ """
23
+ nodes = max(0, int(nodes or 0))
24
+ mode = str(mode or "none")
25
+ if nodes == 0:
26
+ mode = "none"
27
+ if mode not in ("hybrid", "lexical_only", "none"):
28
+ mode = "lexical_only"
29
+ limited = nodes <= 1 or mode != "hybrid"
30
+ if reason is None and limited:
31
+ if nodes == 0:
32
+ reason = "그래프에서 관련 지식을 찾지 못했습니다"
33
+ elif mode == "lexical_only":
34
+ reason = "벡터 검색을 사용할 수 없어 키워드 검색 결과만 사용했습니다"
35
+ else:
36
+ reason = "그래프 기반 컨텍스트가 제한적입니다"
37
+ if not limited:
38
+ reason = None
39
+ return {"mode": mode, "nodes": nodes, "limited": limited, "reason": reason}
40
+
41
+
8
42
  class KnowledgeGraphRetrievalMixin:
9
43
  _GRAPH_VISIBLE_TYPES = (
10
44
  "Computer", # 내 컴퓨터
@@ -411,24 +445,249 @@ class KnowledgeGraphRetrievalMixin:
411
445
  )
412
446
  return {"query": query, "matches": matches}
413
447
 
414
- def context_for_query(
448
+ def hybrid_search(
415
449
  self,
416
450
  query: str,
417
- limit: int = 6,
418
451
  *,
452
+ top_k: int = 20,
453
+ alpha: float = 0.6,
454
+ workspace_id: Optional[str] = None,
419
455
  allowed_workspaces=None,
420
456
  include_legacy_global: bool = False,
421
- ) -> str:
422
- """Return compact graph-backed RAG context for chat generation."""
457
+ lexical_limit: Optional[int] = None,
458
+ vector_limit: Optional[int] = None,
459
+ min_vector_score: float = 0.0,
460
+ ) -> Dict[str, Any]:
461
+ """Unified lexical + vector retrieval with alpha-weighted linear fusion.
462
+
463
+ Runs the SQLite lexical :meth:`search` and the embedding-backed
464
+ ``vector_search`` (sibling mixin via the store MRO), normalizes both
465
+ score spaces to ``[0, 1]``, fuses them as
466
+ ``alpha * vector + (1 - alpha) * lexical`` (the same shape as
467
+ ``lattice_brain.quality.HybridFusion`` — reimplemented here without
468
+ importing that module), and dedupes by ``node_id`` (chunk hits roll up
469
+ to their parent node).
470
+
471
+ Degrades gracefully: when the vector side is unavailable (mixin not
472
+ composed, embedder/index failure) the result falls back to
473
+ lexical-only ranking and reports ``mode: "lexical_only"`` with a
474
+ ``detail`` explaining why. Each match carries per-source ``scores``
475
+ and a ``fusion`` field (``lexical`` / ``vector`` / ``both``).
476
+
477
+ ``workspace_id`` is a convenience for single-workspace callers; the
478
+ richer ``allowed_workspaces`` set wins when both are provided.
479
+ """
423
480
  query = str(query or "").strip()
481
+ try:
482
+ top_k = int(top_k)
483
+ except (TypeError, ValueError):
484
+ top_k = 20
485
+ top_k = max(1, min(top_k, 100))
486
+ try:
487
+ alpha = float(alpha)
488
+ except (TypeError, ValueError):
489
+ alpha = 0.6
490
+ alpha = max(0.0, min(alpha, 1.0))
491
+ if allowed_workspaces is None and workspace_id:
492
+ allowed_workspaces = {str(workspace_id)}
493
+
424
494
  if not query:
425
- return ""
426
- matches = self.search(
495
+ return {
496
+ "query": query,
497
+ "mode": "hybrid",
498
+ "alpha": alpha,
499
+ "top_k": top_k,
500
+ "sources": {"lexical": 0, "vector": 0},
501
+ "matches": [],
502
+ "detail": None,
503
+ }
504
+
505
+ lex_fetch = max(1, min(int(lexical_limit or max(top_k * 2, 20)), 100))
506
+ vec_fetch = max(1, min(int(vector_limit or max(top_k * 2, 20)), 100))
507
+
508
+ lexical_matches = self.search(
427
509
  query,
428
- limit,
510
+ lex_fetch,
429
511
  allowed_workspaces=allowed_workspaces,
430
512
  include_legacy_global=include_legacy_global,
431
513
  ).get("matches", [])
514
+
515
+ mode = "hybrid"
516
+ detail: Optional[str] = None
517
+ vector_matches: List[Dict[str, Any]] = []
518
+ vector_fn = getattr(self, "vector_search", None)
519
+ if not callable(vector_fn):
520
+ mode = "lexical_only"
521
+ detail = "vector search is not available on this store"
522
+ else:
523
+ try:
524
+ vector_matches = list(
525
+ (vector_fn(query, limit=vec_fetch, min_score=min_vector_score) or {}).get(
526
+ "matches", []
527
+ )
528
+ )
529
+ except Exception as exc: # noqa: BLE001 — degrade, never fail the search
530
+ mode = "lexical_only"
531
+ detail = f"vector index unavailable: {exc}"
532
+ vector_matches = []
533
+ if vector_matches and allowed_workspaces is not None:
534
+ vector_matches = self.filter_scoped_nodes(
535
+ vector_matches,
536
+ allowed_workspaces,
537
+ id_key="node_id",
538
+ include_legacy_global=include_legacy_global,
539
+ )
540
+
541
+ def _parent_node_id(match: Dict[str, Any]) -> str:
542
+ # Chunk-level hits dedupe to their parent content node.
543
+ if match.get("type") == "Chunk":
544
+ meta = match.get("metadata") or {}
545
+ parent = meta.get("source_node") or meta.get("parent_source_node")
546
+ if parent:
547
+ return str(parent)
548
+ return str(match.get("node_id") or match.get("id") or "")
549
+
550
+ entries: Dict[str, Dict[str, Any]] = {}
551
+
552
+ def _entry_for(node_id: str, match: Dict[str, Any]) -> Dict[str, Any]:
553
+ entry = entries.get(node_id)
554
+ if entry is None:
555
+ entry = {
556
+ "node_id": node_id,
557
+ "id": match.get("id") or node_id,
558
+ "type": match.get("type"),
559
+ "title": match.get("title"),
560
+ "summary": match.get("summary"),
561
+ "metadata": match.get("metadata") or {},
562
+ "updated_at": match.get("updated_at"),
563
+ "scores": {"lexical": 0.0, "vector": 0.0},
564
+ "_lexical": False,
565
+ "_vector": False,
566
+ }
567
+ entries[node_id] = entry
568
+ return entry
569
+
570
+ for rank, match in enumerate(lexical_matches, start=1):
571
+ node_id = _parent_node_id(match)
572
+ if not node_id:
573
+ continue
574
+ entry = _entry_for(node_id, match)
575
+ entry["scores"]["lexical"] = max(
576
+ entry["scores"]["lexical"], round(1.0 / rank, 6)
577
+ )
578
+ entry["_lexical"] = True
579
+
580
+ # Max-normalize cosine scores into [0, 1] (guard the score-0 falsy trap
581
+ # by comparing explicitly, never with truthiness).
582
+ max_vec = 0.0
583
+ for match in vector_matches:
584
+ raw = match.get("score")
585
+ if raw is not None and float(raw) > max_vec:
586
+ max_vec = float(raw)
587
+ for match in vector_matches:
588
+ node_id = _parent_node_id(match)
589
+ if not node_id:
590
+ continue
591
+ raw = float(match.get("score") or 0.0)
592
+ vec_norm = max(0.0, raw) / max_vec if max_vec > 0 else 0.0
593
+ entry = _entry_for(node_id, match)
594
+ entry["scores"]["vector"] = max(entry["scores"]["vector"], round(vec_norm, 6))
595
+ entry["_vector"] = True
596
+ # Prefer a real snippet when the lexical row had no summary.
597
+ if not entry.get("summary") and match.get("summary"):
598
+ entry["summary"] = match.get("summary")
599
+
600
+ matches: List[Dict[str, Any]] = []
601
+ for entry in entries.values():
602
+ lex_score = float(entry["scores"]["lexical"])
603
+ vec_score = float(entry["scores"]["vector"])
604
+ if mode == "lexical_only":
605
+ fused = lex_score
606
+ else:
607
+ fused = alpha * vec_score + (1.0 - alpha) * lex_score
608
+ entry["score"] = round(fused, 6)
609
+ from_lexical = bool(entry.pop("_lexical", False))
610
+ from_vector = bool(entry.pop("_vector", False))
611
+ if from_lexical and from_vector:
612
+ entry["fusion"] = "both"
613
+ elif from_vector:
614
+ entry["fusion"] = "vector"
615
+ else:
616
+ entry["fusion"] = "lexical"
617
+ matches.append(entry)
618
+
619
+ matches.sort(key=lambda item: (-item["score"], item["node_id"]))
620
+ matches = matches[:top_k]
621
+ for rank, match in enumerate(matches, start=1):
622
+ match["rank"] = rank
623
+ return {
624
+ "query": query,
625
+ "mode": mode,
626
+ "alpha": alpha,
627
+ "top_k": top_k,
628
+ "sources": {"lexical": len(lexical_matches), "vector": len(vector_matches)},
629
+ "matches": matches,
630
+ "detail": detail,
631
+ }
632
+
633
+ def context_for_query(
634
+ self,
635
+ query: str,
636
+ limit: int = 6,
637
+ *,
638
+ allowed_workspaces=None,
639
+ include_legacy_global: bool = False,
640
+ use_hybrid: bool = False,
641
+ with_meta: bool = False,
642
+ ):
643
+ """Return compact graph-backed RAG context for chat generation.
644
+
645
+ ``use_hybrid=True`` sources the matches from :meth:`hybrid_search`
646
+ (lexical + vector fusion) instead of the lexical-only :meth:`search`.
647
+ Default behavior is unchanged, and any hybrid failure silently falls
648
+ back to the legacy lexical path.
649
+
650
+ ``with_meta=True`` (additive, v9.8.0) returns
651
+ ``{"context": str, "quality": {...}}`` instead of the bare string.
652
+ ``quality`` follows :func:`context_quality_signal` and honestly
653
+ reports how the context was retrieved (hybrid vs lexical-only
654
+ fallback vs nothing). The ``context`` value is byte-identical to the
655
+ default ``with_meta=False`` return for the same arguments.
656
+ """
657
+ query = str(query or "").strip()
658
+ if not query:
659
+ if with_meta:
660
+ return {
661
+ "context": "",
662
+ "quality": context_quality_signal(
663
+ "none", 0, reason="질의가 비어 있습니다"
664
+ ),
665
+ }
666
+ return ""
667
+ matches: List[Dict[str, Any]] = []
668
+ retrieval_mode = "none"
669
+ if use_hybrid:
670
+ try:
671
+ hybrid = self.hybrid_search(
672
+ query,
673
+ top_k=limit,
674
+ allowed_workspaces=allowed_workspaces,
675
+ include_legacy_global=include_legacy_global,
676
+ )
677
+ matches = hybrid.get("matches", [])
678
+ if matches:
679
+ retrieval_mode = str(hybrid.get("mode") or "hybrid")
680
+ except Exception: # noqa: BLE001 — context building must never fail
681
+ matches = []
682
+ if not matches:
683
+ matches = self.search(
684
+ query,
685
+ limit,
686
+ allowed_workspaces=allowed_workspaces,
687
+ include_legacy_global=include_legacy_global,
688
+ ).get("matches", [])
689
+ if matches:
690
+ retrieval_mode = "lexical_only"
432
691
  if not matches:
433
692
  topics = _topic_candidates(query, limit=4)
434
693
  if topics:
@@ -471,6 +730,8 @@ class KnowledgeGraphRetrievalMixin:
471
730
  allowed_workspaces,
472
731
  include_legacy_global=include_legacy_global,
473
732
  )
733
+ if matches:
734
+ retrieval_mode = "lexical_only"
474
735
  lines = []
475
736
  for match in matches[:limit]:
476
737
  meta = match.get("metadata") or {}
@@ -485,7 +746,39 @@ class KnowledgeGraphRetrievalMixin:
485
746
  lines.append(
486
747
  f"- [{match['type']}] {match['title']} | source={source} | {summary}"
487
748
  )
488
- return "\n".join(lines)
749
+ context = "\n".join(lines)
750
+ if not with_meta:
751
+ return context
752
+ return {
753
+ "context": context,
754
+ "quality": context_quality_signal(retrieval_mode, len(matches[:limit])),
755
+ }
756
+
757
+ def context_for_query_with_meta(
758
+ self,
759
+ query: str,
760
+ limit: int = 6,
761
+ *,
762
+ allowed_workspaces=None,
763
+ include_legacy_global: bool = False,
764
+ use_hybrid: bool = True,
765
+ ) -> Dict[str, Any]:
766
+ """Additive companion to :meth:`context_for_query` (v9.8.0).
767
+
768
+ Always returns ``{"context": str, "quality": {...}}`` so chat callers
769
+ can surface an honest retrieval signal without changing the legacy
770
+ string-returning contract. Defaults to hybrid retrieval because meta
771
+ consumers want the vector-fallback signal; pass ``use_hybrid=False``
772
+ for the legacy lexical-only sourcing.
773
+ """
774
+ return self.context_for_query(
775
+ query,
776
+ limit,
777
+ allowed_workspaces=allowed_workspaces,
778
+ include_legacy_global=include_legacy_global,
779
+ use_hybrid=use_hybrid,
780
+ with_meta=True,
781
+ )
489
782
 
490
783
  def neighbors(
491
784
  self,
@@ -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
  *,
@@ -368,6 +470,49 @@ class KnowledgeGraphVectorMixin:
368
470
  ],
369
471
  }
370
472
 
473
+ def vector_freshness(self) -> Dict[str, Any]:
474
+ """Compact vector-index freshness summary for API surfaces (v9.8.0).
475
+
476
+ Reduces :meth:`index_status` (``pending = missing + stale``) to the
477
+ fixed contract ``{"status", "pending_items", "total_items", "detail"}``
478
+ with ``status`` in ``ready`` / ``pending`` / ``unavailable``.
479
+
480
+ Never raises: environments where the embedding provider or index
481
+ storage cannot be used report ``"unavailable"`` with the cause in
482
+ ``detail`` instead of surfacing an exception to the API layer.
483
+ """
484
+ try:
485
+ status = self.index_status()
486
+ except Exception as exc: # noqa: BLE001 — freshness must degrade, not fail
487
+ return {
488
+ "status": "unavailable",
489
+ "pending_items": 0,
490
+ "total_items": 0,
491
+ "detail": f"vector index status unavailable: {exc}",
492
+ }
493
+ pending = int(status.get("pending_items") or 0)
494
+ total = int(status.get("source_items") or 0)
495
+ if pending > 0:
496
+ return {
497
+ "status": "pending",
498
+ "pending_items": pending,
499
+ "total_items": total,
500
+ "detail": (
501
+ f"{pending} of {total} items are missing or stale in the vector index"
502
+ ),
503
+ }
504
+ detail = (
505
+ "vector index is up to date"
506
+ if total
507
+ else "vector index is empty (no indexable items yet)"
508
+ )
509
+ return {
510
+ "status": "ready",
511
+ "pending_items": 0,
512
+ "total_items": total,
513
+ "detail": detail,
514
+ }
515
+
371
516
  def vector_search(
372
517
  self,
373
518
  query: str,