ltcai 9.9.2 → 9.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +39 -39
  2. package/docs/CHANGELOG.md +44 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/curator.py +141 -0
  12. package/lattice_brain/graph/documents.py +111 -0
  13. package/lattice_brain/graph/fusion.py +179 -0
  14. package/lattice_brain/graph/projection.py +161 -0
  15. package/lattice_brain/graph/retrieval.py +19 -1
  16. package/lattice_brain/ingestion.py +66 -0
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/automation_intelligence.py +159 -0
  20. package/latticeai/api/browser.py +15 -4
  21. package/latticeai/api/chat.py +27 -2
  22. package/latticeai/api/chat_agent_http.py +191 -3
  23. package/latticeai/api/chat_contracts.py +7 -1
  24. package/latticeai/api/chat_helpers.py +147 -0
  25. package/latticeai/api/chat_intents.py +189 -1
  26. package/latticeai/api/chat_stream.py +17 -1
  27. package/latticeai/api/funnel_metrics.py +31 -0
  28. package/latticeai/api/knowledge_graph.py +30 -0
  29. package/latticeai/api/local_files.py +90 -0
  30. package/latticeai/api/setup.py +134 -1
  31. package/latticeai/api/tools.py +33 -3
  32. package/latticeai/app_factory.py +14 -0
  33. package/latticeai/core/agent.py +91 -9
  34. package/latticeai/core/agent_eval.py +107 -0
  35. package/latticeai/core/file_generation.py +279 -2
  36. package/latticeai/core/legacy_compatibility.py +1 -1
  37. package/latticeai/core/marketplace.py +1 -1
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/runtime/persistence_runtime.py +19 -1
  40. package/latticeai/runtime/router_registration.py +8 -1
  41. package/latticeai/services/app_context.py +3 -0
  42. package/latticeai/services/architecture_readiness.py +1 -1
  43. package/latticeai/services/automation_execution.py +266 -0
  44. package/latticeai/services/automation_intelligence.py +7 -0
  45. package/latticeai/services/command_center.py +20 -1
  46. package/latticeai/services/folder_watch.py +391 -0
  47. package/latticeai/services/funnel_metrics.py +202 -0
  48. package/latticeai/services/product_readiness.py +1 -1
  49. package/latticeai/services/search_service.py +17 -1
  50. package/latticeai/setup/demo_corpus.py +98 -0
  51. package/latticeai/tools/filesystem.py +38 -1
  52. package/package.json +1 -1
  53. package/scripts/bench_models.py +234 -5
  54. package/scripts/check_current_release_docs.mjs +1 -1
  55. package/src-tauri/Cargo.lock +1 -1
  56. package/src-tauri/Cargo.toml +1 -1
  57. package/src-tauri/tauri.conf.json +1 -1
  58. package/static/app/asset-manifest.json +28 -27
  59. package/static/app/assets/Act-DmdruVKV.js +1 -0
  60. package/static/app/assets/{Brain-BnGbytHa.js → Brain-DEY9jLVt.js} +2 -2
  61. package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
  62. package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
  63. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-CVZ09QXi.js} +1 -1
  64. package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
  65. package/static/app/assets/Library-Bp0n-HlW.js +1 -0
  66. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-DxP4efJF.js} +1 -1
  67. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-DRbm7NEq.js} +1 -1
  68. package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
  69. package/static/app/assets/{System-DRHc_6Su.js → System-ByQcmJW-.js} +1 -1
  70. package/static/app/assets/{bot-DZkDdh-A.js → bot-BNDyZLR7.js} +1 -1
  71. package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
  72. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-C6jjYm6i.js} +1 -1
  73. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-DjGIvDBQ.js} +1 -1
  74. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-BlSbwSaT.js} +1 -1
  75. package/static/app/assets/index-Bge3DXW7.css +2 -0
  76. package/static/app/assets/{index-BS_3aN1n.js → index-CHu7cgj3.js} +3 -3
  77. package/static/app/assets/{input-B_U7zvbR.js → input-DVDI0YR3.js} +1 -1
  78. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-BddhEWA0.js} +1 -1
  79. package/static/app/assets/{network-BF13HJJF.js → network-pYQt5oBu.js} +1 -1
  80. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-D7gCdEvS.js} +1 -1
  81. package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
  82. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-sqQmoBKL.js} +1 -1
  83. package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
  84. package/static/app/assets/utils-uQYKXNeq.js +7 -0
  85. package/static/app/index.html +4 -4
  86. package/static/sw.js +1 -1
  87. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  88. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  89. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  90. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  91. package/static/app/assets/Library-PcfVUco6.js +0 -1
  92. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  93. package/static/app/assets/download-C7yxYHvz.js +0 -1
  94. package/static/app/assets/index-DU78Kogz.css +0 -2
  95. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  96. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -23,6 +23,9 @@ from latticeai.core.file_generation import (
23
23
  PREVIEWABLE_EXTENSIONS,
24
24
  generate_file_content,
25
25
  infer_file_target,
26
+ infer_project_manifest,
27
+ repair_bundle_references,
28
+ validate_project_bundle,
26
29
  )
