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
@@ -12,11 +12,21 @@ enable), review-queue gated, local-only, and idempotent per suggestion.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import asyncio
16
+ import os
17
+ import time
15
18
  from typing import Any, Callable, Optional
16
19
 
17
20
  from fastapi import APIRouter, HTTPException, Request
18
21
  from pydantic import BaseModel
19
22
 
23
+ from latticeai.services.automation_execution import (
24
+ build_last_execution,
25
+ dry_run_report,
26
+ enqueue_failed_execution,
27
+ is_automation_workflow,
28
+ summarize_workflow_run,
29
+ )
20
30
  from latticeai.services.automation_intelligence import AutomationIntelligenceService
21
31
  from latticeai.services.brain_automation import find_installed_recipe_workflow
22
32
 
@@ -26,6 +36,21 @@ class SuggestionInstallRequest(BaseModel):
26
36
  enabled: bool = False
27
37
 
28
38
 
39
+ class AutomationRunNowRequest(BaseModel):
40
+ workflow_id: str
41
+ # Dry-run first: the default reports what WOULD happen without side
42
+ # effects; an explicit dry_run=false runs the automation once for real.
43
+ dry_run: bool = True
44
+
45
+
46
+ def _run_now_wait_seconds() -> float:
47
+ raw = os.environ.get("LATTICEAI_AUTOMATION_RUN_NOW_WAIT", "30")
48
+ try:
49
+ return max(0.0, min(float(raw), 300.0))
50
+ except (TypeError, ValueError):
51
+ return 30.0
52
+
53
+
29
54
  def create_automation_intelligence_router(
30
55
  *,
31
56
  service: AutomationIntelligenceService,
@@ -35,7 +60,10 @@ def create_automation_intelligence_router(
35
60
  gate_write: Callable[[Request], Optional[str]],
36
61
  append_audit_event: Callable[..., None],
37
62
  workspace_graph: Callable[[], Any],
63
+ run_executor: Any = None,
64
+ review_queue: Any = None,
38
65
  ) -> APIRouter:
66
+ from lattice_brain.runtime.statuses import RUN_TERMINAL_STATUSES
39
67
  from lattice_brain.workflow import legacy_steps_from_nodes, validate_definition
40
68
 
41
69
  router = APIRouter()
@@ -123,6 +151,137 @@ def create_automation_intelligence_router(
123
151
  "already_installed": False,
124
152
  }
125
153
 
154
+ def _stamp_last_execution(
155
+ workflow_id: str, last_execution: dict, scope: Optional[str]
156
+ ) -> None:
157
+ """Persist the execution record on the workflow's metadata (merge)."""
158
+ try:
159
+ store.update_workflow_definition(
160
+ workflow_id,
161
+ metadata={"last_execution": last_execution},
162
+ workspace_id=scope,
163
+ )
164
+ except Exception: # noqa: BLE001 — surfacing must never undo the run
165
+ pass
166
+
167
+ @router.post("/api/automation/run-now")
168
+ async def automation_run_now(req: AutomationRunNowRequest, request: Request):
169
+ """Run an installed automation once — dry-run by default.
170
+
171
+ * ``dry_run=true``: deterministic report of what WOULD happen; no
172
+ runner executes and nothing but the ``last_execution`` stamp moves.
173
+ * ``dry_run=false``: one real execution through the async run
174
+ executor; the request waits briefly for completion, stamps the
175
+ result, and enqueues failed executions into the review inbox.
176
+ """
177
+ user = require_user(request)
178
+ scope = gate_write(request)
179
+ try:
180
+ workflow = store.get_workflow(req.workflow_id, workspace_id=scope)
181
+ except FileNotFoundError as exc:
182
+ raise HTTPException(
183
+ status_code=404, detail=f"Automation not found: {req.workflow_id}"
184
+ ) from exc
185
+ if not is_automation_workflow(workflow):
186
+ raise HTTPException(
187
+ status_code=404,
188
+ detail=f"Workflow is not an installed automation: {req.workflow_id}",
189
+ )
190
+
191
+ if req.dry_run:
192
+ report = dry_run_report(workflow)
193
+ last_execution = build_last_execution(
194
+ mode="dry_run",
195
+ status=report["status"],
196
+ summary=report["summary"],
197
+ )
198
+ _stamp_last_execution(req.workflow_id, last_execution, scope)
199
+ append_audit_event(
200
+ "automation_run_now",
201
+ user_email=user,
202
+ workflow_id=req.workflow_id,
203
+ dry_run=True,
204
+ status=report["status"],
205
+ )
206
+ return {
207
+ "workflow_id": req.workflow_id,
208
+ "dry_run": True,
209
+ "status": report["status"],
210
+ "report": report,
211
+ "last_execution": last_execution,
212
+ }
213
+
214
+ if run_executor is None:
215
+ raise HTTPException(
216
+ status_code=503,
217
+ detail="Automation execution runtime is not available.",
218
+ )
219
+ started = await run_executor.start_workflow(
220
+ workflow,
221
+ workflow_id=req.workflow_id,
222
+ user_email=user or None,
223
+ scope=scope,
224
+ inputs={"trigger": "run_now"},
225
+ )
226
+ run_id = str(((started or {}).get("run") or {}).get("id") or "")
227
+
228
+ # Bounded, non-cancelling wait: poll the durable run row so a slow
229
+ # execution keeps running in the background instead of being aborted.
230
+ run = (started or {}).get("run") or {}
231
+ deadline = time.monotonic() + _run_now_wait_seconds()
232
+ while time.monotonic() < deadline:
233
+ try:
234
+ run = store.get_workflow_run(run_id, workspace_id=scope)
235
+ except FileNotFoundError:
236
+ break
237
+ if str(run.get("status") or "") in RUN_TERMINAL_STATUSES:
238
+ break
239
+ await asyncio.sleep(0.05)
240
+
241
+ status = str(run.get("status") or "running")
242
+ completed = status in RUN_TERMINAL_STATUSES
243
+ summary = (
244
+ summarize_workflow_run(run)
245
+ if completed
246
+ else "started — still running in the background"
247
+ )
248
+ last_execution = build_last_execution(
249
+ mode="live",
250
+ status=status if completed else "running",
251
+ summary=summary,
252
+ run_id=run_id or None,
253
+ )
254
+ _stamp_last_execution(req.workflow_id, last_execution, scope)
255
+
256
+ review_item = None
257
+ if status == "failed":
258
+ review_item = enqueue_failed_execution(
259
+ review_queue,
260
+ workflow=workflow,
261
+ run_id=run_id or None,
262
+ error=summary,
263
+ user_email=user or None,
264
+ workspace_id=scope,
265
+ )
266
+ append_audit_event(
267
+ "automation_run_now",
268
+ user_email=user,
269
+ workflow_id=req.workflow_id,
270
+ dry_run=False,
271
+ run_id=run_id or None,
272
+ status=last_execution["status"],
273
+ )
274
+ payload = {
275
+ "workflow_id": req.workflow_id,
276
+ "dry_run": False,
277
+ "status": last_execution["status"],
278
+ "run_id": run_id or None,
279
+ "last_execution": last_execution,
280
+ }
281
+ if review_item is not None:
282
+ payload["review_item_id"] = review_item.get("id")
283
+ return payload
284
+
126
285
  return router
127
286
 
128
287
 
@@ -27,7 +27,7 @@ from fastapi import APIRouter, HTTPException, Request
27
27
  from pydantic import BaseModel
28
28
 
29
29
  from latticeai import __version__
30
- from lattice_brain.ingestion import IngestionItem
30
+ from lattice_brain.ingestion import IngestionItem, capture_quality_verdict
31
31
 
32
32
  MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
33
33
  MAX_URL_FETCH_BYTES = 4 * 1024 * 1024 # 4 MB cap on a fetched page
@@ -427,7 +427,10 @@ def create_browser_router(
427
427
  raise HTTPException(status_code=422, detail=str(exc))
428
428
  if not (text or "").strip():
429
429
  return {"status": "empty", "source_type": "web_url", "url": url,
430
- "detail": "No readable text was extracted from the page."}
430
+ "detail": "No readable text was extracted from the page.",
431
+ # Structured CTA (backlog #9): the extraction produced
432
+ # nothing, tell the UI what the user can do about it.
433
+ "capture_quality": capture_quality_verdict(None, source_type="web_url")}
431
434
  res = pipeline.ingest(
432
435
  IngestionItem(
433
436
  source_type="web_url", title=title, text=text, source_uri=url,
@@ -435,7 +438,11 @@ def create_browser_router(
435
438
  ),
436
439
  user_email=user,
437
440
  )
438
- return res.as_dict()
441
+ payload = res.as_dict()
442
+ payload["capture_quality"] = capture_quality_verdict(
443
+ res.extraction_quality, source_type="web_url",
444
+ )
445
+ return payload
439
446
 
440
447
  @router.post("/api/browser/ingest-current-tab")
441
448
  async def ingest_current_tab(req: IngestTabRequest, request: Request):
@@ -481,6 +488,10 @@ def create_browser_router(
481
488
  ),
482
489
  user_email=user,
483
490
  )
484
- return res.as_dict()
491
+ payload = res.as_dict()
492
+ payload["capture_quality"] = capture_quality_verdict(
493
+ res.extraction_quality, source_type="browser_tab",
494
+ )
495
+ return payload
485
496
 
486
497
  return router
@@ -28,6 +28,7 @@ from latticeai.api.chat_documents import (
28
28
  )
29
29
  from latticeai.api.chat_helpers import (
30
30
  _LANG_HINT,
31
+ assess_answer_grounding,
31
32
  build_context_quality,
32
33
  build_recent_chat_context,
33
34
  detect_language,
@@ -44,6 +45,7 @@ from latticeai.api.chat_helpers import (
44
45
  workspace_scope_from_request,
45
46
  )
46
47
  from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
48
+ from latticeai.core.run_store import AgentRunStore
47
49
  from latticeai.api.chat_intents import ChatIntentController
48
50
  from latticeai.api.chat_stream import stream_chat
49
51
  from latticeai.services.app_context import AppContext
@@ -189,6 +191,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
189
191
  base_dir=context.base_dir or Path.cwd(),
190
192
  agent_root=AGENT_ROOT,
191
193
  ensure_agent_root=ensure_agent_root,
194
+ funnel_metrics=context.funnel_metrics,
195
+ run_store=(
196
+ AgentRunStore(Path(context.data_dir) / "agent_runs")
197
+ if context.data_dir
198
+ else None
199
+ ),
192
200
  )
193
201
  intent_controller = ChatIntentController(
194
202
  model_router=model_router,
@@ -209,6 +217,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
209
217
  agent_controller=agent_controller,
210
218
  agent_root=AGENT_ROOT,
211
219
  ingestion_pipeline=context.ingestion_pipeline,
220
+ funnel_metrics=context.funnel_metrics,
212
221
  )
213
222
  document_coordinator = DocumentGenerationCoordinator(
214
223
  model_router=model_router,
@@ -273,7 +282,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
273
282
  )
274
283
 
275
284
  selected_model_id = request_model(req.model)
276
- if is_file_action_request(req.message):
285
+ file_intent = is_file_action_request(req.message)
286
+ if file_intent and context.funnel_metrics is not None:
287
+ # UX funnel (backlog #16): one file-intent request counted once,
288
+ # regardless of which delivery path handles it below.
289
+ context.funnel_metrics.increment("file_requests")
290
+ if file_intent:
277
291
  direct_response = await intent_controller.direct_file_action(
278
292
  req,
279
293
  model_id=selected_model_id,
@@ -284,7 +298,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
284
298
  return direct_response
285
299
  if not selected_model_id:
286
300
  return intent_controller.no_model_response()
287
- if is_file_action_request(req.message):
301
+ if file_intent:
288
302
  return await intent_controller.route_file_to_agent(
289
303
  req,
290
304
  request,
@@ -375,6 +389,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
375
389
  )
376
390
  if isinstance(trace_seed, dict):
377
391
  trace_seed["context_quality"] = context_quality
392
+ if context.funnel_metrics is not None and int(
393
+ context_quality.get("nodes") or 0
394
+ ) > 0:
395
+ # UX funnel (backlog #16): a grounded answer is "first value" —
396
+ # the TTFV clock ends on the first successful recall.
397
+ context.funnel_metrics.record_recall_success()
378
398
 
379
399
  history_message = (
380
400
  f"{req.message}\n[Image attached]" if req.image_data else req.message
@@ -464,6 +484,16 @@ def create_chat_router(context: AppContext) -> APIRouter:
464
484
  req.image_data,
465
485
  )
466
486
  response_text = str(result)
487
+ # Answer-citation binding (backlog #11): annotate — never block — how
488
+ # the answer relates to the retrieved sources. Recorded on the trace
489
+ # so the Review/proof surfaces see the same verdict as the client.
490
+ grounding = assess_answer_grounding(
491
+ response_text,
492
+ trace=trace_seed if isinstance(trace_seed, dict) else None,
493
+ context_quality=context_quality,
494
+ )
495
+ if isinstance(trace_seed, dict):
496
+ trace_seed["grounding"] = grounding
467
497
  trace_record = await chat_service.persist_answer(
468
498
  question=req.message,
469
499
  response=response_text,
@@ -482,6 +512,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
482
512
  "trace_id": trace_record["id"],
483
513
  "trace": trace_record,
484
514
  "context_quality": context_quality,
515
+ "grounding": grounding,
485
516
  }
486
517
  )
487
518