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