27
30
 
28
31
 
@@ -80,6 +83,7 @@ class ChatIntentController:
80
83
  agent_controller: Any,
81
84
  agent_root: Path,
82
85
  ingestion_pipeline: Any = None,
86
+ funnel_metrics: Any = None,
83
87
  ) -> None:
84
88
  self.router = model_router
85
89
  self.config = config
@@ -99,6 +103,16 @@ class ChatIntentController:
99
103
  self.agent_controller = agent_controller
100
104
  self.agent_root = Path(agent_root)
101
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)
102
116
 
103
117
  def no_model_response(self) -> JSONResponse:
104
118
  detail = "No model loaded. Call /models/load first."
@@ -235,7 +249,21 @@ class ChatIntentController:
235
249
  # An explicit path ("report.txt") wins; otherwise infer one from an
236
250
  # explicit type keyword ("html 파일 만들어줘") so weak models never
237
251
  # 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)
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)
239
267
  if not target_path:
240
268
  return None
241
269
  # Never silently overwrite: an existing target gets a _2/_3 suffix so
@@ -316,6 +344,7 @@ class ChatIntentController:
316
344
  workspace_id=workspace_id,
317
345
  conversation_id=getattr(req, "conversation_id", None),
318
346
  )
347
+ self._funnel_increment("real_file_delivered")
319
348
  self.notify("user", req.message, req.source)
320
349
  self.notify("assistant", answer, req.source)
321
350
  payload = {
@@ -354,6 +383,156 @@ class ChatIntentController:
354
383
  )
355
384
  return JSONResponse(content=payload)
356
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
+
357
536
  def _ingest_generated_file(
358
537
  self,
359
538
  rel_path: str,
@@ -419,6 +598,15 @@ class ChatIntentController:
419
598
  )
420
599
  result = await self.agent_controller.agent(agent_request, request)
421
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")
422
610
  self.notify("user", req.message, req.source)
423
611
  self.notify("assistant", answer, req.source)
424
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",
@@ -17,7 +17,15 @@ from latticeai.setup.auto_setup import (
17
17
  verify as auto_setup_verify,
18
18
  )
19
19
  from latticeai.models.router import parse_model_ref
20
+ from latticeai.setup.demo_corpus import (
21
+ DEMO_DOCUMENTS,
22
+ DEMO_METADATA_FLAG,
23
+ DEMO_URI_PREFIX,
24
+ demo_source_uri,
25
+ suggested_questions,
26
+ )
20
27
  from latticeai.setup.wizard import get_recommendations, install_stream, open_url, scan_environment
28
+ from lattice_brain.ingestion import IngestionItem
21
29
 
22
30
 
23
31
  class SetupInstallRequest(BaseModel):
@@ -25,7 +33,18 @@ class SetupInstallRequest(BaseModel):
25
33
  confirmation_token: Optional[str] = None
26
34
 
27
35
 
28
- def create_setup_router(*, model_router, require_user) -> APIRouter:
36
+ class DemoCorpusRequest(BaseModel):
37
+ workspace_id: Optional[str] = None
38
+
39
+
40
+ def create_setup_router(
41
+ *,
42
+ model_router,
43
+ require_user,
44
+ ingestion_pipeline=None,
45
+ knowledge_graph=None,
46
+ workspace_service=None,
47
+ ) -> APIRouter:
29
48
  api_router = APIRouter()
30
49
  router = model_router
31
50
 
@@ -157,6 +176,120 @@ def create_setup_router(*, model_router, require_user) -> APIRouter:
157
176
  return {"status": "ok", "opened": url, "mcp_id": mcp_id}
158
177
 
159
178
 
