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.
Files changed (97) hide show
  1. package/README.md +40 -39
  2. package/docs/CHANGELOG.md +80 -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 +30 -2
  22. package/latticeai/api/chat_agent_http.py +193 -4
  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 +319 -8
  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 +15 -0
  33. package/latticeai/core/agent.py +205 -11
  34. package/latticeai/core/agent_eval.py +107 -0
  35. package/latticeai/core/file_generation.py +337 -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 +6 -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 +6 -1
  49. package/latticeai/services/search_service.py +17 -1
  50. package/latticeai/services/tool_dispatch.py +36 -0
  51. package/latticeai/setup/demo_corpus.py +98 -0
  52. package/latticeai/tools/filesystem.py +38 -1
  53. package/package.json +1 -1
  54. package/scripts/bench_models.py +234 -5
  55. package/scripts/check_current_release_docs.mjs +1 -1
  56. package/src-tauri/Cargo.lock +1 -1
  57. package/src-tauri/Cargo.toml +1 -1
  58. package/src-tauri/tauri.conf.json +1 -1
  59. package/static/app/asset-manifest.json +28 -27
  60. package/static/app/assets/Act-DmdruVKV.js +1 -0
  61. package/static/app/assets/{Brain-CSCDZEHd.js → Brain-DEY9jLVt.js} +2 -2
  62. package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
  63. package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
  64. package/static/app/assets/{Capture-BiuerOio.js → Capture-CVZ09QXi.js} +1 -1
  65. package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
  66. package/static/app/assets/Library-Bp0n-HlW.js +1 -0
  67. package/static/app/assets/{LivingBrain-6FvA0H5T.js → LivingBrain-DxP4efJF.js} +1 -1
  68. package/static/app/assets/{ProductFlow-Cr8bkQPw.js → ProductFlow-DRbm7NEq.js} +1 -1
  69. package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
  70. package/static/app/assets/{System-D6uJacqu.js → System-ByQcmJW-.js} +1 -1
  71. package/static/app/assets/{bot-DgC7xgUl.js → bot-BNDyZLR7.js} +1 -1
  72. package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
  73. package/static/app/assets/{cpu-DDGb4XHl.js → cpu-C6jjYm6i.js} +1 -1
  74. package/static/app/assets/{folder-open-BbVuSO9M.js → folder-open-DjGIvDBQ.js} +1 -1
  75. package/static/app/assets/{hard-drive-yDZR6XYz.js → hard-drive-BlSbwSaT.js} +1 -1
  76. package/static/app/assets/index-Bge3DXW7.css +2 -0
  77. package/static/app/assets/{index-DrZSkwrE.js → index-CHu7cgj3.js} +3 -3
  78. package/static/app/assets/{input-DPIkTs9n.js → input-DVDI0YR3.js} +1 -1
  79. package/static/app/assets/{navigation-B1mkmyUe.js → navigation-BddhEWA0.js} +1 -1
  80. package/static/app/assets/{network-0XDI1Uf_.js → network-pYQt5oBu.js} +1 -1
  81. package/static/app/assets/{primitives-sIQYKyf5.js → primitives-D7gCdEvS.js} +1 -1
  82. package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
  83. package/static/app/assets/{textarea-DvnwpnqS.js → textarea-sqQmoBKL.js} +1 -1
  84. package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
  85. package/static/app/assets/utils-uQYKXNeq.js +7 -0
  86. package/static/app/index.html +4 -4
  87. package/static/sw.js +1 -1
  88. package/static/app/assets/Act-CcSWTQsx.js +0 -1
  89. package/static/app/assets/BrainHome-B5QpJ1Fx.js +0 -2
  90. package/static/app/assets/BrainSignals-CmFD8PWk.js +0 -1
  91. package/static/app/assets/CommandPalette-BrqpT7K2.js +0 -1
  92. package/static/app/assets/Library-BcaiPbum.js +0 -1
  93. package/static/app/assets/ReviewCard-D56TMypk.js +0 -1
  94. package/static/app/assets/download-C-Ei3GFa.js +0 -1
  95. package/static/app/assets/index-CFRhNBuH.css +0 -2
  96. package/static/app/assets/sunrise-DXMjueec.js +0 -1
  97. package/static/app/assets/utils-BrfFHAJl.js +0 -7
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import os
7
+ import re
6
8
  from pathlib import Path
