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
@@ -17,12 +17,19 @@ from latticeai.api.chat_helpers import (
17
17
  format_network_status,
18
18
  inline_file_action_content,
19
19
  )
20
- from latticeai.api.chat_stream import agent_payload_stream, single_answer_response
20
+ from latticeai.api.chat_stream import (
21
+ agent_live_stream,
22
+ agent_payload_stream,
23
+ single_answer_response,
24
+ )
21
25
  from latticeai.core.agent import AgentState
22
26
  from latticeai.core.file_generation import (
23
27
  PREVIEWABLE_EXTENSIONS,
24
28
  generate_file_content,
25
29
  infer_file_target,
30
+ infer_project_manifest,
31
+ repair_bundle_references,
32
+ validate_project_bundle,
26
33
  )
27
34
 
28
35
 
@@ -80,6 +87,7 @@ class ChatIntentController:
80
87
  agent_controller: Any,
81
88
  agent_root: Path,
82
89
  ingestion_pipeline: Any = None,
90
+ funnel_metrics: Any = None,
83
91
  ) -> None:
84
92
  self.router = model_router
85
93
  self.config = config
@@ -99,6 +107,16 @@ class ChatIntentController:
99
107
  self.agent_controller = agent_controller
100
108
  self.agent_root = Path(agent_root)
101
109
  self.ingestion_pipeline = ingestion_pipeline
110
+ self.funnel_metrics = funnel_metrics
111
+
112
+ def _funnel_increment(self, name: str) -> None:
113
+ """UX funnel counter (backlog #16) — advisory, never raises."""
114
+ if self.funnel_metrics is None:
115
+ return
116
+ try:
117
+ self.funnel_metrics.increment(name)
118
+ except Exception as exc: # noqa: BLE001 — metrics must never break chat
119
+ logging.warning("funnel metrics increment failed: %s", exc)
102
120
 
103
121
  def no_model_response(self) -> JSONResponse:
104
122
  detail = "No model loaded. Call /models/load first."
@@ -235,7 +253,21 @@ class ChatIntentController:
235
253
  # An explicit path ("report.txt") wins; otherwise infer one from an
236
254
  # explicit type keyword ("html 파일 만들어줘") so weak models never
237
255
  # have to drive the JSON tool loop just to create a file.
238
- target_path = file_action_target(req.message) or infer_file_target(req.message)
256
+ explicit_target = file_action_target(req.message)
257
+ if explicit_target is None:
258
+ # Multi-file intent ("todo 앱 html+css+js") becomes a real linked
259
+ # project bundle instead of one inlined page. Single-type requests
260
+ # return None here and keep the unchanged single-file flow.
261
+ manifest = infer_project_manifest(req.message)
262
+ if manifest is not None:
263
+ return await self.direct_project_action(
264
+ req,
265
+ manifest,
266
+ model_id=model_id,
267
+ effective_email=effective_email,
268
+ workspace_id=workspace_id,
269
+ )
270
+ target_path = explicit_target or infer_file_target(req.message)
239
271
  if not target_path:
240
272
  return None
241
273
  # Never silently overwrite: an existing target gets a _2/_3 suffix so
@@ -316,6 +348,7 @@ class ChatIntentController:
316
348
  workspace_id=workspace_id,
317
349
  conversation_id=getattr(req, "conversation_id", None),
318
350
  )
351
+ self._funnel_increment("real_file_delivered")
319
352
  self.notify("user", req.message, req.source)
320
353
  self.notify("assistant", answer, req.source)
321
354
  payload = {
@@ -354,6 +387,156 @@ class ChatIntentController:
354
387
  )
355
388
  return JSONResponse(content=payload)
356
389
 
