ltcai 9.9.5 → 10.0.0

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 (129) hide show
  1. package/README.md +39 -37
  2. package/docs/CHANGELOG.md +234 -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 +38 -8
  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/retrieval_vector.py +10 -0
  19. package/lattice_brain/ingestion.py +9 -106
  20. package/lattice_brain/ingestion_jobs.py +133 -0
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +8 -0
  24. package/latticeai/api/chat.py +9 -0
  25. package/latticeai/api/chat_agent_http.py +119 -2
  26. package/latticeai/api/chat_contracts.py +9 -0
  27. package/latticeai/api/chat_documents.py +16 -0
  28. package/latticeai/api/evidence_actions.py +48 -0
  29. package/latticeai/api/permission_mode.py +61 -0
  30. package/latticeai/api/project_sessions.py +112 -0
  31. package/latticeai/api/voice_capture.py +89 -0
  32. package/latticeai/app_factory.py +53 -0
  33. package/latticeai/core/agent.py +381 -43
  34. package/latticeai/core/agent_permission.py +160 -0
  35. package/latticeai/core/agent_profiles.py +110 -0
  36. package/latticeai/core/artifact_ledger.py +109 -0
  37. package/latticeai/core/context_builder.py +101 -2
  38. package/latticeai/core/legacy_compatibility.py +1 -1
  39. package/latticeai/core/marketplace.py +1 -1
  40. package/latticeai/core/permission_mode.py +296 -0
  41. package/latticeai/core/project_sessions.py +337 -0
  42. package/latticeai/core/run_explain.py +421 -0
  43. package/latticeai/core/run_store.py +5 -0
  44. package/latticeai/core/workspace_os.py +15 -80
  45. package/latticeai/core/workspace_review_items.py +107 -0
  46. package/latticeai/integrations/telegram_bot.py +188 -0
  47. package/latticeai/runtime/chat_wiring.py +8 -0
  48. package/latticeai/runtime/context_runtime.py +11 -0
  49. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  50. package/latticeai/runtime/router_registration.py +9 -0
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/brain_intelligence.py +105 -0
  54. package/latticeai/services/evidence_actions.py +254 -0
  55. package/latticeai/services/funnel_metrics.py +111 -16
  56. package/latticeai/services/local_knowledge.py +26 -0
  57. package/latticeai/services/memory_service.py +12 -0
  58. package/latticeai/services/permission_mode_service.py +170 -0
  59. package/latticeai/services/product_readiness.py +1 -1
  60. package/latticeai/services/tool_dispatch.py +73 -4
  61. package/latticeai/services/voice_capture.py +189 -0
  62. package/package.json +6 -4
  63. package/scripts/check_bundle_budget.mjs +11 -0
  64. package/scripts/check_current_release_docs.mjs +1 -1
  65. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  66. package/skills/meeting_notes/SKILL.md +82 -0
  67. package/skills/meeting_notes/examples.md +41 -0
  68. package/skills/meeting_notes/risk.json +9 -0
  69. package/skills/meeting_notes/schema.json +55 -0
  70. package/skills/weekly_review/SKILL.md +75 -0
  71. package/skills/weekly_review/examples.md +39 -0
  72. package/skills/weekly_review/risk.json +9 -0
  73. package/skills/weekly_review/schema.json +55 -0
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -29
  78. package/static/app/assets/Act-BtCREeN1.js +1 -0
  79. package/static/app/assets/AdminConsole-TPeeN18T.js +1 -0
  80. package/static/app/assets/Brain-BKs6JAp0.js +321 -0
  81. package/static/app/assets/BrainHome-BPGOvSd6.js +2 -0
  82. package/static/app/assets/BrainSignals-CtzQZ15J.js +1 -0
  83. package/static/app/assets/Capture-1_NaHWqB.js +1 -0
  84. package/static/app/assets/CommandPalette-pqvQOXe4.js +1 -0
  85. package/static/app/assets/Library-DhvoPvC7.js +1 -0
  86. package/static/app/assets/{LivingBrain-BVWk58MW.js → LivingBrain-DlQ20Q75.js} +1 -1
  87. package/static/app/assets/ProductFlow-BZvGDRi_.js +1 -0
  88. package/static/app/assets/ReviewCard-BWgI0D2s.js +3 -0
  89. package/static/app/assets/System-CSMdYLMy.js +1 -0
  90. package/static/app/assets/activity-Dlfk8YC7.js +1 -0
  91. package/static/app/assets/{bot-VmP0kkeA.js → bot-CDvUB76P.js} +1 -1
  92. package/static/app/assets/brain-xczrohrt.js +1 -0
  93. package/static/app/assets/button-SOdH3Oyf.js +1 -0
  94. package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause-CG1ythH4.js} +1 -1
  95. package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-HXwvjS6W.js} +1 -1
  96. package/static/app/assets/{cpu-CJhjRtNq.js → cpu-B0d-rGyk.js} +1 -1
  97. package/static/app/assets/download-BGIkTQL6.js +1 -0
  98. package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-Dst_Z0_K.js} +1 -1
  99. package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-D53MsWkV.js} +1 -1
  100. package/static/app/assets/index-C_IrlQMV.js +10 -0
  101. package/static/app/assets/index-FxDusbr0.css +2 -0
  102. package/static/app/assets/{input-CSi6OBJ9.js → input-C5m0riF6.js} +1 -1
  103. package/static/app/assets/{network-Ck1nqc-v.js → network-C5a-E5iS.js} +1 -1
  104. package/static/app/assets/primitives-vNXYf58F.js +1 -0
  105. package/static/app/assets/search-DhbSgW6m.js +1 -0
  106. package/static/app/assets/shield-alert-Cc-WVXqN.js +1 -0
  107. package/static/app/assets/{textarea-C4wM0D-Z.js → textarea-BkZ0EqVO.js} +1 -1
  108. package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-CTtKbAOU.js} +1 -1
  109. package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-Dx1fi4Wu.js} +1 -1
  110. package/static/app/assets/users-BFpQXtEF.js +1 -0
  111. package/static/app/assets/utils-BA_lmW3J.js +7 -0
  112. package/static/app/assets/workspace-DBPB0jkX.js +1 -0
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/Act-DmxfbqBp.js +0 -1
  116. package/static/app/assets/Brain-DK_zKCda.js +0 -321
  117. package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
  118. package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
  119. package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
  120. package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
  121. package/static/app/assets/Library-4As9VUvy.js +0 -1
  122. package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
  123. package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
  124. package/static/app/assets/System-DSR2zKWd.js +0 -1
  125. package/static/app/assets/index-7FAfYm4v.css +0 -2
  126. package/static/app/assets/index-CEu0Wqjl.js +0 -10
  127. package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
  128. package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
  129. package/static/app/assets/utils-BqoznzGP.js +0 -7
