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.
- package/README.md +39 -39
- package/docs/CHANGELOG.md +44 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/curator.py +141 -0
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/projection.py +161 -0
- package/lattice_brain/graph/retrieval.py +19 -1
- package/lattice_brain/ingestion.py +66 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +27 -2
- package/latticeai/api/chat_agent_http.py +191 -3
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +189 -1
- package/latticeai/api/chat_stream.py +17 -1
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +30 -0
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +14 -0
- package/latticeai/core/agent.py +91 -9
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/file_generation.py +279 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +20 -1
- package/latticeai/services/folder_watch.py +391 -0
- package/latticeai/services/funnel_metrics.py +202 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +17 -1
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_models.py +234 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +28 -27
- package/static/app/assets/Act-DmdruVKV.js +1 -0
- package/static/app/assets/{Brain-BnGbytHa.js → Brain-DEY9jLVt.js} +2 -2
- package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
- package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
- package/static/app/assets/{Capture-CAKnLcb0.js → Capture-CVZ09QXi.js} +1 -1
- package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
- package/static/app/assets/Library-Bp0n-HlW.js +1 -0
- package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-DxP4efJF.js} +1 -1
- package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-DRbm7NEq.js} +1 -1
- package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
- package/static/app/assets/{System-DRHc_6Su.js → System-ByQcmJW-.js} +1 -1
- package/static/app/assets/{bot-DZkDdh-A.js → bot-BNDyZLR7.js} +1 -1
- package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
- package/static/app/assets/{cpu-DyvsDBbj.js → cpu-C6jjYm6i.js} +1 -1
- package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-DjGIvDBQ.js} +1 -1
- package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-BlSbwSaT.js} +1 -1
- package/static/app/assets/index-Bge3DXW7.css +2 -0
- package/static/app/assets/{index-BS_3aN1n.js → index-CHu7cgj3.js} +3 -3
- package/static/app/assets/{input-B_U7zvbR.js → input-DVDI0YR3.js} +1 -1
- package/static/app/assets/{navigation-xG-gyjbe.js → navigation-BddhEWA0.js} +1 -1
- package/static/app/assets/{network-BF13HJJF.js → network-pYQt5oBu.js} +1 -1
- package/static/app/assets/{primitives-DsuKh3M8.js → primitives-D7gCdEvS.js} +1 -1
- package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
- package/static/app/assets/{textarea-DsDL4ET8.js → textarea-sqQmoBKL.js} +1 -1
- package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
- package/static/app/assets/utils-uQYKXNeq.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-Cx_gZNbE.js +0 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
- package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
- package/static/app/assets/Library-PcfVUco6.js +0 -1
- package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
- package/static/app/assets/download-C7yxYHvz.js +0 -1
- package/static/app/assets/index-DU78Kogz.css +0 -2
- package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
- 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
|
-
|
|
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",
|
package/latticeai/api/setup.py
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/latticeai/api/tools.py
CHANGED
|
@@ -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)
|