390
+ async def direct_project_action(
391
+ self,
392
+ req: Any,
393
+ manifest: Dict[str, Any],
394
+ *,
395
+ model_id: Optional[str],
396
+ effective_email: Optional[str] = None,
397
+ workspace_id: Optional[str] = None,
398
+ ):
399
+ """Artifact Loop: manifest → per-file generate/validate → bundle write.
400
+
401
+ Every file goes through the same model-agnostic pipeline as the
402
+ single-file path; after generation the *bundle* is verified as a whole
403
+ (dangling href/src references are deterministically repaired first),
404
+ then written together under one project directory, exposed through the
405
+ standard ``artifacts[]`` contract, and downloadable as a zip.
406
+ """
407
+ if not model_id:
408
+ return self.no_model_response()
409
+ project_dir = next_available_path(self.agent_root, manifest["name"])
410
+ bundle_files = [str(f["path"]) for f in manifest["files"]]
411
+
412
+ async def _generate(context: str) -> str:
413
+ return str(
414
+ await self.router.generate_as(
415
+ model_id,
416
+ message="Return only the requested file content.",
417
+ context=context,
418
+ max_tokens=max(int(req.max_tokens or 0), 4096),
419
+ temperature=min(req.temperature, 0.3),
420
+ )
421
+ )
422
+
423
+ contents: Dict[str, str] = {}
424
+ file_meta: Dict[str, Dict[str, Any]] = {}
425
+ for spec in manifest["files"]:
426
+ name = str(spec["path"])
427
+ request_text = f"{req.message}\n\nThis file's role: {spec.get('brief', '')}"
428
+ content, meta = await generate_file_content(
429
+ _generate,
430
+ target_path=name,
431
+ user_request=request_text,
432
+ bundle_files=bundle_files,
433
+ )
434
+ contents[name] = content
435
+ file_meta[name] = meta
436
+
437
+ contents, reference_fixes = repair_bundle_references(contents)
438
+ bundle_validation = validate_project_bundle(contents)
439
+
440
+ steps: List[Dict[str, Any]] = []
441
+ created_files: List[Dict[str, Any]] = []
442
+ artifacts: List[Dict[str, Any]] = []
443
+ brain_ingests: List[Dict[str, Any]] = []
444
+ for name, content in contents.items():
445
+ rel_path = f"{project_dir}/{name}"
446
+ try:
447
+ result = self.execute_tool(
448
+ "write_file",
449
+ {"path": rel_path, "content": content},
450
+ )
451
+ except self.tool_error as exc:
452
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
453
+ final_path = result.get("path") or rel_path
454
+ steps.append({
455
+ "state": AgentState.EXECUTING.value,
456
+ "action": "write_file",
457
+ "args": {"path": rel_path},
458
+ "result": result,
459
+ })
460
+ created_files.append({
461
+ "path": final_path,
462
+ "filename": Path(final_path).name,
463
+ "bytes": result.get("bytes", 0),
464
+ "action": "write_file",
465
+ })
466
+ verdict = bundle_validation["files"].get(name, {})
467
+ artifacts.append({
468
+ "kind": "file",
469
+ "path": final_path,
470
+ "filename": Path(final_path).name,
471
+ "bytes": result.get("bytes", 0),
472
+ "previewable": Path(final_path).suffix.lower() in PREVIEWABLE_EXTENSIONS,
473
+ "valid": bool(verdict.get("valid", True)),
474
+ "repaired": bool(file_meta.get(name, {}).get("repaired")),
475
+ })
476
+ ingest = self._ingest_generated_file(
477
+ final_path,
478
+ content,
479
+ effective_email=effective_email,
480
+ workspace_id=workspace_id,
481
+ conversation_id=getattr(req, "conversation_id", None),
482
+ )
483
+ if ingest is not None:
484
+ brain_ingests.append({"path": final_path, **ingest})
485
+
486
+ file_list = ", ".join(Path(a["path"]).name for a in artifacts)
487
+ answer = (
488
+ f"{project_dir} 프로젝트를 만들었습니다 "
489
+ f"({len(artifacts)}개 파일: {file_list})."
490
+ )
491
+ if not bundle_validation["ok"]:
492
+ answer += " 일부 파일은 검증 경고가 있어 결과를 확인해 주세요."
493
+ repaired_any = any(a["repaired"] for a in artifacts)
494
+ if repaired_any:
495
+ answer += " (모델 출력이 불완전한 파일은 자동 보정을 거쳤습니다.)"
496
+
497
+ from urllib.parse import quote
498
+
499
+ payload: Dict[str, Any] = {
500
+ "status": "ok",
501
+ "response": answer,
502
+ "workspace": str(self.agent_root),
503
+ "steps": steps,
504
+ "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
505
+ "final_state": AgentState.DONE.value,
506
+ "created_files": created_files,
507
+ "artifacts": artifacts,
508
+ "routed_to_agent": True,
509
+ "action_route": "direct_project_bundle",
510
+ "project": {
511
+ "dir": project_dir,
512
+ "files": bundle_files,
513
+ "zip_url": f"/tools/download_zip?path={quote(project_dir)}",
514
+ "bundle_validation": bundle_validation,
515
+ "reference_fixes": reference_fixes,
516
+ },
517
+ "generation": {
518
+ "files": file_meta,
519
+ "repaired": repaired_any,
520
+ },
521
+ }
522
+ if brain_ingests:
523
+ payload["brain_ingest"] = brain_ingests
524
+ self._funnel_increment("real_file_delivered")
525
+ self.notify("user", req.message, req.source)
526
+ self.notify("assistant", answer, req.source)
527
+ if req.stream:
528
+ return StreamingResponse(
529
+ agent_payload_stream(
530
+ answer,
531
+ payload,
532
+ router=self.router,
533
+ model_id=model_id,
534
+ ),
535
+ media_type="text/event-stream",
536
+ headers={"X-Model": model_id or "tool", "X-Routed-To": "agent"},
537
+ )
538
+ return JSONResponse(content=payload)
539
+
357
540
  def _ingest_generated_file(
358
541
  self,
359
542
  rel_path: str,
@@ -417,22 +600,46 @@ class ChatIntentController:
417
600
  user_nickname=req.user_nickname,
418
601
  workspace_id=workspace_id,
419
602
  )
