ltcai 9.9.3 → 9.9.4

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 (81) hide show
  1. package/README.md +43 -38
  2. package/docs/CHANGELOG.md +65 -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 +47 -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/retrieval.py +61 -12
  17. package/lattice_brain/graph/retrieval_policy.py +174 -0
  18. package/lattice_brain/graph/retrieval_vector.py +122 -2
  19. package/lattice_brain/portability.py +41 -12
  20. package/lattice_brain/runtime/multi_agent.py +1 -1
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/chat.py +6 -0
  23. package/latticeai/api/chat_agent_http.py +175 -6
  24. package/latticeai/api/chat_intents.py +37 -18
  25. package/latticeai/api/chat_stream.py +76 -2
  26. package/latticeai/api/knowledge_graph.py +35 -1
  27. package/latticeai/core/agent.py +264 -9
  28. package/latticeai/core/enterprise.py +5 -0
  29. package/latticeai/core/file_generation.py +130 -5
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/run_store.py +243 -0
  33. package/latticeai/core/workspace_os.py +1 -1
  34. package/latticeai/models/router.py +25 -12
  35. package/latticeai/services/architecture_readiness.py +1 -1
  36. package/latticeai/services/command_center.py +90 -1
  37. package/latticeai/services/folder_watch.py +5 -0
  38. package/latticeai/services/funnel_metrics.py +9 -1
  39. package/latticeai/services/product_readiness.py +1 -1
  40. package/latticeai/services/search_service.py +43 -13
  41. package/package.json +1 -1
  42. package/scripts/bench_agent_smoke.py +410 -0
  43. package/scripts/check_current_release_docs.mjs +1 -1
  44. package/scripts/funnel_soft_gate.py +192 -0
  45. package/src-tauri/Cargo.lock +1 -1
  46. package/src-tauri/Cargo.toml +1 -1
  47. package/src-tauri/tauri.conf.json +1 -1
  48. package/static/app/asset-manifest.json +29 -28
  49. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  50. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-CTnjox7w.js} +2 -2
  51. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  52. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  53. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-B6vBhFa3.js} +1 -1
  54. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-90u9FWFH.js} +1 -1
  55. package/static/app/assets/{Library-Bp0n-HlW.js → Library-pKCK0_tk.js} +1 -1
  56. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-Jlf2wFqI.js} +1 -1
  57. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-yg1fKP1P.js} +1 -1
  58. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-DWvD7n9h.js} +1 -1
  59. package/static/app/assets/{System-ByQcmJW-.js → System-BAEuHqNY.js} +1 -1
  60. package/static/app/assets/{bot-BNDyZLR7.js → bot-nB_buEZD.js} +1 -1
  61. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  62. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-B-IsFL1y.js} +1 -1
  63. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CEPBHaBl.js} +1 -1
  64. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-hmN0N9cX.js} +1 -1
  65. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CBV_B_Yd.js} +1 -1
  66. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  67. package/static/app/assets/{index-CHu7cgj3.js → index-DrmOCySv.js} +3 -3
  68. package/static/app/assets/{input-DVDI0YR3.js → input-gtVCg-ll.js} +1 -1
  69. package/static/app/assets/{navigation-BddhEWA0.js → navigation-Bot0hvuv.js} +1 -1
  70. package/static/app/assets/{network-pYQt5oBu.js → network-jE42eKfT.js} +1 -1
  71. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-CX2Komon.js} +1 -1
  72. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-BftATuAA.js} +1 -1
  73. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-CiMJfOSI.js} +1 -1
  74. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-B7RPGfFy.js} +1 -1
  75. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  76. package/static/app/index.html +4 -4
  77. package/static/sw.js +1 -1
  78. package/static/app/assets/Act-DmdruVKV.js +0 -1
  79. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  80. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  81. 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
 
@@ -478,10 +478,16 @@ class KnowledgeGraphRetrievalMixin:
478
478
  richer ``allowed_workspaces`` set wins when both are provided.
479
479
 
