ltcai 9.9.2 → 9.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +39 -39
  2. package/docs/CHANGELOG.md +44 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/curator.py +141 -0
  12. package/lattice_brain/graph/documents.py +111 -0
  13. package/lattice_brain/graph/fusion.py +179 -0
  14. package/lattice_brain/graph/projection.py +161 -0
  15. package/lattice_brain/graph/retrieval.py +19 -1
  16. package/lattice_brain/ingestion.py +66 -0
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/automation_intelligence.py +159 -0
  20. package/latticeai/api/browser.py +15 -4
  21. package/latticeai/api/chat.py +27 -2
  22. package/latticeai/api/chat_agent_http.py +191 -3
  23. package/latticeai/api/chat_contracts.py +7 -1
  24. package/latticeai/api/chat_helpers.py +147 -0
  25. package/latticeai/api/chat_intents.py +189 -1
  26. package/latticeai/api/chat_stream.py +17 -1
  27. package/latticeai/api/funnel_metrics.py +31 -0
  28. package/latticeai/api/knowledge_graph.py +30 -0
  29. package/latticeai/api/local_files.py +90 -0
  30. package/latticeai/api/setup.py +134 -1
  31. package/latticeai/api/tools.py +33 -3
  32. package/latticeai/app_factory.py +14 -0
  33. package/latticeai/core/agent.py +91 -9
  34. package/latticeai/core/agent_eval.py +107 -0
  35. package/latticeai/core/file_generation.py +279 -2
  36. package/latticeai/core/legacy_compatibility.py +1 -1
  37. package/latticeai/core/marketplace.py +1 -1
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/runtime/persistence_runtime.py +19 -1
  40. package/latticeai/runtime/router_registration.py +8 -1
  41. package/latticeai/services/app_context.py +3 -0
  42. package/latticeai/services/architecture_readiness.py +1 -1
  43. package/latticeai/services/automation_execution.py +266 -0
  44. package/latticeai/services/automation_intelligence.py +7 -0
  45. package/latticeai/services/command_center.py +20 -1
  46. package/latticeai/services/folder_watch.py +391 -0
  47. package/latticeai/services/funnel_metrics.py +202 -0
  48. package/latticeai/services/product_readiness.py +1 -1
  49. package/latticeai/services/search_service.py +17 -1
  50. package/latticeai/setup/demo_corpus.py +98 -0
  51. package/latticeai/tools/filesystem.py +38 -1
  52. package/package.json +1 -1
  53. package/scripts/bench_models.py +234 -5
  54. package/scripts/check_current_release_docs.mjs +1 -1
  55. package/src-tauri/Cargo.lock +1 -1
  56. package/src-tauri/Cargo.toml +1 -1
  57. package/src-tauri/tauri.conf.json +1 -1
  58. package/static/app/asset-manifest.json +28 -27
  59. package/static/app/assets/Act-DmdruVKV.js +1 -0
  60. package/static/app/assets/{Brain-BnGbytHa.js → Brain-DEY9jLVt.js} +2 -2
  61. package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
  62. package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
  63. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-CVZ09QXi.js} +1 -1
  64. package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
  65. package/static/app/assets/Library-Bp0n-HlW.js +1 -0
  66. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-DxP4efJF.js} +1 -1
  67. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-DRbm7NEq.js} +1 -1
  68. package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
  69. package/static/app/assets/{System-DRHc_6Su.js → System-ByQcmJW-.js} +1 -1
  70. package/static/app/assets/{bot-DZkDdh-A.js → bot-BNDyZLR7.js} +1 -1
  71. package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
  72. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-C6jjYm6i.js} +1 -1
  73. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-DjGIvDBQ.js} +1 -1
  74. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-BlSbwSaT.js} +1 -1
  75. package/static/app/assets/index-Bge3DXW7.css +2 -0
  76. package/static/app/assets/{index-BS_3aN1n.js → index-CHu7cgj3.js} +3 -3
  77. package/static/app/assets/{input-B_U7zvbR.js → input-DVDI0YR3.js} +1 -1
  78. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-BddhEWA0.js} +1 -1
  79. package/static/app/assets/{network-BF13HJJF.js → network-pYQt5oBu.js} +1 -1
  80. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-D7gCdEvS.js} +1 -1
  81. package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
  82. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-sqQmoBKL.js} +1 -1
  83. package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
  84. package/static/app/assets/utils-uQYKXNeq.js +7 -0
  85. package/static/app/index.html +4 -4
  86. package/static/sw.js +1 -1
  87. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  88. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  89. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  90. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  91. package/static/app/assets/Library-PcfVUco6.js +0 -1
  92. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  93. package/static/app/assets/download-C7yxYHvz.js +0 -1
  94. package/static/app/assets/index-DU78Kogz.css +0 -2
  95. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  96. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -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
