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,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.
|
|
21
|
+
PRODUCT_VERSION_TARGET = "9.9.3"
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
@dataclass(frozen=True)
|
|
@@ -60,6 +60,11 @@ PRODUCT_GATES: List[ProductGate] = [
|
|
|
60
60
|
"latticeai/api/chat.py::is_file_action_request",
|
|
61
61
|
"latticeai/api/chat_intents.py::direct_file_action",
|
|
62
62
|
"latticeai/api/chat_intents.py::direct_write_file",
|
|
63
|
+
# v9.9.2 ArtifactWritePipeline: every write path shares the same
|
|
64
|
+
# extract → validate → repair guarantee, proven by the FG harness.
|
|
65
|
+
"latticeai/core/file_generation.py::sanitize_write_content",
|
|
66
|
+
"latticeai/core/agent.py::content_sanitize",
|
|
67
|
+
"tests/unit/test_artifact_write_scenarios.py::test_fg06_agent_dispatch_strips_fences_from_write_file_content",
|
|
63
68
|
"tests/unit/test_chat_telegram_decoupling.py::test_chat_file_creation_intent_writes_real_file",
|
|
64
69
|
],
|
|
65
70
|
),
|
|
@@ -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
|
-
|
|
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: {
|