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.
Files changed (109) hide show
  1. package/README.md +44 -39
  2. package/docs/CHANGELOG.md +109 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +47 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/curator.py +141 -0
  14. package/lattice_brain/graph/discovery_index.py +8 -1
  15. package/lattice_brain/graph/documents.py +111 -0
  16. package/lattice_brain/graph/fusion.py +179 -0
  17. package/lattice_brain/graph/ingest.py +43 -9
  18. package/lattice_brain/graph/projection.py +382 -37
  19. package/lattice_brain/graph/retrieval.py +71 -4
  20. package/lattice_brain/graph/retrieval_policy.py +174 -0
  21. package/lattice_brain/graph/retrieval_vector.py +122 -2
  22. package/lattice_brain/ingestion.py +66 -0
  23. package/lattice_brain/portability.py +41 -12
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/automation_intelligence.py +159 -0
  27. package/latticeai/api/browser.py +15 -4
  28. package/latticeai/api/chat.py +33 -2
  29. package/latticeai/api/chat_agent_http.py +363 -6
  30. package/latticeai/api/chat_contracts.py +7 -1
  31. package/latticeai/api/chat_helpers.py +147 -0
  32. package/latticeai/api/chat_intents.py +217 -10
  33. package/latticeai/api/chat_stream.py +93 -3
  34. package/latticeai/api/funnel_metrics.py +31 -0
  35. package/latticeai/api/knowledge_graph.py +65 -1
  36. package/latticeai/api/local_files.py +90 -0
  37. package/latticeai/api/setup.py +134 -1
  38. package/latticeai/api/tools.py +33 -3
  39. package/latticeai/app_factory.py +14 -0
  40. package/latticeai/core/agent.py +355 -18
  41. package/latticeai/core/agent_eval.py +107 -0
  42. package/latticeai/core/enterprise.py +5 -0
  43. package/latticeai/core/file_generation.py +404 -2
  44. package/latticeai/core/legacy_compatibility.py +1 -1
  45. package/latticeai/core/marketplace.py +1 -1
  46. package/latticeai/core/run_store.py +243 -0
  47. package/latticeai/core/workspace_os.py +1 -1
  48. package/latticeai/models/router.py +25 -12
  49. package/latticeai/runtime/persistence_runtime.py +19 -1
  50. package/latticeai/runtime/router_registration.py +8 -1
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/automation_execution.py +266 -0
  54. package/latticeai/services/automation_intelligence.py +7 -0
  55. package/latticeai/services/command_center.py +110 -2
  56. package/latticeai/services/folder_watch.py +396 -0
  57. package/latticeai/services/funnel_metrics.py +210 -0
  58. package/latticeai/services/product_readiness.py +1 -1
  59. package/latticeai/services/search_service.py +51 -5
  60. package/latticeai/setup/demo_corpus.py +98 -0
  61. package/latticeai/tools/filesystem.py +38 -1
  62. package/package.json +1 -1
  63. package/scripts/bench_agent_smoke.py +410 -0
  64. package/scripts/bench_models.py +234 -5
  65. package/scripts/check_current_release_docs.mjs +1 -1
  66. package/scripts/funnel_soft_gate.py +192 -0
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +29 -27
  71. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  72. package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
  73. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  74. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  75. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
  76. package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
  77. package/static/app/assets/Library-pKCK0_tk.js +1 -0
  78. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
  79. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
  80. package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
  81. package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
  82. package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
  83. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  84. package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
  85. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
  86. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
  87. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
  88. package/static/app/assets/index-7FAfYm4v.css +2 -0
  89. package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
  90. package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
  91. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
  92. package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
  93. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
  94. package/static/app/assets/shield-alert-BftATuAA.js +1 -0
  95. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
  96. package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
  97. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  98. package/static/app/index.html +4 -4
  99. package/static/sw.js +1 -1
  100. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  101. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  102. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  103. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  104. package/static/app/assets/Library-PcfVUco6.js +0 -1
  105. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  106. package/static/app/assets/download-C7yxYHvz.js +0 -1
  107. package/static/app/assets/index-DU78Kogz.css +0 -2
  108. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  109. 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
- "full_rebuild_recommended": orphaned_items > 0,
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`` / ``unavailable``.
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
- if backup.exists():
78
+ try:
78
79
  shutil.copy2(backup, path)
79
- else:
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
- if tmp.exists():
105
- tmp.unlink()
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
- _restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
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
- if staged.exists():
131
- shutil.rmtree(staged)
132
- if dest.exists():
133
- shutil.rmtree(dest)
134
- if backup.exists():
135
- shutil.copytree(backup, dest)
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
- _rollback_sqlite_from_backup(db_dest, backup_dir)
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.2"
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")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.9.2"
3
+ __version__ = "9.9.4"