7
- from typing import Any, Dict, Optional
9
+ from typing import Any, Dict, List, Optional
8
10
 
9
11
  from fastapi import HTTPException, Request
10
12
  from fastapi.responses import JSONResponse, StreamingResponse
@@ -17,7 +19,46 @@ from latticeai.api.chat_helpers import (
17
19
  )
18
20
  from latticeai.api.chat_stream import agent_payload_stream, single_answer_response
19
21
  from latticeai.core.agent import AgentState
20
- from latticeai.core.file_generation import generate_file_content, infer_file_target
22
+ from latticeai.core.file_generation import (
23
+ PREVIEWABLE_EXTENSIONS,
24
+ generate_file_content,
25
+ infer_file_target,
26
+ infer_project_manifest,
27
+ repair_bundle_references,
28
+ validate_project_bundle,
29
+ )
30
+
31
+
32
+ def next_available_path(root: Path, target: str) -> str:
33
+ """Return ``target`` unchanged, or a ``name_2.ext``-style variant when the
34
+ file already exists in the workspace.
35
+
36
+ The direct chat write path must never silently overwrite an existing file:
37
+ overwrites belong to the reviewable-proposal flow. Deterministic suffixing
38
+ keeps "make me an html page" repeatable without a 409 dead end.
39
+ """
40
+ candidate = (root / target)
41
+ if not candidate.exists():
42
+ return target
43
+ stem, suffix = candidate.stem, candidate.suffix
44
+ # An earlier auto-suffix run may have produced name_2 already; restart the
45
+ # numbering from the base name so we fill the first free slot.
46
+ base = re.sub(r"_\d+$", "", stem) or stem
47
+ for index in range(2, 100):
48
+ variant = candidate.with_name(f"{base}_{index}{suffix}")
49
+ if not variant.exists():
50
+ return str(Path(target).with_name(variant.name))
51
+ raise HTTPException(
52
+ status_code=409,
53
+ detail=f"'{target}' 이름의 파일이 너무 많습니다. 다른 이름을 지정해 주세요.",
54
+ )
55
+
56
+
57
+ def ingest_generated_enabled() -> bool:
58
+ """Feature toggle for auto-indexing generated files into the Brain."""
59
+ return os.environ.get("LATTICEAI_INGEST_GENERATED", "1").strip().lower() not in {
60
+ "0", "false", "off", "no",
61
+ }
21
62
 
22
63
 
23
64
  class ChatIntentController:
@@ -41,6 +82,8 @@ class ChatIntentController:
41
82
  execute_tool: Any,
42
83
  agent_controller: Any,
43
84
  agent_root: Path,
85
+ ingestion_pipeline: Any = None,
86
+ funnel_metrics: Any = None,
44
87
  ) -> None:
45
88
  self.router = model_router
46
89
  self.config = config
@@ -59,6 +102,17 @@ class ChatIntentController:
59
102
  self.execute_tool = execute_tool
60
103
  self.agent_controller = agent_controller
61
104
  self.agent_root = Path(agent_root)
105
+ self.ingestion_pipeline = ingestion_pipeline
106
+ self.funnel_metrics = funnel_metrics
107
+
108
+ def _funnel_increment(self, name: str) -> None:
109
+ """UX funnel counter (backlog #16) — advisory, never raises."""
110
+ if self.funnel_metrics is None:
111
+ return
112
+ try:
113
+ self.funnel_metrics.increment(name)
114
+ except Exception as exc: # noqa: BLE001 — metrics must never break chat
115
+ logging.warning("funnel metrics increment failed: %s", exc)
62
116
 
63
117
  def no_model_response(self) -> JSONResponse:
64
118
  detail = "No model loaded. Call /models/load first."
@@ -184,13 +238,39 @@ class ChatIntentController:
184
238
  )
185
239
  return single_answer_response(req, answer, model="client_url")
186
240
 
187
- async def direct_file_action(self, req: Any, *, model_id: Optional[str]):
241
+ async def direct_file_action(
242
+ self,
243
+ req: Any,
244
+ *,
245
+ model_id: Optional[str],
246
+ effective_email: Optional[str] = None,
247
+ workspace_id: Optional[str] = None,
248
+ ):
188
249
  # An explicit path ("report.txt") wins; otherwise infer one from an
