ltcai 9.9.4 → 9.9.9

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 (138) hide show
  1. package/README.md +43 -42
  2. package/docs/CHANGELOG.md +228 -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 +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +45 -9
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/rerank.py +160 -0
  19. package/lattice_brain/graph/retrieval.py +28 -442
  20. package/lattice_brain/graph/retrieval_reads.py +459 -0
  21. package/lattice_brain/graph/retrieval_vector.py +10 -0
  22. package/lattice_brain/graph/store.py +2 -0
  23. package/lattice_brain/ingestion.py +9 -106
  24. package/lattice_brain/ingestion_jobs.py +133 -0
  25. package/lattice_brain/quality.py +26 -6
  26. package/lattice_brain/runtime/multi_agent.py +1 -1
  27. package/latticeai/__init__.py +1 -1
  28. package/latticeai/api/brain_intelligence.py +8 -0
  29. package/latticeai/api/chat.py +9 -0
  30. package/latticeai/api/chat_agent_http.py +206 -48
  31. package/latticeai/api/chat_contracts.py +10 -1
  32. package/latticeai/api/chat_documents.py +16 -0
  33. package/latticeai/api/evidence_actions.py +48 -0
  34. package/latticeai/api/permission_mode.py +61 -0
  35. package/latticeai/api/project_sessions.py +112 -0
  36. package/latticeai/api/voice_capture.py +89 -0
  37. package/latticeai/app_factory.py +53 -0
  38. package/latticeai/core/agent.py +536 -60
  39. package/latticeai/core/agent_permission.py +160 -0
  40. package/latticeai/core/agent_profiles.py +110 -0
  41. package/latticeai/core/artifact_ledger.py +109 -0
  42. package/latticeai/core/context_builder.py +101 -2
  43. package/latticeai/core/legacy_compatibility.py +1 -1
  44. package/latticeai/core/marketplace.py +1 -1
  45. package/latticeai/core/permission_mode.py +296 -0
  46. package/latticeai/core/project_sessions.py +337 -0
  47. package/latticeai/core/run_explain.py +421 -0
  48. package/latticeai/core/run_store.py +7 -0
  49. package/latticeai/core/workspace_os.py +15 -80
  50. package/latticeai/core/workspace_review_items.py +107 -0
  51. package/latticeai/integrations/telegram_bot.py +270 -23
  52. package/latticeai/runtime/chat_wiring.py +8 -0
  53. package/latticeai/runtime/context_runtime.py +11 -0
  54. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  55. package/latticeai/runtime/router_registration.py +9 -0
  56. package/latticeai/services/app_context.py +3 -0
  57. package/latticeai/services/architecture_readiness.py +1 -1
  58. package/latticeai/services/brain_intelligence.py +105 -0
  59. package/latticeai/services/evidence_actions.py +254 -0
  60. package/latticeai/services/funnel_metrics.py +111 -16
  61. package/latticeai/services/local_knowledge.py +26 -0
  62. package/latticeai/services/memory_service.py +12 -0
  63. package/latticeai/services/permission_mode_service.py +170 -0
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/tool_dispatch.py +129 -4
  66. package/latticeai/services/voice_capture.py +189 -0
  67. package/package.json +6 -3
  68. package/scripts/check_bundle_budget.mjs +11 -0
  69. package/scripts/check_current_release_docs.mjs +1 -1
  70. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  71. package/scripts/run_sidecar_e2e.mjs +159 -0
  72. package/skills/meeting_notes/SKILL.md +82 -0
  73. package/skills/meeting_notes/examples.md +41 -0
  74. package/skills/meeting_notes/risk.json +9 -0
  75. package/skills/meeting_notes/schema.json +55 -0
  76. package/skills/weekly_review/SKILL.md +75 -0
  77. package/skills/weekly_review/examples.md +39 -0
  78. package/skills/weekly_review/risk.json +9 -0
  79. package/skills/weekly_review/schema.json +55 -0
  80. package/src-tauri/Cargo.lock +1 -1
  81. package/src-tauri/Cargo.toml +1 -1
  82. package/src-tauri/tauri.conf.json +1 -1
  83. package/static/app/asset-manifest.json +37 -29
  84. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  85. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  86. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  87. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  88. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  89. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  90. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  91. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  92. package/static/app/assets/Library-BVtrSt12.js +1 -0
  93. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  94. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  95. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  96. package/static/app/assets/System-B4nQDy8a.js +1 -0
  97. package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
  98. package/static/app/assets/brain-wVV63_1z.js +1 -0
  99. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  100. package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
  101. package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
  102. package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
  103. package/static/app/assets/download-EJS5_O0f.js +1 -0
  104. package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
  105. package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
  106. package/static/app/assets/index-BFtDYI6l.js +10 -0
  107. package/static/app/assets/index-BY9bpxyx.css +2 -0
  108. package/static/app/assets/input-DEz5UjBz.js +1 -0
  109. package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
  110. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  111. package/static/app/assets/search-ku6-_6wY.js +1 -0
  112. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  113. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  114. package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
  115. package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
  116. package/static/app/assets/users-C0cFMj6o.js +1 -0
  117. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  118. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  119. package/static/app/index.html +4 -4
  120. package/static/sw.js +1 -1
  121. package/static/app/assets/Act-BCmTU0E2.js +0 -1
  122. package/static/app/assets/Brain-CTnjox7w.js +0 -321
  123. package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
  124. package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
  125. package/static/app/assets/Capture-B6vBhFa3.js +0 -1
  126. package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
  127. package/static/app/assets/Library-pKCK0_tk.js +0 -1
  128. package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
  129. package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
  130. package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
  131. package/static/app/assets/System-BAEuHqNY.js +0 -1
  132. package/static/app/assets/index-7FAfYm4v.css +0 -2
  133. package/static/app/assets/index-DrmOCySv.js +0 -10
  134. package/static/app/assets/input-gtVCg-ll.js +0 -1
  135. package/static/app/assets/primitives-CX2Komon.js +0 -1
  136. package/static/app/assets/shield-alert-BftATuAA.js +0 -1
  137. package/static/app/assets/textarea-CiMJfOSI.js +0 -1
  138. package/static/app/assets/utils-SJUNVOj5.js +0 -7