@@ -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"]
@@ -293,6 +293,32 @@ def create_local_knowledge_router(
293
293
  payload["watch"] = watch_status
294
294
  return payload
295
295
 
296
+ @router.get("/knowledge-graph/local/health")
297
+ async def knowledge_graph_local_health(request: Request, error_samples: int = 3):
298
+ """Per-folder memory state (v9.9.7): indexing coverage, failures with
299
+ their stored reasons, and watch state.
300
+
301
+ Vector freshness rides along as an explicitly **global** figure — the
302
+ vector index is not per-folder, and claiming otherwise would invent a
303
+ number.
304
+ """
305
+ require_user(request)
306
+ kg = graph()
307
+ payload = kg.local_source_health(error_samples=error_samples)
308
+ watch_status = watcher.status() if watcher else {"available": False, "active": {}}
309
+ active = watch_status.get("active", {})
310
+ for folder in payload.get("folders", []):
311
+ folder["watch_active"] = folder.get("id") in active
312
+ payload["watch"] = watch_status
313
+ try:
314
+ payload["vector_freshness_global"] = kg.vector_freshness()
315
+ except Exception as exc: # noqa: BLE001 — one missing signal, not a 500
316
+ payload["vector_freshness_global"] = {
317
+ "status": "unavailable", "detail": str(exc),
318
+ "pending_items": 0, "total_items": 0,
319
+ }
320
+ return payload
321
+
296
322
  @router.get("/knowledge-graph/local/watch/status")
297
323
  async def knowledge_graph_local_watch_status(request: Request):
298
324
  require_user(request)
@@ -930,6 +930,15 @@ class MemoryService:
930
930
  similarity = round(float(hit.get("score") or 0.0), 4)
931
931
  if similarity <= 0:
932
932
  continue
933
+ # Citation precision (v9.9.6): a chunk hit knows where in the
934
+ # document it came from (section heading / page). Carry that
935
+ # locator onto the recall row so the citation can say it.
936
+ hit_metadata = hit.get("metadata")
937
+ locator = (
938
+ str(hit_metadata.get("locator") or "")
939
+ if isinstance(hit_metadata, dict)
940
+ else ""
941
+ )
933
942
  existing = by_node_id.get(node_id)
934
943
  if existing is not None:
935
944
  existing["vector_score"] = max(existing.get("vector_score", 0.0), similarity)
@@ -937,6 +946,8 @@ class MemoryService:
937
946
  max(existing.get("score", 0.0), 0.4 * existing.get("score", 0.0) + 0.6 * similarity),
938
947
  4,
939
948
  )
