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.
Files changed (89) hide show
  1. package/README.md +39 -38
  2. package/docs/CHANGELOG.md +100 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +53 -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/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/rerank.py +160 -0
  17. package/lattice_brain/graph/retrieval.py +90 -455
  18. package/lattice_brain/graph/retrieval_policy.py +174 -0
  19. package/lattice_brain/graph/retrieval_reads.py +459 -0
  20. package/lattice_brain/graph/retrieval_vector.py +122 -2
  21. package/lattice_brain/graph/store.py +2 -0
  22. package/lattice_brain/portability.py +41 -12
  23. package/lattice_brain/quality.py +26 -6
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/chat.py +6 -0
  27. package/latticeai/api/chat_agent_http.py +262 -52
  28. package/latticeai/api/chat_contracts.py +1 -1
  29. package/latticeai/api/chat_intents.py +37 -18
  30. package/latticeai/api/chat_stream.py +76 -2
  31. package/latticeai/api/knowledge_graph.py +35 -1
  32. package/latticeai/core/agent.py +420 -27
  33. package/latticeai/core/enterprise.py +5 -0
  34. package/latticeai/core/file_generation.py +130 -5
  35. package/latticeai/core/legacy_compatibility.py +1 -1
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/run_store.py +245 -0
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/integrations/telegram_bot.py +82 -23
  40. package/latticeai/models/router.py +25 -12
  41. package/latticeai/services/architecture_readiness.py +1 -1
  42. package/latticeai/services/command_center.py +90 -1
  43. package/latticeai/services/folder_watch.py +5 -0
  44. package/latticeai/services/funnel_metrics.py +9 -1
  45. package/latticeai/services/product_readiness.py +1 -1
  46. package/latticeai/services/search_service.py +43 -13
  47. package/latticeai/services/tool_dispatch.py +56 -0
  48. package/package.json +2 -1
  49. package/scripts/bench_agent_smoke.py +410 -0
  50. package/scripts/check_current_release_docs.mjs +1 -1
  51. package/scripts/funnel_soft_gate.py +192 -0
  52. package/scripts/run_sidecar_e2e.mjs +159 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +29 -28
  57. package/static/app/assets/Act-DmxfbqBp.js +1 -0
  58. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
  59. package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
  60. package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
  61. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
  62. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
  63. package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
  64. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
  65. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
  66. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
  67. package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
  68. package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
  69. package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
  70. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
  71. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
  72. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
  73. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
  74. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  75. package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
  76. package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
  77. package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
  78. package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
  79. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
  80. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
  81. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
  82. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  83. package/static/app/assets/utils-BqoznzGP.js +7 -0
  84. package/static/app/index.html +4 -4
  85. package/static/sw.js +1 -1
  86. package/static/app/assets/Act-DmdruVKV.js +0 -1
  87. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  88. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  89. 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, *, max_documents: int = 200, max_new_nodes: int = 8
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
+ ]