420
- result = await self.agent_controller.agent(agent_request, request)
421
- answer = str(result.get("response") or "파일 작업을 처리했습니다.")
422
- self.notify("user", req.message, req.source)
423
- self.notify("assistant", answer, req.source)
424
- result["routed_to_agent"] = True
603
+
604
+ def finalize(result: Dict) -> str:
605
+ """History/funnel side effects once the run has a terminal payload."""
606
+ answer = str(result.get("response") or "파일 작업을 처리했습니다.")
607
+ # UX funnel (backlog #16): a file-intent request that the agent loop
608
+ # finished without producing a single artifact is the "code-only"
609
+ # failure mode the >95% real-file goal watches. Paused/failed runs
610
+ # count neither way — they did not answer with code instead of files.
611
+ delivered = bool(result.get("created_files") or result.get("artifacts"))
612
+ if delivered:
613
+ self._funnel_increment("real_file_delivered")
614
+ elif str(result.get("status") or "") == "ok":
615
+ self._funnel_increment("code_only_responses")
616
+ self.notify("user", req.message, req.source)
617
+ self.notify("assistant", answer, req.source)
618
+ result["routed_to_agent"] = True
619
+ return answer
620
+
425
621
  if req.stream:
622
+ # Live loop visibility (review Wave 1.1): run the agent inside the
623
+ # SSE generator so step events stream while EXECUTING; the final
624
+ # frames keep the exact historical payload shape.
625
+ async def start(observer):
626
+ return await self.agent_controller.agent(
627
+ agent_request, request, on_step=observer
628
+ )
629
+
426
630
  return StreamingResponse(
427
- agent_payload_stream(
428
- answer,
429
- result,
631
+ agent_live_stream(
632
+ start,
430
633
  router=self.router,
431
634
  model_id=model_id,
635
+ finalize=finalize,
432
636
  ),
433
637
  media_type="text/event-stream",
434
638
  headers={"X-Model": model_id or "agent", "X-Routed-To": "agent"},
435
639
  )
640
+
641
+ result = await self.agent_controller.agent(agent_request, request)
642
+ finalize(result)
436
643
  return JSONResponse(content=result)
437
644
 
438
645
 
@@ -2,13 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
6
7
  import logging
7
- from typing import Any, AsyncIterator, Dict, Optional
8
+ from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Optional
8
9
 
9
10
  from fastapi.responses import JSONResponse, StreamingResponse
10
11
 
11
- from latticeai.api.chat_helpers import single_text_stream
12
+ from latticeai.api.chat_helpers import assess_answer_grounding, single_text_stream
12
13
 
13
14
 
14
15
  def single_answer_response(req: Any, answer: str, *, model: str):
@@ -37,6 +38,74 @@ def agent_payload_stream(
37
38
  return _stream()
38
39
 
39
40
 
41
+ def agent_live_stream(
42
+ start: Callable[[Callable[[Dict[str, Any]], None]], Awaitable[Dict[str, Any]]],
43
+ *,
44
+ router: Any,
45
+ model_id: Optional[str] = None,
46
+ finalize: Optional[Callable[[Dict[str, Any]], str]] = None,
47
+ ) -> AsyncIterator[str]:
48
+ """Live SSE for an agent run (review Wave 1.1).
49
+
50
+ ``start(observer)`` runs the agent with a per-run step observer; every
51
+ observed step is emitted immediately as a named ``agent_step`` frame, so
52
+ the user watches the loop work instead of staring at silence. When the
53
+ run finishes, ``finalize(result)`` (history/funnel side effects) produces
54
+ the answer text and the classic final payload frames follow — clients
55
+ that ignore named events see exactly the historical stream shape.
56
+ """
57
+
58
+ async def _stream() -> AsyncIterator[str]:
59
+ response_model = model_id or router.current_model_id
60
+ queue: asyncio.Queue = asyncio.Queue()
61
+
62
+ def observer(event: Dict[str, Any]) -> None:
63
+ # Called synchronously from the agent coroutine on this loop.
64
+ queue.put_nowait(event)
65
+
66
+ task = asyncio.create_task(start(observer))
67
+ try:
68
+ while True:
69
+ getter = asyncio.create_task(queue.get())
70
+ done, _ = await asyncio.wait(
71
+ {getter, task}, return_when=asyncio.FIRST_COMPLETED
72
+ )
73
+ if getter in done:
74
+ step = getter.result()
75
+ yield (
76
+ "event: agent_step\n"
77
+ f"data: {json.dumps(step, ensure_ascii=False)}\n\n"
78
+ )
79
+ continue
80
+ getter.cancel()
81
+ break
82
+ while not queue.empty():
83
+ step = queue.get_nowait()
84
+ yield (
85
+ "event: agent_step\n"
86
+ f"data: {json.dumps(step, ensure_ascii=False)}\n\n"
87
+ )
88
+ result = task.result()
89
+ except Exception as exc: # noqa: BLE001 — already streaming a 200
90
+ logging.warning("agent live stream failed: %s", exc)
91
+ detail = getattr(exc, "detail", None) or str(exc)
92
+ yield f"data: {json.dumps({'error': str(detail), 'model': response_model}, ensure_ascii=False)}\n\n"
93
+ yield "data: [DONE]\n\n"
94
+ return
95
+
96
+ answer = str(result.get("response") or "작업을 완료했습니다.")
97
+ if finalize is not None:
98
+ try:
99
+ answer = finalize(result)
100
+ except Exception as exc: # noqa: BLE001 — side effects must not kill the stream
101
+ logging.warning("agent live stream finalize failed: %s", exc)
102
+ yield f"data: {json.dumps({'chunk': answer, 'model': response_model, 'agent': result}, ensure_ascii=False)}\n\n"
103
+ yield f"data: {json.dumps({'chunk': '', 'model': response_model, 'agent': result}, ensure_ascii=False)}\n\n"
104
+ yield "data: [DONE]\n\n"
105
+
106
+ return _stream()
107
+
108
+
40
109
  async def stream_chat(
41
110
  req: Any,
42
111
  context: str,
@@ -87,6 +156,7 @@ async def stream_chat(
87
156
  persisted_response = f"{persisted_response}\n\n[stream_error] {stream_error}"
88
157
 
89
158
  trace_record = None
159
+ grounding = None
90
160
  try:
91
161
  answer_trace = trace_seed or chat_service.build_graph_trace(
92
162
  req.message,
@@ -94,6 +164,19 @@ async def stream_chat(
94
164
  context,
95
165
  allowed_workspaces={workspace_id} if workspace_id else None,
96
166
  )
167
+ # Answer-citation binding (backlog #11): same honest verdict as the
168
+ # non-streaming path, recorded on the persisted trace + trailer.
169
+ try:
170
+ grounding = assess_answer_grounding(
171
+ full_response,
172
+ trace=answer_trace if isinstance(answer_trace, dict) else None,
173
+ context_quality=context_quality,
174
+ )
175
+ if isinstance(answer_trace, dict):
176
+ answer_trace["grounding"] = grounding
177
+ except Exception as exc: # noqa: BLE001 — annotation must never break streaming
178
+ logging.warning("answer grounding assessment failed: %s", exc)
179
+ grounding = None
97
180
  trace_record = await chat_service.persist_answer(
98
181
  question=req.message,
99
182
  response=persisted_response,
@@ -114,10 +197,17 @@ async def stream_chat(
114
197
  trailer.update({"trace_id": trace_record["id"], "trace": trace_record})
115
198
  if context_quality is not None:
116
199
  trailer["context_quality"] = context_quality
200
+ if grounding is not None:
201
+ trailer["grounding"] = grounding
117
202
  if stream_error:
118
203
  trailer["error"] = stream_error
119
204
  yield f"data: {json.dumps(trailer, ensure_ascii=False)}\n\n"
120
205
  yield "data: [DONE]\n\n"
121
206
 
122
207
 
123
- __all__ = ["agent_payload_stream", "single_answer_response", "stream_chat"]
208
+ __all__ = [
209
+ "agent_live_stream",
210
+ "agent_payload_stream",
211
+ "single_answer_response",
212
+ "stream_chat",
213
+ ]
@@ -0,0 +1,31 @@
1
+ """Admin-only UX funnel metrics endpoint (backlog #16).
2
+
3
+ Exposes :class:`~latticeai.services.funnel_metrics.FunnelMetricsService`
4
+ snapshots at ``GET /api/admin/funnel-metrics``. Follows the admin router
5
+ convention: gate through the injected ``require_admin`` callable, read-only,
6
+ no side effects.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Callable
12
+
13
+ from fastapi import APIRouter, Request
14
+
15
+
16
+ def create_funnel_metrics_router(
17
+ *,
18
+ service: Any,
19
+ require_admin: Callable[[Request], Any],
20
+ ) -> APIRouter:
21
+ router = APIRouter()
22
+
23
+ @router.get("/api/admin/funnel-metrics")
24
+ async def funnel_metrics(request: Request):
25
+ require_admin(request)
26
+ return service.snapshot()
27
+
28
+ return router
29
+
30
+
31
+ __all__ = ["create_funnel_metrics_router"]
@@ -7,7 +7,7 @@ data endpoints back the v3 SPA (Files, Hybrid Search, graph explorer).
7
7
  """
8
8
 
9
9
  from pathlib import Path
10
- from typing import Any, Callable, Dict, Optional
10
+ from typing import Any, Callable, Dict, List, Optional
11
11
 
12
12
  from fastapi import APIRouter, HTTPException, Request
13
13
  from pydantic import BaseModel
@@ -28,6 +28,21 @@ class KnowledgeGraphIngestRequest(BaseModel):
28
28
  metadata: Optional[Dict[str, Any]] = None
29
29
 
30
30
 
31
+ class CurateNoiseRequest(BaseModel):
32
+ # Dry-run by default: the job *reports* removals until explicitly applied.
33
+ dry_run: bool = True
34
+ max_df_ratio: float = 0.8
35
+ min_doc_frequency: int = 1
36
+ min_corpus_docs: int = 5
37
+ normalize_verbs: bool = True
38
+ max_removals: int = 200
39
+
40
+
41
+ class PromotionActionRequest(BaseModel):
42
+ # None applies/rejects every pending promotion; otherwise only these ids.
43
+ ids: Optional[List[str]] = None
44
+
45
+
31
46
  def _workspace_scope_from_request(request: Request) -> Optional[str]:
32
47
  header = request.headers.get("X-Workspace-Id")
33
48
  if header and header.strip():
@@ -143,6 +158,55 @@ def create_knowledge_graph_router(
143
158
  (require_admin or require_user)(request)
144
159
  return graph().curate()
145
160
 
161
+ @router.post("/knowledge-graph/curate/noise")
162
+ async def knowledge_graph_curate_noise(req: CurateNoiseRequest, request: Request):
163
+ """Noise-reduction curation job (backlog #10).
164
+
165
+ Dry-run by default: reports which heuristic concept nodes would be
166
+ removed (low IDF / below the frequency floor) and which relation
167
+ verbs would be normalized, without changing the graph. Set
168
+ ``dry_run=false`` to apply. User-created nodes are never removed.
169
+ Administrative like ``/knowledge-graph/curate`` when roles exist.
170
+ """
171
+ (require_admin or require_user)(request)
172
+ return graph().curate_noise(
173
+ dry_run=req.dry_run,
174
+ max_df_ratio=req.max_df_ratio,
175
+ min_doc_frequency=req.min_doc_frequency,
176
+ min_corpus_docs=req.min_corpus_docs,
177
+ normalize_verbs=req.normalize_verbs,
178
+ max_removals=req.max_removals,
179
+ )
180
+
181
+ @router.get("/knowledge-graph/promotions")
182
+ async def knowledge_graph_promotions(request: Request):
183
+ """Pending curator promotions awaiting human review (review Wave 4).
184
+
185
+ Populated when ``curate()`` runs in review mode (explicit
186
+ ``review_mode=True`` or the LATTICEAI_GRAPH_PROMOTION_REVIEW env
187
+ opt-in). Administrative like ``/knowledge-graph/curate``: the queue
188
+ governs the shared graph.
189
+ """
190
+ (require_admin or require_user)(request)
191
+ pending = graph().pending_promotions()
192
+ return {"pending": pending, "total": len(pending)}
193
+
194
+ @router.post("/knowledge-graph/promotions/apply")
195
+ async def knowledge_graph_promotions_apply(
196
+ req: PromotionActionRequest, request: Request
197
+ ):
198
+ """Apply pending promotions (all when ``ids`` is omitted)."""
199
+ (require_admin or require_user)(request)
200
+ return graph().apply_pending_promotions(ids=req.ids)
201
+
202
+ @router.post("/knowledge-graph/promotions/reject")
203
+ async def knowledge_graph_promotions_reject(
204
+ req: PromotionActionRequest, request: Request
205
+ ):
206
+ """Reject pending promotions without writing (all when ``ids`` omitted)."""
207
+ (require_admin or require_user)(request)
208
+ return graph().reject_pending_promotions(ids=req.ids)
209
+
146
210
  @router.get("/knowledge-graph/provenance/coverage")
147
211
  async def knowledge_graph_provenance_coverage(request: Request):
148
212
  require_user(request)
@@ -49,6 +49,16 @@ class FolderIngestRequest(BaseModel):
49
49
  approval_token: Optional[str] = None
50
50
 
51
51
 
52
+ class FolderWatchEnableRequest(BaseModel):
53
+ path: str
54
+ recursive: bool = True
55
+ workspace_id: Optional[str] = None
56
+ # Watching continuously reads local disk → same approval dance as
57
+ # /api/ingestion/folder. This is the explicit opt-in the review requires.
58
+ approved: bool = False
59
+ approval_token: Optional[str] = None
60
+
61
+
52
62
  def create_local_files_router(
53
63
  *,
54
64
  require_user,
@@ -64,9 +74,26 @@ def create_local_files_router(
64
74
  data_dir: Optional[Path] = None,
65
75
  allowed_workspaces_for=None,
66
76
  workspace_service=None,
77
+ folder_watch=None,
67
78
  ) -> APIRouter:
68
79
  router = APIRouter()
69
80
 
81
+ # ── opt-in folder watch service (backlog #8) ──────────────────────────────
82
+ # Constructed here (not injected) unless a test provides one; restore()
83
+ # only resumes watches persisted with the explicit opt-in — a fresh
84
+ # data_dir never starts a polling thread.
85
+ if folder_watch is None and ingestion_pipeline is not None and data_dir is not None:
86
+ try:
87
+ from latticeai.services.folder_watch import FolderWatchService
88
+
89
+ folder_watch = FolderWatchService(
90
+ pipeline=ingestion_pipeline,
91
+ config_path=Path(data_dir) / "folder_watch.json",
92
+ )
93
+ folder_watch.restore()
94
+ except Exception: # noqa: BLE001 — watch mode is optional, never blocks routes
95
+ folder_watch = None
96
+
70
97
  @router.get("/api/local-agent/status")
71
98
  async def local_agent_status(request: Request):
72
99
  """Real on-device runtime status — the 'Local Agent' is the Lattice
@@ -307,6 +334,68 @@ def create_local_files_router(
307
334
  )
308
335
  return summary
309
336
 
337
+ # ── folder watch mode: opt-in, off by default (backlog #8) ────────────────
338
+ def _require_folder_watch():
339
+ _require_pipeline()
340
+ if folder_watch is None:
341
+ raise HTTPException(status_code=503, detail="Folder watch service is unavailable.")
342
+
343
+ @router.get("/api/ingestion/watch")
344
+ async def folder_watch_status(request: Request):
345
+ """Watch-mode status: stored opt-ins, poller state, last scan results."""
346
+ require_user(request)
347
+ _require_folder_watch()
348
+ return folder_watch.status()
349
+
350
+ @router.post("/api/ingestion/watch")
351
+ async def folder_watch_enable(req: FolderWatchEnableRequest, request: Request):
352
+ """Explicitly opt a previously-ingested folder into watch mode.
353
+
354
+ Follows the same local-read approval dance as ``/api/ingestion/folder``.
355
+ Enabling snapshots the folder as the baseline; only *future* new or
356
+ changed files are re-ingested (through the normal pipeline, with the
357
+ watch owner's workspace scope).
358
+ """
359
+ current_user = permission_gateway.require_local_user(request)
360
+ _require_folder_watch()
361
+ workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
362
+ path = (req.path or "").strip()
363
+ if not path:
364
+ raise HTTPException(status_code=400, detail="path is required.")
365
+ if not req.approved:
366
+ return permission_gateway.local_permission_response(path, "read", current_user)
367
+ permission_gateway.require_local_approval(
368
+ token=req.approval_token,
369
+ path=path,
370
+ action="read",
371
+ user_email=current_user,
372
+ )
373
+ result = folder_watch.enable(
374
+ path,
375
+ owner=current_user or None,
376
+ workspace_id=workspace_id,
377
+ recursive=req.recursive,
378
+ )
379
+ if result.get("status") != "ok":
380
+ raise HTTPException(status_code=400, detail=result.get("detail") or "watch enable failed")
381
+ return result
382
+
383
+ @router.delete("/api/ingestion/watch")
384
+ async def folder_watch_disable(
385
+ request: Request,
386
+ watch_id: Optional[str] = None,
387
+ path: Optional[str] = None,
388
+ ):
389
+ """Opt back out of watch mode (removes the stored consent record)."""
390
+ require_user(request)
391
+ _require_folder_watch()
392
+ if not watch_id and not path:
393
+ raise HTTPException(status_code=400, detail="watch_id or path is required.")
394
+ result = folder_watch.disable(watch_id=watch_id, path=path)
395
+ if result.get("status") == "not_found":
396
+ raise HTTPException(status_code=404, detail="watch not found")
397
+ return result
398
+
310
399
  @router.post("/local/write")
311
400
  async def local_write_endpoint(req: LocalWriteRequest, request: Request):
312
401
  current_user = permission_gateway.require_local_user(request)
@@ -353,6 +442,7 @@ def create_local_files_router(
353
442
 
354
443
  __all__ = [
355
444
  "FolderIngestRequest",
445
+ "FolderWatchEnableRequest",
356
446
  "LocalAccessRequest",
357
447
  "LocalWriteRequest",
358
448
  "create_local_files_router",