949
+ if locator and not existing.get("locator"):
950
+ existing["locator"] = locator
940
951
  else:
941
952
  matched = _matched_terms(hit.get("title"), hit.get("summary"))
942
953
  row = {
@@ -948,6 +959,7 @@ class MemoryService:
948
959
  "score": round(max(_lexical_score(matched), 0.6 * similarity), 4),
949
960
  "matched_terms": matched,
950
961
  "vector_score": similarity,
962
+ **({"locator": locator} if locator else {}),
951
963
  }
952
964
  results.append(row)
953
965
  if node_id:
@@ -0,0 +1,170 @@
1
+ """Persisted permission-mode preference (v9.9.8).
2
+
3
+ Stores the active mode under the data dir so every surface (web, VS Code,
4
+ Telegram, agent loop) shares one dial. Scope:
5
+
6
+ * global default (env ``LATTICEAI_PERMISSION_MODE`` or strict)
7
+ * per-user override
8
+ * per-workspace override (wins over user)
9
+
10
+ Changing to ``bypass`` requires ``acknowledge_risk=True`` once; the ack is
11
+ audited. Circuit breakers are enforced by ``latticeai.core.permission_mode``,
12
+ not by this store.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import threading
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Dict, Optional
21
+
22
+ from latticeai.core.io_utils import atomic_write_json
23
+ from latticeai.core.permission_mode import (
24
+ DEFAULT_MODE,
25
+ PermissionMode,
26
+ mode_catalog,
27
+ mode_contract,
28
+ normalize_mode,
29
+ )
30
+
31
+
32
+ class PermissionModeService:
33
+ """Load / save the autonomy dial."""
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ data_dir: Path,
39
+ default_mode: PermissionMode | str = DEFAULT_MODE,
40
+ audit: Optional[Callable[..., None]] = None,
41
+ ) -> None:
42
+ self._path = Path(data_dir) / "permission_mode.json"
43
+ self._default = normalize_mode(default_mode)
44
+ self._audit = audit or (lambda *a, **kw: None)
45
+ self._lock = threading.Lock()
46
+
47
+ def rebind_data_dir(self, data_dir: Path) -> None:
48
+ """Point the store at the app's real data dir.
49
+
50
+ The wiring may instantiate this service lazily before routers know the
51
+ configured data dir; rebinding keeps one file of record instead of
52
+ stranding writes under the fallback path.
53
+ """
54
+ with self._lock:
55
+ self._path = Path(data_dir) / "permission_mode.json"
56
+
57
+ def rebind_audit(self, audit: Callable[..., None]) -> None:
58
+ """Attach the real audit sink once app wiring provides one."""
59
+ with self._lock:
60
+ self._audit = audit
61
+
62
+ def _read(self) -> Dict[str, Any]:
63
+ if not self._path.exists():
64
+ return {"default": self._default.value, "users": {}, "workspaces": {}}
65
+ try:
66
+ data = json.loads(self._path.read_text(encoding="utf-8"))
67
+ except Exception:
68
+ return {"default": self._default.value, "users": {}, "workspaces": {}}
69
+ if not isinstance(data, dict):
70
+ return {"default": self._default.value, "users": {}, "workspaces": {}}
71
+ data.setdefault("default", self._default.value)
72
+ data.setdefault("users", {})
73
+ data.setdefault("workspaces", {})
74
+ return data
75
+
76
+ def _write(self, data: Dict[str, Any]) -> None:
77
+ self._path.parent.mkdir(parents=True, exist_ok=True)
78
+ atomic_write_json(self._path, data)
79
+
80
+ def _resolve_from(
81
+ self,
82
+ data: Dict[str, Any],
83
+ *,
84
+ user_email: Optional[str],
85
+ workspace_id: Optional[str],
86
+ ) -> PermissionMode:
87
+ """Scope precedence: workspace → user → process default.
88
+
89
+ Pure over ``data`` and lock-free, so holders of ``_lock`` can reuse it
90
+ without re-entering a non-reentrant lock.
91
+ """
92
+ if workspace_id:
93
+ ws = (data.get("workspaces") or {}).get(str(workspace_id))
94
+ if ws:
95
+ return normalize_mode(ws)
96
+ if user_email:
97
+ user = (data.get("users") or {}).get(str(user_email).lower())
98
+ if user:
99
+ return normalize_mode(user)
100
+ return normalize_mode(data.get("default") or self._default)
101
+
102
+ def resolve(
103
+ self,
104
+ *,
105
+ user_email: Optional[str] = None,
106
+ workspace_id: Optional[str] = None,
107
+ ) -> PermissionMode:
108
+ with self._lock:
109
+ data = self._read()
110
+ return self._resolve_from(
111
+ data, user_email=user_email, workspace_id=workspace_id,
112
+ )
113
+
114
+ def get(
115
+ self,
116
+ *,
117
+ user_email: Optional[str] = None,
118
+ workspace_id: Optional[str] = None,
119
+ ) -> Dict[str, Any]:
120
+ mode = self.resolve(user_email=user_email, workspace_id=workspace_id)
121
+ contract = mode_contract(mode)
122
+ contract["catalog"] = mode_catalog()
123
+ contract["scope"] = {
124
+ "user_email": user_email,
125
+ "workspace_id": workspace_id,
126
+ }
127
+ return contract
128
+
129
+ def set_mode(
130
+ self,
131
+ mode: PermissionMode | str,
132
+ *,
133
+ user_email: Optional[str] = None,
134
+ workspace_id: Optional[str] = None,
135
+ acknowledge_risk: bool = False,
136
+ source: str = "api",
137
+ ) -> Dict[str, Any]:
138
+ mode = normalize_mode(mode)
139
+ if mode == PermissionMode.BYPASS and not acknowledge_risk:
140
+ raise PermissionError(
141
+ "bypass mode requires acknowledge_risk=true "
142
+ "(YOLO inside the agent workspace; circuit breakers still apply)"
143
+ )
144
+ with self._lock:
145
+ data = self._read()
146
+ # Lock-free helper on purpose: calling ``resolve`` here would
147
+ # re-enter ``_lock`` and deadlock every mode change.
148
+ previous = self._resolve_from(
149
+ data, user_email=user_email, workspace_id=workspace_id,
150
+ )
151
+ if workspace_id:
152
+ data.setdefault("workspaces", {})[str(workspace_id)] = mode.value
153
+ elif user_email:
154
+ data.setdefault("users", {})[str(user_email).lower()] = mode.value
155
+ else:
156
+ data["default"] = mode.value
157
+ self._write(data)
158
+ self._audit(
159
+ "permission_mode_changed",
160
+ user_email=user_email,
161
+ workspace_id=workspace_id,
162
+ previous=previous.value,
163
+ mode=mode.value,
164
+ source=source,
165
+ acknowledge_risk=bool(acknowledge_risk),
166
+ )
167
+ return self.get(user_email=user_email, workspace_id=workspace_id)
168
+
169
+
170
+ __all__ = ["PermissionModeService"]
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "9.9.5"
21
+ PRODUCT_VERSION_TARGET = "10.0.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)