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.
- package/README.md +98 -306
- package/auto_setup.py +11 -1
- package/docs/CHANGELOG.md +91 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.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/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 +301 -8
- package/lattice_brain/graph/retrieval_vector.py +145 -0
- package/lattice_brain/ingestion.py +757 -16
- 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 +39 -2
- package/latticeai/api/change_proposals.py +32 -3
- package/latticeai/api/chat.py +17 -0
- package/latticeai/api/chat_helpers.py +48 -0
- package/latticeai/api/chat_stream.py +9 -1
- package/latticeai/api/local_files.py +120 -2
- package/latticeai/api/review_queue.py +66 -2
- package/latticeai/app_factory.py +3 -0
- package/latticeai/core/agent.py +193 -135
- package/latticeai/core/agent_eval.py +278 -4
- package/latticeai/core/legacy_compatibility.py +15 -1
- package/latticeai/core/marketplace.py +1 -1
- 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/automation_intelligence.py +151 -14
- package/latticeai/services/brain_intelligence.py +169 -1
- package/latticeai/services/change_proposals.py +56 -4
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/review_queue.py +43 -2
- 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/brain_quality_eval.py +1 -1
- package/scripts/check_current_release_docs.mjs +1 -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-BkOEmwBi.js → Act-Dd3z8AzF.js} +2 -1
- package/static/app/assets/Brain-BMkgdWnI.js +321 -0
- package/static/app/assets/Capture-D2Aw9gkv.js +1 -0
- package/static/app/assets/Library-Yreq-KW5.js +1 -0
- package/static/app/assets/System-CXNmmtEo.js +1 -0
- package/static/app/assets/{index-85wQvEie.css → index-7gY9t9Sd.css} +1 -1
- package/static/app/assets/index-CndfILiF.js +18 -0
- package/static/app/assets/primitives-DxsIXb6G.js +1 -0
- package/static/app/assets/textarea-DH7ne8VI.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/Brain-C9ITUsQ_.js +0 -321
- package/static/app/assets/Capture-C-ppTeud.js +0 -1
- package/static/app/assets/Library-CGQbgWTu.js +0 -1
- package/static/app/assets/System-djmj0n2_.js +0 -1
- package/static/app/assets/index-AF0-4XVv.js +0 -18
- package/static/app/assets/primitives-jbb2qv4Q.js +0 -1
- 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
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|