ltcai 9.9.1 → 9.9.3
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 +40 -39
- package/docs/CHANGELOG.md +80 -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/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/curator.py +141 -0
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/projection.py +161 -0
- package/lattice_brain/graph/retrieval.py +19 -1
- package/lattice_brain/ingestion.py +66 -0
- 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 +30 -2
- package/latticeai/api/chat_agent_http.py +193 -4
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +319 -8
- package/latticeai/api/chat_stream.py +17 -1
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +30 -0
- 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 +15 -0
- package/latticeai/core/agent.py +205 -11
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/file_generation.py +337 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +6 -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 +20 -1
- package/latticeai/services/folder_watch.py +391 -0
- package/latticeai/services/funnel_metrics.py +202 -0
- package/latticeai/services/product_readiness.py +6 -1
- package/latticeai/services/search_service.py +17 -1
- package/latticeai/services/tool_dispatch.py +36 -0
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_models.py +234 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- 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 +28 -27
- package/static/app/assets/Act-DmdruVKV.js +1 -0
- package/static/app/assets/{Brain-CSCDZEHd.js → Brain-DEY9jLVt.js} +2 -2
- package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
- package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
- package/static/app/assets/{Capture-BiuerOio.js → Capture-CVZ09QXi.js} +1 -1
- package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
- package/static/app/assets/Library-Bp0n-HlW.js +1 -0
- package/static/app/assets/{LivingBrain-6FvA0H5T.js → LivingBrain-DxP4efJF.js} +1 -1
- package/static/app/assets/{ProductFlow-Cr8bkQPw.js → ProductFlow-DRbm7NEq.js} +1 -1
- package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
- package/static/app/assets/{System-D6uJacqu.js → System-ByQcmJW-.js} +1 -1
- package/static/app/assets/{bot-DgC7xgUl.js → bot-BNDyZLR7.js} +1 -1
- package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
- package/static/app/assets/{cpu-DDGb4XHl.js → cpu-C6jjYm6i.js} +1 -1
- package/static/app/assets/{folder-open-BbVuSO9M.js → folder-open-DjGIvDBQ.js} +1 -1
- package/static/app/assets/{hard-drive-yDZR6XYz.js → hard-drive-BlSbwSaT.js} +1 -1
- package/static/app/assets/index-Bge3DXW7.css +2 -0
- package/static/app/assets/{index-DrZSkwrE.js → index-CHu7cgj3.js} +3 -3
- package/static/app/assets/{input-DPIkTs9n.js → input-DVDI0YR3.js} +1 -1
- package/static/app/assets/{navigation-B1mkmyUe.js → navigation-BddhEWA0.js} +1 -1
- package/static/app/assets/{network-0XDI1Uf_.js → network-pYQt5oBu.js} +1 -1
- package/static/app/assets/{primitives-sIQYKyf5.js → primitives-D7gCdEvS.js} +1 -1
- package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
- package/static/app/assets/{textarea-DvnwpnqS.js → textarea-sqQmoBKL.js} +1 -1
- package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
- package/static/app/assets/utils-uQYKXNeq.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-CcSWTQsx.js +0 -1
- package/static/app/assets/BrainHome-B5QpJ1Fx.js +0 -2
- package/static/app/assets/BrainSignals-CmFD8PWk.js +0 -1
- package/static/app/assets/CommandPalette-BrqpT7K2.js +0 -1
- package/static/app/assets/Library-BcaiPbum.js +0 -1
- package/static/app/assets/ReviewCard-D56TMypk.js +0 -1
- package/static/app/assets/download-C-Ei3GFa.js +0 -1
- package/static/app/assets/index-CFRhNBuH.css +0 -2
- package/static/app/assets/sunrise-DXMjueec.js +0 -1
- package/static/app/assets/utils-BrfFHAJl.js +0 -7
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Query-class aware retrieval fusion weights (backlog #5, review §7.2 E).
|
|
2
|
+
|
|
3
|
+
Hybrid retrieval fuses keyword/lexical, vector, and graph channels. A single
|
|
4
|
+
static weight set treats "이 함수 왜 죽어?" and "어제 회의에서 뭐 결정했지?"
|
|
5
|
+
identically, which is measurably wrong: code questions live on exact
|
|
6
|
+
identifiers (lexical), person questions lean on relationship edges (graph),
|
|
7
|
+
recency questions need fresher lexical hits over semantic neighbors.
|
|
8
|
+
|
|
9
|
+
This module is deliberately tiny and deterministic:
|
|
10
|
+
|
|
11
|
+
* :func:`classify_query` — pure ko/en heuristics → ``fact | code | person |
|
|
12
|
+
recency`` (``fact`` is the fallback class and its weights are identical to
|
|
13
|
+
the historical defaults, so fact-class behavior is byte-compatible).
|
|
14
|
+
* :data:`DEFAULT_FUSION_WEIGHTS` — the per-class weight table. ``keyword`` /
|
|
15
|
+
``vector`` / ``graph`` feed the three-channel service fusion
|
|
16
|
+
(``SearchService.hybrid_search``); ``alpha`` is the vector share for the
|
|
17
|
+
two-channel graph-layer fusion (``KnowledgeGraphStore.hybrid_search``).
|
|
18
|
+
* :func:`fusion_profile` — classify + resolve weights, honoring the
|
|
19
|
+
``LATTICEAI_FUSION_WEIGHTS`` env override (JSON, partial per-class merges,
|
|
20
|
+
e.g. ``{"code": {"alpha": 0.2}}``).
|
|
21
|
+
|
|
22
|
+
Never uses ``or`` on scores (score 0 is falsy but valid) and never raises:
|
|
23
|
+
a malformed override is ignored, an unknown class falls back to ``fact``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from typing import Any, Dict, Mapping, Optional
|
|
32
|
+
|
|
33
|
+
QUERY_CLASSES = ("fact", "code", "person", "recency")
|
|
34
|
+
|
|
35
|
+
FUSION_WEIGHTS_ENV = "LATTICEAI_FUSION_WEIGHTS"
|
|
36
|
+
|
|
37
|
+
# Per-class fusion weights. "fact" mirrors the pre-fusion-gate defaults
|
|
38
|
+
# (SearchService DEFAULT_HYBRID_WEIGHTS + graph-layer alpha=0.6) so the
|
|
39
|
+
# fallback class introduces zero behavioral drift.
|
|
40
|
+
DEFAULT_FUSION_WEIGHTS: Dict[str, Dict[str, float]] = {
|
|
41
|
+
"fact": {"keyword": 0.35, "vector": 0.40, "graph": 0.25, "alpha": 0.60},
|
|
42
|
+
# Code recall lives on exact identifiers/tokens → lexical dominates.
|
|
43
|
+
"code": {"keyword": 0.55, "vector": 0.25, "graph": 0.20, "alpha": 0.35},
|
|
44
|
+
# Person questions are relationship questions → graph channel leads.
|
|
45
|
+
"person": {"keyword": 0.30, "vector": 0.30, "graph": 0.40, "alpha": 0.45},
|
|
46
|
+
# Recency questions want fresh literal hits over semantic neighbors.
|
|
47
|
+
"recency": {"keyword": 0.45, "vector": 0.35, "graph": 0.20, "alpha": 0.50},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ── query-class heuristics (pure, ko/en) ─────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
_CODE_FENCE_RE = re.compile(r"```|`[^`\n]+`")
|
|
53
|
+
_CODE_IDENT_RE = re.compile(
|
|
54
|
+
# ``\b`` fails between an identifier and an attached Korean particle
|
|
55
|
+
# ("ingest_folder가"), so use explicit ASCII-boundary lookbehinds instead.
|
|
56
|
+
r"[A-Za-z_][A-Za-z0-9_]*\(\)" # call syntax foo()
|
|
57
|
+
r"|(?<![A-Za-z0-9_])[a-z0-9]+_[a-z0-9_]+" # snake_case
|
|
58
|
+
r"|(?<![A-Za-z0-9_])[a-z]+[A-Z][A-Za-z0-9]*" # camelCase
|
|
59
|
+
r"|(?<![A-Za-z0-9_])[\w-]+\.(?:py|js|jsx|ts|tsx|json|yaml|yml|css|html|sql|sh|go|rs|java|rb)(?![A-Za-z0-9])"
|
|
60
|
+
)
|
|
61
|
+
_CODE_WORD_RE = re.compile(
|
|
62
|
+
r"\b(?:def|class|import|function|traceback|exception|stack\s*trace|"
|
|
63
|
+
r"bug|error|null|undefined|api|sql|regex)\b"
|
|
64
|
+
r"|코드|함수|버그|에러|오류|스택|컴파일|빌드\s*실패|구현",
|
|
65
|
+
re.IGNORECASE,
|
|
66
|
+
)
|
|
67
|
+
_PERSON_RE = re.compile(
|
|
68
|
+
r"\b(?:who|whom|whose)\b|누구|어떤\s*사람|담당자|팀원|동료|만난\s*사람"
|
|
69
|
+
r"|[\w.+-]+@[\w-]+\.[\w.]+" # an email address names a person
|
|
70
|
+
r"|[가-힣]{2,4}\s*(?:님|씨)(?:\s|$|[을를이가은는의??.!,])",
|
|
71
|
+
re.IGNORECASE,
|
|
72
|
+
)
|
|
73
|
+
_RECENCY_RE = re.compile(
|
|
74
|
+
r"최근|어제|오늘|그저께|방금|아까|지난\s*주|지난주|지난\s*달|지난달"
|
|
75
|
+
r"|이번\s*주|이번주|이번\s*달|이번달"
|
|
76
|
+
r"|\b(?:recent|recently|yesterday|today|latest|last\s+(?:week|month|night|meeting))\b",
|
|
77
|
+
re.IGNORECASE,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def classify_query(query: Any) -> str:
|
|
82
|
+
"""Classify a retrieval query into fact / code / person / recency.
|
|
83
|
+
|
|
84
|
+
Precedence: code (strongest structural signal) → recency (explicit time
|
|
85
|
+
words) → person → fact. Deterministic, never raises, empty → fact.
|
|
86
|
+
"""
|
|
87
|
+
text = str(query or "").strip()
|
|
88
|
+
if not text:
|
|
89
|
+
return "fact"
|
|
90
|
+
if _CODE_FENCE_RE.search(text) or _CODE_IDENT_RE.search(text) or _CODE_WORD_RE.search(text):
|
|
91
|
+
return "code"
|
|
92
|
+
if _RECENCY_RE.search(text):
|
|
93
|
+
return "recency"
|
|
94
|
+
if _PERSON_RE.search(text):
|
|
95
|
+
return "person"
|
|
96
|
+
return "fact"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _env_overrides() -> Dict[str, Dict[str, float]]:
|
|
100
|
+
"""Parse LATTICEAI_FUSION_WEIGHTS (JSON) → per-class partial overrides."""
|
|
101
|
+
raw = os.getenv(FUSION_WEIGHTS_ENV, "").strip()
|
|
102
|
+
if not raw:
|
|
103
|
+
return {}
|
|
104
|
+
try:
|
|
105
|
+
payload = json.loads(raw)
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
return {}
|
|
108
|
+
if not isinstance(payload, dict):
|
|
109
|
+
return {}
|
|
110
|
+
overrides: Dict[str, Dict[str, float]] = {}
|
|
111
|
+
for cls, table in payload.items():
|
|
112
|
+
if cls not in DEFAULT_FUSION_WEIGHTS or not isinstance(table, dict):
|
|
113
|
+
continue
|
|
114
|
+
cleaned: Dict[str, float] = {}
|
|
115
|
+
for key, value in table.items():
|
|
116
|
+
if key not in ("keyword", "vector", "graph", "alpha"):
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
cleaned[key] = max(0.0, min(1.0, float(value)))
|
|
120
|
+
except (TypeError, ValueError):
|
|
121
|
+
continue
|
|
122
|
+
if cleaned:
|
|
123
|
+
overrides[str(cls)] = cleaned
|
|
124
|
+
return overrides
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fusion_weight_table(
|
|
128
|
+
overrides: Optional[Mapping[str, Mapping[str, float]]] = None,
|
|
129
|
+
) -> Dict[str, Dict[str, float]]:
|
|
130
|
+
"""Full per-class table: defaults ← env override ← caller override."""
|
|
131
|
+
table = {cls: dict(weights) for cls, weights in DEFAULT_FUSION_WEIGHTS.items()}
|
|
132
|
+
for source in (_env_overrides(), overrides or {}):
|
|
133
|
+
for cls, partial in source.items():
|
|
134
|
+
if cls in table and isinstance(partial, Mapping):
|
|
135
|
+
for key, value in partial.items():
|
|
136
|
+
if key in table[cls]:
|
|
137
|
+
try:
|
|
138
|
+
table[cls][key] = max(0.0, min(1.0, float(value)))
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
continue
|
|
141
|
+
return table
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fusion_profile(
|
|
145
|
+
query: Any,
|
|
146
|
+
*,
|
|
147
|
+
overrides: Optional[Mapping[str, Mapping[str, float]]] = None,
|
|
148
|
+
) -> Dict[str, Any]:
|
|
149
|
+
"""Classify ``query`` and return its resolved fusion weights.
|
|
150
|
+
|
|
151
|
+
Returns ``{"query_class": str, "weights": {keyword, vector, graph},
|
|
152
|
+
"alpha": float}``. ``weights`` feeds the three-channel service fusion;
|
|
153
|
+
``alpha`` feeds the two-channel graph-layer fusion.
|
|
154
|
+
"""
|
|
155
|
+
query_class = classify_query(query)
|
|
156
|
+
table = fusion_weight_table(overrides)
|
|
157
|
+
resolved = table.get(query_class)
|
|
158
|
+
if resolved is None: # defensive — classify_query only emits known classes
|
|
159
|
+
query_class = "fact"
|
|
160
|
+
resolved = table["fact"]
|
|
161
|
+
return {
|
|
162
|
+
"query_class": query_class,
|
|
163
|
+
"weights": {
|
|
164
|
+
"keyword": resolved["keyword"],
|
|
165
|
+
"vector": resolved["vector"],
|
|
166
|
+
"graph": resolved["graph"],
|
|
167
|
+
},
|
|
168
|
+
"alpha": resolved["alpha"],
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = [
|
|
173
|
+
"DEFAULT_FUSION_WEIGHTS",
|
|
174
|
+
"FUSION_WEIGHTS_ENV",
|
|
175
|
+
"QUERY_CLASSES",
|
|
176
|
+
"classify_query",
|
|
177
|
+
"fusion_profile",
|
|
178
|
+
"fusion_weight_table",
|
|
179
|
+
]
|
|
@@ -514,6 +514,167 @@ class KnowledgeGraphProjectionMixin:
|
|
|
514
514
|
"skipped_total": len(overlay["skipped"]),
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
_NOISE_CONTENT_TYPES = (
|
|
518
|
+
"Document",
|
|
519
|
+
"File",
|
|
520
|
+
"CodeFile",
|
|
521
|
+
"Message",
|
|
522
|
+
"AIResponse",
|
|
523
|
+
"Chat",
|
|
524
|
+
"Page",
|
|
525
|
+
"Slide",
|
|
526
|
+
"Spreadsheet",
|
|
527
|
+
)
|
|
528
|
+
_NOISE_CONCEPT_TYPES = ("Concept", "Feature", "Topic", "Code", "Error")
|
|
529
|
+
|
|
530
|
+
def curate_noise(
|
|
531
|
+
self,
|
|
532
|
+
*,
|
|
533
|
+
dry_run: bool = True,
|
|
534
|
+
max_df_ratio: float = 0.8,
|
|
535
|
+
min_doc_frequency: int = 1,
|
|
536
|
+
min_corpus_docs: int = 5,
|
|
537
|
+
normalize_verbs: bool = True,
|
|
538
|
+
max_removals: int = 200,
|
|
539
|
+
) -> Dict[str, Any]:
|
|
540
|
+
"""Noise-reduction curation job (backlog #10, review §7.2 D).
|
|
541
|
+
|
|
542
|
+
(a) Removes heuristic concept nodes (``auto_extracted`` /
|
|
543
|
+
``graph_curator``-promoted) whose document frequency marks them as
|
|
544
|
+
noise: ubiquitous (low IDF — linked from more than ``max_df_ratio`` of
|
|
545
|
+
content docs once the corpus has ``min_corpus_docs``) or below the
|
|
546
|
+
``min_doc_frequency`` floor. Explicitly user-created nodes are never
|
|
547
|
+
touched, whatever their stats.
|
|
548
|
+
|
|
549
|
+
(b) Normalizes free-string relation verbs on the legacy edge table via
|
|
550
|
+
the ko/en dictionary in :mod:`lattice_brain.graph.curator`
|
|
551
|
+
('만들다/만든/creates' → 'created', …), merging rows that collide
|
|
552
|
+
after the rename.
|
|
553
|
+
|
|
554
|
+
``dry_run=True`` (the default) only *reports* what would change.
|
|
555
|
+
"""
|
|
556
|
+
from .curator import (
|
|
557
|
+
build_relation_verb_index,
|
|
558
|
+
plan_concept_noise_reduction,
|
|
559
|
+
plan_relation_normalization,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
max_removals = max(0, int(max_removals))
|
|
563
|
+
# Operate on the legacy write tables directly: they are the mutation
|
|
564
|
+
# target, and raw free-string verbs only exist there (the v4 write
|
|
565
|
+
# door normalizes new edges; the kgv2_* read views collapse
|
|
566
|
+
# legacy_type and would hide exactly the rows this job cleans up).
|
|
567
|
+
nt, et = "nodes", "edges"
|
|
568
|
+
with self._connect() as conn:
|
|
569
|
+
content_ph = ",".join("?" for _ in self._NOISE_CONTENT_TYPES)
|
|
570
|
+
total_docs = conn.execute(
|
|
571
|
+
f"SELECT COUNT(*) AS c FROM {nt} WHERE type IN ({content_ph})",
|
|
572
|
+
self._NOISE_CONTENT_TYPES,
|
|
573
|
+
).fetchone()["c"]
|
|
574
|
+
|
|
575
|
+
concept_ph = ",".join("?" for _ in self._NOISE_CONCEPT_TYPES)
|
|
576
|
+
concept_rows = conn.execute(
|
|
577
|
+
f"SELECT id, type, title, metadata_json FROM {nt} WHERE type IN ({concept_ph})",
|
|
578
|
+
self._NOISE_CONCEPT_TYPES,
|
|
579
|
+
).fetchall()
|
|
580
|
+
concepts = []
|
|
581
|
+
for row in concept_rows:
|
|
582
|
+
meta = _safe_loads(row["metadata_json"]) or {}
|
|
583
|
+
heuristic = bool(meta.get("auto_extracted")) or (
|
|
584
|
+
meta.get("source") == "graph_curator" or meta.get("curated") is True
|
|
585
|
+
)
|
|
586
|
+
# Document frequency: distinct *content* nodes linked to this
|
|
587
|
+
# concept in either direction.
|
|
588
|
+
df = conn.execute(
|
|
589
|
+
f"""
|
|
590
|
+
SELECT COUNT(DISTINCT n.id) AS c
|
|
591
|
+
FROM {et} e
|
|
592
|
+
JOIN {nt} n
|
|
593
|
+
ON n.id = CASE WHEN e.to_node = ? THEN e.from_node ELSE e.to_node END
|
|
594
|
+
WHERE (e.to_node = ? OR e.from_node = ?)
|
|
595
|
+
AND n.type IN ({content_ph})
|
|
596
|
+
""",
|
|
597
|
+
(row["id"], row["id"], row["id"], *self._NOISE_CONTENT_TYPES),
|
|
598
|
+
).fetchone()["c"]
|
|
599
|
+
concepts.append({
|
|
600
|
+
"id": row["id"],
|
|
601
|
+
"label": row["title"],
|
|
602
|
+
"type": row["type"],
|
|
603
|
+
"df": int(df or 0),
|
|
604
|
+
"heuristic": heuristic,
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
plan = plan_concept_noise_reduction(
|
|
608
|
+
concepts,
|
|
609
|
+
total_docs,
|
|
610
|
+
max_df_ratio=max_df_ratio,
|
|
611
|
+
min_doc_frequency=min_doc_frequency,
|
|
612
|
+
min_corpus_docs=min_corpus_docs,
|
|
613
|
+
)
|
|
614
|
+
removals = plan["remove"][:max_removals]
|
|
615
|
+
|
|
616
|
+
verb_index = build_relation_verb_index()
|
|
617
|
+
edge_type_rows = conn.execute(
|
|
618
|
+
f"SELECT DISTINCT type FROM {et}"
|
|
619
|
+
).fetchall()
|
|
620
|
+
verb_plan = (
|
|
621
|
+
plan_relation_normalization(
|
|
622
|
+
(row["type"] for row in edge_type_rows), index=verb_index,
|
|
623
|
+
)
|
|
624
|
+
if normalize_verbs
|
|
625
|
+
else {}
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
removed_count = 0
|
|
629
|
+
renamed_edges = 0
|
|
630
|
+
if not dry_run:
|
|
631
|
+
for decision in removals:
|
|
632
|
+
node_id = decision["id"]
|
|
633
|
+
conn.execute(
|
|
634
|
+
"DELETE FROM edges WHERE from_node=? OR to_node=?",
|
|
635
|
+
(node_id, node_id),
|
|
636
|
+
)
|
|
637
|
+
conn.execute(
|
|
638
|
+
"DELETE FROM vector_embeddings WHERE item_id=?", (node_id,)
|
|
639
|
+
)
|
|
640
|
+
conn.execute("DELETE FROM nodes WHERE id=?", (node_id,))
|
|
641
|
+
self._v2_delete_nodes(conn, [node_id])
|
|
642
|
+
removed_count += 1
|
|
643
|
+
for original, canonical in verb_plan.items():
|
|
644
|
+
renamed_edges += conn.execute(
|
|
645
|
+
"SELECT COUNT(*) AS c FROM edges WHERE type=?", (original,)
|
|
646
|
+
).fetchone()["c"]
|
|
647
|
+
# UNIQUE(from_node, to_node, type): merge rows that collide
|
|
648
|
+
# after the rename instead of failing the UPDATE.
|
|
649
|
+
conn.execute(
|
|
650
|
+
"UPDATE OR IGNORE edges SET type=? WHERE type=?",
|
|
651
|
+
(canonical, original),
|
|
652
|
+
)
|
|
653
|
+
conn.execute("DELETE FROM edges WHERE type=?", (original,))
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
"status": "ok",
|
|
657
|
+
"dry_run": bool(dry_run),
|
|
658
|
+
"total_content_docs": int(total_docs or 0),
|
|
659
|
+
"concepts_examined": len(concepts),
|
|
660
|
+
"remove": removals,
|
|
661
|
+
"remove_total": len(plan["remove"]),
|
|
662
|
+
"kept": len(plan["keep"]),
|
|
663
|
+
"protected_user_nodes": sum(
|
|
664
|
+
1 for item in plan["keep"] if item.get("reason") == "user_created_protected"
|
|
665
|
+
),
|
|
666
|
+
"verb_normalizations": verb_plan,
|
|
667
|
+
"applied": {
|
|
668
|
+
"removed_nodes": removed_count,
|
|
669
|
+
"renamed_edges": renamed_edges,
|
|
670
|
+
},
|
|
671
|
+
"thresholds": {
|
|
672
|
+
"max_df_ratio": float(max_df_ratio),
|
|
673
|
+
"min_doc_frequency": int(min_doc_frequency),
|
|
674
|
+
"min_corpus_docs": int(min_corpus_docs),
|
|
675
|
+
},
|
|
676
|
+
}
|
|
677
|
+
|
|
517
678
|
def mark_superseded(self, old_node_id: str, new_node_id: str) -> Dict[str, Any]:
|
|
518
679
|
"""Record that ``old_node_id`` was replaced by ``new_node_id``.
|
|
519
680
|
|
|
@@ -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,12 @@ 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
|
+
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.
|
|
479
485
|
"""
|
|
480
486
|
query = str(query or "").strip()
|
|
481
487
|
try:
|
|
@@ -483,6 +489,16 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
483
489
|
except (TypeError, ValueError):
|
|
484
490
|
top_k = 20
|
|
485
491
|
top_k = max(1, min(top_k, 100))
|
|
492
|
+
query_class: Optional[str] = None
|
|
493
|
+
if alpha is None:
|
|
494
|
+
try:
|
|
495
|
+
from .fusion import fusion_profile
|
|
496
|
+
|
|
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
|
|
501
|
+
alpha = 0.6
|
|
486
502
|
try:
|
|
487
503
|
alpha = float(alpha)
|
|
488
504
|
except (TypeError, ValueError):
|
|
@@ -496,6 +512,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
496
512
|
"query": query,
|
|
497
513
|
"mode": "hybrid",
|
|
498
514
|
"alpha": alpha,
|
|
515
|
+
"query_class": query_class,
|
|
499
516
|
"top_k": top_k,
|
|
500
517
|
"sources": {"lexical": 0, "vector": 0},
|
|
501
518
|
"matches": [],
|
|
@@ -624,6 +641,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
624
641
|
"query": query,
|
|
625
642
|
"mode": mode,
|
|
626
643
|
"alpha": alpha,
|
|
644
|
+
"query_class": query_class,
|
|
627
645
|
"top_k": top_k,
|
|
628
646
|
"sources": {"lexical": len(lexical_matches), "vector": len(vector_matches)},
|
|
629
647
|
"matches": matches,
|
|
@@ -245,6 +245,72 @@ def assess_extraction_quality(
|
|
|
245
245
|
return {"score": round(score, 4), "level": _quality_level(score), "reasons": reasons}
|
|
246
246
|
|
|
247
247
|
|
|
248
|
+
# ── capture quality CTA (backlog #9, review §7.2 C) ──────────────────────────
|
|
249
|
+
# Structured verdict over the same extraction-quality schema the rest of the
|
|
250
|
+
# pipeline uses, so capture surfaces (browser extension, read-url) can render
|
|
251
|
+
# an honest "this capture is thin" CTA instead of silently storing junk.
|
|
252
|
+
CAPTURE_SUGGESTIONS_THIN = ["recapture", "paste_manually", "highlight_source"]
|
|
253
|
+
_CAPTURE_REASON_LABELS = {
|
|
254
|
+
"empty_text": "추출된 본문이 비어 있습니다",
|
|
255
|
+
"very_short_text": "추출된 본문이 매우 짧습니다",
|
|
256
|
+
"short_text": "추출된 본문이 짧습니다",
|
|
257
|
+
"no_sentence_structure": "문장 구조가 거의 없습니다",
|
|
258
|
+
"low_character_diversity": "반복 문자가 대부분입니다",
|
|
259
|
+
"repetitive_lines": "같은 줄이 반복됩니다",
|
|
260
|
+
"repetitive_words": "같은 단어가 반복됩니다",
|
|
261
|
+
"high_whitespace_ratio": "공백이 지나치게 많습니다",
|
|
262
|
+
"fragmented_lines": "줄이 잘게 조각나 있습니다",
|
|
263
|
+
"nav_menu_remnants": "메뉴/내비게이션 잔여물이 많습니다",
|
|
264
|
+
"boilerplate_markers": "상용구 텍스트가 많습니다",
|
|
265
|
+
"no_extracted_text": "추출된 텍스트가 없습니다",
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def capture_quality_verdict(
|
|
270
|
+
extraction_quality: Optional[Dict[str, Any]],
|
|
271
|
+
*,
|
|
272
|
+
source_type: Optional[str] = None,
|
|
273
|
+
) -> Dict[str, Any]:
|
|
274
|
+
"""Structured CTA verdict from a pipeline ``extraction_quality`` dict.
|
|
275
|
+
|
|
276
|
+
``{"status": "thin"|"ok", "reason": str|None, "suggestions": [...],
|
|
277
|
+
"score": float|None, "level": str|None}``. ``thin`` (level == "low", the
|
|
278
|
+
same threshold as the ingest warning) carries actionable suggestions —
|
|
279
|
+
``recapture`` / ``paste_manually`` / ``highlight_source`` — so the UI can
|
|
280
|
+
offer the user a way to fix the capture instead of hiding the problem.
|
|
281
|
+
Deterministic and never raises; ``None`` input yields an honest ``thin``.
|
|
282
|
+
"""
|
|
283
|
+
if not isinstance(extraction_quality, dict):
|
|
284
|
+
return {
|
|
285
|
+
"status": "thin",
|
|
286
|
+
"reason": _CAPTURE_REASON_LABELS["no_extracted_text"],
|
|
287
|
+
"reason_codes": ["no_extracted_text"],
|
|
288
|
+
"suggestions": list(CAPTURE_SUGGESTIONS_THIN),
|
|
289
|
+
"score": None,
|
|
290
|
+
"level": None,
|
|
291
|
+
}
|
|
292
|
+
level = str(extraction_quality.get("level") or "")
|
|
293
|
+
score = extraction_quality.get("score")
|
|
294
|
+
reasons = [str(item) for item in (extraction_quality.get("reasons") or [])]
|
|
295
|
+
thin = level == "low"
|
|
296
|
+
reason = None
|
|
297
|
+
if thin:
|
|
298
|
+
labeled = [
|
|
299
|
+
_CAPTURE_REASON_LABELS[code]
|
|
300
|
+
for code in reasons
|
|
301
|
+
if code in _CAPTURE_REASON_LABELS
|
|
302
|
+
]
|
|
303
|
+
reason = "; ".join(labeled) if labeled else QUALITY_LOW_WARNING
|
|
304
|
+
return {
|
|
305
|
+
"status": "thin" if thin else "ok",
|
|
306
|
+
"reason": reason,
|
|
307
|
+
"reason_codes": reasons if thin else [],
|
|
308
|
+
"suggestions": list(CAPTURE_SUGGESTIONS_THIN) if thin else [],
|
|
309
|
+
"score": score,
|
|
310
|
+
"level": level or None,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
248
314
|
def _load_latticeignore(root: Path) -> List[str]:
|
|
249
315
|
"""Parse ``root/.latticeignore`` → glob patterns (gitignore-like subset)."""
|
|
250
316
|
ignore_file = root / LATTICEIGNORE_FILENAME
|
|
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
|
|
|
48
48
|
from ..utils import now_iso as _now
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
MULTI_AGENT_VERSION = "9.9.
|
|
51
|
+
MULTI_AGENT_VERSION = "9.9.3"
|
|
52
52
|
|
|
53
53
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
54
54
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
|
@@ -12,11 +12,21 @@ enable), review-queue gated, local-only, and idempotent per suggestion.
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
15
18
|
from typing import Any, Callable, Optional
|
|
16
19
|
|
|
17
20
|
from fastapi import APIRouter, HTTPException, Request
|
|
18
21
|
from pydantic import BaseModel
|
|
19
22
|
|
|
23
|
+
from latticeai.services.automation_execution import (
|
|
24
|
+
build_last_execution,
|
|
25
|
+
dry_run_report,
|
|
26
|
+
enqueue_failed_execution,
|
|
27
|
+
is_automation_workflow,
|
|
28
|
+
summarize_workflow_run,
|
|
29
|
+
)
|
|
20
30
|
from latticeai.services.automation_intelligence import AutomationIntelligenceService
|
|
21
31
|
from latticeai.services.brain_automation import find_installed_recipe_workflow
|
|
22
32
|
|
|
@@ -26,6 +36,21 @@ class SuggestionInstallRequest(BaseModel):
|
|
|
26
36
|
enabled: bool = False
|
|
27
37
|
|
|
28
38
|
|
|
39
|
+
class AutomationRunNowRequest(BaseModel):
|
|
40
|
+
workflow_id: str
|
|
41
|
+
# Dry-run first: the default reports what WOULD happen without side
|
|
42
|
+
# effects; an explicit dry_run=false runs the automation once for real.
|
|
43
|
+
dry_run: bool = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_now_wait_seconds() -> float:
|
|
47
|
+
raw = os.environ.get("LATTICEAI_AUTOMATION_RUN_NOW_WAIT", "30")
|
|
48
|
+
try:
|
|
49
|
+
return max(0.0, min(float(raw), 300.0))
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return 30.0
|
|
52
|
+
|
|
53
|
+
|
|
29
54
|
def create_automation_intelligence_router(
|
|
30
55
|
*,
|
|
31
56
|
service: AutomationIntelligenceService,
|
|
@@ -35,7 +60,10 @@ def create_automation_intelligence_router(
|
|
|
35
60
|
gate_write: Callable[[Request], Optional[str]],
|
|
36
61
|
append_audit_event: Callable[..., None],
|
|
37
62
|
workspace_graph: Callable[[], Any],
|
|
63
|
+
run_executor: Any = None,
|
|
64
|
+
review_queue: Any = None,
|
|
38
65
|
) -> APIRouter:
|
|
66
|
+
from lattice_brain.runtime.statuses import RUN_TERMINAL_STATUSES
|
|
39
67
|
from lattice_brain.workflow import legacy_steps_from_nodes, validate_definition
|
|
40
68
|
|
|
41
69
|
router = APIRouter()
|
|
@@ -123,6 +151,137 @@ def create_automation_intelligence_router(
|
|
|
123
151
|
"already_installed": False,
|
|
124
152
|
}
|
|
125
153
|
|
|
154
|
+
def _stamp_last_execution(
|
|
155
|
+
workflow_id: str, last_execution: dict, scope: Optional[str]
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Persist the execution record on the workflow's metadata (merge)."""
|
|
158
|
+
try:
|
|
159
|
+
store.update_workflow_definition(
|
|
160
|
+
workflow_id,
|
|
161
|
+
metadata={"last_execution": last_execution},
|
|
162
|
+
workspace_id=scope,
|
|
163
|
+
)
|
|
164
|
+
except Exception: # noqa: BLE001 — surfacing must never undo the run
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
@router.post("/api/automation/run-now")
|
|
168
|
+
async def automation_run_now(req: AutomationRunNowRequest, request: Request):
|
|
169
|
+
"""Run an installed automation once — dry-run by default.
|
|
170
|
+
|
|
171
|
+
* ``dry_run=true``: deterministic report of what WOULD happen; no
|
|
172
|
+
runner executes and nothing but the ``last_execution`` stamp moves.
|
|
173
|
+
* ``dry_run=false``: one real execution through the async run
|
|
174
|
+
executor; the request waits briefly for completion, stamps the
|
|
175
|
+
result, and enqueues failed executions into the review inbox.
|
|
176
|
+
"""
|
|
177
|
+
user = require_user(request)
|
|
178
|
+
scope = gate_write(request)
|
|
179
|
+
try:
|
|
180
|
+
workflow = store.get_workflow(req.workflow_id, workspace_id=scope)
|
|
181
|
+
except FileNotFoundError as exc:
|
|
182
|
+
raise HTTPException(
|
|
183
|
+
status_code=404, detail=f"Automation not found: {req.workflow_id}"
|
|
184
|
+
) from exc
|
|
185
|
+
if not is_automation_workflow(workflow):
|
|
186
|
+
raise HTTPException(
|
|
187
|
+
status_code=404,
|
|
188
|
+
detail=f"Workflow is not an installed automation: {req.workflow_id}",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if req.dry_run:
|
|
192
|
+
report = dry_run_report(workflow)
|
|
193
|
+
last_execution = build_last_execution(
|
|
194
|
+
mode="dry_run",
|
|
195
|
+
status=report["status"],
|
|
196
|
+
summary=report["summary"],
|
|
197
|
+
)
|
|
198
|
+
_stamp_last_execution(req.workflow_id, last_execution, scope)
|
|
199
|
+
append_audit_event(
|
|
200
|
+
"automation_run_now",
|
|
201
|
+
user_email=user,
|
|
202
|
+
workflow_id=req.workflow_id,
|
|
203
|
+
dry_run=True,
|
|
204
|
+
status=report["status"],
|
|
205
|
+
)
|
|
206
|
+
return {
|
|
207
|
+
"workflow_id": req.workflow_id,
|
|
208
|
+
"dry_run": True,
|
|
209
|
+
"status": report["status"],
|
|
210
|
+
"report": report,
|
|
211
|
+
"last_execution": last_execution,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if run_executor is None:
|
|
215
|
+
raise HTTPException(
|
|
216
|
+
status_code=503,
|
|
217
|
+
detail="Automation execution runtime is not available.",
|
|
218
|
+
)
|
|
219
|
+
started = await run_executor.start_workflow(
|
|
220
|
+
workflow,
|
|
221
|
+
workflow_id=req.workflow_id,
|
|
222
|
+
user_email=user or None,
|
|
223
|
+
scope=scope,
|
|
224
|
+
inputs={"trigger": "run_now"},
|
|
225
|
+
)
|
|
226
|
+
run_id = str(((started or {}).get("run") or {}).get("id") or "")
|
|
227
|
+
|
|
228
|
+
# Bounded, non-cancelling wait: poll the durable run row so a slow
|
|
229
|
+
# execution keeps running in the background instead of being aborted.
|
|
230
|
+
run = (started or {}).get("run") or {}
|
|
231
|
+
deadline = time.monotonic() + _run_now_wait_seconds()
|
|
232
|
+
while time.monotonic() < deadline:
|
|
233
|
+
try:
|
|
234
|
+
run = store.get_workflow_run(run_id, workspace_id=scope)
|
|
235
|
+
except FileNotFoundError:
|
|
236
|
+
break
|
|
237
|
+
if str(run.get("status") or "") in RUN_TERMINAL_STATUSES:
|
|
238
|
+
break
|
|
239
|
+
await asyncio.sleep(0.05)
|
|
240
|
+
|
|
241
|
+
status = str(run.get("status") or "running")
|
|
242
|
+
completed = status in RUN_TERMINAL_STATUSES
|
|
243
|
+
summary = (
|
|
244
|
+
summarize_workflow_run(run)
|
|
245
|
+
if completed
|
|
246
|
+
else "started — still running in the background"
|
|
247
|
+
)
|
|
248
|
+
last_execution = build_last_execution(
|
|
249
|
+
mode="live",
|
|
250
|
+
status=status if completed else "running",
|
|
251
|
+
summary=summary,
|
|
252
|
+
run_id=run_id or None,
|
|
253
|
+
)
|
|
254
|
+
_stamp_last_execution(req.workflow_id, last_execution, scope)
|
|
255
|
+
|
|
256
|
+
review_item = None
|
|
257
|
+
if status == "failed":
|
|
258
|
+
review_item = enqueue_failed_execution(
|
|
259
|
+
review_queue,
|
|
260
|
+
workflow=workflow,
|
|
261
|
+
run_id=run_id or None,
|
|
262
|
+
error=summary,
|
|
263
|
+
user_email=user or None,
|
|
264
|
+
workspace_id=scope,
|
|
265
|
+
)
|
|
266
|
+
append_audit_event(
|
|
267
|
+
"automation_run_now",
|
|
268
|
+
user_email=user,
|
|
269
|
+
workflow_id=req.workflow_id,
|
|
270
|
+
dry_run=False,
|
|
271
|
+
run_id=run_id or None,
|
|
272
|
+
status=last_execution["status"],
|
|
273
|
+
)
|
|
274
|
+
payload = {
|
|
275
|
+
"workflow_id": req.workflow_id,
|
|
276
|
+
"dry_run": False,
|
|
277
|
+
"status": last_execution["status"],
|
|
278
|
+
"run_id": run_id or None,
|
|
279
|
+
"last_execution": last_execution,
|
|
280
|
+
}
|
|
281
|
+
if review_item is not None:
|
|
282
|
+
payload["review_item_id"] = review_item.get("id")
|
|
283
|
+
return payload
|
|
284
|
+
|
|
126
285
|
return router
|
|
127
286
|
|
|
128
287
|
|