ltcai 9.9.2 → 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 +44 -39
- package/docs/CHANGELOG.md +109 -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/curator.py +141 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +382 -37
- package/lattice_brain/graph/retrieval.py +71 -4
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/ingestion.py +66 -0
- 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/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +33 -2
- package/latticeai/api/chat_agent_http.py +363 -6
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +217 -10
- package/latticeai/api/chat_stream.py +93 -3
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +65 -1
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +14 -0
- package/latticeai/core/agent.py +355 -18
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +404 -2
- 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/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +110 -2
- package/latticeai/services/folder_watch.py +396 -0
- package/latticeai/services/funnel_metrics.py +210 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +51 -5
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/bench_models.py +234 -5
- 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 -27
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-BnGbytHa.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-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
- package/static/app/assets/Library-pKCK0_tk.js +1 -0
- package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
- package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
- package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/index-7FAfYm4v.css +2 -0
- package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/shield-alert-BftATuAA.js +1 -0
- package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
- 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-Cx_gZNbE.js +0 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
- package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
- package/static/app/assets/Library-PcfVUco6.js +0 -1
- package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
- package/static/app/assets/download-C7yxYHvz.js +0 -1
- package/static/app/assets/index-DU78Kogz.css +0 -2
- package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
- package/static/app/assets/utils-Dh-Dwtkb.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,332 @@ 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
|
+
|
|
678
|
+
_NOISE_CONTENT_TYPES = (
|
|
679
|
+
"Document",
|
|
680
|
+
"File",
|
|
681
|
+
"CodeFile",
|
|
682
|
+
"Message",
|
|
683
|
+
"AIResponse",
|
|
684
|
+
"Chat",
|
|
685
|
+
"Page",
|
|
686
|
+
"Slide",
|
|
687
|
+
"Spreadsheet",
|
|
688
|
+
)
|
|
689
|
+
_NOISE_CONCEPT_TYPES = ("Concept", "Feature", "Topic", "Code", "Error")
|
|
690
|
+
|
|
691
|
+
def curate_noise(
|
|
692
|
+
self,
|
|
693
|
+
*,
|
|
694
|
+
dry_run: bool = True,
|
|
695
|
+
max_df_ratio: float = 0.8,
|
|
696
|
+
min_doc_frequency: int = 1,
|
|
697
|
+
min_corpus_docs: int = 5,
|
|
698
|
+
normalize_verbs: bool = True,
|
|
699
|
+
max_removals: int = 200,
|
|
700
|
+
) -> Dict[str, Any]:
|
|
701
|
+
"""Noise-reduction curation job (backlog #10, review §7.2 D).
|
|
702
|
+
|
|
703
|
+
(a) Removes heuristic concept nodes (``auto_extracted`` /
|
|
704
|
+
``graph_curator``-promoted) whose document frequency marks them as
|
|
705
|
+
noise: ubiquitous (low IDF — linked from more than ``max_df_ratio`` of
|
|
706
|
+
content docs once the corpus has ``min_corpus_docs``) or below the
|
|
707
|
+
``min_doc_frequency`` floor. Explicitly user-created nodes are never
|
|
708
|
+
touched, whatever their stats.
|
|
709
|
+
|
|
710
|
+
(b) Normalizes free-string relation verbs on the legacy edge table via
|
|
711
|
+
the ko/en dictionary in :mod:`lattice_brain.graph.curator`
|
|
712
|
+
('만들다/만든/creates' → 'created', …), merging rows that collide
|
|
713
|
+
after the rename.
|
|
714
|
+
|
|
715
|
+
``dry_run=True`` (the default) only *reports* what would change.
|
|
716
|
+
"""
|
|
717
|
+
from .curator import (
|
|
718
|
+
build_relation_verb_index,
|
|
719
|
+
plan_concept_noise_reduction,
|
|
720
|
+
plan_relation_normalization,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
max_removals = max(0, int(max_removals))
|
|
724
|
+
# Operate on the legacy write tables directly: they are the mutation
|
|
725
|
+
# target, and raw free-string verbs only exist there (the v4 write
|
|
726
|
+
# door normalizes new edges; the kgv2_* read views collapse
|
|
727
|
+
# legacy_type and would hide exactly the rows this job cleans up).
|
|
728
|
+
nt, et = "nodes", "edges"
|
|
729
|
+
with self._connect() as conn:
|
|
730
|
+
content_ph = ",".join("?" for _ in self._NOISE_CONTENT_TYPES)
|
|
731
|
+
total_docs = conn.execute(
|
|
732
|
+
f"SELECT COUNT(*) AS c FROM {nt} WHERE type IN ({content_ph})",
|
|
733
|
+
self._NOISE_CONTENT_TYPES,
|
|
734
|
+
).fetchone()["c"]
|
|
735
|
+
|
|
736
|
+
concept_ph = ",".join("?" for _ in self._NOISE_CONCEPT_TYPES)
|
|
737
|
+
concept_rows = conn.execute(
|
|
738
|
+
f"SELECT id, type, title, metadata_json FROM {nt} WHERE type IN ({concept_ph})",
|
|
739
|
+
self._NOISE_CONCEPT_TYPES,
|
|
740
|
+
).fetchall()
|
|
741
|
+
concepts = []
|
|
742
|
+
for row in concept_rows:
|
|
743
|
+
meta = _safe_loads(row["metadata_json"]) or {}
|
|
744
|
+
heuristic = bool(meta.get("auto_extracted")) or (
|
|
745
|
+
meta.get("source") == "graph_curator" or meta.get("curated") is True
|
|
746
|
+
)
|
|
747
|
+
# Document frequency: distinct *content* nodes linked to this
|
|
748
|
+
# concept in either direction.
|
|
749
|
+
df = conn.execute(
|
|
750
|
+
f"""
|
|
751
|
+
SELECT COUNT(DISTINCT n.id) AS c
|
|
752
|
+
FROM {et} e
|
|
753
|
+
JOIN {nt} n
|
|
754
|
+
ON n.id = CASE WHEN e.to_node = ? THEN e.from_node ELSE e.to_node END
|
|
755
|
+
WHERE (e.to_node = ? OR e.from_node = ?)
|
|
756
|
+
AND n.type IN ({content_ph})
|
|
757
|
+
""",
|
|
758
|
+
(row["id"], row["id"], row["id"], *self._NOISE_CONTENT_TYPES),
|
|
759
|
+
).fetchone()["c"]
|
|
760
|
+
concepts.append({
|
|
761
|
+
"id": row["id"],
|
|
762
|
+
"label": row["title"],
|
|
763
|
+
"type": row["type"],
|
|
764
|
+
"df": int(df or 0),
|
|
765
|
+
"heuristic": heuristic,
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
plan = plan_concept_noise_reduction(
|
|
769
|
+
concepts,
|
|
770
|
+
total_docs,
|
|
771
|
+
max_df_ratio=max_df_ratio,
|
|
772
|
+
min_doc_frequency=min_doc_frequency,
|
|
773
|
+
min_corpus_docs=min_corpus_docs,
|
|
774
|
+
)
|
|
775
|
+
removals = plan["remove"][:max_removals]
|
|
776
|
+
|
|
777
|
+
verb_index = build_relation_verb_index()
|
|
778
|
+
edge_type_rows = conn.execute(
|
|
779
|
+
f"SELECT DISTINCT type FROM {et}"
|
|
780
|
+
).fetchall()
|
|
781
|
+
verb_plan = (
|
|
782
|
+
plan_relation_normalization(
|
|
783
|
+
(row["type"] for row in edge_type_rows), index=verb_index,
|
|
784
|
+
)
|
|
785
|
+
if normalize_verbs
|
|
786
|
+
else {}
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
removed_count = 0
|
|
790
|
+
renamed_edges = 0
|
|
791
|
+
if not dry_run:
|
|
792
|
+
for decision in removals:
|
|
793
|
+
node_id = decision["id"]
|
|
794
|
+
conn.execute(
|
|
795
|
+
"DELETE FROM edges WHERE from_node=? OR to_node=?",
|
|
796
|
+
(node_id, node_id),
|
|
797
|
+
)
|
|
798
|
+
conn.execute(
|
|
799
|
+
"DELETE FROM vector_embeddings WHERE item_id=?", (node_id,)
|
|
800
|
+
)
|
|
801
|
+
conn.execute("DELETE FROM nodes WHERE id=?", (node_id,))
|
|
802
|
+
self._v2_delete_nodes(conn, [node_id])
|
|
803
|
+
removed_count += 1
|
|
804
|
+
for original, canonical in verb_plan.items():
|
|
805
|
+
renamed_edges += conn.execute(
|
|
806
|
+
"SELECT COUNT(*) AS c FROM edges WHERE type=?", (original,)
|
|
807
|
+
).fetchone()["c"]
|
|
808
|
+
# UNIQUE(from_node, to_node, type): merge rows that collide
|
|
809
|
+
# after the rename instead of failing the UPDATE.
|
|
810
|
+
conn.execute(
|
|
811
|
+
"UPDATE OR IGNORE edges SET type=? WHERE type=?",
|
|
812
|
+
(canonical, original),
|
|
813
|
+
)
|
|
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
|
+
)
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
"status": "ok",
|
|
825
|
+
"dry_run": bool(dry_run),
|
|
826
|
+
"total_content_docs": int(total_docs or 0),
|
|
827
|
+
"concepts_examined": len(concepts),
|
|
828
|
+
"remove": removals,
|
|
829
|
+
"remove_total": len(plan["remove"]),
|
|
830
|
+
"kept": len(plan["keep"]),
|
|
831
|
+
"protected_user_nodes": sum(
|
|
832
|
+
1 for item in plan["keep"] if item.get("reason") == "user_created_protected"
|
|
833
|
+
),
|
|
834
|
+
"verb_normalizations": verb_plan,
|
|
835
|
+
"applied": {
|
|
836
|
+
"removed_nodes": removed_count,
|
|
837
|
+
"renamed_edges": renamed_edges,
|
|
838
|
+
},
|
|
839
|
+
"thresholds": {
|
|
840
|
+
"max_df_ratio": float(max_df_ratio),
|
|
841
|
+
"min_doc_frequency": int(min_doc_frequency),
|
|
842
|
+
"min_corpus_docs": int(min_corpus_docs),
|
|
843
|
+
},
|
|
844
|
+
}
|
|
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
|
+
|
|
517
862
|
def mark_superseded(self, old_node_id: str, new_node_id: str) -> Dict[str, Any]:
|
|
518
863
|
"""Record that ``old_node_id`` was replaced by ``new_node_id``.
|
|
519
864
|
|
|
@@ -450,7 +450,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
450
450
|
query: str,
|
|
451
451
|
*,
|
|
452
452
|
top_k: int = 20,
|
|
453
|
-
alpha: float =
|
|
453
|
+
alpha: Optional[float] = None,
|
|
454
454
|
workspace_id: Optional[str] = None,
|
|
455
455
|
allowed_workspaces=None,
|
|
456
456
|
include_legacy_global: bool = False,
|
|
@@ -476,6 +476,18 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
476
476
|
|
|
477
477
|
``workspace_id`` is a convenience for single-workspace callers; the
|
|
478
478
|
richer ``allowed_workspaces`` set wins when both are provided.
|
|
479
|
+
|
|
480
|
+
``alpha=None`` (the default) resolves the vector share from the
|
|
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.
|
|
479
491
|
"""
|
|
480
492
|
query = str(query or "").strip()
|
|
481
493
|
try:
|
|
@@ -483,6 +495,26 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
483
495
|
except (TypeError, ValueError):
|
|
484
496
|
top_k = 20
|
|
485
497
|
top_k = max(1, min(top_k, 100))
|
|
498
|
+
query_class: Optional[str] = None
|
|
499
|
+
search_query = query
|
|
500
|
+
rewrite_rules: List[str] = []
|
|
501
|
+
recency_half_life_days: Optional[float] = None
|
|
502
|
+
if alpha is None:
|
|
503
|
+
try:
|
|
504
|
+
from .retrieval_policy import resolve_policy
|
|
505
|
+
|
|
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
|
|
517
|
+
alpha = 0.6
|
|
486
518
|
try:
|
|
487
519
|
alpha = float(alpha)
|
|
488
520
|
except (TypeError, ValueError):
|
|
@@ -496,9 +528,11 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
496
528
|
"query": query,
|
|
497
529
|
"mode": "hybrid",
|
|
498
530
|
"alpha": alpha,
|
|
531
|
+
"query_class": query_class,
|
|
499
532
|
"top_k": top_k,
|
|
500
533
|
"sources": {"lexical": 0, "vector": 0},
|
|
501
534
|
"matches": [],
|
|
535
|
+
"policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
|
|
502
536
|
"detail": None,
|
|
503
537
|
}
|
|
504
538
|
|
|
@@ -506,7 +540,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
506
540
|
vec_fetch = max(1, min(int(vector_limit or max(top_k * 2, 20)), 100))
|
|
507
541
|
|
|
508
542
|
lexical_matches = self.search(
|
|
509
|
-
|
|
543
|
+
search_query,
|
|
510
544
|
lex_fetch,
|
|
511
545
|
allowed_workspaces=allowed_workspaces,
|
|
512
546
|
include_legacy_global=include_legacy_global,
|
|
@@ -522,7 +556,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
522
556
|
else:
|
|
523
557
|
try:
|
|
524
558
|
vector_matches = list(
|
|
525
|
-
(vector_fn(
|
|
559
|
+
(vector_fn(search_query, limit=vec_fetch, min_score=min_vector_score) or {}).get(
|
|
526
560
|
"matches", []
|
|
527
561
|
)
|
|
528
562
|
)
|
|
@@ -530,6 +564,17 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
530
564
|
mode = "lexical_only"
|
|
531
565
|
detail = f"vector index unavailable: {exc}"
|
|
532
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
|
|
533
578
|
if vector_matches and allowed_workspaces is not None:
|
|
534
579
|
vector_matches = self.filter_scoped_nodes(
|
|
535
580
|
vector_matches,
|
|
@@ -616,19 +661,41 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
616
661
|
entry["fusion"] = "lexical"
|
|
617
662
|
matches.append(entry)
|
|
618
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
|
+
|
|
619
681
|
matches.sort(key=lambda item: (-item["score"], item["node_id"]))
|
|
620
682
|
matches = matches[:top_k]
|
|
621
683
|
for rank, match in enumerate(matches, start=1):
|
|
622
684
|
match["rank"] = rank
|
|
623
|
-
|
|
685
|
+
result = {
|
|
624
686
|
"query": query,
|
|
625
687
|
"mode": mode,
|
|
626
688
|
"alpha": alpha,
|
|
689
|
+
"query_class": query_class,
|
|
627
690
|
"top_k": top_k,
|
|
628
691
|
"sources": {"lexical": len(lexical_matches), "vector": len(vector_matches)},
|
|
629
692
|
"matches": matches,
|
|
693
|
+
"policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
|
|
630
694
|
"detail": detail,
|
|
631
695
|
}
|
|
696
|
+
if vector_degraded is not None:
|
|
697
|
+
result["vector_degraded"] = vector_degraded
|
|
698
|
+
return result
|
|
632
699
|
|
|
633
700
|
def context_for_query(
|
|
634
701
|
self,
|