480
480
  ``alpha=None`` (the default) resolves the vector share from the
481
- query-class fusion table (:mod:`lattice_brain.graph.fusion`):
482
- fact 0.6 (the historical default) / code 0.35 / person 0.45 /
483
- recency 0.5, config-overridable via ``LATTICEAI_FUSION_WEIGHTS``.
484
- Passing an explicit ``alpha`` pins it exactly as before.
481
+ single retrieval policy (:mod:`lattice_brain.graph.retrieval_policy`,
482
+ which wraps the query-class fusion table): fact 0.6 (the historical
483
+ default) / code 0.35 / person 0.45 / recency 0.5, config-overridable
484
+ via ``LATTICEAI_FUSION_WEIGHTS``. The policy also supplies a
485
+ deterministic rule-based query rewrite (echoed additively under
486
+ ``"policy"``; the response ``"query"`` stays the original) and, for
487
+ the ``recency`` class only, an age-decay half-life that dampens each
488
+ fused score into the ``[0.5, 1.0]`` band (``scores.age_decay``).
489
+ Passing an explicit ``alpha`` pins it exactly as before and disables
490
+ rewrite + decay.
485
491
  """
486
492
  query = str(query or "").strip()
487
493
  try:
@@ -490,14 +496,24 @@ class KnowledgeGraphRetrievalMixin:
490
496
  top_k = 20
491
497
  top_k = max(1, min(top_k, 100))
492
498
  query_class: Optional[str] = None
499
+ search_query = query
500
+ rewrite_rules: List[str] = []
501
+ recency_half_life_days: Optional[float] = None
493
502
  if alpha is None:
494
503
  try:
495
- from .fusion import fusion_profile
504
+ from .retrieval_policy import resolve_policy
496
505
 
497
- profile = fusion_profile(query)
498
- query_class = profile["query_class"]
499
- alpha = float(profile["alpha"])
500
- except Exception: # noqa: BLE001 — fusion table must never break search
506
+ policy = resolve_policy(query)
507
+ query_class = policy["query_class"]
508
+ alpha = float(policy["alpha"])
509
+ rewrite_rules = list(policy.get("rewrite_rules") or [])
510
+ rewritten = str(policy.get("search_query") or "")
511
+ if rewritten and rewritten != query:
512
+ search_query = rewritten
513
+ half_life = policy.get("recency_half_life_days")
514
+ if half_life is not None:
515
+ recency_half_life_days = float(half_life)
516
+ except Exception: # noqa: BLE001 — policy resolution must never break search
501
517
  alpha = 0.6
502
518
  try:
503
519
  alpha = float(alpha)
@@ -516,6 +532,7 @@ class KnowledgeGraphRetrievalMixin:
516
532
  "top_k": top_k,
517
533
  "sources": {"lexical": 0, "vector": 0},
518
534
  "matches": [],
535
+ "policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
519
536
  "detail": None,
520
537
  }
521
538
 
@@ -523,7 +540,7 @@ class KnowledgeGraphRetrievalMixin:
523
540
  vec_fetch = max(1, min(int(vector_limit or max(top_k * 2, 20)), 100))
524
541
 
525
542
  lexical_matches = self.search(
526
- query,
543
+ search_query,
527
544
  lex_fetch,
528
545
  allowed_workspaces=allowed_workspaces,
529
546
  include_legacy_global=include_legacy_global,
@@ -539,7 +556,7 @@ class KnowledgeGraphRetrievalMixin:
539
556
  else:
540
557
  try:
541
558
  vector_matches = list(
542
- (vector_fn(query, limit=vec_fetch, min_score=min_vector_score) or {}).get(
559
+ (vector_fn(search_query, limit=vec_fetch, min_score=min_vector_score) or {}).get(
543
560
  "matches", []
544
561
  )
545
562
  )
@@ -547,6 +564,17 @@ class KnowledgeGraphRetrievalMixin:
547
564
  mode = "lexical_only"
548
565
  detail = f"vector index unavailable: {exc}"
549
566
  vector_matches = []
567
+ # An embedder swap makes the vector channel silently return zero rows
568
+ # (vector_search filters on the CURRENT model/dim). Surface the honest
569
+ # cause additively without changing the mode string.
570
+ vector_degraded: Optional[str] = None
571
+ if mode == "hybrid" and not vector_matches:
572
+ try:
573
+ fingerprint_fn = getattr(self, "embedder_fingerprint_status", None)
574
+ if callable(fingerprint_fn) and fingerprint_fn().get("stale_embedder"):
575
+ vector_degraded = "stale_embedder"
576
+ except Exception: # noqa: BLE001 — fingerprint status must never break search
577
+ vector_degraded = None
550
578
  if vector_matches and allowed_workspaces is not None:
551
579
  vector_matches = self.filter_scoped_nodes(
552
580
  vector_matches,
@@ -633,11 +661,28 @@ class KnowledgeGraphRetrievalMixin:
633
661
  entry["fusion"] = "lexical"
634
662
  matches.append(entry)
635
663
 
664
+ # Recency-class age decay (retrieval_policy): dampen each fused score
665
+ # into the [0.5, 1.0] band so old-but-relevant items sink without ever
666
+ # being zeroed. Other classes skip this block byte-identically.
667
+ if recency_half_life_days is not None:
668
+ decay_now = datetime.now()
669
+ for match in matches:
670
+ stamp = match.get("updated_at")
671
+ if _parse_iso(stamp):
672
+ multiplier = 0.5 + 0.5 * _recency_score(
673
+ stamp, now=decay_now, half_life_days=recency_half_life_days
674
+ )
675
+ else:
676
+ # Unknown age is not evidence of staleness — never dampen.
677
+ multiplier = 1.0
678
+ match["scores"]["age_decay"] = round(multiplier, 6)
679
+ match["score"] = round(float(match["score"]) * multiplier, 6)
680
+
636
681
  matches.sort(key=lambda item: (-item["score"], item["node_id"]))
637
682
  matches = matches[:top_k]
638
683
  for rank, match in enumerate(matches, start=1):
639
684
  match["rank"] = rank
640
- return {
685
+ result = {
641
686
  "query": query,
642
687
  "mode": mode,
643
688
  "alpha": alpha,
@@ -645,8 +690,12 @@ class KnowledgeGraphRetrievalMixin:
645
690
  "top_k": top_k,
646
691
  "sources": {"lexical": len(lexical_matches), "vector": len(vector_matches)},
647
692
  "matches": matches,
693
+ "policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
648
694
  "detail": detail,
649
695
  }
696
+ if vector_degraded is not None:
697
+ result["vector_degraded"] = vector_degraded
698
+ return result
650
699
 
651
700
  def context_for_query(
652
701
  self,
@@ -0,0 +1,174 @@
1
+ """Single retrieval policy consulted by both hybrid fusion layers.
2
+
3
+ Review 2026-07-25 (UX/harness/loop/KG-RAG), Wave 0.2 — "RetrievalPolicy
4
+ 단일화" + "query rewrite" + "recency age decay". Before this module the two
5
+ hybrid entry points (``KnowledgeGraphStore.hybrid_search`` two-channel
6
+ alpha fusion and ``SearchService.hybrid_search`` three-channel weight
7
+ fusion) each resolved :func:`lattice_brain.graph.fusion.fusion_profile`
8
+ independently and had no shared place to hang retrieval-wide behavior.
9
+ This module is that single place:
10
+
11
+ * :func:`rewrite_query` — deterministic, rule-based ko/en normalization
12
+ (NO LLM). Strips a conservative fixed list of leading/trailing politeness
13
+ fillers ("~좀 알려줘", "궁금해", "뭐였지", "what is", "please", …) and
14
+ collapses repeated whitespace. Code-class queries pass through untouched
15
+ (except whitespace collapse) because exact identifiers/filenames ARE the
16
+ retrieval signal. ``LATTICEAI_QUERY_REWRITE=0`` disables rewriting.
17
+ * :func:`resolve_policy` — composes the rewrite with
18
+ :func:`~lattice_brain.graph.fusion.fusion_profile` (the weight table is
19
+ NOT duplicated here) and adds ``recency_half_life_days``, which is
20
+ non-``None`` ONLY for the ``recency`` query class — the honest signal
21
+ that age decay applies exactly where the fusion layers wire it.
22
+
23
+ Both functions are pure, deterministic, and never raise; the ``fact``
24
+ query class resolves to the historical default weights, so fact-class
25
+ behavior stays byte-compatible. Scores are never combined with ``or``
26
+ (score ``0.0`` is falsy but valid).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ import re
33
+ from typing import Any, Dict, List, Mapping, Optional
34
+
35
+ from .fusion import classify_query, fusion_profile
36
+
37
+ QUERY_REWRITE_ENV = "LATTICEAI_QUERY_REWRITE"
38
+
39
+ # Half-life (days) for the recency-class age decay. Matches the default of
40
+ # lattice_brain.graph._kg_fsutil._recency_score, which the fusion layers use
41
+ # to compute the actual multiplier — this module only declares the policy.
42
+ RECENCY_HALF_LIFE_DAYS = 14.0
43
+
44
+ # A filler strip is applied only when the remaining query keeps at least this
45
+ # many characters — never rewrite a query down to (near-)nothing.
46
+ _MIN_REMAINDER_CHARS = 4
47
+
48
+ _WS_RE = re.compile(r"\s+")
49
+
50
+ # Conservative trailing Korean politeness/filler phrases ("… 좀 알려줘",
51
+ # "… 궁금해", "… 뭐였지"). Anchored at end-of-string, must start the query or
52
+ # follow whitespace so word interiors are never clipped.
53
+ _KO_FILLER_TAIL_RE = re.compile(
54
+ r"(?:^|(?<=\s))(?:좀\s+)?"
55
+ r"(?:알려\s*줘요?|알려\s*주세요|알려\s*줄래요?"
56
+ r"|말해\s*줘요?|말해\s*주세요"
57
+ r"|설명해\s*줘요?|설명해\s*주세요"
58
+ r"|궁금해요?|궁금합니다"
59
+ r"|뭐였지|뭐였더라|뭐더라|뭐지|뭐야"
60
+ r")\s*[?!.…~]*\s*$"
61
+ )
62
+
63
+ # Conservative leading English filler ("what is …", "tell me about …",
64
+ # optionally prefixed with "please").
65
+ _EN_FILLER_LEAD_RE = re.compile(
66
+ r"^(?:please\s+)?(?:tell\s+me\s+about|what\s+is|what\s+was|what\s+are|what's)\s+",
67
+ re.IGNORECASE,
68
+ )
69
+
70
+ # Conservative trailing English politeness ("…, please?").
71
+ _EN_FILLER_TAIL_RE = re.compile(r"[,\s]*\bplease\b\s*[?!.]*\s*$", re.IGNORECASE)
72
+
73
+ _FILLER_RULES = (
74
+ ("strip_filler_ko", _KO_FILLER_TAIL_RE),
75
+ ("strip_filler_en_leading", _EN_FILLER_LEAD_RE),
76
+ ("strip_filler_en_trailing", _EN_FILLER_TAIL_RE),
77
+ )
78
+
79
+
80
+ def _rewrite_enabled() -> bool:
81
+ raw = os.getenv(QUERY_REWRITE_ENV, "").strip().lower()
82
+ return raw not in {"0", "false", "no", "off"}
83
+
84
+
85
+ def rewrite_query(query: Any) -> Dict[str, Any]:
86
+ """Deterministic rule-based ko/en query normalization (never raises).
87
+
88
+ Returns ``{"original": str, "rewritten": str, "rules": [rule names]}``.
89
+ Only three conservative rules exist: whitespace collapse, trailing
90
+ Korean filler strip, and leading/trailing English filler strip — each
91
+ filler strip applies only when the remainder keeps >= 4 characters.
92
+ Code-class queries (exact identifiers/filenames are the signal) are
93
+ returned unchanged apart from whitespace collapse. The
94
+ ``LATTICEAI_QUERY_REWRITE=0`` kill-switch disables rewriting entirely
95
+ (``rewritten == original``). Empty input yields empty output.
96
+ """
97
+ try:
98
+ text = str(query or "").strip()
99
+ except Exception: # noqa: BLE001 — rewrite must never raise
100
+ return {"original": "", "rewritten": "", "rules": []}
101
+ if not text:
102
+ return {"original": "", "rewritten": "", "rules": []}
103
+ original = text
104
+ if not _rewrite_enabled():
105
+ return {"original": original, "rewritten": original, "rules": []}
106
+
107
+ rules: List[str] = []
108
+ rewritten = _WS_RE.sub(" ", text).strip()
109
+ if rewritten != original:
110
+ rules.append("collapse_whitespace")
111
+
112
+ if classify_query(original) == "code":
113
+ # Exact identifiers/filenames are the retrieval signal — never touch
114
+ # them (whitespace collapse is the only permitted normalization).
115
+ return {"original": original, "rewritten": rewritten, "rules": rules}
116
+
117
+ for name, pattern in _FILLER_RULES:
118
+ candidate = pattern.sub("", rewritten).strip()
119
+ if candidate != rewritten and len(candidate) >= _MIN_REMAINDER_CHARS:
120
+ rewritten = candidate
121
+ rules.append(name)
122
+ return {"original": original, "rewritten": rewritten, "rules": rules}
123
+
124
+
125
+ def resolve_policy(
126
+ query: Any,
127
+ *,
128
+ overrides: Optional[Mapping[str, Mapping[str, float]]] = None,
129
+ ) -> Dict[str, Any]:
130
+ """Resolve the full retrieval policy for ``query`` (never raises).
131
+
132
+ Composes :func:`rewrite_query` with
133
+ :func:`~lattice_brain.graph.fusion.fusion_profile` (classification runs
134
+ on the ORIGINAL query, so ``query_class`` / ``weights`` / ``alpha`` are
135
+ byte-identical to calling ``fusion_profile`` directly, as both hybrid
136
+ layers did before this module). Returns::
137
+
138
+ {
139
+ "query_class": "fact" | "code" | "person" | "recency",
140
+ "weights": {"keyword", "vector", "graph"}, # service fusion
141
+ "alpha": float, # graph-layer fusion
142
+ "original_query": str,
143
+ "search_query": str, # the rewritten form to search with
144
+ "rewrite_rules": [str],
145
+ "recency_half_life_days": float | None, # 14.0 only for recency
146
+ }
147
+
148
+ ``recency_half_life_days`` is non-``None`` only for the ``recency``
149
+ class — the honest contract that age decay applies exactly where the
150
+ fusion layers wire it, and nowhere else.
151
+ """
152
+ rewrite = rewrite_query(query)
153
+ profile = fusion_profile(rewrite["original"], overrides=overrides)
154
+ query_class = profile["query_class"]
155
+ search_query = rewrite["rewritten"] if rewrite["rewritten"] else rewrite["original"]
156
+ return {
157
+ "query_class": query_class,
158
+ "weights": dict(profile["weights"]),
159
+ "alpha": float(profile["alpha"]),
160
+ "original_query": rewrite["original"],
161
+ "search_query": search_query,
162
+ "rewrite_rules": list(rewrite["rules"]),
163
+ "recency_half_life_days": (
164
+ RECENCY_HALF_LIFE_DAYS if query_class == "recency" else None
165
+ ),
166
+ }
167
+
168
+
169
+ __all__ = [
170
+ "QUERY_REWRITE_ENV",
171
+ "RECENCY_HALF_LIFE_DAYS",
172
+ "resolve_policy",
173
+ "rewrite_query",
174
+ ]