179
+ # ── First Value Loop demo corpus (backlog #3, review §3.3 P0) ────────────
180
+
181
+ def _require_demo_pipeline():
182
+ if ingestion_pipeline is None or not ingestion_pipeline.available():
183
+ raise HTTPException(
184
+ status_code=503, detail="Knowledge Graph ingestion is disabled.",
185
+ )
186
+ if knowledge_graph is None:
187
+ raise HTTPException(status_code=503, detail="Knowledge Graph is disabled.")
188
+
189
+ def _demo_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
190
+ header = request.headers.get("X-Workspace-Id")
191
+ header = header.strip() if header and header.strip() else None
192
+ supplied = [value for value in (body_workspace, header) if value]
193
+ if len(set(supplied)) > 1:
194
+ raise HTTPException(status_code=403, detail="Workspace selectors must match.")
195
+ requested = supplied[0] if supplied else None
196
+ if workspace_service is None:
197
+ return requested
198
+ try:
199
+ return workspace_service.resolve_write_scope(requested, user or None)
200
+ except PermissionError as exc:
201
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
202
+
203
+ @api_router.get("/api/setup/demo-corpus")
204
+ async def demo_corpus_status(request: Request):
205
+ """Whether the demo corpus is installed + the suggestion chips."""
206
+ require_user(request)
207
+ _require_demo_pipeline()
208
+ installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
209
+ return {
210
+ "installed": bool(installed),
211
+ "documents": installed,
212
+ "document_count": len(installed),
213
+ "suggested_questions": suggested_questions(),
214
+ }
215
+
216
+ @api_router.post("/api/setup/demo-corpus")
217
+ async def demo_corpus_install(request: Request, req: Optional[DemoCorpusRequest] = None):
218
+ """One-click ingest of the 3 built-in demo documents (idempotent).
219
+
220
+ Every document goes through the normal IngestionPipeline door with
221
+ ``demo://`` provenance so it is honest (real nodes, real chunks, real
222
+ provenance) and removable via DELETE. Re-POSTing reports duplicates
223
+ instead of duplicating.
224
+ """
225
+ user = require_user(request)
226
+ _require_demo_pipeline()
227
+ workspace_id = _demo_workspace(request, req.workspace_id if req else None, user)
228
+ results = []
229
+ ingested = 0
230
+ duplicates = 0
231
+ failed = 0
232
+ for doc in DEMO_DOCUMENTS:
233
+ result = ingestion_pipeline.ingest(
234
+ IngestionItem(
235
+ source_type="note",
236
+ title=doc["title"],
237
+ text=doc["text"],
238
+ source_uri=demo_source_uri(doc["id"]),
239
+ owner=user or None,
240
+ workspace_id=workspace_id,
241
+ metadata={DEMO_METADATA_FLAG: True, "demo_id": doc["id"]},
242
+ ),
243
+ user_email=user or None,
244
+ )
245
+ if result.status != "ok":
246
+ failed += 1
247
+ elif result.duplicate:
248
+ duplicates += 1
249
+ else:
250
+ ingested += 1
251
+ results.append({
252
+ "demo_id": doc["id"],
253
+ "title": doc["title"],
254
+ "source_uri": demo_source_uri(doc["id"]),
255
+ "status": result.status,
256
+ "node_id": result.node_id,
257
+ "duplicate": result.duplicate,
258
+ "chunk_count": result.chunk_count,
259
+ "detail": result.detail,
260
+ })
261
+ status = "ok" if failed == 0 else ("partial" if (ingested + duplicates) else "failed")
262
+ return {
263
+ "status": status,
264
+ "ingested": ingested,
265
+ "duplicates": duplicates,
266
+ "failed": failed,
267
+ "documents": results,
268
+ "suggested_questions": suggested_questions(),
269
+ }
270
+
271
+ @api_router.delete("/api/setup/demo-corpus")
272
+ async def demo_corpus_remove(request: Request):
273
+ """Remove every demo document (node + chunks + edges + orphan source)."""
274
+ require_user(request)
275
+ _require_demo_pipeline()
276
+ installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
277
+ removed = []
278
+ for doc in installed:
279
+ outcome = knowledge_graph.delete_document_tree(doc["id"])
280
+ removed.append({
281
+ "node_id": doc["id"],
282
+ "title": doc.get("title"),
283
+ "source_uri": doc.get("source_uri"),
284
+ "status": outcome.get("status"),
285
+ "removed_nodes": outcome.get("removed_nodes", 0),
286
+ })
287
+ return {
288
+ "status": "ok",
289
+ "removed_count": len(removed),
290
+ "removed": removed,
291
+ }
292
+
160
293
  @api_router.post("/permissions/open/{permission_id}")
161
294
  async def open_permission_settings(permission_id: str, request: Request):
162
295
  require_user(request)
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
  from fastapi import APIRouter, File, HTTPException, Request, UploadFile
14
- from fastapi.responses import FileResponse
14
+ from fastapi.responses import FileResponse, Response
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from latticeai.api.computer_use import create_computer_use_router
@@ -543,8 +543,38 @@ def create_tools_router(
543
543
  filename=target.name,
544
544
  media_type="application/octet-stream",
545
545
  )
546
-
547
-
546
+
547
+
548
+ @api_router.get("/tools/download_zip")
549
+ async def tools_download_zip(path: str, request: Request):
550
+ """Serve a generated project directory from the agent workspace as a zip.
551
+
552
+ The multi-file artifact loop writes bundles like ``todo-app/`` —
553
+ this is their one-click download. Path confinement mirrors
554
+ ``/tools/download``: anything outside the workspace is refused.
555
+ """
556
+ require_user(request)
557
+ from urllib.parse import unquote
558
+
559
+ from latticeai.tools.filesystem import zip_workspace_dir
560
+
561
+ rel = unquote(path).lstrip("/")
562
+ target = (AGENT_ROOT / rel).resolve()
563
+ if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
564
+ raise HTTPException(status_code=403, detail="경로가 작업 공간 밖입니다.")
565
+ if not target.exists() or not target.is_dir():
566
+ raise HTTPException(status_code=404, detail="디렉터리가 없습니다.")
567
+ try:
568
+ payload, filename = zip_workspace_dir(rel)
569
+ except ToolError as exc:
570
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
571
+ return Response(
572
+ content=payload,
573
+ media_type="application/zip",
574
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
575
+ )
576
+
577
+
548
578
  @api_router.post("/upload/document")
549
579
  async def upload_document(request: Request, file: UploadFile = File(...)):
550
580
  current_user = require_user(request)