189
250
  # explicit type keyword ("html 파일 만들어줘") so weak models never
190
251
  # have to drive the JSON tool loop just to create a file.
191
- target_path = file_action_target(req.message) or infer_file_target(req.message)
252
+ explicit_target = file_action_target(req.message)
253
+ if explicit_target is None:
254
+ # Multi-file intent ("todo 앱 html+css+js") becomes a real linked
255
+ # project bundle instead of one inlined page. Single-type requests
256
+ # return None here and keep the unchanged single-file flow.
257
+ manifest = infer_project_manifest(req.message)
258
+ if manifest is not None:
259
+ return await self.direct_project_action(
260
+ req,
261
+ manifest,
262
+ model_id=model_id,
263
+ effective_email=effective_email,
264
+ workspace_id=workspace_id,
265
+ )
266
+ target_path = explicit_target or infer_file_target(req.message)
192
267
  if not target_path:
193
268
  return None
269
+ # Never silently overwrite: an existing target gets a _2/_3 suffix so
270
+ # regeneration is safe and repeatable without the proposal flow.
271
+ deduped_path = next_available_path(self.agent_root, target_path)
272
+ renamed = deduped_path != target_path
273
+ target_path = deduped_path
194
274
  content = inline_file_action_content(req.message)
195
275
  generation_meta: Optional[Dict[str, Any]] = None
196
276
  if content is None and not model_id:
@@ -227,19 +307,44 @@ class ChatIntentController:
227
307
  )
228
308
  except self.tool_error as exc:
229
309
  raise HTTPException(status_code=400, detail=str(exc)) from exc
230
- answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
231
- if generation_meta and generation_meta.get("repaired"):
310
+ final_path = result.get("path") or target_path
311
+ answer = f"{final_path} 파일을 만들었습니다."
312
+ if renamed:
313
+ answer += " (같은 이름의 파일이 있어 새 이름으로 저장했습니다.)"
314
+ repaired = bool(generation_meta and generation_meta.get("repaired"))
315
+ if repaired:
232
316
  answer += (
233
317
  " (모델 출력이 불완전해 자동 보정을 거쳐 유효한 파일로 저장했습니다.)"
234
318
  )
235
319
  created_files = [
236
320
  {
237
- "path": result.get("path") or target_path,
238
- "filename": Path(result.get("path") or target_path).name,
321
+ "path": final_path,
322
+ "filename": Path(final_path).name,
239
323
  "bytes": result.get("bytes", 0),
240
324
  "action": "write_file",
241
325
  }
242
326
  ]
327
+ # Artifact-first contract: every file the request produced, with the
328
+ # honesty flags the UI needs (valid vs repaired scaffold, previewable).
329
+ artifacts: List[Dict[str, Any]] = [
330
+ {
331
+ "kind": "file",
332
+ "path": final_path,
333
+ "filename": Path(final_path).name,
334
+ "bytes": result.get("bytes", 0),
335
+ "previewable": Path(final_path).suffix.lower() in PREVIEWABLE_EXTENSIONS,
336
+ "valid": True,
337
+ "repaired": repaired,
338
+ }
339
+ ]
340
+ brain_ingest = self._ingest_generated_file(
341
+ final_path,
342
+ content,
343
+ effective_email=effective_email,
344
+ workspace_id=workspace_id,
345
+ conversation_id=getattr(req, "conversation_id", None),
346
+ )
347
+ self._funnel_increment("real_file_delivered")
243
348
  self.notify("user", req.message, req.source)
244
349
  self.notify("assistant", answer, req.source)
245
350
  payload = {
@@ -257,11 +362,14 @@ class ChatIntentController:
257
362
  "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
258
363
  "final_state": AgentState.DONE.value,
259
364
  "created_files": created_files,
365
+ "artifacts": artifacts,
260
366
  "routed_to_agent": True,
261
367
  "action_route": "direct_write_file",
262
368
  }
263
369
  if generation_meta is not None:
264
370
  payload["generation"] = generation_meta
371
+ if brain_ingest is not None:
372
+ payload["brain_ingest"] = brain_ingest
265
373
  if req.stream:
