ltcai 9.9.3 → 9.9.5
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 +39 -38
- package/docs/CHANGELOG.md +100 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/SURFACE_PARITY.md +53 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common.py +335 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +221 -37
- package/lattice_brain/graph/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +90 -455
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/quality.py +26 -6
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +6 -0
- package/latticeai/api/chat_agent_http.py +262 -52
- package/latticeai/api/chat_contracts.py +1 -1
- package/latticeai/api/chat_intents.py +37 -18
- package/latticeai/api/chat_stream.py +76 -2
- package/latticeai/api/knowledge_graph.py +35 -1
- package/latticeai/core/agent.py +420 -27
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +130 -5
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +245 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/telegram_bot.py +82 -23
- package/latticeai/models/router.py +25 -12
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/command_center.py +90 -1
- package/latticeai/services/folder_watch.py +5 -0
- package/latticeai/services/funnel_metrics.py +9 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +43 -13
- package/latticeai/services/tool_dispatch.py +56 -0
- package/package.json +2 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -0
- package/scripts/run_sidecar_e2e.mjs +159 -0
- 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 +29 -28
- package/static/app/assets/Act-DmxfbqBp.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
- package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
- package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
- package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
- package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
- package/static/app/assets/utils-BqoznzGP.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmdruVKV.js +0 -1
- package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
- package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
- package/static/app/assets/utils-uQYKXNeq.js +0 -7
|
@@ -4,6 +4,22 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from ._kg_common import * # noqa: F403,F401
|
|
6
6
|
|
|
7
|
+
# ── promotion review mode (review 2026-07-25 Wave 4) ─────────────────────────
|
|
8
|
+
# When enabled, curate() parks would-be Topic promotions in graph_meta for a
|
|
9
|
+
# human decision instead of writing them immediately. Explicit review_mode=
|
|
10
|
+
# argument wins; otherwise this env opt-in decides; default stays auto-promote.
|
|
11
|
+
_PROMOTION_REVIEW_ENV = "LATTICEAI_GRAPH_PROMOTION_REVIEW"
|
|
12
|
+
_PENDING_PROMOTIONS_KEY = "pending_promotions"
|
|
13
|
+
_PENDING_PROMOTIONS_CAP = 100
|
|
14
|
+
|
|
15
|
+
# graph_meta stamp written by an applied (dry_run=False) noise-curate run; the
|
|
16
|
+
# Command Center hygiene advisory reads it to pace its suggestion (Wave 2.5).
|
|
17
|
+
_LAST_NOISE_CURATE_KEY = "last_noise_curate_at"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _promotion_review_default() -> bool:
|
|
21
|
+
return os.getenv(_PROMOTION_REVIEW_ENV, "").strip().lower() in ("1", "true", "yes")
|
|
22
|
+
|
|
7
23
|
|
|
8
24
|
class KnowledgeGraphProjectionMixin:
|
|
9
25
|
_FTS_SQL = """
|
|
@@ -408,7 +424,11 @@ class KnowledgeGraphProjectionMixin:
|
|
|
408
424
|
)
|
|
409
425
|
|
|
410
426
|
def curate(
|
|
411
|
-
self,
|
|
427
|
+
self,
|
|
428
|
+
*,
|
|
429
|
+
max_documents: int = 200,
|
|
430
|
+
max_new_nodes: int = 8,
|
|
431
|
+
review_mode: Optional[bool] = None,
|
|
412
432
|
) -> Dict[str, Any]:
|
|
413
433
|
"""On-demand graph curation (T4.4 — graph_curator goes live).
|
|
414
434
|
|
|
@@ -418,6 +438,14 @@ class KnowledgeGraphProjectionMixin:
|
|
|
418
438
|
Topic nodes (with MENTIONS edges back to their sources and a real
|
|
419
439
|
importance_score in nodes_v2). Explicit and observable — the result
|
|
420
440
|
reports everything promoted AND everything skipped, with reasons.
|
|
441
|
+
|
|
442
|
+
``review_mode`` (review 2026-07-25 Wave 4): when True, nothing is
|
|
443
|
+
written — the would-be promotions are parked in ``graph_meta`` as
|
|
444
|
+
``pending_promotions`` for a human decision via
|
|
445
|
+
:meth:`apply_pending_promotions` / :meth:`reject_pending_promotions`.
|
|
446
|
+
Explicit argument wins; ``None`` falls back to the
|
|
447
|
+
``LATTICEAI_GRAPH_PROMOTION_REVIEW`` env opt-in; default stays the
|
|
448
|
+
historical auto-promote behavior.
|
|
421
449
|
"""
|
|
422
450
|
from .curator import auto_build_graph_overlay
|
|
423
451
|
|
|
@@ -464,46 +492,37 @@ class KnowledgeGraphProjectionMixin:
|
|
|
464
492
|
existing_node_labels=existing_labels,
|
|
465
493
|
max_new_nodes=max(1, min(int(max_new_nodes), 50)),
|
|
466
494
|
)
|
|
495
|
+
valid_ids = {row["id"] for row in rows}
|
|
496
|
+
review = review_mode if review_mode is not None else _promotion_review_default()
|
|
497
|
+
if review:
|
|
498
|
+
proposed_at = _now()
|
|
499
|
+
proposed = [
|
|
500
|
+
{
|
|
501
|
+
"id": f"topic:{_slug(promo['label'])}",
|
|
502
|
+
"label": promo["label"],
|
|
503
|
+
"importance": promo["importance"],
|
|
504
|
+
"aliases": promo["aliases"],
|
|
505
|
+
"sources": [s for s in promo["sources"][:10] if s in valid_ids],
|
|
506
|
+
"proposed_at": proposed_at,
|
|
507
|
+
}
|
|
508
|
+
for promo in overlay["promotions"]
|
|
509
|
+
]
|
|
510
|
+
with self._connect() as conn:
|
|
511
|
+
merged = self._merge_pending_promotions(conn, proposed)
|
|
512
|
+
return {
|
|
513
|
+
"status": "pending_review",
|
|
514
|
+
"documents_scanned": len(documents),
|
|
515
|
+
"candidates_total": overlay["candidates_total"],
|
|
516
|
+
"pending": proposed,
|
|
517
|
+
"pending_total": len(merged),
|
|
518
|
+
"skipped": overlay["skipped"][:50],
|
|
519
|
+
"skipped_total": len(overlay["skipped"]),
|
|
520
|
+
}
|
|
467
521
|
promoted: List[Dict[str, Any]] = []
|
|
468
522
|
with self._connect() as conn:
|
|
469
|
-
valid_ids = {row["id"] for row in rows}
|
|
470
523
|
for promo in overlay["promotions"]:
|
|
471
|
-
topic_id = f"topic:{_slug(promo['label'])}"
|
|
472
|
-
self._upsert_node(
|
|
473
|
-
conn,
|
|
474
|
-
topic_id,
|
|
475
|
-
"Topic",
|
|
476
|
-
promo["label"],
|
|
477
|
-
metadata={
|
|
478
|
-
"curated": True,
|
|
479
|
-
"importance": promo["importance"],
|
|
480
|
-
"aliases": promo["aliases"],
|
|
481
|
-
"source": "graph_curator",
|
|
482
|
-
},
|
|
483
|
-
)
|
|
484
|
-
conn.execute(
|
|
485
|
-
"UPDATE nodes_v2 SET importance_score=? WHERE id=?",
|
|
486
|
-
(float(promo["importance"]), topic_id),
|
|
487
|
-
)
|
|
488
|
-
linked = 0
|
|
489
|
-
for source_id in promo["sources"][:10]:
|
|
490
|
-
if source_id in valid_ids:
|
|
491
|
-
self._upsert_edge(
|
|
492
|
-
conn,
|
|
493
|
-
source_id,
|
|
494
|
-
topic_id,
|
|
495
|
-
"MENTIONS",
|
|
496
|
-
weight=0.6,
|
|
497
|
-
metadata={"source": "graph_curator"},
|
|
498
|
-
)
|
|
499
|
-
linked += 1
|
|
500
524
|
promoted.append(
|
|
501
|
-
|
|
502
|
-
"node_id": topic_id,
|
|
503
|
-
"label": promo["label"],
|
|
504
|
-
"importance": promo["importance"],
|
|
505
|
-
"linked_sources": linked,
|
|
506
|
-
}
|
|
525
|
+
self._write_promotion(conn, promo, valid_source_ids=valid_ids)
|
|
507
526
|
)
|
|
508
527
|
return {
|
|
509
528
|
"status": "ok",
|
|
@@ -514,6 +533,148 @@ class KnowledgeGraphProjectionMixin:
|
|
|
514
533
|
"skipped_total": len(overlay["skipped"]),
|
|
515
534
|
}
|
|
516
535
|
|
|
536
|
+
def _write_promotion(
|
|
537
|
+
self,
|
|
538
|
+
conn: sqlite3.Connection,
|
|
539
|
+
promo: Dict[str, Any],
|
|
540
|
+
*,
|
|
541
|
+
valid_source_ids: Optional[set] = None,
|
|
542
|
+
) -> Dict[str, Any]:
|
|
543
|
+
"""Write one curator promotion: Topic node + importance + MENTIONS edges.
|
|
544
|
+
|
|
545
|
+
Single write path shared by direct ``curate()`` and
|
|
546
|
+
:meth:`apply_pending_promotions`, so a human-approved promotion lands
|
|
547
|
+
exactly like an auto-promoted one. ``valid_source_ids`` restricts the
|
|
548
|
+
linkable sources to this curate run's scanned rows; when ``None``
|
|
549
|
+
(apply-after-review), each stored source is checked for existence so a
|
|
550
|
+
node deleted between propose and apply is skipped, not an error.
|
|
551
|
+
"""
|
|
552
|
+
topic_id = str(promo.get("id") or f"topic:{_slug(str(promo['label']))}")
|
|
553
|
+
self._upsert_node(
|
|
554
|
+
conn,
|
|
555
|
+
topic_id,
|
|
556
|
+
"Topic",
|
|
557
|
+
str(promo["label"]),
|
|
558
|
+
metadata={
|
|
559
|
+
"curated": True,
|
|
560
|
+
"importance": promo["importance"],
|
|
561
|
+
"aliases": list(promo.get("aliases") or []),
|
|
562
|
+
"source": "graph_curator",
|
|
563
|
+
},
|
|
564
|
+
)
|
|
565
|
+
conn.execute(
|
|
566
|
+
"UPDATE nodes_v2 SET importance_score=? WHERE id=?",
|
|
567
|
+
(float(promo["importance"]), topic_id),
|
|
568
|
+
)
|
|
569
|
+
linked = 0
|
|
570
|
+
for source_id in list(promo.get("sources") or [])[:10]:
|
|
571
|
+
if valid_source_ids is not None:
|
|
572
|
+
if source_id not in valid_source_ids:
|
|
573
|
+
continue
|
|
574
|
+
elif not conn.execute(
|
|
575
|
+
"SELECT 1 FROM nodes WHERE id=?", (source_id,)
|
|
576
|
+
).fetchone():
|
|
577
|
+
continue
|
|
578
|
+
self._upsert_edge(
|
|
579
|
+
conn,
|
|
580
|
+
source_id,
|
|
581
|
+
topic_id,
|
|
582
|
+
"MENTIONS",
|
|
583
|
+
weight=0.6,
|
|
584
|
+
metadata={"source": "graph_curator"},
|
|
585
|
+
)
|
|
586
|
+
linked += 1
|
|
587
|
+
return {
|
|
588
|
+
"node_id": topic_id,
|
|
589
|
+
"label": promo["label"],
|
|
590
|
+
"importance": promo["importance"],
|
|
591
|
+
"linked_sources": linked,
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
# ── pending promotion queue (review 2026-07-25 Wave 4) ───────────────────
|
|
595
|
+
|
|
596
|
+
def _read_pending_promotions(
|
|
597
|
+
self, conn: sqlite3.Connection
|
|
598
|
+
) -> List[Dict[str, Any]]:
|
|
599
|
+
try:
|
|
600
|
+
row = conn.execute(
|
|
601
|
+
"SELECT value FROM graph_meta WHERE key=?",
|
|
602
|
+
(_PENDING_PROMOTIONS_KEY,),
|
|
603
|
+
).fetchone()
|
|
604
|
+
except sqlite3.Error:
|
|
605
|
+
return []
|
|
606
|
+
if not row or not row["value"]:
|
|
607
|
+
return []
|
|
608
|
+
try:
|
|
609
|
+
parsed = json.loads(row["value"])
|
|
610
|
+
except (TypeError, ValueError):
|
|
611
|
+
return []
|
|
612
|
+
if not isinstance(parsed, list):
|
|
613
|
+
return []
|
|
614
|
+
return [
|
|
615
|
+
item for item in parsed if isinstance(item, dict) and item.get("id")
|
|
616
|
+
]
|
|
617
|
+
|
|
618
|
+
def _store_pending_promotions(
|
|
619
|
+
self, conn: sqlite3.Connection, entries: List[Dict[str, Any]]
|
|
620
|
+
) -> None:
|
|
621
|
+
conn.execute(
|
|
622
|
+
"INSERT OR REPLACE INTO graph_meta(key, value) VALUES (?, ?)",
|
|
623
|
+
(_PENDING_PROMOTIONS_KEY, json.dumps(entries, ensure_ascii=False)),
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def _merge_pending_promotions(
|
|
627
|
+
self, conn: sqlite3.Connection, proposed: List[Dict[str, Any]]
|
|
628
|
+
) -> List[Dict[str, Any]]:
|
|
629
|
+
"""Merge new proposals into the stored queue (dedupe by id, cap 100)."""
|
|
630
|
+
merged: Dict[str, Dict[str, Any]] = {}
|
|
631
|
+
for item in self._read_pending_promotions(conn) + list(proposed):
|
|
632
|
+
merged[str(item["id"])] = item # newest proposal wins per id
|
|
633
|
+
entries = list(merged.values())[-_PENDING_PROMOTIONS_CAP:]
|
|
634
|
+
self._store_pending_promotions(conn, entries)
|
|
635
|
+
return entries
|
|
636
|
+
|
|
637
|
+
def pending_promotions(self) -> List[Dict[str, Any]]:
|
|
638
|
+
"""List promotions waiting for a human decision (review mode)."""
|
|
639
|
+
with self._connect() as conn:
|
|
640
|
+
return self._read_pending_promotions(conn)
|
|
641
|
+
|
|
642
|
+
def apply_pending_promotions(
|
|
643
|
+
self, ids: Optional[List[str]] = None
|
|
644
|
+
) -> Dict[str, Any]:
|
|
645
|
+
"""Apply stored pending promotions (all of them when ``ids`` is None).
|
|
646
|
+
|
|
647
|
+
Uses the exact node-writing path as direct ``curate()`` via
|
|
648
|
+
:meth:`_write_promotion`; applied entries leave the queue.
|
|
649
|
+
"""
|
|
650
|
+
wanted = None if ids is None else {str(item) for item in ids}
|
|
651
|
+
applied: List[Dict[str, Any]] = []
|
|
652
|
+
remaining: List[Dict[str, Any]] = []
|
|
653
|
+
with self._connect() as conn:
|
|
654
|
+
for promo in self._read_pending_promotions(conn):
|
|
655
|
+
if wanted is not None and str(promo.get("id")) not in wanted:
|
|
656
|
+
remaining.append(promo)
|
|
657
|
+
continue
|
|
658
|
+
applied.append(self._write_promotion(conn, promo))
|
|
659
|
+
self._store_pending_promotions(conn, remaining)
|
|
660
|
+
return {"status": "ok", "applied": applied, "remaining": len(remaining)}
|
|
661
|
+
|
|
662
|
+
def reject_pending_promotions(
|
|
663
|
+
self, ids: Optional[List[str]] = None
|
|
664
|
+
) -> Dict[str, Any]:
|
|
665
|
+
"""Drop pending promotions without writing (all when ``ids`` is None)."""
|
|
666
|
+
wanted = None if ids is None else {str(item) for item in ids}
|
|
667
|
+
rejected: List[str] = []
|
|
668
|
+
remaining: List[Dict[str, Any]] = []
|
|
669
|
+
with self._connect() as conn:
|
|
670
|
+
for promo in self._read_pending_promotions(conn):
|
|
671
|
+
if wanted is not None and str(promo.get("id")) not in wanted:
|
|
672
|
+
remaining.append(promo)
|
|
673
|
+
continue
|
|
674
|
+
rejected.append(str(promo.get("id")))
|
|
675
|
+
self._store_pending_promotions(conn, remaining)
|
|
676
|
+
return {"status": "ok", "rejected": rejected, "remaining": len(remaining)}
|
|
677
|
+
|
|
517
678
|
_NOISE_CONTENT_TYPES = (
|
|
518
679
|
"Document",
|
|
519
680
|
"File",
|
|
@@ -651,6 +812,13 @@ class KnowledgeGraphProjectionMixin:
|
|
|
651
812
|
(canonical, original),
|
|
652
813
|
)
|
|
653
814
|
conn.execute("DELETE FROM edges WHERE type=?", (original,))
|
|
815
|
+
# Stamp every applied run — even a no-op one means the graph
|
|
816
|
+
# was inspected, so the Command Center hygiene advisory
|
|
817
|
+
# (review 2026-07-25 Wave 2.5) stops re-suggesting for a while.
|
|
818
|
+
conn.execute(
|
|
819
|
+
"INSERT OR REPLACE INTO graph_meta(key, value) VALUES (?, ?)",
|
|
820
|
+
(_LAST_NOISE_CURATE_KEY, _now()),
|
|
821
|
+
)
|
|
654
822
|
|
|
655
823
|
return {
|
|
656
824
|
"status": "ok",
|
|
@@ -675,6 +843,22 @@ class KnowledgeGraphProjectionMixin:
|
|
|
675
843
|
},
|
|
676
844
|
}
|
|
677
845
|
|
|
846
|
+
def last_noise_curate_at(self) -> Optional[str]:
|
|
847
|
+
"""Timestamp of the last applied (dry_run=False) noise-curate run.
|
|
848
|
+
|
|
849
|
+
``None`` when the job never ran or the meta table is unreadable —
|
|
850
|
+
advisory readers treat both as "curation is due" (fail-open).
|
|
851
|
+
"""
|
|
852
|
+
try:
|
|
853
|
+
with self._connect() as conn:
|
|
854
|
+
row = conn.execute(
|
|
855
|
+
"SELECT value FROM graph_meta WHERE key=?",
|
|
856
|
+
(_LAST_NOISE_CURATE_KEY,),
|
|
857
|
+
).fetchone()
|
|
858
|
+
except sqlite3.Error:
|
|
859
|
+
return None
|
|
860
|
+
return str(row["value"]) if row and row["value"] else None
|
|
861
|
+
|
|
678
862
|
def mark_superseded(self, old_node_id: str, new_node_id: str) -> Dict[str, Any]:
|
|
679
863
|
"""Record that ``old_node_id`` was replaced by ``new_node_id``.
|
|
680
864
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Optional cross-encoder rerank for hybrid retrieval (v9.9.5).
|
|
2
|
+
|
|
3
|
+
Default path is identity (fused score preserved) — no model download, no
|
|
4
|
+
latency tax, no unearned claims. Opt in with::
|
|
5
|
+
|
|
6
|
+
LATTICEAI_CROSS_ENCODER_RERANK=1
|
|
7
|
+
# optional model id (sentence-transformers CrossEncoder):
|
|
8
|
+
LATTICEAI_CROSS_ENCODER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
|
|
9
|
+
|
|
10
|
+
When the env kill-switch is off, or ``sentence_transformers`` / the model is
|
|
11
|
+
unavailable, :func:`rerank_matches` returns the candidates unchanged and
|
|
12
|
+
reports ``mode="identity"``. Failures never raise into the search path.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
CROSS_ENCODER_RERANK_ENV = "LATTICEAI_CROSS_ENCODER_RERANK"
|
|
24
|
+
CROSS_ENCODER_MODEL_ENV = "LATTICEAI_CROSS_ENCODER_MODEL"
|
|
25
|
+
DEFAULT_CROSS_ENCODER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
26
|
+
|
|
27
|
+
_model_cache: Dict[str, Any] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rerank_enabled() -> bool:
|
|
31
|
+
raw = os.getenv(CROSS_ENCODER_RERANK_ENV, "").strip().lower()
|
|
32
|
+
return raw in {"1", "true", "yes", "on"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _model_id() -> str:
|
|
36
|
+
raw = os.getenv(CROSS_ENCODER_MODEL_ENV, "").strip()
|
|
37
|
+
return raw or DEFAULT_CROSS_ENCODER_MODEL
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _candidate_text(match: Dict[str, Any]) -> str:
|
|
41
|
+
parts = [
|
|
42
|
+
str(match.get("title") or ""),
|
|
43
|
+
str(match.get("summary") or ""),
|
|
44
|
+
str((match.get("metadata") or {}).get("snippet") or ""),
|
|
45
|
+
]
|
|
46
|
+
return " ".join(p for p in parts if p).strip() or str(match.get("node_id") or "")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_cross_encoder(model_id: str) -> Any:
|
|
50
|
+
if model_id in _model_cache:
|
|
51
|
+
return _model_cache[model_id]
|
|
52
|
+
from sentence_transformers import CrossEncoder # type: ignore
|
|
53
|
+
|
|
54
|
+
model = CrossEncoder(model_id)
|
|
55
|
+
_model_cache[model_id] = model
|
|
56
|
+
return model
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def identity_rerank(
|
|
60
|
+
query: str,
|
|
61
|
+
candidates: List[Dict[str, Any]],
|
|
62
|
+
*,
|
|
63
|
+
top_k: Optional[int] = None,
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""Preserve fused ordering; stamp identity scores for an honest contract."""
|
|
66
|
+
del query # identity path does not use the query text
|
|
67
|
+
ranked = list(candidates)
|
|
68
|
+
for item in ranked:
|
|
69
|
+
item["rerank_score"] = float(item.get("score") or item.get("fused_score") or 0.0)
|
|
70
|
+
if top_k is not None:
|
|
71
|
+
ranked = ranked[: max(1, int(top_k))]
|
|
72
|
+
return {
|
|
73
|
+
"matches": ranked,
|
|
74
|
+
"mode": "identity",
|
|
75
|
+
"model": None,
|
|
76
|
+
"detail": None,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cross_encoder_rerank(
|
|
81
|
+
query: str,
|
|
82
|
+
candidates: List[Dict[str, Any]],
|
|
83
|
+
*,
|
|
84
|
+
top_k: Optional[int] = None,
|
|
85
|
+
model_id: Optional[str] = None,
|
|
86
|
+
) -> Dict[str, Any]:
|
|
87
|
+
"""Score (query, candidate) pairs with a CrossEncoder when available."""
|
|
88
|
+
if not candidates:
|
|
89
|
+
return {
|
|
90
|
+
"matches": [],
|
|
91
|
+
"mode": "cross_encoder",
|
|
92
|
+
"model": model_id or _model_id(),
|
|
93
|
+
"detail": None,
|
|
94
|
+
}
|
|
95
|
+
mid = model_id or _model_id()
|
|
96
|
+
try:
|
|
97
|
+
model = _load_cross_encoder(mid)
|
|
98
|
+
except Exception as exc: # noqa: BLE001 — never break search
|
|
99
|
+
logger.info("cross-encoder unavailable (%s); falling back to identity", exc)
|
|
100
|
+
result = identity_rerank(query, candidates, top_k=top_k)
|
|
101
|
+
result["detail"] = f"cross_encoder_unavailable: {exc}"
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
pairs = [[str(query or ""), _candidate_text(c)] for c in candidates]
|
|
105
|
+
try:
|
|
106
|
+
scores = model.predict(pairs)
|
|
107
|
+
except Exception as exc: # noqa: BLE001
|
|
108
|
+
logger.warning("cross-encoder predict failed: %s", exc)
|
|
109
|
+
result = identity_rerank(query, candidates, top_k=top_k)
|
|
110
|
+
result["detail"] = f"cross_encoder_predict_failed: {exc}"
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
ranked = list(candidates)
|
|
114
|
+
for item, score in zip(ranked, scores):
|
|
115
|
+
item["rerank_score"] = float(score)
|
|
116
|
+
# Surface the rerank score as the primary ranking key while keeping
|
|
117
|
+
# the pre-rerank fused score under scores.fused for audit.
|
|
118
|
+
scores_map = item.setdefault("scores", {})
|
|
119
|
+
if isinstance(scores_map, dict):
|
|
120
|
+
scores_map.setdefault("fused", float(item.get("score") or 0.0))
|
|
121
|
+
scores_map["rerank"] = float(score)
|
|
122
|
+
item["score"] = float(score)
|
|
123
|
+
ranked.sort(key=lambda m: (-float(m.get("rerank_score") or 0.0), str(m.get("node_id") or "")))
|
|
124
|
+
if top_k is not None:
|
|
125
|
+
ranked = ranked[: max(1, int(top_k))]
|
|
126
|
+
for rank, match in enumerate(ranked, start=1):
|
|
127
|
+
match["rank"] = rank
|
|
128
|
+
return {
|
|
129
|
+
"matches": ranked,
|
|
130
|
+
"mode": "cross_encoder",
|
|
131
|
+
"model": mid,
|
|
132
|
+
"detail": None,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def rerank_matches(
|
|
137
|
+
query: str,
|
|
138
|
+
candidates: List[Dict[str, Any]],
|
|
139
|
+
*,
|
|
140
|
+
top_k: Optional[int] = None,
|
|
141
|
+
force: Optional[bool] = None,
|
|
142
|
+
) -> Dict[str, Any]:
|
|
143
|
+
"""Public entry: cross-encoder when enabled, else identity.
|
|
144
|
+
|
|
145
|
+
``force=True/False`` overrides the env kill-switch (tests only).
|
|
146
|
+
"""
|
|
147
|
+
enabled = _rerank_enabled() if force is None else bool(force)
|
|
148
|
+
if not enabled:
|
|
149
|
+
return identity_rerank(query, candidates, top_k=top_k)
|
|
150
|
+
return cross_encoder_rerank(query, candidates, top_k=top_k)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = [
|
|
154
|
+
"CROSS_ENCODER_MODEL_ENV",
|
|
155
|
+
"CROSS_ENCODER_RERANK_ENV",
|
|
156
|
+
"DEFAULT_CROSS_ENCODER_MODEL",
|
|
157
|
+
"cross_encoder_rerank",
|
|
158
|
+
"identity_rerank",
|
|
159
|
+
"rerank_matches",
|
|
160
|
+
]
|