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.
- package/README.md +43 -38
- package/docs/CHANGELOG.md +65 -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 +47 -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/retrieval.py +61 -12
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/portability.py +41 -12
- 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 +175 -6
- 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 +264 -9
- 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 +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- 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/package.json +1 -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/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-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.js → Brain-CTnjox7w.js} +2 -2
- package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
- package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
- package/static/app/assets/{Capture-CVZ09QXi.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-90u9FWFH.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-pKCK0_tk.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-DWvD7n9h.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-B-IsFL1y.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-BftATuAA.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-B7RPGfFy.js} +1 -1
- package/static/app/assets/utils-SJUNVOj5.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
|
|
|
@@ -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
|
-
|
|
482
|
-
|
|
483
|
-
recency 0.5, config-overridable
|
|
484
|
-
|
|
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 .
|
|
504
|
+
from .retrieval_policy import resolve_policy
|
|
496
505
|
|
|
497
|
-
|
|
498
|
-
query_class =
|
|
499
|
-
alpha = float(
|
|
500
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
]
|