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
|
@@ -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
|
+
]
|
|
@@ -13,6 +13,78 @@ class KnowledgeGraphVectorMixin:
|
|
|
13
13
|
retrieval/write helpers (e.g. self._vector_text_for_node) through the MRO.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
+
# ── embedder fingerprint (review Wave 2.2 — stale_embedder) ──────────────
|
|
17
|
+
# vector_search filters on the CURRENT model/dim, so swapping the embedder
|
|
18
|
+
# silently yields zero vector rows. The fingerprint persisted in graph_meta
|
|
19
|
+
# records which embedder actually built the index; a mismatch is surfaced
|
|
20
|
+
# as the honest ``stale_embedder`` signal instead of a silent degradation.
|
|
21
|
+
|
|
22
|
+
_EMBEDDER_FINGERPRINT_KEY = "embedder_fingerprint"
|
|
23
|
+
|
|
24
|
+
def _embedder_fingerprint_record(
|
|
25
|
+
self, conn: sqlite3.Connection
|
|
26
|
+
) -> Optional[Dict[str, Any]]:
|
|
27
|
+
"""Read the recorded embedder fingerprint from graph_meta (or None)."""
|
|
28
|
+
row = conn.execute(
|
|
29
|
+
"SELECT value FROM graph_meta WHERE key=?",
|
|
30
|
+
(self._EMBEDDER_FINGERPRINT_KEY,),
|
|
31
|
+
).fetchone()
|
|
32
|
+
if not row:
|
|
33
|
+
return None
|
|
34
|
+
payload = _safe_loads(row["value"])
|
|
35
|
+
if not isinstance(payload, dict) or not payload.get("model_id"):
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
dim = int(payload.get("dim") or 0)
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
dim = 0
|
|
41
|
+
return {"model_id": str(payload["model_id"]), "dim": dim}
|
|
42
|
+
|
|
43
|
+
def _write_embedder_fingerprint(self, conn: sqlite3.Connection) -> Dict[str, Any]:
|
|
44
|
+
"""Persist the CURRENT embedder identity (same transaction as caller)."""
|
|
45
|
+
fingerprint = {
|
|
46
|
+
"model_id": self._embedding_model.model_id,
|
|
47
|
+
"dim": int(self._embedding_model.dim),
|
|
48
|
+
}
|
|
49
|
+
conn.execute(
|
|
50
|
+
"INSERT OR REPLACE INTO graph_meta(key, value) VALUES (?, ?)",
|
|
51
|
+
(self._EMBEDDER_FINGERPRINT_KEY, _json(fingerprint)),
|
|
52
|
+
)
|
|
53
|
+
return fingerprint
|
|
54
|
+
|
|
55
|
+
def record_embedder_fingerprint(self) -> Dict[str, Any]:
|
|
56
|
+
"""Record the current embedder (model_id + dim) as the index builder."""
|
|
57
|
+
with self._connect() as conn:
|
|
58
|
+
return self._write_embedder_fingerprint(conn)
|
|
59
|
+
|
|
60
|
+
def embedder_fingerprint_status(self) -> Dict[str, Any]:
|
|
61
|
+
"""Compare the current embedder against the recorded index fingerprint.
|
|
62
|
+
|
|
63
|
+
Returns ``{"current": {model_id, dim}, "recorded": {...} | None,
|
|
64
|
+
"stale_embedder": bool}``. ``stale_embedder`` is True only when a
|
|
65
|
+
fingerprint was recorded AND it differs from the current embedder —
|
|
66
|
+
an unrecorded index (legacy DBs, nothing indexed yet) is honestly
|
|
67
|
+
"unknown", never reported stale. Never raises.
|
|
68
|
+
"""
|
|
69
|
+
current = {
|
|
70
|
+
"model_id": self._embedding_model.model_id,
|
|
71
|
+
"dim": int(self._embedding_model.dim),
|
|
72
|
+
}
|
|
73
|
+
recorded: Optional[Dict[str, Any]] = None
|
|
74
|
+
try:
|
|
75
|
+
with self._connect() as conn:
|
|
76
|
+
recorded = self._embedder_fingerprint_record(conn)
|
|
77
|
+
except Exception: # noqa: BLE001 — status must degrade, never raise
|
|
78
|
+
recorded = None
|
|
79
|
+
stale = bool(
|
|
80
|
+
recorded is not None
|
|
81
|
+
and (
|
|
82
|
+
recorded.get("model_id") != current["model_id"]
|
|
83
|
+
or recorded.get("dim") != current["dim"]
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return {"current": current, "recorded": recorded, "stale_embedder": stale}
|
|
87
|
+
|
|
16
88
|
def _iter_vector_source_items(
|
|
17
89
|
self,
|
|
18
90
|
conn: sqlite3.Connection,
|
|
@@ -153,6 +225,11 @@ class KnowledgeGraphVectorMixin:
|
|
|
153
225
|
indexed += 1
|
|
154
226
|
else:
|
|
155
227
|
skipped += 1
|
|
228
|
+
if indexed and self._embedder_fingerprint_record(conn) is None:
|
|
229
|
+
# First successful vector write establishes the fingerprint;
|
|
230
|
+
# later incremental writes never overwrite it (only a full
|
|
231
|
+
# rebuild may flip it after an embedder swap).
|
|
232
|
+
self._write_embedder_fingerprint(conn)
|
|
156
233
|
summary.update(
|
|
157
234
|
{
|
|
158
235
|
"items_total": len(items),
|
|
@@ -254,6 +331,10 @@ class KnowledgeGraphVectorMixin:
|
|
|
254
331
|
op_id,
|
|
255
332
|
),
|
|
256
333
|
)
|
|
334
|
+
# A successful rebuild (re)establishes which embedder built
|
|
335
|
+
# the index — this is the only path that may flip a recorded
|
|
336
|
+
# fingerprint after an embedder swap.
|
|
337
|
+
self._write_embedder_fingerprint(conn)
|
|
257
338
|
return {
|
|
258
339
|
"status": "completed",
|
|
259
340
|
"operation_id": op_id,
|
|
@@ -401,8 +482,10 @@ class KnowledgeGraphVectorMixin:
|
|
|
401
482
|
"within_target": float(duration_ms) <= 10_000,
|
|
402
483
|
}
|
|
403
484
|
)
|
|
485
|
+
embedder_status = self.embedder_fingerprint_status()
|
|
404
486
|
return {
|
|
405
487
|
"status": "ready" if pending == 0 else "needs_reindex",
|
|
488
|
+
"embedder": embedder_status,
|
|
406
489
|
"storage": {
|
|
407
490
|
"db_path": str(self.db_path),
|
|
408
491
|
"backend": "sqlite",
|
|
@@ -449,7 +532,11 @@ class KnowledgeGraphVectorMixin:
|
|
|
449
532
|
"backlog_reasons": backlog_reasons,
|
|
450
533
|
"backlog_samples": backlog_samples,
|
|
451
534
|
"incremental_reindex_recommended": pending > 0,
|
|
452
|
-
|
|
535
|
+
# A stale embedder means every old-model row must be re-embedded;
|
|
536
|
+
# only a full rebuild (which re-records the fingerprint) heals it.
|
|
537
|
+
"full_rebuild_recommended": bool(
|
|
538
|
+
orphaned_items > 0 or embedder_status["stale_embedder"]
|
|
539
|
+
),
|
|
453
540
|
"latency_budget": latency_budget,
|
|
454
541
|
},
|
|
455
542
|
"operations": [
|
|
@@ -475,7 +562,11 @@ class KnowledgeGraphVectorMixin:
|
|
|
475
562
|
|
|
476
563
|
Reduces :meth:`index_status` (``pending = missing + stale``) to the
|
|
477
564
|
fixed contract ``{"status", "pending_items", "total_items", "detail"}``
|
|
478
|
-
with ``status`` in ``ready`` / ``pending`` / ``
|
|
565
|
+
with ``status`` in ``ready`` / ``pending`` / ``stale_embedder`` /
|
|
566
|
+
``unavailable``. ``stale_embedder`` (review Wave 2.2) is reported only
|
|
567
|
+
when the recorded embedder fingerprint differs from the current
|
|
568
|
+
embedder AND rows indexed under the old model still exist — the index
|
|
569
|
+
needs a full rebuild, not an incremental sync.
|
|
479
570
|
|
|
480
571
|
Never raises: environments where the embedding provider or index
|
|
481
572
|
storage cannot be used report ``"unavailable"`` with the cause in
|
|
@@ -492,6 +583,35 @@ class KnowledgeGraphVectorMixin:
|
|
|
492
583
|
}
|
|
493
584
|
pending = int(status.get("pending_items") or 0)
|
|
494
585
|
total = int(status.get("source_items") or 0)
|
|
586
|
+
embedder = status.get("embedder") or {}
|
|
587
|
+
if embedder.get("stale_embedder"):
|
|
588
|
+
old_model_rows = 0
|
|
589
|
+
try:
|
|
590
|
+
with self._connect() as conn:
|
|
591
|
+
old_model_rows = int(
|
|
592
|
+
conn.execute(
|
|
593
|
+
"SELECT COUNT(*) AS c FROM vector_embeddings "
|
|
594
|
+
"WHERE embedding_model<>? OR embedding_dim<>?",
|
|
595
|
+
(
|
|
596
|
+
self._embedding_model.model_id,
|
|
597
|
+
int(self._embedding_model.dim),
|
|
598
|
+
),
|
|
599
|
+
).fetchone()["c"]
|
|
600
|
+
)
|
|
601
|
+
except Exception: # noqa: BLE001 — keep the existing statuses on failure
|
|
602
|
+
old_model_rows = 0
|
|
603
|
+
if old_model_rows > 0:
|
|
604
|
+
recorded = embedder.get("recorded") or {}
|
|
605
|
+
return {
|
|
606
|
+
"status": "stale_embedder",
|
|
607
|
+
"pending_items": pending,
|
|
608
|
+
"total_items": total,
|
|
609
|
+
"detail": (
|
|
610
|
+
f"embedding model changed ({recorded.get('model_id')} → "
|
|
611
|
+
f"{self._embedding_model.model_id}); {old_model_rows} indexed "
|
|
612
|
+
"rows still use the previous model — run a full vector index rebuild"
|
|
613
|
+
),
|
|
614
|
+
}
|
|
495
615
|
if pending > 0:
|
|
496
616
|
return {
|
|
497
617
|
"status": "pending",
|
|
@@ -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
|
|
@@ -14,6 +14,7 @@ any cloud service. Two complementary mechanisms, both fully local:
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import logging
|
|
17
18
|
import os
|
|
18
19
|
import shutil
|
|
19
20
|
import sqlite3
|
|
@@ -74,9 +75,12 @@ def _checkpoint_sqlite(db_path: Path) -> None:
|
|
|
74
75
|
|
|
75
76
|
|
|
76
77
|
def _restore_sibling(path: Path, backup: Path) -> None:
|
|
77
|
-
|
|
78
|
+
try:
|
|
78
79
|
shutil.copy2(backup, path)
|
|
79
|
-
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
# The backup vanished (or never existed — transient -wal/-shm): the
|
|
82
|
+
# honest reconstruction is "no such sibling", never a crash that
|
|
83
|
+
# masks the error that triggered the rollback.
|
|
80
84
|
path.unlink(missing_ok=True)
|
|
81
85
|
|
|
82
86
|
|
|
@@ -101,10 +105,22 @@ def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
|
|
|
101
105
|
sibling.unlink(missing_ok=True)
|
|
102
106
|
os.replace(tmp, dest)
|
|
103
107
|
except Exception:
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
# Recovery I/O must never replace the swap error being reported —
|
|
109
|
+
# an exception raised here would mask it (the CI-observed
|
|
110
|
+
# "[Errno 2]" over the real failure). Best-effort restore, then
|
|
111
|
+
# always re-raise the original.
|
|
112
|
+
try:
|
|
113
|
+
tmp.unlink(missing_ok=True)
|
|
114
|
+
except OSError as cleanup_exc:
|
|
115
|
+
logging.warning("restore tmp cleanup failed: %s", cleanup_exc)
|
|
106
116
|
for sibling in _sqlite_siblings(dest):
|
|
107
|
-
|
|
117
|
+
try:
|
|
118
|
+
_restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
|
|
119
|
+
except OSError as rollback_exc:
|
|
120
|
+
logging.warning(
|
|
121
|
+
"restore sibling rollback incomplete for %s: %s",
|
|
122
|
+
sibling, rollback_exc,
|
|
123
|
+
)
|
|
108
124
|
raise
|
|
109
125
|
|
|
110
126
|
|
|
@@ -127,12 +143,17 @@ def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path)
|
|
|
127
143
|
shutil.rmtree(dest)
|
|
128
144
|
os.replace(staged, dest)
|
|
129
145
|
except Exception:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
146
|
+
# Same masking guard as the sqlite swap: rollback I/O is
|
|
147
|
+
# best-effort and the original failure always propagates.
|
|
148
|
+
try:
|
|
149
|
+
if staged.exists():
|
|
150
|
+
shutil.rmtree(staged)
|
|
151
|
+
if dest.exists():
|
|
152
|
+
shutil.rmtree(dest)
|
|
153
|
+
if backup.exists():
|
|
154
|
+
shutil.copytree(backup, dest)
|
|
155
|
+
except OSError as rollback_exc:
|
|
156
|
+
logging.warning("blob tree rollback incomplete: %s", rollback_exc)
|
|
136
157
|
raise
|
|
137
158
|
|
|
138
159
|
|
|
@@ -307,7 +328,15 @@ class KGPortabilityService:
|
|
|
307
328
|
blob_src = tmp / "blobs"
|
|
308
329
|
_replace_tree_with_backup(blob_src if blob_src.exists() else None, blob_dest, backup_dir)
|
|
309
330
|
except Exception:
|
|
310
|
-
|
|
331
|
+
# The rollback is best-effort recovery; an I/O slip inside
|
|
332
|
+
# it must never mask the restore failure being reported.
|
|
333
|
+
try:
|
|
334
|
+
_rollback_sqlite_from_backup(db_dest, backup_dir)
|
|
335
|
+
except OSError as rollback_exc:
|
|
336
|
+
logging.warning(
|
|
337
|
+
"pre-restore rollback incomplete (backup kept at %s): %s",
|
|
338
|
+
backup_dir, rollback_exc,
|
|
339
|
+
)
|
|
311
340
|
raise
|
|
312
341
|
stats = self._kg.stats()
|
|
313
342
|
return {
|
|
@@ -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.4"
|
|
52
52
|
|
|
53
53
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
54
54
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED