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