ltcai 9.9.2 → 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.
Files changed (96) hide show
  1. package/README.md +39 -39
  2. package/docs/CHANGELOG.md +44 -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/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/curator.py +141 -0
  12. package/lattice_brain/graph/documents.py +111 -0
  13. package/lattice_brain/graph/fusion.py +179 -0
  14. package/lattice_brain/graph/projection.py +161 -0
  15. package/lattice_brain/graph/retrieval.py +19 -1
  16. package/lattice_brain/ingestion.py +66 -0
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/automation_intelligence.py +159 -0
  20. package/latticeai/api/browser.py +15 -4
  21. package/latticeai/api/chat.py +27 -2
  22. package/latticeai/api/chat_agent_http.py +191 -3
  23. package/latticeai/api/chat_contracts.py +7 -1
  24. package/latticeai/api/chat_helpers.py +147 -0
  25. package/latticeai/api/chat_intents.py +189 -1
  26. package/latticeai/api/chat_stream.py +17 -1
  27. package/latticeai/api/funnel_metrics.py +31 -0
  28. package/latticeai/api/knowledge_graph.py +30 -0
  29. package/latticeai/api/local_files.py +90 -0
  30. package/latticeai/api/setup.py +134 -1
  31. package/latticeai/api/tools.py +33 -3
  32. package/latticeai/app_factory.py +14 -0
  33. package/latticeai/core/agent.py +91 -9
  34. package/latticeai/core/agent_eval.py +107 -0
  35. package/latticeai/core/file_generation.py +279 -2
  36. package/latticeai/core/legacy_compatibility.py +1 -1
  37. package/latticeai/core/marketplace.py +1 -1
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/runtime/persistence_runtime.py +19 -1
  40. package/latticeai/runtime/router_registration.py +8 -1
  41. package/latticeai/services/app_context.py +3 -0
  42. package/latticeai/services/architecture_readiness.py +1 -1
  43. package/latticeai/services/automation_execution.py +266 -0
  44. package/latticeai/services/automation_intelligence.py +7 -0
  45. package/latticeai/services/command_center.py +20 -1
  46. package/latticeai/services/folder_watch.py +391 -0
  47. package/latticeai/services/funnel_metrics.py +202 -0
  48. package/latticeai/services/product_readiness.py +1 -1
  49. package/latticeai/services/search_service.py +17 -1
  50. package/latticeai/setup/demo_corpus.py +98 -0
  51. package/latticeai/tools/filesystem.py +38 -1
  52. package/package.json +1 -1
  53. package/scripts/bench_models.py +234 -5
  54. package/scripts/check_current_release_docs.mjs +1 -1
  55. package/src-tauri/Cargo.lock +1 -1
  56. package/src-tauri/Cargo.toml +1 -1
  57. package/src-tauri/tauri.conf.json +1 -1
  58. package/static/app/asset-manifest.json +28 -27
  59. package/static/app/assets/Act-DmdruVKV.js +1 -0
  60. package/static/app/assets/{Brain-BnGbytHa.js → Brain-DEY9jLVt.js} +2 -2
  61. package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
  62. package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
  63. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-CVZ09QXi.js} +1 -1
  64. package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
  65. package/static/app/assets/Library-Bp0n-HlW.js +1 -0
  66. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-DxP4efJF.js} +1 -1
  67. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-DRbm7NEq.js} +1 -1
  68. package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
  69. package/static/app/assets/{System-DRHc_6Su.js → System-ByQcmJW-.js} +1 -1
  70. package/static/app/assets/{bot-DZkDdh-A.js → bot-BNDyZLR7.js} +1 -1
  71. package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
  72. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-C6jjYm6i.js} +1 -1
  73. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-DjGIvDBQ.js} +1 -1
  74. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-BlSbwSaT.js} +1 -1
  75. package/static/app/assets/index-Bge3DXW7.css +2 -0
  76. package/static/app/assets/{index-BS_3aN1n.js → index-CHu7cgj3.js} +3 -3
  77. package/static/app/assets/{input-B_U7zvbR.js → input-DVDI0YR3.js} +1 -1
  78. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-BddhEWA0.js} +1 -1
  79. package/static/app/assets/{network-BF13HJJF.js → network-pYQt5oBu.js} +1 -1
  80. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-D7gCdEvS.js} +1 -1
  81. package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
  82. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-sqQmoBKL.js} +1 -1
  83. package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
  84. package/static/app/assets/utils-uQYKXNeq.js +7 -0
  85. package/static/app/index.html +4 -4
  86. package/static/sw.js +1 -1
  87. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  88. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  89. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  90. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  91. package/static/app/assets/Library-PcfVUco6.js +0 -1
  92. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  93. package/static/app/assets/download-C7yxYHvz.js +0 -1
  94. package/static/app/assets/index-DU78Kogz.css +0 -2
  95. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  96. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -0,0 +1,391 @@