@@ -0,0 +1,116 @@
1
+ """Wire PermissionMode into the running app without a large app_factory rewrite.
2
+
3
+ Creates one process-wide :class:`PermissionModeService`, points
4
+ ``ToolDispatchService.permission_mode`` at it, and registers the HTTP dial.
5
+ Called from ``register_review_and_brain_tail_routers`` (which already has
6
+ ``data_dir``, ``require_user``, ``append_audit_event``) and from
7
+ ``build_chat_agent_runtime_from_context`` so the agent loop sees the same dial.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Optional
16
+
17
+ from latticeai.core.permission_mode import DEFAULT_MODE, normalize_mode
18
+ from latticeai.services.permission_mode_service import PermissionModeService
19
+
20
+ _LOCK = threading.Lock()
21
+ _SHARED: Optional[PermissionModeService] = None
22
+
23
+
24
+ def _default_data_dir() -> Path:
25
+ raw = os.environ.get("LATTICEAI_DATA_DIR", "").strip()
26
+ if raw:
27
+ return Path(raw)
28
+ return Path.home() / ".ltcai"
29
+
30
+
31
+ def _env_default_mode() -> Any:
32
+ return normalize_mode(os.environ.get("LATTICEAI_PERMISSION_MODE", DEFAULT_MODE.value))
33
+
34
+
35
+ def get_permission_mode_service(
36
+ *,
37
+ data_dir: Optional[Path] = None,
38
+ audit: Optional[Callable[..., None]] = None,
39
+ ) -> PermissionModeService:
40
+ """Process-wide singleton.
41
+
42
+ An early lazy caller (a tool dispatch before routers are mounted) would
43
+ otherwise pin the service to the fallback ``data_dir`` with no audit sink.
44
+ Explicit arguments therefore *rebind* an already-created service instead of
45
+ being silently dropped, so the app's real data dir and audit log always win.
46
+ """
47
+ global _SHARED
48
+ with _LOCK:
49
+ if _SHARED is None:
50
+ _SHARED = PermissionModeService(
51
+ data_dir=Path(data_dir) if data_dir is not None else _default_data_dir(),
52
+ default_mode=_env_default_mode(),
53
+ audit=audit,
54
+ )
55
+ return _SHARED
56
+ if data_dir is not None:
57
+ _SHARED.rebind_data_dir(Path(data_dir))
58
+ if audit is not None:
59
+ _SHARED.rebind_audit(audit)
60
+ return _SHARED
61
+
62
+
63
+ def resolve_active_permission_mode(
64
+ *,
65
+ user_email: Optional[str] = None,
66
+ workspace_id: Optional[str] = None,
67
+ ) -> Any:
68
+ return get_permission_mode_service().resolve(
69
+ user_email=user_email, workspace_id=workspace_id,
70
+ )
71
+
72
+
73
+ def bind_dispatch_permission_mode(dispatch_service: Any = None) -> None:
74
+ """Point tool dispatch at the shared mode resolver.
75
+
76
+ The bound callable accepts scope kwargs so per-user and per-workspace
77
+ overrides actually reach ``enforce_policy`` — an unscoped resolver would
78
+ always return the process-wide default and make the dial a no-op.
79
+ """
80
+ from latticeai.services.tool_dispatch import DEFAULT_TOOL_DISPATCH_SERVICE
81
+
82
+ service = dispatch_service or DEFAULT_TOOL_DISPATCH_SERVICE
83
+ service.permission_mode = resolve_active_permission_mode
84
+
85
+
86
+ def register_permission_mode_router(
87
+ app: Any,
88
+ *,
89
+ require_user: Callable[..., str],
90
+ data_dir: Optional[Path] = None,
91
+ append_audit_event: Optional[Callable[..., None]] = None,
92
+ ) -> Any:
93
+ """Install GET/POST /api/permission-mode on ``app``. Idempotent by route path."""
94
+ from latticeai.api.permission_mode import create_permission_mode_router
95
+
96
+ svc = get_permission_mode_service(data_dir=data_dir, audit=append_audit_event)
97
+ bind_dispatch_permission_mode()
98
+ # Skip if already mounted (re-entrant factory / tests).
99
+ existing = {
100
+ getattr(route, "path", None)
101
+ for route in getattr(app, "routes", ())
102
+ }
103
+ if "/api/permission-mode" in existing:
104
+ return svc
105
+ app.include_router(
106
+ create_permission_mode_router(service=svc, require_user=require_user)
107
+ )
108
+ return svc
109
+
110
+
111
+ __all__ = [
112
+ "get_permission_mode_service",
113
+ "resolve_active_permission_mode",
114
+ "bind_dispatch_permission_mode",
115
+ "register_permission_mode_router",
116
+ ]
@@ -653,4 +653,13 @@ def register_review_and_brain_tail_routers(
653
653
  workspace_service=workspace_service,
654
654
  ),
655
655
  )
656
+ # Permission mode dial (v9.9.8): mount last so data_dir + audit are known.
657
+ from latticeai.runtime.permission_mode_wiring import register_permission_mode_router
658
+
659
+ register_permission_mode_router(
660
+ app,
661
+ require_user=require_user,
662
+ data_dir=data_dir,
663
+ append_audit_event=append_audit_event,
664
+ )
656
665
  return brain_network
@@ -33,6 +33,9 @@ class AppContext:
33
33
  local_kg_watcher: Any = None
34
34
  chat_service: Any = None
35
35
  context_assembler: Any = None
36
+ # Re-search loop (v9.9.6): conversation-scoped ledger of just-written
37
+ # artifacts, so a follow-up turn sees them before indexing catches up.
38
+ artifact_ledger: Any = None
36
39
  brain_memory: Any = None
37
40
  # Unified ingestion gateway (lattice_brain.ingestion.IngestionPipeline);
38
41
  # None when the knowledge graph is disabled.
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.9.4"
20
+ ARCHITECTURE_VERSION_TARGET = "9.9.9"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -391,6 +391,111 @@ class BrainIntelligenceService:
391
391
  "generated_at": _now(),
392
392
  }
393
393
 
394
+ def garden_overview(
395
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None,
396
+ limit: int = 8,
397
+ ) -> Dict[str, Any]:
398
+ """The knowledge garden in four beds (v9.9.7).
399
+
400
+ Living Brain answers "how healthy is my knowledge?" in aggregate.
401
+ A gardener asks four concrete questions instead, and this answers all
402
+ four from one workspace-scoped graph sample plus the memory tier:
403
+
404
+ * **recent** — what came in lately (the garden's new growth);
405
+ * **contradictions** — what disagrees with itself (needs weeding);
406
+ * **stale** — what has not been touched in a long time;
407
+ * **frequent** — what the rest of the graph leans on most (by degree).
408
+
409
+ Read-only and honest: when the graph is unavailable every bed is empty
410
+ and ``available`` is false — the view never invents plants.
411
+ """
412
+ # Explicit 0 clamps to 1 — `limit or 8` would silently re-expand it.
413
+ try:
414
+ limit = max(1, min(int(limit), 50))
415
+ except (TypeError, ValueError):
416
+ limit = 8
417
+ sample = self._graph_sample(workspace_id=workspace_id)
418
+ nodes, edges = sample["nodes"], sample["edges"]
419
+ now = datetime.now(timezone.utc)
420
+ recent_cutoff = now - timedelta(days=_RECENT_DAYS)
421
+ stale_cutoff = now - timedelta(days=_STALE_DAYS)
422
+
423
+ def _slim(node: Dict[str, Any], **extra: Any) -> Dict[str, Any]:
424
+ return {
425
+ "id": node.get("id"),
426
+ "type": node.get("type"),
427
+ "title": str(node.get("title") or "")[:120],
428
+ "updated_at": node.get("updated_at"),
429
+ **extra,
430
+ }
431
+
432
+ recent: List[Dict[str, Any]] = []
433
+ stale: List[Dict[str, Any]] = []
434
+ for node in nodes:
435
+ # Chunks are retrieval plumbing, not knowledge a gardener tends.
436
+ if str(node.get("type") or "") == "Chunk":
437
+ continue
438
+ ts = _parse_ts(node.get("updated_at"))
439
+ if ts is None:
440
+ continue
441
+ if ts >= recent_cutoff:
442
+ recent.append(node)
443
+ elif ts < stale_cutoff:
444
+ stale.append(node)
445
+ recent.sort(key=lambda n: str(n.get("updated_at") or ""), reverse=True)
446
+ stale.sort(key=lambda n: str(n.get("updated_at") or ""))
447
+
448
+ # "Frequent" is degree, not a guess: how many relations actually point
449
+ # at a node. Chunks are retrieval plumbing, never garden plants.
450
+ degree: Dict[str, int] = {}
451
+ for edge in edges:
452
+ for key in ("source", "target"):
453
+ node_id = str(edge.get(key) or "")
454
+ if node_id:
455
+ degree[node_id] = degree.get(node_id, 0) + 1
456
+ by_id = {str(node.get("id")): node for node in nodes}
457
+ frequent = [
458
+ _slim(by_id[node_id], degree=count)
459
+ for node_id, count in sorted(degree.items(), key=lambda kv: kv[1], reverse=True)
460
+ if node_id in by_id and str(by_id[node_id].get("type") or "") != "Chunk"
461
+ ][:limit]
462
+
463
+ contradiction_items: List[Dict[str, Any]] = []
464
+ contradiction_count = 0
465
+ try:
466
+ found = self.contradictions(user_email=user_email, workspace_id=workspace_id)
467
+ items = found.get("items") if isinstance(found, dict) else None
468
+ if isinstance(items, list):
469
+ contradiction_count = len(items)
470
+ contradiction_items = items[:limit]
471
+ except Exception: # noqa: BLE001 — one empty bed, never a broken view
472
+ LOGGER.exception("garden overview contradictions failed")
473
+
474
+ return {
475
+ "available": sample["available"],
476
+ "window_days": _RECENT_DAYS,
477
+ "stale_threshold_days": _STALE_DAYS,
478
+ "beds": {
479
+ "recent": {
480
+ "count": len(recent),
481
+ "items": [_slim(node) for node in recent[:limit]],
482
+ },
483
+ "contradictions": {
484
+ "count": contradiction_count,
485
+ "items": contradiction_items,
486
+ },
487
+ "stale": {
488
+ "count": len(stale),
489
+ "items": [_slim(node) for node in stale[:limit]],
490
+ },
491
+ "frequent": {
492
+ "count": len(frequent),
493
+ "items": frequent,
494
+ },
495
+ },
496
+ "generated_at": _now(),
497
+ }
498
+
394
499
  # ── graph-layer proactive quality (v9.6.x) ───────────────────────────
395
500
 
396
501
  def graph_duplicates(
@@ -0,0 +1,254 @@
1
+ """Evidence → action bridge (v9.9.6).
2
+
3
+ Answers already carry citations and a grounding badge, and clicking a source
4
+ already opens the stored chunk. The loop stopped there: to *use* the evidence
5
+ the user had to retype the request and hope the model retrieved the same
6
+ sources again.
7
+
8
+ This service closes that gap deterministically. Given the citations an answer
9
+ actually used, it resolves them against the graph and composes ready-to-send,
10
+ evidence-scoped prompts — "이 근거로 요약 만들기", "체크리스트 만들기",
11
+ "문서 파일 만들기", "한 페이지로 만들기". No model runs here: the output is a
12
+ prompt plus the resolved evidence, so the existing chat/file-generation path
13
+ stays the single execution road.
14
+
15
+ Honesty rules:
16
+
17
+ * Citations that no longer resolve are reported in ``missing`` — never
18
+ silently dropped, and never invented.
19
+ * When nothing resolves, ``actions`` is empty and ``reason`` says why. The UI
20
+ must not offer an action the Brain cannot ground.
21
+ * Every composed prompt tells the model to use only the quoted evidence and
22
+ to say so when the evidence does not cover the request.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import re
29
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
30
+
31
+ LOGGER = logging.getLogger(__name__)
32
+
33
+ __all__ = ["EvidenceActionService", "EVIDENCE_ACTIONS"]
34
+
35
+ # Per-source excerpt cap. Long enough to carry the claim, short enough that a
36
+ # four-citation prompt still fits a small local model's context.
37
+ _EXCERPT_CHARS = 600
38
+ _MAX_SOURCES = 8
39
+
40
+
41
+ def _phrase(ko: str, en: str) -> Dict[str, str]:
42
+ return {"ko": ko, "en": en}
43
+
44
+
45
+ # The action catalog is closed and deterministic — the UI renders exactly
46
+ # these, and each one names the artifact it produces.
47
+ EVIDENCE_ACTIONS: List[Dict[str, Any]] = [
48
+ {
49
+ "id": "summary",
50
+ "label": _phrase("이 근거로 요약 만들기", "Summarize from this evidence"),
51
+ "instruction": _phrase(
52
+ "핵심만 5줄 이내로 요약해 주세요.",
53
+ "Summarize the key points in five lines or fewer.",
54
+ ),
55
+ "kind": "chat",
56
+ },
57
+ {
58
+ "id": "checklist",
59
+ "label": _phrase("이 근거로 체크리스트 만들기", "Build a checklist"),
60
+ "instruction": _phrase(
61
+ "실행 가능한 체크리스트를 만들어 주세요. 각 항목은 한 줄이고, 근거가 있는 항목만 넣으세요.",
62
+ "Build an actionable checklist. One line per item, only items the evidence supports.",
63
+ ),
64
+ "kind": "chat",
65
+ },
66
+ {
67
+ "id": "document",
68
+ "label": _phrase("이 근거로 문서 파일 만들기", "Write a document file"),
69
+ "instruction": _phrase(
70
+ "정리된 마크다운 문서를 만들어 {path} 파일로 저장해 주세요.",
71
+ "Write a structured markdown document and save it as {path}.",
72
+ ),
73
+ "kind": "file",
74
+ "extension": ".md",
75
+ },
76
+ {
77
+ "id": "page",
78
+ "label": _phrase("이 근거로 한 페이지 만들기", "Build a one-page view"),
79
+ "instruction": _phrase(
80
+ "내용을 한눈에 보는 HTML 한 페이지로 만들어 {path} 파일로 저장해 주세요.",
81
+ "Build a single self-contained HTML page and save it as {path}.",
82
+ ),
83
+ "kind": "file",
84
+ "extension": ".html",
85
+ },
86
+ ]
87
+
88
+ _SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
89
+
90
+
91
+ def slugify(text: str, *, fallback: str = "evidence-note") -> str:
92
+ """Deterministic, filesystem-safe stem for a suggested artifact path.
93
+
94
+ Non-ASCII text (Korean questions are the common case) leaves nothing to
95
+ slug — the fallback keeps the suggestion predictable instead of producing
96
+ a mangled or empty filename.
97
+ """
98
+ slug = _SLUG_STRIP_RE.sub("-", str(text or "").lower()).strip("-")
99
+ slug = "-".join(part for part in slug.split("-") if part)[:48].strip("-")
100
+ return slug or fallback
101
+
102
+
103
+ class EvidenceActionService:
104
+ """Compose evidence-scoped follow-up actions for an answer's citations."""
105
+
106
+ def __init__(
107
+ self,
108
+ *,
109
+ node_reader: Optional[Callable[..., Mapping[str, Any]]] = None,
110
+ excerpt_chars: int = _EXCERPT_CHARS,
111
+ ) -> None:
112
+ self._node_reader = node_reader
113
+ self._excerpt_chars = max(120, int(excerpt_chars))
114
+
115
+ # ── evidence resolution ──────────────────────────────────────────────
116
+
117
+ def _read_node(self, node_id: str, allowed_workspaces: Any) -> Optional[Mapping[str, Any]]:
118
+ if self._node_reader is None:
119
+ return None
120
+ try:
121
+ return self._node_reader(node_id, allowed_workspaces=allowed_workspaces)
122
+ except TypeError:
123
+ # Readers without scope support (tests, older ports).
124
+ try:
125
+ return self._node_reader(node_id)
126
+ except Exception as exc: # noqa: BLE001 — resolution is best-effort
127
+ LOGGER.debug("evidence node read failed for %s: %s", node_id, exc)
128
+ return None
129
+ except Exception as exc: # noqa: BLE001
130
+ LOGGER.debug("evidence node read failed for %s: %s", node_id, exc)
131
+ return None
132
+
133
+ def resolve(
134
+ self,
135
+ source_ids: Sequence[str],
136
+ *,
137
+ allowed_workspaces: Any = None,
138
+ ) -> Dict[str, Any]:
139
+ """Resolve citation ids to ``{"sources": [...], "missing": [...]}``."""
140
+ sources: List[Dict[str, Any]] = []
141
+ missing: List[str] = []
142
+ seen: set = set()
143
+ for raw in list(source_ids or [])[:_MAX_SOURCES]:
144
+ node_id = str(raw or "").strip()
145
+ if not node_id or node_id in seen:
146
+ continue
147
+ seen.add(node_id)
148
+ node = self._read_node(node_id, allowed_workspaces)
149
+ if not isinstance(node, Mapping):
150
+ missing.append(node_id)
151
+ continue
152
+ # /api/graph/node wraps the record; get_node returns it directly.
153
+ record = node.get("node") if isinstance(node.get("node"), Mapping) else node
154
+ title = str(record.get("title") or record.get("id") or node_id)
155
+ body = str(record.get("summary") or record.get("content") or "").strip()
156
+ metadata = record.get("metadata")
157
+ metadata = metadata if isinstance(metadata, Mapping) else {}
158
+ origin = ""
159
+ for key in ("relative_path", "file_path", "filename", "source_uri", "source"):
160
+ value = metadata.get(key)
161
+ if value:
162
+ origin = str(value)
163
+ break
164
+ sources.append({
165
+ "id": node_id,
166
+ "title": title,
167
+ "type": str(record.get("type") or ""),
168
+ "origin": origin,
169
+ "excerpt": body[: self._excerpt_chars],
170
+ "truncated": len(body) > self._excerpt_chars,
171
+ })
172
+ return {"sources": sources, "missing": missing}
173
+
174
+ # ── prompt composition ───────────────────────────────────────────────
175
+
176
+ def _evidence_block(self, sources: Sequence[Mapping[str, Any]], language: str) -> str:
177
+ header = "[근거 자료]" if language == "ko" else "[EVIDENCE]"
178
+ lines = [header]
179
+ for index, source in enumerate(sources, start=1):
180
+ origin = f" ({source['origin']})" if source.get("origin") else ""
181
+ lines.append(f"{index}. {source['title']}{origin}")
182
+ excerpt = str(source.get("excerpt") or "").strip()
183
+ if excerpt:
184
+ suffix = " …" if source.get("truncated") else ""
185
+ lines.append(f" {excerpt}{suffix}")
186
+ return "\n".join(lines)
187
+
188
+ def _guard(self, language: str) -> str:
189
+ if language == "ko":
190
+ return (
191
+ "위 근거 자료에 있는 내용만 사용하세요. 근거에 없는 사실은 지어내지 말고, "
192
+ "근거가 부족하면 '이 부분은 근거가 없습니다'라고 적으세요."
193
+ )
194
+ return (
195
+ "Use only the evidence quoted above. Do not invent facts that are not in it; "
196
+ "when the evidence does not cover something, say so explicitly."
197
+ )
198
+
199
+ def actions_for(
200
+ self,
201
+ *,
202
+ question: str = "",
203
+ source_ids: Sequence[str] = (),
204
+ language: str = "ko",
205
+ allowed_workspaces: Any = None,
206
+ ) -> Dict[str, Any]:
207
+ """Ready-to-send, evidence-scoped follow-up actions for one answer."""
208
+ language = "ko" if str(language or "ko").lower().startswith("ko") else "en"
209
+ resolved = self.resolve(source_ids, allowed_workspaces=allowed_workspaces)
210
+ sources = resolved["sources"]
211
+ question_text = str(question or "").strip()
212
+ if not sources:
213
+ return {
214
+ "sources": [],
215
+ "missing": resolved["missing"],
216
+ "actions": [],
217
+ "reason": (
218
+ "근거로 쓸 출처를 찾지 못했습니다."
219
+ if language == "ko"
220
+ else "No usable evidence could be resolved."
221
+ ),
222
+ }
223
+
224
+ evidence = self._evidence_block(sources, language)
225
+ guard = self._guard(language)
226
+ stem = slugify(question_text)
227
+ actions: List[Dict[str, Any]] = []
228
+ for spec in EVIDENCE_ACTIONS:
229
+ path = f"{stem}{spec['extension']}" if spec.get("extension") else ""
230
+ instruction = spec["instruction"][language].replace("{path}", path)
231
+ question_line = (
232
+ (f"원래 질문: {question_text}" if language == "ko" else f"Original question: {question_text}")
233
+ if question_text
234
+ else ""
235
+ )
236
+ prompt = "\n\n".join(
237
+ part for part in (evidence, instruction, guard, question_line) if part
238
+ )
239
+ action: Dict[str, Any] = {
240
+ "id": spec["id"],
241
+ "kind": spec["kind"],
242
+ "label": dict(spec["label"]),
243
+ "prompt": prompt,
244
+ "source_ids": [source["id"] for source in sources],
245
+ }
246
+ if path:
247
+ action["suggested_path"] = path
248
+ actions.append(action)
249
+ return {
250
+ "sources": sources,
251
+ "missing": resolved["missing"],
252
+ "actions": actions,
253
+ "reason": "",
254
+ }
@@ -181,30 +181,125 @@ class FunnelMetricsService:
181
181
  return round(delta, 1) if delta >= 0 else None
182
182
 
183
183
  def snapshot(self) -> Dict[str, Any]:
184
- """Counters + derived rates for the admin surface."""
184
+ """Counters + derived rates + actionable alerts for the admin surface."""
185
185
  with self._lock:
186
186
  counters = {name: int(self._state.get(name) or 0) for name in COUNTER_NAMES}
187
187
  firsts = {name: self._state.get(name) for name in _FIRST_NAMES}
188
+ rates = {
189
+ "real_file_rate": _rate(
190
+ counters["real_file_delivered"], counters["file_requests"]
191
+ ),
192
+ "code_only_rate": _rate(
193
+ counters["code_only_responses"], counters["file_requests"]
194
+ ),
195
+ "needs_review_rate": _rate(
196
+ counters["needs_review_runs"], counters["agent_runs"]
197
+ ),
198
+ "approval_resume_rate": _rate(
199
+ counters["approval_resumes"], counters["approval_pauses"]
200
+ ),
201
+ }
188
202
  return {
189
203
  "counters": counters,
190
204
  "firsts": firsts,
191
- "rates": {
192
- "real_file_rate": _rate(
193
- counters["real_file_delivered"], counters["file_requests"]
194
- ),
195
- "code_only_rate": _rate(
196
- counters["code_only_responses"], counters["file_requests"]
197
- ),
198
- "needs_review_rate": _rate(
199
- counters["needs_review_runs"], counters["agent_runs"]
200
- ),
201
- "approval_resume_rate": _rate(
202
- counters["approval_resumes"], counters["approval_pauses"]
203
- ),
204
- },
205
+ "rates": rates,
206
+ "alerts": funnel_alerts(counters, rates),
205
207
  "ttfv_seconds": self.ttfv_seconds(),
206
208
  "generated_at": _utc_now_iso(),
207
209
  }
208
210
 
209
211
 
210
- __all__ = ["FunnelMetricsService", "COUNTER_NAMES"]
212
+ # ── alerts (review 2026-07-27 P2 #10) ────────────────────────────────────────
213
+ # The funnel already measured the right things; nothing turned a bad number
214
+ # into a decision, so a regression was only visible to whoever opened the
215
+ # admin page. These thresholds convert rates into named, actionable signals.
216
+ #
217
+ # Minimum samples per rule exist so a single unlucky run never raises an
218
+ # alarm — an alert with n=1 is noise, and noisy alerts get ignored.
219
+
220
+ REAL_FILE_RATE_FLOOR = 0.95
221
+ CODE_ONLY_RATE_CEILING = 0.05
222
+ NEEDS_REVIEW_RATE_CEILING = 0.25
223
+ APPROVAL_RESUME_RATE_FLOOR = 0.5
224
+ MIN_SAMPLES = 10
225
+
226
+
227
+ def _alert(
228
+ key: str, severity: str, ko: str, en: str, **detail: Any
229
+ ) -> Dict[str, Any]:
230
+ return {"key": key, "severity": severity, "ko": ko, "en": en, **detail}
231
+
232
+
233
+ def funnel_alerts(
234
+ counters: Dict[str, int], rates: Dict[str, Optional[float]]
235
+ ) -> list:
236
+ """Turn funnel rates into named signals a person can act on.
237
+
238
+ Pure function of the snapshot — no I/O, no clock — so the thresholds are
239
+ testable and the same numbers always produce the same alerts. Rules stay
240
+ silent below :data:`MIN_SAMPLES`: an alert nobody can trust is worse than
241
+ no alert.
242
+ """
243
+ alerts: list = []
244
+ file_requests = int(counters.get("file_requests") or 0)
245
+ agent_runs = int(counters.get("agent_runs") or 0)
246
+ approval_pauses = int(counters.get("approval_pauses") or 0)
247
+
248
+ real_file_rate = rates.get("real_file_rate")
249
+ if file_requests >= MIN_SAMPLES and real_file_rate is not None:
250
+ if real_file_rate < REAL_FILE_RATE_FLOOR:
251
+ alerts.append(_alert(
252
+ "real_file_rate_low", "warning",
253
+ f"파일 요청 중 실제 파일이 나온 비율이 {real_file_rate:.0%}입니다 "
254
+ f"(목표 {REAL_FILE_RATE_FLOOR:.0%}). 파일 생성 파이프라인을 확인하세요.",
255
+ f"Only {real_file_rate:.0%} of file requests produced a real file "
256
+ f"(target {REAL_FILE_RATE_FLOOR:.0%}). Check the file-generation pipeline.",
257
+ value=real_file_rate, threshold=REAL_FILE_RATE_FLOOR, samples=file_requests,
258
+ ))
259
+
260
+ code_only_rate = rates.get("code_only_rate")
261
+ if file_requests >= MIN_SAMPLES and code_only_rate is not None:
262
+ if code_only_rate > CODE_ONLY_RATE_CEILING:
263
+ alerts.append(_alert(
264
+ "code_only_rate_high", "warning",
265
+ f"파일을 요청했는데 코드/설명만 돌아온 비율이 {code_only_rate:.0%}입니다.",
266
+ f"{code_only_rate:.0%} of file requests came back as code or prose only.",
267
+ value=code_only_rate, threshold=CODE_ONLY_RATE_CEILING, samples=file_requests,
268
+ ))
269
+
270
+ needs_review_rate = rates.get("needs_review_rate")
271
+ if agent_runs >= MIN_SAMPLES and needs_review_rate is not None:
272
+ if needs_review_rate > NEEDS_REVIEW_RATE_CEILING:
273
+ alerts.append(_alert(
274
+ "needs_review_rate_high", "warning",
275
+ f"에이전트 실행의 {needs_review_rate:.0%}가 '검토 필요'로 끝났습니다. "
276
+ "더 큰 모델을 쓰거나 요청을 작게 나누세요.",
277
+ f"{needs_review_rate:.0%} of agent runs ended as NEEDS_REVIEW. "
278
+ "Use a larger model or split requests into smaller steps.",
279
+ value=needs_review_rate, threshold=NEEDS_REVIEW_RATE_CEILING, samples=agent_runs,
280
+ ))
281
+
282
+ resume_rate = rates.get("approval_resume_rate")
283
+ if approval_pauses >= MIN_SAMPLES and resume_rate is not None:
284
+ if resume_rate < APPROVAL_RESUME_RATE_FLOOR:
285
+ alerts.append(_alert(
286
+ "approval_resume_rate_low", "info",
287
+ f"승인 대기 중 실제로 이어서 실행된 비율이 {resume_rate:.0%}입니다. "
288
+ "승인 카드가 잘 보이는지 확인하세요.",
289
+ f"Only {resume_rate:.0%} of paused runs were resumed. "
290
+ "Check that the approval card is actually reaching users.",
291
+ value=resume_rate, threshold=APPROVAL_RESUME_RATE_FLOOR, samples=approval_pauses,
292
+ ))
293
+
294
+ if int(counters.get("ingest_completions") or 0) > 0 and not counters.get("recall_successes"):
295
+ alerts.append(_alert(
296
+ "no_grounded_recall", "warning",
297
+ "자료는 들어왔지만 근거 있는 회상이 아직 한 번도 없었습니다. 검색/인덱싱을 확인하세요.",
298
+ "Content was ingested but no answer has ever been grounded in it yet — "
299
+ "check retrieval and indexing.",
300
+ samples=int(counters.get("ingest_completions") or 0),
301
+ ))
302
+ return alerts
303
+
304
+
305
+ __all__ = ["FunnelMetricsService", "COUNTER_NAMES", "funnel_alerts"]