266
374
  return StreamingResponse(
267
375
  agent_payload_stream(
@@ -275,6 +383,200 @@ class ChatIntentController:
275
383
  )
276
384
  return JSONResponse(content=payload)
277
385
 
386
+ async def direct_project_action(
387
+ self,
388
+ req: Any,
389
+ manifest: Dict[str, Any],
390
+ *,
391
+ model_id: Optional[str],
392
+ effective_email: Optional[str] = None,
393
+ workspace_id: Optional[str] = None,
394
+ ):
395
+ """Artifact Loop: manifest → per-file generate/validate → bundle write.
396
+
397
+ Every file goes through the same model-agnostic pipeline as the
398
+ single-file path; after generation the *bundle* is verified as a whole
399
+ (dangling href/src references are deterministically repaired first),
400
+ then written together under one project directory, exposed through the
401
+ standard ``artifacts[]`` contract, and downloadable as a zip.
402
+ """
403
+ if not model_id:
404
+ return self.no_model_response()
405
+ project_dir = next_available_path(self.agent_root, manifest["name"])
406
+ bundle_files = [str(f["path"]) for f in manifest["files"]]
407
+
408
+ async def _generate(context: str) -> str:
409
+ return str(
410
+ await self.router.generate_as(
411
+ model_id,
412
+ message="Return only the requested file content.",
413
+ context=context,
414
+ max_tokens=max(int(req.max_tokens or 0), 4096),
415
+ temperature=min(req.temperature, 0.3),
416
+ )
417
+ )
418
+
419
+ contents: Dict[str, str] = {}
420
+ file_meta: Dict[str, Dict[str, Any]] = {}
421
+ for spec in manifest["files"]:
422
+ name = str(spec["path"])
423
+ request_text = f"{req.message}\n\nThis file's role: {spec.get('brief', '')}"
424
+ content, meta = await generate_file_content(
425
+ _generate,
426
+ target_path=name,
427
+ user_request=request_text,
428
+ bundle_files=bundle_files,
429
+ )
430
+ contents[name] = content
431
+ file_meta[name] = meta
432
+
433
+ contents, reference_fixes = repair_bundle_references(contents)
434
+ bundle_validation = validate_project_bundle(contents)
435
+
436
+ steps: List[Dict[str, Any]] = []
437
+ created_files: List[Dict[str, Any]] = []
438
+ artifacts: List[Dict[str, Any]] = []
439
+ brain_ingests: List[Dict[str, Any]] = []
440
+ for name, content in contents.items():
441
+ rel_path = f"{project_dir}/{name}"
442
+ try:
443
+ result = self.execute_tool(
444
+ "write_file",
445
+ {"path": rel_path, "content": content},
446
+ )
447
+ except self.tool_error as exc:
448
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
449
+ final_path = result.get("path") or rel_path
450
+ steps.append({
451
+ "state": AgentState.EXECUTING.value,
452
+ "action": "write_file",
453
+ "args": {"path": rel_path},
454
+ "result": result,
455
+ })
456
+ created_files.append({
457
+ "path": final_path,
458
+ "filename": Path(final_path).name,
459
+ "bytes": result.get("bytes", 0),
460
+ "action": "write_file",
461
+ })
462
+ verdict = bundle_validation["files"].get(name, {})
463
+ artifacts.append({
464
+ "kind": "file",
465
+ "path": final_path,
466
+ "filename": Path(final_path).name,
467
+ "bytes": result.get("bytes", 0),
468
+ "previewable": Path(final_path).suffix.lower() in PREVIEWABLE_EXTENSIONS,
469
+ "valid": bool(verdict.get("valid", True)),
470
+ "repaired": bool(file_meta.get(name, {}).get("repaired")),
471
+ })
472
+ ingest = self._ingest_generated_file(
473
+ final_path,
474
+ content,
475
+ effective_email=effective_email,
476
+ workspace_id=workspace_id,
477
+ conversation_id=getattr(req, "conversation_id", None),
478
+ )
479
+ if ingest is not None:
480
+ brain_ingests.append({"path": final_path, **ingest})
481
+
482
+ file_list = ", ".join(Path(a["path"]).name for a in artifacts)
483
+ answer = (
484
+ f"{project_dir} 프로젝트를 만들었습니다 "
485
+ f"({len(artifacts)}개 파일: {file_list})."
486
+ )
487
+ if not bundle_validation["ok"]:
488
+ answer += " 일부 파일은 검증 경고가 있어 결과를 확인해 주세요."
489
+ repaired_any = any(a["repaired"] for a in artifacts)
490
+ if repaired_any:
491
+ answer += " (모델 출력이 불완전한 파일은 자동 보정을 거쳤습니다.)"
492
+
493
+ from urllib.parse import quote
494
+
495
+ payload: Dict[str, Any] = {
496
+ "status": "ok",
497
+ "response": answer,
498
+ "workspace": str(self.agent_root),
499
+ "steps": steps,
500
+ "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
501
+ "final_state": AgentState.DONE.value,
502
+ "created_files": created_files,
503
+ "artifacts": artifacts,
504
+ "routed_to_agent": True,
505
+ "action_route": "direct_project_bundle",
506
+ "project": {
507
+ "dir": project_dir,
508
+ "files": bundle_files,
509
+ "zip_url": f"/tools/download_zip?path={quote(project_dir)}",
510
+ "bundle_validation": bundle_validation,
511
+ "reference_fixes": reference_fixes,
512
+ },
513
+ "generation": {
514
+ "files": file_meta,
515
+ "repaired": repaired_any,
516
+ },
517
+ }
518
+ if brain_ingests:
519
+ payload["brain_ingest"] = brain_ingests
520
+ self._funnel_increment("real_file_delivered")
521
+ self.notify("user", req.message, req.source)
522
+ self.notify("assistant", answer, req.source)
523
+ if req.stream:
524
+ return StreamingResponse(
525
+ agent_payload_stream(
526
+ answer,
527
+ payload,
528
+ router=self.router,
529
+ model_id=model_id,
530
+ ),
531
+ media_type="text/event-stream",
532
+ headers={"X-Model": model_id or "tool", "X-Routed-To": "agent"},
533
+ )
534
+ return JSONResponse(content=payload)
535
+
536
+ def _ingest_generated_file(
537
+ self,
538
+ rel_path: str,
539
+ content: Optional[str],
540
+ *,
541
+ effective_email: Optional[str],
542
+ workspace_id: Optional[str],
543
+ conversation_id: Optional[str],
544
+ ) -> Optional[Dict[str, Any]]:
545
+ """Optionally index a just-generated file into the Brain.
546
+
547
+ The knowledge promise is "what Lattice makes, Lattice remembers":
548
+ without this, generated files are invisible to recall until the user
549
+ re-uploads them. Best-effort and additive — ingestion problems never
550
+ fail the file creation, and LATTICEAI_INGEST_GENERATED=0 disables it.
551
+ """
552
+ if self.ingestion_pipeline is None or not ingest_generated_enabled():
553
+ return None
554
+ try:
555
+ from lattice_brain.ingestion import IngestionItem
556
+
557
+ item = IngestionItem(
558
+ source_type="file",
559
+ title=Path(rel_path).name,
560
+ text=content,
561
+ path=str(self.agent_root / rel_path),
562
+ source_uri=f"workspace://{rel_path}",
563
+ owner=effective_email,
564
+ workspace_id=workspace_id,
565
+ conversation_id=conversation_id,
566
+ metadata={"origin": "generated_file", "route": "direct_write_file"},
567
+ )
568
+ result = self.ingestion_pipeline.ingest(item, user_email=effective_email)
569
+ payload = result.as_dict() if hasattr(result, "as_dict") else dict(result or {})
570
+ return {
571
+ "status": payload.get("status"),
572
+ "node_id": payload.get("node_id"),
573
+ "chunk_count": payload.get("chunk_count", 0),
574
+ "duplicate": payload.get("duplicate", False),
575
+ }
576
+ except Exception as exc: # noqa: BLE001 — indexing must never break creation
577
+ logging.warning("generated-file ingest failed: %s", exc)
578
+ return {"status": "failed", "detail": str(exc)[:200]}
579
+
278
580
  async def route_file_to_agent(
279
581
  self,
280
582
  req: Any,
@@ -296,6 +598,15 @@ class ChatIntentController:
296
598
  )
297
599
  result = await self.agent_controller.agent(agent_request, request)
298
600
  answer = str(result.get("response") or "파일 작업을 처리했습니다.")
601
+ # UX funnel (backlog #16): a file-intent request that the agent loop
602
+ # finished without producing a single artifact is the "code-only"
603
+ # failure mode the >95% real-file goal watches. Paused/failed runs
604
+ # count neither way — they did not answer with code instead of files.
605
+ delivered = bool(result.get("created_files") or result.get("artifacts"))
606
+ if delivered:
607
+ self._funnel_increment("real_file_delivered")
608
+ elif str(result.get("status") or "") == "ok":
609
+ self._funnel_increment("code_only_responses")
299
610
  self.notify("user", req.message, req.source)
300
611
  self.notify("assistant", answer, req.source)
301
612
  result["routed_to_agent"] = True
@@ -8,7 +8,7 @@ from typing import Any, AsyncIterator, Dict, Optional
8
8
 
9
9
  from fastapi.responses import JSONResponse, StreamingResponse
10
10
 
11
- from latticeai.api.chat_helpers import single_text_stream
11
+ from latticeai.api.chat_helpers import assess_answer_grounding, single_text_stream
12
12
 
13
13
 
14
14
  def single_answer_response(req: Any, answer: str, *, model: str):
@@ -87,6 +87,7 @@ async def stream_chat(
87
87
  persisted_response = f"{persisted_response}\n\n[stream_error] {stream_error}"
88
88
 
89
89
  trace_record = None
90
+ grounding = None
90
91
  try:
91
92
  answer_trace = trace_seed or chat_service.build_graph_trace(
92
93
  req.message,
@@ -94,6 +95,19 @@ async def stream_chat(
94
95
  context,
95
96
  allowed_workspaces={workspace_id} if workspace_id else None,
96
97
  )
98
+ # Answer-citation binding (backlog #11): same honest verdict as the
99
+ # non-streaming path, recorded on the persisted trace + trailer.
100
+ try:
101
+ grounding = assess_answer_grounding(
102
+ full_response,
103
+ trace=answer_trace if isinstance(answer_trace, dict) else None,
104
+ context_quality=context_quality,
105
+ )
106
+ if isinstance(answer_trace, dict):
107
+ answer_trace["grounding"] = grounding
108
+ except Exception as exc: # noqa: BLE001 — annotation must never break streaming
109
+ logging.warning("answer grounding assessment failed: %s", exc)
110
+ grounding = None
97
111
  trace_record = await chat_service.persist_answer(
98
112
  question=req.message,
99
113
  response=persisted_response,
@@ -114,6 +128,8 @@ async def stream_chat(
114
128
  trailer.update({"trace_id": trace_record["id"], "trace": trace_record})
115
129
  if context_quality is not None:
116
130
  trailer["context_quality"] = context_quality
131
+ if grounding is not None:
132
+ trailer["grounding"] = grounding
117
133
  if stream_error:
118
134
  trailer["error"] = stream_error
119
135
  yield f"data: {json.dumps(trailer, ensure_ascii=False)}\n\n"
@@ -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"]
@@ -28,6 +28,16 @@ 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
+
31
41
  def _workspace_scope_from_request(request: Request) -> Optional[str]:
32
42
  header = request.headers.get("X-Workspace-Id")
33
43
  if header and header.strip():
@@ -143,6 +153,26 @@ def create_knowledge_graph_router(
143
153
  (require_admin or require_user)(request)
144
154
  return graph().curate()
145
155
 
156
+ @router.post("/knowledge-graph/curate/noise")
157
+ async def knowledge_graph_curate_noise(req: CurateNoiseRequest, request: Request):
158
+ """Noise-reduction curation job (backlog #10).
159
+
160
+ Dry-run by default: reports which heuristic concept nodes would be
161
+ removed (low IDF / below the frequency floor) and which relation
162
+ verbs would be normalized, without changing the graph. Set
163
+ ``dry_run=false`` to apply. User-created nodes are never removed.
164
+ Administrative like ``/knowledge-graph/curate`` when roles exist.
165
+ """
166
+ (require_admin or require_user)(request)
167
+ return graph().curate_noise(
168
+ dry_run=req.dry_run,
169
+ max_df_ratio=req.max_df_ratio,
170
+ min_doc_frequency=req.min_doc_frequency,
171
+ min_corpus_docs=req.min_corpus_docs,
172
+ normalize_verbs=req.normalize_verbs,
173
+ max_removals=req.max_removals,
174
+ )
175
+
146
176
  @router.get("/knowledge-graph/provenance/coverage")
147
177
  async def knowledge_graph_provenance_coverage(request: Request):
148
178
  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",