- return res.as_dict()
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
- return res.as_dict()
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
@@ -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,
@@ -209,6 +211,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
209
211
  agent_controller=agent_controller,
210
212
  agent_root=AGENT_ROOT,
211
213
  ingestion_pipeline=context.ingestion_pipeline,
214
+ funnel_metrics=context.funnel_metrics,
212
215
  )
213
216
  document_coordinator = DocumentGenerationCoordinator(
214
217
  model_router=model_router,
@@ -273,7 +276,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
273
276
  )
274
277
 
275
278
  selected_model_id = request_model(req.model)
276
- if is_file_action_request(req.message):
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:
277
285
  direct_response = await intent_controller.direct_file_action(
278
286
  req,
279
287
  model_id=selected_model_id,
@@ -284,7 +292,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
284
292
  return direct_response
285
293
  if not selected_model_id:
286
294
  return intent_controller.no_model_response()
287
- if is_file_action_request(req.message):
295
+ if file_intent:
288
296
  return await intent_controller.route_file_to_agent(
289
297
  req,
290
298
  request,
@@ -375,6 +383,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
375
383
  )
376
384
  if isinstance(trace_seed, dict):
377
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()
378
392
 
379
393
  history_message = (
380
394
  f"{req.message}\n[Image attached]" if req.image_data else req.message
@@ -464,6 +478,16 @@ def create_chat_router(context: AppContext) -> APIRouter:
464
478
  req.image_data,
465
479
  )
466
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
467
491
  trace_record = await chat_service.persist_answer(
468
492
  question=req.message,
469
493
  response=response_text,
@@ -482,6 +506,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
482
506
  "trace_id": trace_record["id"],
483
507
  "trace": trace_record,
484
508
  "context_quality": context_quality,
509
+ "grounding": grounding,
485
510
  }
486
511
  )
487
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,7 +18,7 @@ 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.core.agent import AgentRunContext, AgentState, normalize_plan
21
22
  from latticeai.services.tool_dispatch import collect_artifacts, collect_created_files
22
23
 
23
24
 
@@ -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,
@@ -316,8 +403,22 @@ class AgentHTTPController:
316
403
  req: AgentResumeRequest,
317
404
  request: Request,
318
405
  ) -> Dict[str, Any]:
319
- """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
+ """
320
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
+ )
321
422
  with self._pending_lock:
322
423
  now = time.monotonic()
323
424
  for context_id, pending in list(self._pending.items()):
@@ -345,7 +446,94 @@ class AgentHTTPController:
345
446
  ctx.transcript[-1].update(ctx.plan)
346
447
  ctx.executing_model = req.executing_model or ctx.executing_model
347
448
  ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
348
- self.runtime.approve(ctx, current_user)
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)
349
537
  return await self._finish(
350
538
  ctx,
351
539
  original_request,
@@ -39,11 +39,17 @@ class AgentRequest(BaseModel):
39
39
 
40
40
 
41
41
  class AgentResumeRequest(BaseModel):
42
- context_id: str
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():