ltcai 9.9.5 → 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.
- package/README.md +41 -36
- package/docs/CHANGELOG.md +193 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +3 -1
- package/docs/PERMISSION_MODE.md +107 -0
- package/docs/SURFACE_PARITY.md +38 -8
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/context.py +39 -4
- package/lattice_brain/graph/_kg_common.py +189 -14
- package/lattice_brain/graph/curator.py +62 -0
- package/lattice_brain/graph/discovery.py +76 -0
- package/lattice_brain/graph/ingest.py +37 -7
- package/lattice_brain/graph/retrieval_vector.py +10 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +8 -0
- package/latticeai/api/chat.py +9 -0
- package/latticeai/api/chat_agent_http.py +119 -2
- package/latticeai/api/chat_contracts.py +9 -0
- package/latticeai/api/chat_documents.py +16 -0
- package/latticeai/api/evidence_actions.py +48 -0
- package/latticeai/api/permission_mode.py +61 -0
- package/latticeai/api/project_sessions.py +112 -0
- package/latticeai/api/voice_capture.py +89 -0
- package/latticeai/app_factory.py +53 -0
- package/latticeai/core/agent.py +381 -43
- package/latticeai/core/agent_permission.py +160 -0
- package/latticeai/core/agent_profiles.py +110 -0
- package/latticeai/core/artifact_ledger.py +109 -0
- package/latticeai/core/context_builder.py +101 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/permission_mode.py +296 -0
- package/latticeai/core/project_sessions.py +337 -0
- package/latticeai/core/run_explain.py +421 -0
- package/latticeai/core/run_store.py +5 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +188 -0
- package/latticeai/runtime/chat_wiring.py +8 -0
- package/latticeai/runtime/context_runtime.py +11 -0
- package/latticeai/runtime/permission_mode_wiring.py +116 -0
- package/latticeai/runtime/router_registration.py +9 -0
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +105 -0
- package/latticeai/services/evidence_actions.py +254 -0
- package/latticeai/services/funnel_metrics.py +111 -16
- package/latticeai/services/local_knowledge.py +26 -0
- package/latticeai/services/memory_service.py +12 -0
- package/latticeai/services/permission_mode_service.py +170 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +73 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +5 -3
- package/scripts/check_bundle_budget.mjs +11 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +148 -0
- package/skills/meeting_notes/SKILL.md +82 -0
- package/skills/meeting_notes/examples.md +41 -0
- package/skills/meeting_notes/risk.json +9 -0
- package/skills/meeting_notes/schema.json +55 -0
- package/skills/weekly_review/SKILL.md +75 -0
- package/skills/weekly_review/examples.md +39 -0
- package/skills/weekly_review/risk.json +9 -0
- package/skills/weekly_review/schema.json +55 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -29
- package/static/app/assets/Act-BFbpl2_Y.js +1 -0
- package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
- package/static/app/assets/Brain-DGrV0heZ.js +321 -0
- package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
- package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
- package/static/app/assets/Capture-DRaf1zLH.js +1 -0
- package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
- package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
- package/static/app/assets/Library-BVtrSt12.js +1 -0
- package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
- package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
- package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
- package/static/app/assets/System-B4nQDy8a.js +1 -0
- package/static/app/assets/{bot-VmP0kkeA.js → bot-DtKkLfXj.js} +1 -1
- package/static/app/assets/brain-wVV63_1z.js +1 -0
- package/static/app/assets/button-BTxZDMFJ.js +1 -0
- package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CJhjRtNq.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-BFnwi1Lu.js} +1 -1
- package/static/app/assets/index-BFtDYI6l.js +10 -0
- package/static/app/assets/index-BY9bpxyx.css +2 -0
- package/static/app/assets/input-DEz5UjBz.js +1 -0
- package/static/app/assets/{network-Ck1nqc-v.js → network-zKa3jAwl.js} +1 -1
- package/static/app/assets/primitives-jNrZm2O9.js +1 -0
- package/static/app/assets/search-ku6-_6wY.js +1 -0
- package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
- package/static/app/assets/textarea-DpcQkn75.js +1 -0
- package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-CcVlXlBd.js} +1 -1
- package/static/app/assets/users-C0cFMj6o.js +1 -0
- package/static/app/assets/utils-DYf7pqrR.js +7 -0
- package/static/app/assets/workspace-CjthHsLN.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmxfbqBp.js +0 -1
- package/static/app/assets/Brain-DK_zKCda.js +0 -321
- package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
- package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
- package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
- package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
- package/static/app/assets/Library-4As9VUvy.js +0 -1
- package/static/app/assets/LivingBrain-BVWk58MW.js +0 -1
- package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
- package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
- package/static/app/assets/System-DSR2zKWd.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-CEu0Wqjl.js +0 -10
- package/static/app/assets/input-CSi6OBJ9.js +0 -1
- package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
- package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
- package/static/app/assets/textarea-C4wM0D-Z.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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"]
|