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
package/latticeai/api/browser.py
CHANGED
|
@@ -27,7 +27,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
27
27
|
from pydantic import BaseModel
|
|
28
28
|
|
|
29
29
|
from latticeai import __version__
|
|
30
|
-
from lattice_brain.ingestion import IngestionItem
|
|
30
|
+
from lattice_brain.ingestion import IngestionItem, capture_quality_verdict
|
|
31
31
|
|
|
32
32
|
MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
|
|
33
33
|
MAX_URL_FETCH_BYTES = 4 * 1024 * 1024 # 4 MB cap on a fetched page
|
|
@@ -427,7 +427,10 @@ def create_browser_router(
|
|
|
427
427
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
428
428
|
if not (text or "").strip():
|
|
429
429
|
return {"status": "empty", "source_type": "web_url", "url": url,
|
|
430
|
-
"detail": "No readable text was extracted from the page."
|
|
430
|
+
"detail": "No readable text was extracted from the page.",
|
|
431
|
+
# Structured CTA (backlog #9): the extraction produced
|
|
432
|
+
# nothing, tell the UI what the user can do about it.
|
|
433
|
+
"capture_quality": capture_quality_verdict(None, source_type="web_url")}
|
|
431
434
|
res = pipeline.ingest(
|
|
432
435
|
IngestionItem(
|
|
433
436
|
source_type="web_url", title=title, text=text, source_uri=url,
|
|
@@ -435,7 +438,11 @@ def create_browser_router(
|
|
|
435
438
|
),
|
|
436
439
|
user_email=user,
|
|
437
440
|
)
|
|
438
|
-
|
|
441
|
+
payload = res.as_dict()
|
|
442
|
+
payload["capture_quality"] = capture_quality_verdict(
|
|
443
|
+
res.extraction_quality, source_type="web_url",
|
|
444
|
+
)
|
|
445
|
+
return payload
|
|
439
446
|
|
|
440
447
|
@router.post("/api/browser/ingest-current-tab")
|
|
441
448
|
async def ingest_current_tab(req: IngestTabRequest, request: Request):
|
|
@@ -481,6 +488,10 @@ def create_browser_router(
|
|
|
481
488
|
),
|
|
482
489
|
user_email=user,
|
|
483
490
|
)
|
|
484
|
-
|
|
491
|
+
payload = res.as_dict()
|
|
492
|
+
payload["capture_quality"] = capture_quality_verdict(
|
|
493
|
+
res.extraction_quality, source_type="browser_tab",
|
|
494
|
+
)
|
|
495
|
+
return payload
|
|
485
496
|
|
|
486
497
|
return router
|
package/latticeai/api/chat.py
CHANGED
|
@@ -28,6 +28,7 @@ from latticeai.api.chat_documents import (
|
|
|
28
28
|
)
|
|
29
29
|
from latticeai.api.chat_helpers import (
|
|
30
30
|
_LANG_HINT,
|
|
31
|
+
assess_answer_grounding,
|
|
31
32
|
build_context_quality,
|
|
32
33
|
build_recent_chat_context,
|
|
33
34
|
detect_language,
|
|
@@ -189,6 +190,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
189
190
|
base_dir=context.base_dir or Path.cwd(),
|
|
190
191
|
agent_root=AGENT_ROOT,
|
|
191
192
|
ensure_agent_root=ensure_agent_root,
|
|
193
|
+
funnel_metrics=context.funnel_metrics,
|
|
192
194
|
)
|
|
193
195
|
intent_controller = ChatIntentController(
|
|
194
196
|
model_router=model_router,
|
|
@@ -208,6 +210,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
208
210
|
execute_tool=execute_tool,
|
|
209
211
|
agent_controller=agent_controller,
|
|
210
212
|
agent_root=AGENT_ROOT,
|
|
213
|
+
ingestion_pipeline=context.ingestion_pipeline,
|
|
214
|
+
funnel_metrics=context.funnel_metrics,
|
|
211
215
|
)
|
|
212
216
|
document_coordinator = DocumentGenerationCoordinator(
|
|
213
217
|
model_router=model_router,
|
|
@@ -272,16 +276,23 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
272
276
|
)
|
|
273
277
|
|
|
274
278
|
selected_model_id = request_model(req.model)
|
|
275
|
-
|
|
279
|
+
file_intent = is_file_action_request(req.message)
|
|
280
|
+
if file_intent and context.funnel_metrics is not None:
|
|
281
|
+
# UX funnel (backlog #16): one file-intent request counted once,
|
|
282
|
+
# regardless of which delivery path handles it below.
|
|
283
|
+
context.funnel_metrics.increment("file_requests")
|
|
284
|
+
if file_intent:
|
|
276
285
|
direct_response = await intent_controller.direct_file_action(
|
|
277
286
|
req,
|
|
278
287
|
model_id=selected_model_id,
|
|
288
|
+
effective_email=effective_email,
|
|
289
|
+
workspace_id=workspace_id,
|
|
279
290
|
)
|
|
280
291
|
if direct_response is not None:
|
|
281
292
|
return direct_response
|
|
282
293
|
if not selected_model_id:
|
|
283
294
|
return intent_controller.no_model_response()
|
|
284
|
-
if
|
|
295
|
+
if file_intent:
|
|
285
296
|
return await intent_controller.route_file_to_agent(
|
|
286
297
|
req,
|
|
287
298
|
request,
|
|
@@ -372,6 +383,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
372
383
|
)
|
|
373
384
|
if isinstance(trace_seed, dict):
|
|
374
385
|
trace_seed["context_quality"] = context_quality
|
|
386
|
+
if context.funnel_metrics is not None and int(
|
|
387
|
+
context_quality.get("nodes") or 0
|
|
388
|
+
) > 0:
|
|
389
|
+
# UX funnel (backlog #16): a grounded answer is "first value" —
|
|
390
|
+
# the TTFV clock ends on the first successful recall.
|
|
391
|
+
context.funnel_metrics.record_recall_success()
|
|
375
392
|
|
|
376
393
|
history_message = (
|
|
377
394
|
f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
@@ -461,6 +478,16 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
461
478
|
req.image_data,
|
|
462
479
|
)
|
|
463
480
|
response_text = str(result)
|
|
481
|
+
# Answer-citation binding (backlog #11): annotate — never block — how
|
|
482
|
+
# the answer relates to the retrieved sources. Recorded on the trace
|
|
483
|
+
# so the Review/proof surfaces see the same verdict as the client.
|
|
484
|
+
grounding = assess_answer_grounding(
|
|
485
|
+
response_text,
|
|
486
|
+
trace=trace_seed if isinstance(trace_seed, dict) else None,
|
|
487
|
+
context_quality=context_quality,
|
|
488
|
+
)
|
|
489
|
+
if isinstance(trace_seed, dict):
|
|
490
|
+
trace_seed["grounding"] = grounding
|
|
464
491
|
trace_record = await chat_service.persist_answer(
|
|
465
492
|
question=req.message,
|
|
466
493
|
response=response_text,
|
|
@@ -479,6 +506,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
479
506
|
"trace_id": trace_record["id"],
|
|
480
507
|
"trace": trace_record,
|
|
481
508
|
"context_quality": context_quality,
|
|
509
|
+
"grounding": grounding,
|
|
482
510
|
}
|
|
483
511
|
)
|
|
484
512
|
|
|
@@ -9,6 +9,7 @@ import re
|
|
|
9
9
|
import secrets
|
|
10
10
|
import threading
|
|
11
11
|
import time
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Any, Dict
|
|
14
15
|
|
|
@@ -17,8 +18,8 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
17
18
|
from lattice_brain.runtime.hooks import dispatch_tool
|
|
18
19
|
from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
|
|
19
20
|
from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
|
|
20
|
-
from latticeai.core.agent import AgentRunContext, AgentState
|
|
21
|
-
from latticeai.services.tool_dispatch import collect_created_files
|
|
21
|
+
from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
|
|
22
|
+
from latticeai.services.tool_dispatch import collect_artifacts, collect_created_files
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
class AgentHTTPController:
|
|
@@ -40,6 +41,7 @@ class AgentHTTPController:
|
|
|
40
41
|
base_dir: Path,
|
|
41
42
|
agent_root: Path,
|
|
42
43
|
ensure_agent_root: Any,
|
|
44
|
+
funnel_metrics: Any = None,
|
|
43
45
|
) -> None:
|
|
44
46
|
self.runtime = runtime
|
|
45
47
|
self.model_router = model_router
|
|
@@ -56,9 +58,16 @@ class AgentHTTPController:
|
|
|
56
58
|
self.base_dir = Path(base_dir)
|
|
57
59
|
self.agent_root = Path(agent_root)
|
|
58
60
|
self.ensure_agent_root = ensure_agent_root
|
|
61
|
+
self.funnel_metrics = funnel_metrics
|
|
59
62
|
self._pending: Dict[str, tuple] = {}
|
|
60
63
|
self._pending_lock = threading.Lock()
|
|
61
64
|
self._pending_ttl_seconds = 15 * 60
|
|
65
|
+
# awaiting_approval runs: run_id -> paused run state + approval token.
|
|
66
|
+
# Fail-closed: governed steps only ever execute after resume presents
|
|
67
|
+
# the matching, unexpired token for this run (or via the legacy
|
|
68
|
+
# explicit human-in-loop context flow above).
|
|
69
|
+
self._approvals: Dict[str, Dict[str, Any]] = {}
|
|
70
|
+
self._approval_ttl_seconds = 10 * 60
|
|
62
71
|
self._background_tasks: set[asyncio.Task] = set()
|
|
63
72
|
|
|
64
73
|
def register_routes(self, router: APIRouter) -> None:
|
|
@@ -232,6 +241,18 @@ class AgentHTTPController:
|
|
|
232
241
|
"loop": ctx.trace.summary(),
|
|
233
242
|
}
|
|
234
243
|
|
|
244
|
+
# Interactive approval: when the plan needs human approval, pause the
|
|
245
|
+
# run as awaiting_approval (short-TTL token) instead of failing it.
|
|
246
|
+
# ``getattr`` keeps injected fake runtimes without the preview method
|
|
247
|
+
# on the historical fail-closed path.
|
|
248
|
+
requirements_probe = getattr(self.runtime, "approval_requirements", None)
|
|
249
|
+
if callable(requirements_probe):
|
|
250
|
+
requirements = requirements_probe(ctx)
|
|
251
|
+
if requirements.get("requires_approval"):
|
|
252
|
+
return self._pause_for_approval(
|
|
253
|
+
ctx, req, language_hint, current_user, requirements,
|
|
254
|
+
)
|
|
255
|
+
|
|
235
256
|
self.runtime.approve(ctx, current_user, approved_by_human=False)
|
|
236
257
|
return await self._finish(
|
|
237
258
|
ctx,
|
|
@@ -242,6 +263,62 @@ class AgentHTTPController:
|
|
|
242
263
|
max_retry,
|
|
243
264
|
)
|
|
244
265
|
|
|
266
|
+
def _pause_for_approval(
|
|
267
|
+
self,
|
|
268
|
+
ctx: AgentRunContext,
|
|
269
|
+
req: AgentRequest,
|
|
270
|
+
language_hint: str,
|
|
271
|
+
current_user: str,
|
|
272
|
+
requirements: Dict[str, Any],
|
|
273
|
+
) -> Dict[str, Any]:
|
|
274
|
+
"""Park a plan that needs approval and hand the user a resume token."""
|
|
275
|
+
run_id = secrets.token_urlsafe(16)
|
|
276
|
+
approval_token = secrets.token_urlsafe(32)
|
|
277
|
+
now_monotonic = time.monotonic()
|
|
278
|
+
expires_at = (
|
|
279
|
+
datetime.now(timezone.utc) + timedelta(seconds=self._approval_ttl_seconds)
|
|
280
|
+
).isoformat(timespec="seconds")
|
|
281
|
+
with self._pending_lock:
|
|
282
|
+
self._purge_expired_approvals_locked(now_monotonic)
|
|
283
|
+
self._approvals[run_id] = {
|
|
284
|
+
"ctx": ctx,
|
|
285
|
+
"req": req,
|
|
286
|
+
"language_hint": language_hint,
|
|
287
|
+
"user": current_user,
|
|
288
|
+
"token": approval_token,
|
|
289
|
+
"expires_monotonic": now_monotonic + self._approval_ttl_seconds,
|
|
290
|
+
"expires_at": expires_at,
|
|
291
|
+
}
|
|
292
|
+
ctx.state_history.append(AgentState.WAITING_APPROVAL.value)
|
|
293
|
+
message = (
|
|
294
|
+
"이 작업에는 승인이 필요한 단계가 있어 실행을 잠시 멈췄습니다. "
|
|
295
|
+
"계획을 확인한 뒤 승인하면 이어서 실행합니다."
|
|
296
|
+
)
|
|
297
|
+
return {
|
|
298
|
+
"status": "awaiting_approval",
|
|
299
|
+
"run_id": run_id,
|
|
300
|
+
"approval": {
|
|
301
|
+
"token": approval_token,
|
|
302
|
+
"expires_at": expires_at,
|
|
303
|
+
"plan_summary": requirements.get("plan_summary", ""),
|
|
304
|
+
},
|
|
305
|
+
"response": message,
|
|
306
|
+
"plan": ctx.plan,
|
|
307
|
+
"steps": ctx.transcript,
|
|
308
|
+
"state_history": ctx.state_history,
|
|
309
|
+
"final_state": AgentState.WAITING_APPROVAL.value,
|
|
310
|
+
"non_auto_steps": requirements.get("non_auto_steps", []),
|
|
311
|
+
"planning_model": req.planning_model or self.model_router.current_model_id,
|
|
312
|
+
"executing_model": req.executing_model or self.model_router.current_model_id,
|
|
313
|
+
"reviewing_model": req.reviewing_model or self.model_router.current_model_id,
|
|
314
|
+
"loop": ctx.trace.summary(),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
def _purge_expired_approvals_locked(self, now_monotonic: float) -> None:
|
|
318
|
+
for run_id, entry in list(self._approvals.items()):
|
|
319
|
+
if now_monotonic >= entry["expires_monotonic"]:
|
|
320
|
+
self._approvals.pop(run_id, None)
|
|
321
|
+
|
|
245
322
|
async def _finish(
|
|
246
323
|
self,
|
|
247
324
|
ctx: AgentRunContext,
|
|
@@ -299,6 +376,16 @@ class AgentHTTPController:
|
|
|
299
376
|
)
|
|
300
377
|
except Exception as exc:
|
|
301
378
|
logging.warning("workspace agent run record failed: %s", exc)
|
|
379
|
+
if self.funnel_metrics is not None:
|
|
380
|
+
# UX funnel (backlog #16): every completed agent run counts, and
|
|
381
|
+
# NEEDS_REVIEW terminals feed the needs_review_rate the review
|
|
382
|
+
# gates watch. Advisory only — a metrics failure never fails chat.
|
|
383
|
+
try:
|
|
384
|
+
self.funnel_metrics.increment("agent_runs")
|
|
385
|
+
if ctx.state == AgentState.NEEDS_REVIEW:
|
|
386
|
+
self.funnel_metrics.increment("needs_review_runs")
|
|
387
|
+
except Exception as exc: # noqa: BLE001
|
|
388
|
+
logging.warning("funnel metrics increment failed: %s", exc)
|
|
302
389
|
return {
|
|
303
390
|
"status": "ok" if ctx.state == AgentState.DONE else "failed",
|
|
304
391
|
"response": message,
|
|
@@ -307,6 +394,7 @@ class AgentHTTPController:
|
|
|
307
394
|
"state_history": ctx.state_history,
|
|
308
395
|
"final_state": ctx.state.value,
|
|
309
396
|
"created_files": collect_created_files(ctx.transcript),
|
|
397
|
+
"artifacts": collect_artifacts(ctx.transcript),
|
|
310
398
|
"loop": ctx.trace.summary(),
|
|
311
399
|
}
|
|
312
400
|
|
|
@@ -315,8 +403,22 @@ class AgentHTTPController:
|
|
|
315
403
|
req: AgentResumeRequest,
|
|
316
404
|
request: Request,
|
|
317
405
|
) -> Dict[str, Any]:
|
|
318
|
-
"""Resume a paused agent after human approval of the plan.
|
|
406
|
+
"""Resume a paused agent after human approval of the plan.
|
|
407
|
+
|
|
408
|
+
Two entry modes share this endpoint:
|
|
409
|
+
|
|
410
|
+
* ``run_id`` + ``approval_token`` — an ``awaiting_approval`` run
|
|
411
|
+
(token-validated, short TTL, bound to the pausing user);
|
|
412
|
+
* ``context_id`` — the legacy explicit human-in-loop pause.
|
|
413
|
+
"""
|
|
319
414
|
current_user = self.require_user(request)
|
|
415
|
+
if req.run_id:
|
|
416
|
+
return await self._resume_approval(req, current_user)
|
|
417
|
+
if not req.context_id:
|
|
418
|
+
raise HTTPException(
|
|
419
|
+
status_code=400,
|
|
420
|
+
detail="run_id (with approval_token) or context_id is required.",
|
|
421
|
+
)
|
|
320
422
|
with self._pending_lock:
|
|
321
423
|
now = time.monotonic()
|
|
322
424
|
for context_id, pending in list(self._pending.items()):
|
|
@@ -344,7 +446,94 @@ class AgentHTTPController:
|
|
|
344
446
|
ctx.transcript[-1].update(ctx.plan)
|
|
345
447
|
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
346
448
|
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
347
|
-
|
|
449
|
+
# The authenticated owner of this pending context explicitly approved
|
|
450
|
+
# the plan — record it as a human approval so approval-gated steps can
|
|
451
|
+
# actually run instead of terminating the run as FAILED.
|
|
452
|
+
self.runtime.approve(ctx, current_user, approved_by_human=True)
|
|
453
|
+
return await self._finish(
|
|
454
|
+
ctx,
|
|
455
|
+
original_request,
|
|
456
|
+
language_hint,
|
|
457
|
+
current_user,
|
|
458
|
+
max(1, min(original_request.max_steps, 50)),
|
|
459
|
+
3,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
async def _resume_approval(
|
|
463
|
+
self,
|
|
464
|
+
req: AgentResumeRequest,
|
|
465
|
+
current_user: str,
|
|
466
|
+
) -> Dict[str, Any]:
|
|
467
|
+
"""Validate an awaiting_approval token, then continue or cancel the run."""
|
|
468
|
+
now_monotonic = time.monotonic()
|
|
469
|
+
with self._pending_lock:
|
|
470
|
+
entry = self._approvals.get(req.run_id or "")
|
|
471
|
+
if entry is None:
|
|
472
|
+
raise HTTPException(
|
|
473
|
+
status_code=404,
|
|
474
|
+
detail="Agent run not found. It may have expired — start a new request.",
|
|
475
|
+
)
|
|
476
|
+
if entry["user"] != current_user:
|
|
477
|
+
raise HTTPException(
|
|
478
|
+
status_code=403,
|
|
479
|
+
detail="Agent run belongs to another user.",
|
|
480
|
+
)
|
|
481
|
+
if now_monotonic >= entry["expires_monotonic"]:
|
|
482
|
+
self._approvals.pop(req.run_id, None)
|
|
483
|
+
raise HTTPException(
|
|
484
|
+
status_code=410,
|
|
485
|
+
detail="Approval token expired. Start a new request.",
|
|
486
|
+
)
|
|
487
|
+
supplied = req.approval_token or ""
|
|
488
|
+
if not supplied or not secrets.compare_digest(entry["token"], supplied):
|
|
489
|
+
raise HTTPException(
|
|
490
|
+
status_code=403,
|
|
491
|
+
detail="Invalid approval token for this run.",
|
|
492
|
+
)
|
|
493
|
+
# Token validated — the pending run is consumed either way.
|
|
494
|
+
self._approvals.pop(req.run_id, None)
|
|
495
|
+
self._purge_expired_approvals_locked(now_monotonic)
|
|
496
|
+
|
|
497
|
+
ctx: AgentRunContext = entry["ctx"]
|
|
498
|
+
original_request: AgentRequest = entry["req"]
|
|
499
|
+
language_hint: str = entry["language_hint"]
|
|
500
|
+
|
|
501
|
+
approved = req.approve if req.approve is not None else req.approved
|
|
502
|
+
if not approved:
|
|
503
|
+
message = "사용자가 계획을 취소했습니다."
|
|
504
|
+
try:
|
|
505
|
+
self.workspace_store.record_agent_run(
|
|
506
|
+
agent_id="agent:executor",
|
|
507
|
+
status="cancelled",
|
|
508
|
+
input_text=original_request.message,
|
|
509
|
+
output_text=message,
|
|
510
|
+
user_email=current_user or None,
|
|
511
|
+
workspace_id=original_request.workspace_id,
|
|
512
|
+
mode="llm",
|
|
513
|
+
timeline=ctx.transcript,
|
|
514
|
+
relationships=["agent:planner", "agent:reviewer"],
|
|
515
|
+
graph=self.workspace_graph(),
|
|
516
|
+
)
|
|
517
|
+
except Exception as exc:
|
|
518
|
+
logging.warning("workspace agent run record failed: %s", exc)
|
|
519
|
+
return {
|
|
520
|
+
"status": "cancelled",
|
|
521
|
+
"run_id": req.run_id,
|
|
522
|
+
"response": message,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
edited_plan = req.edited_plan or req.modified_plan
|
|
526
|
+
if edited_plan:
|
|
527
|
+
plan, plan_fixes = normalize_plan(edited_plan, original_request.message)
|
|
528
|
+
ctx.plan = plan
|
|
529
|
+
ctx.transcript.append({
|
|
530
|
+
"state": AgentState.WAITING_APPROVAL.value,
|
|
531
|
+
"edited_plan": True,
|
|
532
|
+
**({"plan_fixes": plan_fixes} if plan_fixes else {}),
|
|
533
|
+
})
|
|
534
|
+
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
535
|
+
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
536
|
+
self.runtime.approve(ctx, current_user, approved_by_human=True)
|
|
348
537
|
return await self._finish(
|
|
349
538
|
ctx,
|
|
350
539
|
original_request,
|
|
@@ -39,11 +39,17 @@ class AgentRequest(BaseModel):
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class AgentResumeRequest(BaseModel):
|
|
42
|
-
|
|
42
|
+
# Legacy human-in-loop pause (kept working): context_id + approved.
|
|
43
|
+
context_id: Optional[str] = None
|
|
43
44
|
approved: bool = True
|
|
44
45
|
modified_plan: Optional[dict] = None
|
|
45
46
|
executing_model: Optional[str] = None
|
|
46
47
|
reviewing_model: Optional[str] = None
|
|
48
|
+
# awaiting_approval flow (v9.10): run_id + short-TTL approval token.
|
|
49
|
+
run_id: Optional[str] = None
|
|
50
|
+
approval_token: Optional[str] = None
|
|
51
|
+
approve: Optional[bool] = None
|
|
52
|
+
edited_plan: Optional[dict] = None
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
class AgentEvalRequest(BaseModel):
|
|
@@ -245,6 +245,153 @@ def build_context_quality(
|
|
|
245
245
|
return context_quality_signal("lexical_only", len(matches))
|
|
246
246
|
|
|
247
247
|
|
|
248
|
+
# ── answer-citation binding (backlog #11, review §7.2 E #4) ──────────────────
|
|
249
|
+
_GROUNDING_TOKEN_RE = re.compile(r"[0-9A-Za-z가-힣]{2,}")
|
|
250
|
+
# High-frequency tokens that overlap by accident, not by grounding.
|
|
251
|
+
_GROUNDING_STOP_TOKENS = frozenset({
|
|
252
|
+
"그리고", "그러나", "하지만", "그래서", "있습니다", "입니다", "합니다",
|
|
253
|
+
"있는", "없는", "위해", "통해", "대한", "관련", "경우", "내용", "answer",
|
|
254
|
+
"the", "and", "for", "with", "this", "that", "from", "have", "are",
|
|
255
|
+
"was", "were", "can", "will", "not", "you", "your",
|
|
256
|
+
})
|
|
257
|
+
GROUNDING_MIN_OVERLAP_TOKENS = 2
|
|
258
|
+
GROUNDING_MIN_OVERLAP_RATIO = 0.08
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _grounding_tokens(text: str) -> set:
|
|
262
|
+
return {
|
|
263
|
+
token.lower()
|
|
264
|
+
for token in _GROUNDING_TOKEN_RE.findall(str(text or ""))
|
|
265
|
+
if token.lower() not in _GROUNDING_STOP_TOKENS
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def assess_answer_grounding(
|
|
270
|
+
answer: str,
|
|
271
|
+
*,
|
|
272
|
+
trace: Optional[Dict[str, object]] = None,
|
|
273
|
+
context_quality: Optional[Dict[str, object]] = None,
|
|
274
|
+
) -> Dict[str, object]:
|
|
275
|
+
"""Bind an answer to its retrieved sources — honestly (never blocks).
|
|
276
|
+
|
|
277
|
+
Heuristic: for each retrieved source (the answer trace's ``graph_nodes``
|
|
278
|
+
/ ``source_files``), measure content-token overlap between the answer and
|
|
279
|
+
the source's title+summary, and accept explicit citations (the answer
|
|
280
|
+
names the source title or filename). Returns::
|
|
281
|
+
|
|
282
|
+
{"status": "supported", "label": "근거 있음", "source_ids": [...],
|
|
283
|
+
"cited": [...], "overlap": float}
|
|
284
|
+
{"status": "unsupported", "label": "근거 없음", "source_ids": [], ...}
|
|
285
|
+
{"status": "no_context", "label": "근거 없음", ...} # nothing retrieved
|
|
286
|
+
|
|
287
|
+
``no_context`` (still labeled 근거 없음) lets the UI distinguish "the
|
|
288
|
+
Brain had nothing" from "the Brain had sources but the answer ignored
|
|
289
|
+
them". Annotation only — the answer is never modified or blocked.
|
|
290
|
+
"""
|
|
291
|
+
answer_text = str(answer or "").strip()
|
|
292
|
+
trace = trace if isinstance(trace, dict) else {}
|
|
293
|
+
sources: List[Dict[str, object]] = []
|
|
294
|
+
seen_ids = set()
|
|
295
|
+
for node in list(trace.get("graph_nodes") or []):
|
|
296
|
+
if not isinstance(node, dict):
|
|
297
|
+
continue
|
|
298
|
+
node_id = str(node.get("id") or node.get("node_id") or "")
|
|
299
|
+
if not node_id or node_id in seen_ids:
|
|
300
|
+
continue
|
|
301
|
+
seen_ids.add(node_id)
|
|
302
|
+
meta = node.get("metadata") if isinstance(node.get("metadata"), dict) else {}
|
|
303
|
+
sources.append({
|
|
304
|
+
"id": node_id,
|
|
305
|
+
"title": str(node.get("title") or ""),
|
|
306
|
+
"body": " ".join(
|
|
307
|
+
str(part or "")
|
|
308
|
+
for part in (node.get("title"), node.get("summary"), meta.get("filename"))
|
|
309
|
+
),
|
|
310
|
+
})
|
|
311
|
+
for src in list(trace.get("source_files") or []):
|
|
312
|
+
if not isinstance(src, dict):
|
|
313
|
+
continue
|
|
314
|
+
node_id = str(src.get("node_id") or src.get("source") or "")
|
|
315
|
+
if not node_id or node_id in seen_ids:
|
|
316
|
+
continue
|
|
317
|
+
seen_ids.add(node_id)
|
|
318
|
+
sources.append({
|
|
319
|
+
"id": node_id,
|
|
320
|
+
"title": str(src.get("node_title") or ""),
|
|
321
|
+
"body": " ".join(
|
|
322
|
+
str(part or "") for part in (src.get("node_title"), src.get("source"))
|
|
323
|
+
),
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
mode = str((context_quality or {}).get("mode") or "none")
|
|
327
|
+
if not sources:
|
|
328
|
+
return {
|
|
329
|
+
"status": "no_context",
|
|
330
|
+
"label": "근거 없음",
|
|
331
|
+
"source_ids": [],
|
|
332
|
+
"cited": [],
|
|
333
|
+
"overlap": 0.0,
|
|
334
|
+
"reason": (
|
|
335
|
+
"검색된 출처가 없습니다"
|
|
336
|
+
if mode == "none"
|
|
337
|
+
else "출처 후보를 답변에 연결하지 못했습니다"
|
|
338
|
+
),
|
|
339
|
+
}
|
|
340
|
+
if not answer_text:
|
|
341
|
+
return {
|
|
342
|
+
"status": "unsupported",
|
|
343
|
+
"label": "근거 없음",
|
|
344
|
+
"source_ids": [],
|
|
345
|
+
"cited": [],
|
|
346
|
+
"overlap": 0.0,
|
|
347
|
+
"reason": "답변이 비어 있습니다",
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
answer_tokens = _grounding_tokens(answer_text)
|
|
351
|
+
answer_lower = answer_text.lower()
|
|
352
|
+
cited: List[Dict[str, object]] = []
|
|
353
|
+
best_overlap = 0.0
|
|
354
|
+
for source in sources:
|
|
355
|
+
source_tokens = _grounding_tokens(source["body"])
|
|
356
|
+
if not source_tokens:
|
|
357
|
+
continue
|
|
358
|
+
shared = answer_tokens & source_tokens
|
|
359
|
+
denominator = max(1, min(len(source_tokens), 60))
|
|
360
|
+
ratio = len(shared) / denominator
|
|
361
|
+
best_overlap = max(best_overlap, ratio)
|
|
362
|
+
title = str(source.get("title") or "").strip().lower()
|
|
363
|
+
explicit = bool(title) and len(title) >= 4 and title in answer_lower
|
|
364
|
+
# Explicit-citation OR token-overlap binding. Comparisons are explicit
|
|
365
|
+
# (a 0.0 score is falsy but a valid value — never use `or` defaults).
|
|
366
|
+
if explicit or (
|
|
367
|
+
len(shared) >= GROUNDING_MIN_OVERLAP_TOKENS
|
|
368
|
+
and ratio >= GROUNDING_MIN_OVERLAP_RATIO
|
|
369
|
+
):
|
|
370
|
+
cited.append({
|
|
371
|
+
"id": source["id"],
|
|
372
|
+
"title": source.get("title"),
|
|
373
|
+
"overlap": round(ratio, 4),
|
|
374
|
+
"explicit": explicit,
|
|
375
|
+
})
|
|
376
|
+
if cited:
|
|
377
|
+
return {
|
|
378
|
+
"status": "supported",
|
|
379
|
+
"label": "근거 있음",
|
|
380
|
+
"source_ids": [item["id"] for item in cited],
|
|
381
|
+
"cited": cited,
|
|
382
|
+
"overlap": round(best_overlap, 4),
|
|
383
|
+
"reason": None,
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
"status": "unsupported",
|
|
387
|
+
"label": "근거 없음",
|
|
388
|
+
"source_ids": [],
|
|
389
|
+
"cited": [],
|
|
390
|
+
"overlap": round(best_overlap, 4),
|
|
391
|
+
"reason": "답변이 검색된 출처의 내용을 사용하지 않았습니다",
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
248
395
|
def workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
249
396
|
header = request.headers.get("X-Workspace-Id")
|
|
250
397
|
if header and header.strip():
|