1
+ """Opt-in folder watch mode for the unified ingestion pipeline (backlog #8).
2
+
3
+ Watches previously-ingested folders and incrementally re-ingests new/changed
4
+ files through the normal :class:`IngestionPipeline` door (same filters, same
5
+ quality pipeline, same workspace scoping, same provenance).
6
+
7
+ Design decisions (review §7.2 B + risk section):
8
+
9
+ * **Default OFF, explicit opt-in.** A watch exists only after an explicit API
10
+ call. The stored config is the durable record of that consent; ``restore()``
11
+ resumes only entries persisted with ``enabled: true`` and never invents one.
12
+ * **Polling with mtime snapshots**, not an OS watcher. ``watchdog`` is already
13
+ a dependency, but polling is deterministic, portable, and testable
14
+ (``scan_once`` runs synchronously); the interval is configurable via
15
+ ``LATTICEAI_FOLDER_WATCH_INTERVAL`` (seconds, default 30).
16
+ * **Reuses the folder-ingest filters** (skip dirs, ``.latticeignore``,
17
+ extension allow-list, size cap) so watch mode can never ingest something a
18
+ manual folder ingest would have refused.
19
+ * Deleted files are *counted* but their graph nodes are not removed — node
20
+ deletion is a destructive operation that stays behind explicit user flows.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import threading
28
+ import uuid
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional
31
+
32
+ from lattice_brain.ingestion import (
33
+ DEFAULT_FOLDER_EXTENSIONS,
34
+ DEFAULT_MAX_FILE_BYTES,
35
+ FOLDER_DEFAULT_SKIP_DIRS,
36
+ FOLDER_DOCUMENT_EXTENSIONS,
37
+ LATTICEIGNORE_FILENAME,
38
+ IngestionItem,
39
+ _load_latticeignore,
40
+ _matches_ignore,
41
+ )
42
+ from latticeai.core.timeutil import now_iso as _now_iso
43
+
44
+ WATCH_INTERVAL_ENV = "LATTICEAI_FOLDER_WATCH_INTERVAL"
45
+ DEFAULT_WATCH_INTERVAL_SECONDS = 30.0
46
+ MAX_FILES_PER_SCAN = 200 # per-watch per-scan ingest cap (thrash guard)
47
+
48
+
49
+ def _default_interval() -> float:
50
+ raw = os.getenv(WATCH_INTERVAL_ENV, "").strip()
51
+ try:
52
+ value = float(raw) if raw else DEFAULT_WATCH_INTERVAL_SECONDS
53
+ except ValueError:
54
+ value = DEFAULT_WATCH_INTERVAL_SECONDS
55
+ return max(1.0, value)
56
+
57
+
58
+ class FolderWatchService:
59
+ """Poll-based incremental re-ingestion of explicitly opted-in folders."""
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ pipeline: Any,
65
+ config_path: Path,
66
+ interval_seconds: Optional[float] = None,
67
+ ) -> None:
68
+ self._pipeline = pipeline
69
+ self._config_path = Path(config_path)
70
+ self._interval = float(interval_seconds) if interval_seconds else _default_interval()
71
+ self._lock = threading.Lock()
72
+ self._stop = threading.Event()
73
+ self._thread: Optional[threading.Thread] = None
74
+ self._watches: Dict[str, Dict[str, Any]] = self._load_config()
75
+
76
+ # ── persistence ──────────────────────────────────────────────────────────
77
+ def _load_config(self) -> Dict[str, Dict[str, Any]]:
78
+ try:
79
+ payload = json.loads(self._config_path.read_text(encoding="utf-8"))
80
+ except (OSError, ValueError):
81
+ return {}
82
+ watches = payload.get("watches")
83
+ return dict(watches) if isinstance(watches, dict) else {}
84
+
85
+ def _save_config(self) -> None:
86
+ self._config_path.parent.mkdir(parents=True, exist_ok=True)
87
+ tmp = self._config_path.with_suffix(".tmp")
88
+ tmp.write_text(
89
+ json.dumps({"watches": self._watches}, ensure_ascii=False, indent=2),
90
+ encoding="utf-8",
91
+ )
92
+ tmp.replace(self._config_path)
93
+
94
+ # ── public API ───────────────────────────────────────────────────────────
95
+ def enable(
96
+ self,
97
+ path: Any,
98
+ *,
99
+ owner: Optional[str] = None,
100
+ workspace_id: Optional[str] = None,
101
+ recursive: bool = True,
102
+ ) -> Dict[str, Any]:
103
+ """Explicit opt-in: snapshot the folder now, ingest only future changes.
104
+
105
+ The folder is assumed to have been ingested already (the UI offers
106
+ watch after a folder ingest); the enable-time snapshot is the baseline,
107
+ so enabling never re-ingests the whole folder.
108
+ """
109
+ root = Path(path).expanduser()
110
+ if not root.is_dir():
111
+ return {"status": "failed", "detail": f"not a directory: {root}"}
112
+ root = root.resolve()
113
+ with self._lock:
114
+ for watch in self._watches.values():
115
+ same_scope = (watch.get("workspace_id") or None) == (workspace_id or None)
116
+ if watch.get("path") == str(root) and same_scope:
117
+ watch["enabled"] = True
118
+ watch["snapshot"] = self._snapshot(root, recursive=bool(watch.get("recursive", True)))
119
+ self._save_config()
120
+ self._ensure_thread()
121
+ return {"status": "ok", "watch": self._public_watch(watch), "already_watching": True}
122
+ watch_id = f"watch_{uuid.uuid4().hex[:12]}"
123
+ watch = {
124
+ "id": watch_id,
125
+ "path": str(root),
126
+ "owner": owner,
127
+ "workspace_id": workspace_id,
128
+ "recursive": bool(recursive),
129
+ "enabled": True,
130
+ "created_at": _now_iso(),
131
+ "last_scan_at": None,
132
+ "last_result": None,
133
+ "snapshot": self._snapshot(root, recursive=bool(recursive)),
134
+ }
135
+ self._watches[watch_id] = watch
136
+ self._save_config()
137
+ self._ensure_thread()
138
+ return {"status": "ok", "watch": self._public_watch(watch), "already_watching": False}
139
+
140
+ def disable(
141
+ self,
142
+ *,
143
+ watch_id: Optional[str] = None,
144
+ path: Optional[str] = None,
145
+ ) -> Dict[str, Any]:
146
+ """Opt back out: remove the stored consent record entirely."""
147
+ with self._lock:
148
+ target_id = None
149
+ if watch_id and watch_id in self._watches:
150
+ target_id = watch_id
151
+ elif path:
152
+ wanted = str(Path(path).expanduser().resolve())
153
+ for wid, watch in self._watches.items():
154
+ if watch.get("path") == wanted:
155
+ target_id = wid
156
+ break
157
+ if target_id is None:
158
+ return {"status": "not_found"}
159
+ removed = self._watches.pop(target_id)
160
+ self._save_config()
161
+ if not any(w.get("enabled") for w in self._watches.values()):
162
+ self._stop_thread_locked()
163
+ return {"status": "ok", "watch": self._public_watch(removed)}
164
+
165
+ def status(self) -> Dict[str, Any]:
166
+ with self._lock:
167
+ watches = [self._public_watch(w) for w in self._watches.values()]
168
+ polling = self._thread is not None and self._thread.is_alive()
169
+ return {
170
+ "enabled_count": sum(1 for w in watches if w.get("enabled")),
171
+ "polling": polling,
172
+ "interval_seconds": self._interval,
173
+ "watches": watches,
174
+ "note": (
175
+ "Watch mode is opt-in and off by default. Deleted files are "
176
+ "counted but never auto-removed from the Brain."
177
+ ),
178
+ }
179
+
180
+ def restore(self) -> Dict[str, Any]:
181
+ """Resume polling for watches persisted with the explicit opt-in.
182
+
183
+ Never starts anything when the stored config has no enabled entries —
184
+ an empty/missing config means the user never opted in.
185
+ """
186
+ with self._lock:
187
+ enabled = [w for w in self._watches.values() if w.get("enabled")]
188
+ if enabled:
189
+ self._ensure_thread()
190
+ return {"restored": len(enabled), "polling": bool(enabled)}
191
+
192
+ def stop_all(self) -> None:
193
+ """Stop the polling thread (keeps the stored opt-in for next start)."""
194
+ with self._lock:
195
+ self._stop_thread_locked()
196
+
197
+ def scan_once(self, watch_id: str) -> Dict[str, Any]:
198
+ """Synchronously scan one watch and ingest new/changed files."""
199
+ with self._lock:
200
+ watch = self._watches.get(watch_id)
201
+ if watch is None:
202
+ return {"status": "not_found", "watch_id": watch_id}
203
+ if not watch.get("enabled"):
204
+ return {"status": "disabled", "watch_id": watch_id}
205
+ snapshot = dict(watch.get("snapshot") or {})
206
+ root = Path(watch["path"])
207
+ recursive = bool(watch.get("recursive", True))
208
+ owner = watch.get("owner")
209
+ workspace_id = watch.get("workspace_id")
210
+
211
+ result: Dict[str, Any] = {
212
+ "status": "ok",
213
+ "watch_id": watch_id,
214
+ "new": 0,
215
+ "changed": 0,
216
+ "removed": 0,
217
+ "ingested": 0,
218
+ "duplicate": 0,
219
+ "failed": 0,
220
+ "errors": [],
221
+ }
222
+ if not root.is_dir():
223
+ result.update(status="failed", detail=f"folder unavailable: {root}")
224
+ self._record_scan(watch_id, result, snapshot=None)
225
+ return result
226
+
227
+ current = self._snapshot(root, recursive=recursive)
228
+ changed_rel: List[str] = []
229
+ for rel, stamp in current.items():
230
+ previous = snapshot.get(rel)
231
+ if previous is None:
232
+ result["new"] += 1
233
+ changed_rel.append(rel)
234
+ elif previous != stamp:
235
+ result["changed"] += 1
236
+ changed_rel.append(rel)
237
+ result["removed"] = sum(1 for rel in snapshot if rel not in current)
238
+
239
+ for rel in changed_rel[:MAX_FILES_PER_SCAN]:
240
+ path = root / rel
241
+ ext = path.suffix.lower()
242
+ metadata: Dict[str, Any] = {
243
+ "relative_path": rel,
244
+ "folder_watch": True,
245
+ "watch_id": watch_id,
246
+ }
247
+ if ext in FOLDER_DOCUMENT_EXTENSIONS:
248
+ source_type = "pdf"
249
+ else:
250
+ source_type = "file"
251
+ try:
252
+ content = path.read_text(encoding="utf-8", errors="ignore")
253
+ except OSError as exc:
254
+ result["failed"] += 1
255
+ if len(result["errors"]) < 20:
256
+ result["errors"].append({"path": rel, "detail": f"read failed: {exc}"})
257
+ continue
258
+ metadata["extracted"] = {"content": content, "chars": len(content)}
259
+ outcome = self._pipeline.ingest(
260
+ IngestionItem(
261
+ source_type=source_type,
262
+ title=path.name,
263
+ path=str(path),
264
+ source_uri=str(path),
265
+ owner=owner,
266
+ workspace_id=workspace_id,
267
+ metadata=metadata,
268
+ ),
269
+ user_email=owner,
270
+ )
271
+ if outcome.status == "ok":
272
+ if outcome.duplicate:
273
+ result["duplicate"] += 1
274
+ else:
275
+ result["ingested"] += 1
276
+ else:
277
+ result["failed"] += 1
278
+ if len(result["errors"]) < 20:
279
+ result["errors"].append({
280
+ "path": rel,
281
+ "detail": outcome.detail or outcome.status,
282
+ })
283
+ if len(changed_rel) > MAX_FILES_PER_SCAN:
284
+ result["truncated"] = True
285
+ self._record_scan(watch_id, result, snapshot=current)
286
+ return result
287
+
288
+ # ── internals ────────────────────────────────────────────────────────────
289
+ def _record_scan(
290
+ self,
291
+ watch_id: str,
292
+ result: Dict[str, Any],
293
+ *,
294
+ snapshot: Optional[Dict[str, Any]],
295
+ ) -> None:
296
+ with self._lock:
297
+ watch = self._watches.get(watch_id)
298
+ if watch is None:
299
+ return
300
+ watch["last_scan_at"] = _now_iso()
301
+ watch["last_result"] = {
302
+ key: result[key]
303
+ for key in ("status", "new", "changed", "removed", "ingested", "duplicate", "failed")
304
+ if key in result
305
+ }
306
+ if snapshot is not None:
307
+ watch["snapshot"] = snapshot
308
+ self._save_config()
309
+
310
+ def _snapshot(self, root: Path, *, recursive: bool) -> Dict[str, List[float]]:
311
+ """{relative_posix_path: [mtime, size]} using the folder-ingest filters."""
312
+ patterns = _load_latticeignore(root)
313
+ snapshot: Dict[str, List[float]] = {}
314
+ for dirpath, dirnames, filenames in os.walk(root):
315
+ current = Path(dirpath)
316
+ rel_dir = current.relative_to(root)
317
+ kept: List[str] = []
318
+ for name in sorted(dirnames):
319
+ if name in FOLDER_DEFAULT_SKIP_DIRS or name.startswith("."):
320
+ continue
321
+ rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
322
+ if _matches_ignore(rel, name, is_dir=True, patterns=patterns):
323
+ continue
324
+ kept.append(name)
325
+ dirnames[:] = kept if recursive else []
326
+ for name in sorted(filenames):
327
+ if name == LATTICEIGNORE_FILENAME or name.startswith("."):
328
+ continue
329
+ path = current / name
330
+ rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
331
+ if _matches_ignore(rel, name, is_dir=False, patterns=patterns):
332
+ continue
333
+ if path.suffix.lower() not in DEFAULT_FOLDER_EXTENSIONS:
334
+ continue
335
+ try:
336
+ stat = path.stat()
337
+ except OSError:
338
+ continue
339
+ if stat.st_size > DEFAULT_MAX_FILE_BYTES:
340
+ continue
341
+ snapshot[rel] = [round(stat.st_mtime, 3), stat.st_size]
342
+ return snapshot
343
+
344
+ @staticmethod
345
+ def _public_watch(watch: Dict[str, Any]) -> Dict[str, Any]:
346
+ return {
347
+ "id": watch.get("id"),
348
+ "path": watch.get("path"),
349
+ "owner": watch.get("owner"),
350
+ "workspace_id": watch.get("workspace_id"),
351
+ "recursive": bool(watch.get("recursive", True)),
352
+ "enabled": bool(watch.get("enabled")),
353
+ "created_at": watch.get("created_at"),
354
+ "last_scan_at": watch.get("last_scan_at"),
355
+ "last_result": watch.get("last_result"),
356
+ "tracked_files": len(watch.get("snapshot") or {}),
357
+ }
358
+
359
+ def _ensure_thread(self) -> None:
360
+ """Start the poller (caller holds the lock)."""
361
+ if self._thread is not None and self._thread.is_alive():
362
+ return
363
+ self._stop = threading.Event()
364
+ self._thread = threading.Thread(
365
+ target=self._poll_loop, name="folder-watch-poller", daemon=True,
366
+ )
367
+ self._thread.start()
368
+
369
+ def _stop_thread_locked(self) -> None:
370
+ self._stop.set()
371
+ self._thread = None
372
+
373
+ def _poll_loop(self) -> None:
374
+ stop = self._stop
375
+ while not stop.wait(self._interval):
376
+ with self._lock:
377
+ enabled_ids = [
378
+ wid for wid, watch in self._watches.items() if watch.get("enabled")
379
+ ]
380
+ if not enabled_ids:
381
+ return
382
+ for watch_id in enabled_ids:
383
+ if stop.is_set():
384
+ return
385
+ try:
386
+ self.scan_once(watch_id)
387
+ except Exception: # noqa: BLE001 — one bad watch must not kill the poller
388
+ continue
389
+
390
+
391
+ __all__ = ["FolderWatchService", "DEFAULT_WATCH_INTERVAL_SECONDS", "WATCH_INTERVAL_ENV"]
@@ -0,0 +1,202 @@
1
+ """UX funnel metrics — lightweight runtime counters (backlog #16, review §4.3).
2
+
3
+ Tracks the product's core value funnel with honest, local-only counters:
4
+
5
+ * ``file_requests`` — chat requests recognized as file intents;
6
+ * ``real_file_delivered`` — file intents that produced actual artifacts;
7
+ * ``code_only_responses`` — file intents that finished with only a
8
+ code/prose answer (the failure mode the >95% real-file goal watches);
9
+ * ``agent_runs`` — completed chat agent runs (rate denominator);
10
+ * ``needs_review_runs`` — agent runs that ended in ``NEEDS_REVIEW``;
11
+ * ``ingest_completions`` — successful ingestion pipeline completions;
12
+ * ``recall_successes`` — chat turns whose context was grounded in at
13
+ least one Brain node.
14
+
15
+ TTFV ("time to first value") derives from two first-occurrence timestamps:
16
+ the first successful ingest and the first grounded recall/answer.
17
+
18
+ Design constraints (deliberate):
19
+
20
+ * **Cheap.** One JSON file under the data dir, atomic replace on write, a
21
+ single ``threading.Lock`` — no background threads, no new dependencies.
22
+ * **Never breaks the product.** Every public method swallows I/O errors;
23
+ metrics are advisory observability, not a gate.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import os
31
+ import tempfile
32
+ import threading
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+ from typing import Any, Dict, Optional
36
+
37
+ LOGGER = logging.getLogger(__name__)
38
+
39
+ COUNTER_NAMES = (
40
+ "file_requests",
41
+ "real_file_delivered",
42
+ "code_only_responses",
43
+ "agent_runs",
44
+ "needs_review_runs",
45
+ "ingest_completions",
46
+ "recall_successes",
47
+ )
48
+
49
+ _FIRST_NAMES = ("first_ingest_at", "first_value_at")
50
+
51
+
52
+ def _utc_now_iso() -> str:
53
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
54
+
55
+
56
+ def _parse_iso(value: Any) -> Optional[datetime]:
57
+ if not value:
58
+ return None
59
+ try:
60
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
61
+ except ValueError:
62
+ return None
63
+ if parsed.tzinfo is None:
64
+ parsed = parsed.replace(tzinfo=timezone.utc)
65
+ return parsed
66
+
67
+
68
+ def _rate(numerator: int, denominator: int) -> Optional[float]:
69
+ """Honest rate: ``None`` (not 0.0) when there is no denominator yet."""
70
+ if denominator <= 0:
71
+ return None
72
+ return round(numerator / denominator, 4)
73
+
74
+
75
+ class FunnelMetricsService:
76
+ """Thread-safe, JSON-persisted funnel counters."""
77
+
78
+ def __init__(self, path: Any) -> None:
79
+ self._path = Path(path)
80
+ self._lock = threading.Lock()
81
+ self._state: Dict[str, Any] = self._load()
82
+
83
+ # ── persistence ──────────────────────────────────────────────────────
84
+
85
+ def _load(self) -> Dict[str, Any]:
86
+ state: Dict[str, Any] = {name: 0 for name in COUNTER_NAMES}
87
+ for name in _FIRST_NAMES:
88
+ state[name] = None
89
+ try:
90
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
91
+ except FileNotFoundError:
92
+ return state
93
+ except Exception as exc: # noqa: BLE001 — corrupt metrics never break startup
94
+ LOGGER.warning("funnel metrics load failed (%s); starting fresh", exc)
95
+ return state
96
+ if isinstance(raw, dict):
97
+ for name in COUNTER_NAMES:
98
+ try:
99
+ state[name] = max(0, int(raw.get(name) or 0))
100
+ except (TypeError, ValueError):
101
+ state[name] = 0
102
+ for name in _FIRST_NAMES:
103
+ value = raw.get(name)
104
+ state[name] = str(value) if value else None
105
+ return state
106
+
107
+ def _save_locked(self) -> None:
108
+ try:
109
+ self._path.parent.mkdir(parents=True, exist_ok=True)
110
+ fd, tmp_name = tempfile.mkstemp(
111
+ prefix=self._path.name, dir=str(self._path.parent)
112
+ )
113
+ try:
114
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
115
+ json.dump(self._state, handle, ensure_ascii=False, indent=2)
116
+ os.replace(tmp_name, self._path)
117
+ finally:
118
+ if os.path.exists(tmp_name):
119
+ try:
120
+ os.unlink(tmp_name)
121
+ except OSError:
122
+ pass
123
+ except Exception as exc: # noqa: BLE001 — metrics persistence is best-effort
124
+ LOGGER.warning("funnel metrics save failed: %s", exc)
125
+
126
+ # ── mutation ─────────────────────────────────────────────────────────
127
+
128
+ def increment(self, name: str, by: int = 1) -> None:
129
+ """Increment a known counter; unknown names are ignored (logged)."""
130
+ if name not in COUNTER_NAMES:
131
+ LOGGER.warning("funnel metrics: unknown counter %r ignored", name)
132
+ return
133
+ try:
134
+ step = int(by)
135
+ except (TypeError, ValueError):
136
+ step = 1
137
+ if step <= 0:
138
+ return
139
+ with self._lock:
140
+ self._state[name] = int(self._state.get(name) or 0) + step
141
+ self._save_locked()
142
+
143
+ def _mark_first_locked(self, name: str) -> None:
144
+ if not self._state.get(name):
145
+ self._state[name] = _utc_now_iso()
146
+
147
+ def record_ingest(self, *, duplicate: bool = False) -> None:
148
+ """A successful ingestion completed; the first one starts the TTFV clock."""
149
+ with self._lock:
150
+ self._state["ingest_completions"] = (
151
+ int(self._state.get("ingest_completions") or 0) + 1
152
+ )
153
+ self._mark_first_locked("first_ingest_at")
154
+ self._save_locked()
155
+
156
+ def record_recall_success(self) -> None:
157
+ """A chat answer was grounded in the Brain; the first one ends TTFV."""
158
+ with self._lock:
159
+ self._state["recall_successes"] = (
160
+ int(self._state.get("recall_successes") or 0) + 1
161
+ )
162
+ # First value only counts after knowledge actually entered the Brain.
163
+ if self._state.get("first_ingest_at"):
164
+ self._mark_first_locked("first_value_at")
165
+ self._save_locked()
166
+
167
+ # ── reads ────────────────────────────────────────────────────────────
168
+
169
+ def ttfv_seconds(self) -> Optional[float]:
170
+ with self._lock:
171
+ first_ingest = _parse_iso(self._state.get("first_ingest_at"))
172
+ first_value = _parse_iso(self._state.get("first_value_at"))
173
+ if first_ingest is None or first_value is None:
174
+ return None
175
+ delta = (first_value - first_ingest).total_seconds()
176
+ return round(delta, 1) if delta >= 0 else None
177
+
178
+ def snapshot(self) -> Dict[str, Any]:
179
+ """Counters + derived rates for the admin surface."""
180
+ with self._lock:
181
+ counters = {name: int(self._state.get(name) or 0) for name in COUNTER_NAMES}
182
+ firsts = {name: self._state.get(name) for name in _FIRST_NAMES}
183
+ return {
184
+ "counters": counters,
185
+ "firsts": firsts,
186
+ "rates": {
187
+ "real_file_rate": _rate(
188
+ counters["real_file_delivered"], counters["file_requests"]
189
+ ),
190
+ "code_only_rate": _rate(
191
+ counters["code_only_responses"], counters["file_requests"]
192
+ ),
193
+ "needs_review_rate": _rate(
194
+ counters["needs_review_runs"], counters["agent_runs"]
195
+ ),
196
+ },
197
+ "ttfv_seconds": self.ttfv_seconds(),
198
+ "generated_at": _utc_now_iso(),
199
+ }
200
+
201
+
202
+ __all__ = ["FunnelMetricsService", "COUNTER_NAMES"]
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "9.9.2"
21
+ PRODUCT_VERSION_TARGET = "9.9.3"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
  from dataclasses import dataclass
10
10
  from typing import Any, Dict, Mapping, Optional
11
11
 
12
+ from lattice_brain.graph.fusion import fusion_profile
13
+
12
14
 
13
15
  DEFAULT_HYBRID_WEIGHTS = {
14
16
  "keyword": 0.35,
@@ -402,7 +404,18 @@ class SearchService:
402
404
  allowed_workspaces=None,
403
405
  include_legacy_global: bool = False,
404
406
  ) -> Dict[str, Any]:
405
- weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
407
+ # Query-class fusion (backlog #5): when the caller does not pin
408
+ # explicit weights, detect the query class (fact/code/person/recency)
409
+ # and use its per-class channel weights. Explicit weights still win,
410
+ # and the "fact" class equals DEFAULT_HYBRID_WEIGHTS, so pinned-weight
411
+ # callers and fact-class queries behave exactly as before.
412
+ query_class: Optional[str] = None
413
+ if weights is None:
414
+ profile = fusion_profile(query)
415
+ query_class = profile["query_class"]
416
+ weights = dict(profile["weights"])
417
+ else:
418
+ weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights)}
406
419
  # Scope each channel at the source so out-of-scope rows never enter the
407
420
  # fusion set (defense-in-depth — the fused result is re-scoped below too).
408
421
  channels = {
@@ -464,9 +477,12 @@ class SearchService:
464
477
  "weights": weights,
465
478
  "sources": match.get("sources", []),
466
479
  }
480
+ if query_class is not None:
481
+ match["fusion"]["query_class"] = query_class
467
482
  return {
468
483
  "query": query,
469
484
  "mode": "hybrid",
485
+ "query_class": query_class,
470
486
  "weights": weights,
471
487
  "channels": {
472
488
  name: {