ltcai 9.7.0 → 9.9.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.
- package/README.md +114 -320
- package/docs/BENCHMARKS.md +107 -0
- package/docs/CHANGELOG.md +72 -0
- package/docs/CI_AND_RELEASE_GATES.md +106 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +11 -8
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +15 -6
- package/docs/PERFORMANCE.md +9 -1
- package/docs/SECURITY_AUDIT.md +91 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/USABILITY_AUDIT.md +164 -0
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +4 -2
- package/docs/kg-schema.md +1 -1
- package/docs/spec-vs-impl.md +8 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/retrieval.py +93 -4
- package/lattice_brain/graph/retrieval_vector.py +43 -0
- package/lattice_brain/ingestion.py +399 -14
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +12 -0
- package/latticeai/api/chat.py +17 -0
- package/latticeai/api/chat_helpers.py +48 -0
- package/latticeai/api/chat_stream.py +9 -1
- package/latticeai/api/local_files.py +120 -2
- package/latticeai/api/review_queue.py +7 -0
- package/latticeai/core/agent.py +105 -8
- package/latticeai/core/agent_eval.py +222 -1
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/tool_governor.py +92 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +151 -14
- package/latticeai/services/brain_intelligence.py +72 -0
- package/latticeai/services/change_proposals.py +165 -23
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +33 -0
- package/package.json +5 -3
- package/scripts/bench_models.py +312 -0
- package/scripts/check_bundle_budget.mjs +103 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_doc_status.mjs +149 -0
- package/scripts/generate_sbom.py +70 -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 +27 -11
- package/static/app/assets/Act-CfOPUKPs.js +2 -0
- package/static/app/assets/Brain-Cgkh0Hpn.js +321 -0
- package/static/app/assets/BrainHome-D8e3wQiW.js +3 -0
- package/static/app/assets/BrainSignals-BjRBA18L.js +1 -0
- package/static/app/assets/Capture-CQQYQ1Ga.js +1 -0
- package/static/app/assets/CommandPalette-pss56Mg4.js +1 -0
- package/static/app/assets/Library-CoI3xYJk.js +1 -0
- package/static/app/assets/LivingBrain-CaU_hCxQ.js +1 -0
- package/static/app/assets/ProductFlow-Db-1O71-.js +1 -0
- package/static/app/assets/System-Dfocn0zN.js +1 -0
- package/static/app/assets/bot-Bp2x1i6F.js +1 -0
- package/static/app/assets/circle-check-CyGal42W.js +1 -0
- package/static/app/assets/cpu-DQFk82hG.js +1 -0
- package/static/app/assets/download-C70gKQVi.js +1 -0
- package/static/app/assets/folder-open-DNGytsVw.js +1 -0
- package/static/app/assets/hard-drive-CYo_bEl4.js +1 -0
- package/static/app/assets/i18n-BBMJshCW.js +7 -0
- package/static/app/assets/index-BjaXCeOZ.js +10 -0
- package/static/app/assets/{index-85wQvEie.css → index-BqRcLZR3.css} +1 -1
- package/static/app/assets/input-ChWD-Fsh.js +1 -0
- package/static/app/assets/navigation-D9D9_FPC.js +1 -0
- package/static/app/assets/network-DzLDGmtV.js +1 -0
- package/static/app/assets/primitives-CmaSAyGG.js +1 -0
- package/static/app/assets/sparkles-DXiaM1NS.js +1 -0
- package/static/app/assets/textarea-CV43-o79.js +1 -0
- package/static/app/index.html +4 -2
- package/static/sw.js +1 -1
- package/static/app/assets/Act-B6c39ays.js +0 -2
- package/static/app/assets/Brain-D7Qg4k6M.js +0 -321
- package/static/app/assets/Capture-VF_di68r.js +0 -1
- package/static/app/assets/Library-D_Gis2PA.js +0 -1
- package/static/app/assets/System-C5s5H2ov.js +0 -1
- package/static/app/assets/index-DJC_2oub.js +0 -18
- package/static/app/assets/primitives-DL4Nip8C.js +0 -1
- package/static/app/assets/textarea-woZfCXHy.js +0 -1
|
@@ -44,6 +44,63 @@ _ADDITIVE_TOOLS = frozenset({
|
|
|
44
44
|
"knowledge_graph_ingest",
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
+
# ── Mutating-tool inventory (single source of truth) ─────────────────────
|
|
48
|
+
# Every tool with a side effect is classified into exactly one category so a
|
|
49
|
+
# CI check can prove nothing slips through ungoverned. Categories:
|
|
50
|
+
# new_artifact — only ever creates new content (additive)
|
|
51
|
+
# existing_content_update — can rewrite content that already exists
|
|
52
|
+
# delete — removes existing content
|
|
53
|
+
# external_side_effect — acts outside the Brain (shell, deploy, desktop, net)
|
|
54
|
+
# internal_state — agent-internal bookkeeping, not user content
|
|
55
|
+
NEW_ARTIFACT = "new_artifact"
|
|
56
|
+
EXISTING_CONTENT_UPDATE = "existing_content_update"
|
|
57
|
+
DELETE = "delete"
|
|
58
|
+
EXTERNAL_SIDE_EFFECT = "external_side_effect"
|
|
59
|
+
INTERNAL_STATE = "internal_state"
|
|
60
|
+
|
|
61
|
+
MUTATING_TOOL_INVENTORY: Dict[str, str] = {
|
|
62
|
+
# text files — fully proposal-capable (staged + applied as reviewed)
|
|
63
|
+
"write_file": EXISTING_CONTENT_UPDATE,
|
|
64
|
+
"edit_file": EXISTING_CONTENT_UPDATE,
|
|
65
|
+
# binary/document creators — can overwrite an existing file, but their
|
|
66
|
+
# output cannot be staged as a text diff, so an overwrite is fail-closed.
|
|
67
|
+
"create_docx": EXISTING_CONTENT_UPDATE,
|
|
68
|
+
"create_xlsx": EXISTING_CONTENT_UPDATE,
|
|
69
|
+
"create_pptx": EXISTING_CONTENT_UPDATE,
|
|
70
|
+
"create_pdf": EXISTING_CONTENT_UPDATE,
|
|
71
|
+
# home-sandbox write — same story: overwrite is fail-closed until the
|
|
72
|
+
# proposal service learns to stage home-sandbox paths.
|
|
73
|
+
"local_write": EXISTING_CONTENT_UPDATE,
|
|
74
|
+
# additive-only writes
|
|
75
|
+
"create_web_project": NEW_ARTIFACT,
|
|
76
|
+
"knowledge_save": NEW_ARTIFACT,
|
|
77
|
+
"obsidian_save": NEW_ARTIFACT,
|
|
78
|
+
"knowledge_graph_ingest": NEW_ARTIFACT,
|
|
79
|
+
# agent-internal state
|
|
80
|
+
"todo_write": INTERNAL_STATE,
|
|
81
|
+
# deletions
|
|
82
|
+
"delete_file": DELETE,
|
|
83
|
+
"remove_file": DELETE,
|
|
84
|
+
"clear_history": DELETE,
|
|
85
|
+
# external side effects (approval-gated, never proposal-based)
|
|
86
|
+
"run_command": EXTERNAL_SIDE_EFFECT,
|
|
87
|
+
"build_project": EXTERNAL_SIDE_EFFECT,
|
|
88
|
+
"deploy_project": EXTERNAL_SIDE_EFFECT,
|
|
89
|
+
"computer_click": EXTERNAL_SIDE_EFFECT,
|
|
90
|
+
"computer_type": EXTERNAL_SIDE_EFFECT,
|
|
91
|
+
"computer_key": EXTERNAL_SIDE_EFFECT,
|
|
92
|
+
"computer_scroll": EXTERNAL_SIDE_EFFECT,
|
|
93
|
+
"computer_drag": EXTERNAL_SIDE_EFFECT,
|
|
94
|
+
"computer_move": EXTERNAL_SIDE_EFFECT,
|
|
95
|
+
"computer_open_app": EXTERNAL_SIDE_EFFECT,
|
|
96
|
+
"computer_open_url": EXTERNAL_SIDE_EFFECT,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Tools whose existing-content update the ChangeProposalService can actually
|
|
100
|
+
# stage AND apply as a reviewed proposal. A tool that is proposal_required but
|
|
101
|
+
# NOT here is fail-closed (blocked) rather than silently applied.
|
|
102
|
+
PROPOSAL_CAPABLE_TOOLS = frozenset({"write_file", "edit_file"})
|
|
103
|
+
|
|
47
104
|
|
|
48
105
|
def classify_tool_call(
|
|
49
106
|
name: str,
|
|
@@ -83,19 +140,53 @@ def classify_tool_call(
|
|
|
83
140
|
change = CHANGE_ADDITIVE
|
|
84
141
|
reason = "write tool without an existing target"
|
|
85
142
|
|
|
143
|
+
proposal_required = change in {CHANGE_MUTATION, CHANGE_DESTRUCTIVE}
|
|
144
|
+
proposal_supported = name in PROPOSAL_CAPABLE_TOOLS
|
|
145
|
+
# A change that must be reviewed as a proposal but that we cannot stage is
|
|
146
|
+
# fail-closed: callers must block it instead of applying it silently.
|
|
147
|
+
fail_closed = proposal_required and not proposal_supported
|
|
148
|
+
|
|
86
149
|
return {
|
|
87
150
|
"tool": name,
|
|
88
151
|
"change_class": change,
|
|
89
|
-
"proposal_required":
|
|
152
|
+
"proposal_required": proposal_required,
|
|
153
|
+
"proposal_supported": proposal_supported,
|
|
154
|
+
"fail_closed": fail_closed,
|
|
90
155
|
"reason": reason,
|
|
91
156
|
}
|
|
92
157
|
|
|
93
158
|
|
|
159
|
+
def assert_governance_coverage(tool_names: "Any") -> None:
|
|
160
|
+
"""Raise if any side-effecting registry tool is not classified.
|
|
161
|
+
|
|
162
|
+
A new mutating tool added to the registry without an inventory entry makes
|
|
163
|
+
this raise, so CI fails closed instead of shipping an ungoverned mutator.
|
|
164
|
+
Read-only tools (risk ``read``) are intentionally exempt.
|
|
165
|
+
"""
|
|
166
|
+
missing = [
|
|
167
|
+
name for name in tool_names
|
|
168
|
+
if name not in MUTATING_TOOL_INVENTORY
|
|
169
|
+
]
|
|
170
|
+
if missing:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
"ungoverned mutating tools (add to MUTATING_TOOL_INVENTORY): "
|
|
173
|
+
+ ", ".join(sorted(missing))
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
94
177
|
__all__ = [
|
|
95
178
|
"CHANGE_READ",
|
|
96
179
|
"CHANGE_ADDITIVE",
|
|
97
180
|
"CHANGE_MUTATION",
|
|
98
181
|
"CHANGE_DESTRUCTIVE",
|
|
99
182
|
"CHANGE_EXEC",
|
|
183
|
+
"NEW_ARTIFACT",
|
|
184
|
+
"EXISTING_CONTENT_UPDATE",
|
|
185
|
+
"DELETE",
|
|
186
|
+
"EXTERNAL_SIDE_EFFECT",
|
|
187
|
+
"INTERNAL_STATE",
|
|
188
|
+
"MUTATING_TOOL_INVENTORY",
|
|
189
|
+
"PROPOSAL_CAPABLE_TOOLS",
|
|
100
190
|
"classify_tool_call",
|
|
191
|
+
"assert_governance_coverage",
|
|
101
192
|
]
|
|
@@ -49,7 +49,7 @@ __all__ = [
|
|
|
49
49
|
"remove_skill_directory",
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
-
WORKSPACE_OS_VERSION = "9.
|
|
52
|
+
WORKSPACE_OS_VERSION = "9.9.0"
|
|
53
53
|
|
|
54
54
|
# Workspace types separate single-user Personal workspaces from shared
|
|
55
55
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -22,6 +22,14 @@ automation suggestions:
|
|
|
22
22
|
|
|
23
23
|
Every suggestion id is deterministic, so install calls are idempotent and the
|
|
24
24
|
UI can safely re-request suggestions without duplicates.
|
|
25
|
+
|
|
26
|
+
v9.8.0 quality layer (additive): every suggestion carries a deterministic
|
|
27
|
+
``confidence`` score plus its ``confidence_factors`` evidence (repeat count,
|
|
28
|
+
distinct phrasings, intent match, related Brain nodes / indexed files);
|
|
29
|
+
suggestions below a minimum confidence are suppressed, duplicate suggestions
|
|
30
|
+
targeting an already-suggested or already-installed starter recipe are
|
|
31
|
+
deduplicated, and the response reports the suppression counters under
|
|
32
|
+
``quality``.
|
|
25
33
|
"""
|
|
26
34
|
|
|
27
35
|
from __future__ import annotations
|
|
@@ -40,6 +48,50 @@ _MIN_PATTERN_COUNT = 2
|
|
|
40
48
|
_MAX_HISTORY = 4000
|
|
41
49
|
_SIGNATURE_SIMILARITY = 0.6
|
|
42
50
|
|
|
51
|
+
# Suggestion quality gates (v9.8.0, additive). Suggestions below the minimum
|
|
52
|
+
# confidence are suppressed (insufficient evidence); suggestions between the
|
|
53
|
+
# minimum and the low-confidence threshold are shown but flagged so the UI
|
|
54
|
+
# can render them less prominently.
|
|
55
|
+
_MIN_SUGGESTION_CONFIDENCE = 0.35
|
|
56
|
+
_LOW_CONFIDENCE_THRESHOLD = 0.5
|
|
57
|
+
_KG_GROUNDING_LIMIT = 5
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _question_confidence(
|
|
61
|
+
count: int,
|
|
62
|
+
examples: List[str],
|
|
63
|
+
recipe_id: Optional[str],
|
|
64
|
+
kg_related: Optional[int],
|
|
65
|
+
) -> tuple:
|
|
66
|
+
"""Deterministic confidence for a recurring-question suggestion.
|
|
67
|
+
|
|
68
|
+
Evidence factors: how often the question repeats, how many distinct
|
|
69
|
+
phrasings exist, whether it maps onto a known starter-recipe intent, and
|
|
70
|
+
(when the graph is available) how many Brain nodes relate to it.
|
|
71
|
+
"""
|
|
72
|
+
score = 0.3 + 0.5 * min(1.0, (int(count) - 1) / 4)
|
|
73
|
+
score += min(0.15, 0.05 * len(examples or []))
|
|
74
|
+
if recipe_id:
|
|
75
|
+
score += 0.15
|
|
76
|
+
if kg_related:
|
|
77
|
+
score += min(0.2, 0.05 * int(kg_related))
|
|
78
|
+
factors = {
|
|
79
|
+
"repeat_count": int(count),
|
|
80
|
+
"distinct_examples": len(examples or []),
|
|
81
|
+
"intent_match": bool(recipe_id),
|
|
82
|
+
"kg_related_nodes": kg_related,
|
|
83
|
+
}
|
|
84
|
+
return round(min(1.0, score), 2), factors
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _source_confidence(indexed: int, watch_enabled: bool) -> tuple:
|
|
88
|
+
"""Deterministic confidence for a knowledge-source digest suggestion."""
|
|
89
|
+
score = 0.25 + 0.6 * min(1.0, int(indexed) / 25)
|
|
90
|
+
if watch_enabled:
|
|
91
|
+
score += 0.1
|
|
92
|
+
factors = {"indexed_files": int(indexed), "watch_enabled": bool(watch_enabled)}
|
|
93
|
+
return round(min(1.0, score), 2), factors
|
|
94
|
+
|
|
43
95
|
_QUESTION_HINT_RE = re.compile(
|
|
44
96
|
r"(\?|어때|뭐야|뭐가|뭘까|알려줘|보여줘|정리해|요약해|정리 좀|요약 좀|해줘"
|
|
45
97
|
r"|what|how|why|when|where|status|summar|remind|list|show me|tell me)",
|
|
@@ -223,38 +275,101 @@ class AutomationIntelligenceService:
|
|
|
223
275
|
LOGGER.exception("automation intelligence source read failed")
|
|
224
276
|
return []
|
|
225
277
|
|
|
278
|
+
def _kg_related_count(
|
|
279
|
+
self, text: str, *, workspace_id: Optional[str]
|
|
280
|
+
) -> Optional[int]:
|
|
281
|
+
"""Count Brain nodes related to a recurring question (grounding evidence).
|
|
282
|
+
|
|
283
|
+
Returns ``None`` when the graph is unavailable so confidence scoring can
|
|
284
|
+
distinguish "grounding impossible" from "grounded with zero hits".
|
|
285
|
+
Scoping mirrors the conversation-history read: an explicit workspace is
|
|
286
|
+
strict, no workspace falls back to the unscoped/legacy view.
|
|
287
|
+
"""
|
|
288
|
+
if not self._enable_graph or not hasattr(self._kg, "search"):
|
|
289
|
+
return None
|
|
290
|
+
try:
|
|
291
|
+
report = self._kg.search(
|
|
292
|
+
str(text or "")[:200],
|
|
293
|
+
limit=_KG_GROUNDING_LIMIT,
|
|
294
|
+
allowed_workspaces={workspace_id} if workspace_id is not None else None,
|
|
295
|
+
include_legacy_global=workspace_id is None,
|
|
296
|
+
)
|
|
297
|
+
except Exception:
|
|
298
|
+
LOGGER.exception("automation intelligence KG grounding failed")
|
|
299
|
+
return None
|
|
300
|
+
if not isinstance(report, dict):
|
|
301
|
+
return None
|
|
302
|
+
return len(report.get("matches") or [])
|
|
303
|
+
|
|
226
304
|
# ── suggestions ──────────────────────────────────────────────────────
|
|
227
305
|
|
|
228
|
-
def
|
|
229
|
-
|
|
306
|
+
def _installed_workflows(
|
|
307
|
+
self, *, workspace_id: Optional[str]
|
|
308
|
+
) -> tuple:
|
|
309
|
+
"""Map installed automation workflows by suggestion id and recipe id.
|
|
310
|
+
|
|
311
|
+
The recipe map lets a recurring-question suggestion that targets an
|
|
312
|
+
already-installed starter recipe surface as *installed* instead of
|
|
313
|
+
re-suggesting the same automation — the install API is idempotent on
|
|
314
|
+
exactly this provenance, so the read side must agree.
|
|
315
|
+
"""
|
|
316
|
+
by_suggestion: Dict[str, Dict[str, Any]] = {}
|
|
317
|
+
by_recipe: Dict[str, Dict[str, Any]] = {}
|
|
230
318
|
if self._store is None:
|
|
231
|
-
return
|
|
319
|
+
return by_suggestion, by_recipe
|
|
232
320
|
try:
|
|
233
321
|
workflows = self._store.list_workflows(workspace_id=workspace_id).get("workflows") or []
|
|
234
322
|
except Exception:
|
|
235
323
|
LOGGER.exception("automation intelligence workflow read failed")
|
|
236
|
-
return
|
|
324
|
+
return by_suggestion, by_recipe
|
|
237
325
|
for workflow in workflows:
|
|
238
326
|
metadata = (workflow or {}).get("metadata") or {}
|
|
239
|
-
|
|
240
|
-
if
|
|
241
|
-
|
|
242
|
-
|
|
327
|
+
created_from = metadata.get("created_from")
|
|
328
|
+
if created_from == "automation_suggestion" and metadata.get("suggestion_id"):
|
|
329
|
+
by_suggestion[str(metadata["suggestion_id"])] = workflow
|
|
330
|
+
elif created_from == "brain_automation_recipe" and metadata.get("recipe_id"):
|
|
331
|
+
by_recipe[str(metadata["recipe_id"])] = workflow
|
|
332
|
+
return by_suggestion, by_recipe
|
|
243
333
|
|
|
244
334
|
def suggestions(
|
|
245
335
|
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
246
336
|
) -> Dict[str, Any]:
|
|
247
337
|
pattern_report = self.question_patterns(user_email=user_email, workspace_id=workspace_id)
|
|
248
|
-
|
|
338
|
+
by_suggestion, by_recipe = self._installed_workflows(workspace_id=workspace_id)
|
|
249
339
|
|
|
250
340
|
items: List[Dict[str, Any]] = []
|
|
341
|
+
suppressed_low_confidence = 0
|
|
342
|
+
suppressed_duplicates = 0
|
|
343
|
+
seen_recipe_ids: set = set()
|
|
344
|
+
|
|
345
|
+
# Patterns arrive sorted by (count, last_asked) desc, so the first
|
|
346
|
+
# suggestion per recipe is the strongest — later ones targeting the
|
|
347
|
+
# same recipe would install the identical workflow and are suppressed.
|
|
251
348
|
for pattern in pattern_report["patterns"]:
|
|
349
|
+
recipe_id = pattern["recipe_id"]
|
|
350
|
+
if recipe_id and recipe_id in seen_recipe_ids:
|
|
351
|
+
suppressed_duplicates += 1
|
|
352
|
+
continue
|
|
353
|
+
kg_related = self._kg_related_count(
|
|
354
|
+
pattern["representative"], workspace_id=workspace_id
|
|
355
|
+
)
|
|
356
|
+
confidence, factors = _question_confidence(
|
|
357
|
+
pattern["count"], pattern["examples"], recipe_id, kg_related
|
|
358
|
+
)
|
|
359
|
+
if confidence < _MIN_SUGGESTION_CONFIDENCE:
|
|
360
|
+
suppressed_low_confidence += 1
|
|
361
|
+
continue
|
|
362
|
+
if recipe_id:
|
|
363
|
+
seen_recipe_ids.add(recipe_id)
|
|
252
364
|
suggestion_id = _stable_id("sug-q", pattern["id"])
|
|
365
|
+
workflow = by_suggestion.get(suggestion_id) or (
|
|
366
|
+
by_recipe.get(str(recipe_id)) if recipe_id else None
|
|
367
|
+
)
|
|
253
368
|
items.append({
|
|
254
369
|
"id": suggestion_id,
|
|
255
370
|
"kind": "recurring_question",
|
|
256
371
|
"intent": pattern["intent"],
|
|
257
|
-
"recipe_id":
|
|
372
|
+
"recipe_id": recipe_id,
|
|
258
373
|
"title": pattern["representative"],
|
|
259
374
|
"reason": {
|
|
260
375
|
"type": "repeated_question",
|
|
@@ -263,8 +378,11 @@ class AutomationIntelligenceService:
|
|
|
263
378
|
"examples": pattern["examples"],
|
|
264
379
|
},
|
|
265
380
|
"cadence": "daily",
|
|
266
|
-
"
|
|
267
|
-
"
|
|
381
|
+
"confidence": confidence,
|
|
382
|
+
"confidence_factors": factors,
|
|
383
|
+
"low_confidence": confidence < _LOW_CONFIDENCE_THRESHOLD,
|
|
384
|
+
"installed": workflow is not None,
|
|
385
|
+
"workflow_id": (workflow or {}).get("id"),
|
|
268
386
|
})
|
|
269
387
|
|
|
270
388
|
for source in self._knowledge_sources():
|
|
@@ -272,6 +390,14 @@ class AutomationIntelligenceService:
|
|
|
272
390
|
indexed = sum(int(v or 0) for v in file_status.values())
|
|
273
391
|
if indexed <= 0:
|
|
274
392
|
continue
|
|
393
|
+
confidence, factors = _source_confidence(
|
|
394
|
+
indexed, bool(source.get("watch_enabled"))
|
|
395
|
+
)
|
|
396
|
+
if confidence < _MIN_SUGGESTION_CONFIDENCE:
|
|
397
|
+
# A barely-indexed folder is not enough evidence for a digest
|
|
398
|
+
# automation yet — do not suggest.
|
|
399
|
+
suppressed_low_confidence += 1
|
|
400
|
+
continue
|
|
275
401
|
suggestion_id = _stable_id("sug-src", str(source.get("id") or source.get("root_path") or ""))
|
|
276
402
|
items.append({
|
|
277
403
|
"id": suggestion_id,
|
|
@@ -286,13 +412,22 @@ class AutomationIntelligenceService:
|
|
|
286
412
|
"watch_enabled": bool(source.get("watch_enabled")),
|
|
287
413
|
},
|
|
288
414
|
"cadence": "when new knowledge arrives",
|
|
289
|
-
"
|
|
290
|
-
"
|
|
415
|
+
"confidence": confidence,
|
|
416
|
+
"confidence_factors": factors,
|
|
417
|
+
"low_confidence": confidence < _LOW_CONFIDENCE_THRESHOLD,
|
|
418
|
+
"installed": suggestion_id in by_suggestion,
|
|
419
|
+
"workflow_id": (by_suggestion.get(suggestion_id) or {}).get("id"),
|
|
291
420
|
})
|
|
292
421
|
|
|
293
422
|
return {
|
|
294
423
|
"suggestions": items,
|
|
295
424
|
"questions_scanned": pattern_report["questions_scanned"],
|
|
425
|
+
"quality": {
|
|
426
|
+
"min_confidence": _MIN_SUGGESTION_CONFIDENCE,
|
|
427
|
+
"low_confidence_threshold": _LOW_CONFIDENCE_THRESHOLD,
|
|
428
|
+
"suppressed_low_confidence": suppressed_low_confidence,
|
|
429
|
+
"suppressed_duplicates": suppressed_duplicates,
|
|
430
|
+
},
|
|
296
431
|
"consent": {
|
|
297
432
|
"default_state": "draft_disabled",
|
|
298
433
|
"local_only": True,
|
|
@@ -403,6 +538,7 @@ class AutomationIntelligenceService:
|
|
|
403
538
|
"suggestion_kind": kind,
|
|
404
539
|
"suggestion_title": title,
|
|
405
540
|
"suggestion_reason": suggestion.get("reason"),
|
|
541
|
+
"suggestion_confidence": suggestion.get("confidence"),
|
|
406
542
|
"automation_state": "enabled" if enabled else "draft_disabled",
|
|
407
543
|
"local_only": True,
|
|
408
544
|
"external_actions": False,
|
|
@@ -442,6 +578,7 @@ class AutomationIntelligenceService:
|
|
|
442
578
|
"suggestions": suggestion_report["suggestions"],
|
|
443
579
|
"questions_scanned": suggestion_report["questions_scanned"],
|
|
444
580
|
"installed": installed,
|
|
581
|
+
"quality": suggestion_report["quality"],
|
|
445
582
|
"consent": suggestion_report["consent"],
|
|
446
583
|
"generated_at": _now(),
|
|
447
584
|
}
|
|
@@ -257,6 +257,78 @@ class BrainIntelligenceService:
|
|
|
257
257
|
"generated_at": _now(),
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
# ── vector freshness (v9.8.0) ────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def vector_freshness(
|
|
263
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
264
|
+
) -> Dict[str, Any]:
|
|
265
|
+
"""Fixed-contract vector index freshness for ``/api/brain/vector-freshness``.
|
|
266
|
+
|
|
267
|
+
Always returns ``{"status": "ready"|"pending"|"unavailable",
|
|
268
|
+
"pending_items": int, "total_items": int, "detail": str}`` and never
|
|
269
|
+
raises. The vector index is store-global (not workspace-partitioned);
|
|
270
|
+
scope arguments are accepted for router symmetry but do not narrow
|
|
271
|
+
the report.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def _unavailable(detail: str) -> Dict[str, Any]:
|
|
275
|
+
return {
|
|
276
|
+
"status": "unavailable",
|
|
277
|
+
"pending_items": 0,
|
|
278
|
+
"total_items": 0,
|
|
279
|
+
"detail": detail,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if not self._enable_graph or self._kg is None:
|
|
283
|
+
return _unavailable("knowledge graph is disabled; no vector index is configured")
|
|
284
|
+
|
|
285
|
+
freshness_fn = getattr(self._kg, "vector_freshness", None)
|
|
286
|
+
if callable(freshness_fn):
|
|
287
|
+
try:
|
|
288
|
+
raw = freshness_fn() or {}
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
LOGGER.exception("vector freshness read failed")
|
|
291
|
+
return _unavailable(f"vector freshness read failed: {exc}")
|
|
292
|
+
status = str(raw.get("status") or "unavailable")
|
|
293
|
+
if status == "needs_reindex":
|
|
294
|
+
status = "pending"
|
|
295
|
+
if status not in {"ready", "pending", "unavailable"}:
|
|
296
|
+
status = "unavailable"
|
|
297
|
+
return {
|
|
298
|
+
"status": status,
|
|
299
|
+
"pending_items": int(raw.get("pending_items") or 0),
|
|
300
|
+
"total_items": int(raw.get("total_items") or 0),
|
|
301
|
+
"detail": str(raw.get("detail") or ""),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
# Older/lighter stores: summarize index_status directly.
|
|
305
|
+
status_fn = getattr(self._kg, "index_status", None)
|
|
306
|
+
if callable(status_fn):
|
|
307
|
+
try:
|
|
308
|
+
raw = status_fn() or {}
|
|
309
|
+
except Exception as exc:
|
|
310
|
+
LOGGER.exception("vector index status read failed")
|
|
311
|
+
return _unavailable(f"vector index status unavailable: {exc}")
|
|
312
|
+
pending = int(raw.get("pending_items") or 0)
|
|
313
|
+
total = int(raw.get("source_items") or 0)
|
|
314
|
+
if pending > 0:
|
|
315
|
+
return {
|
|
316
|
+
"status": "pending",
|
|
317
|
+
"pending_items": pending,
|
|
318
|
+
"total_items": total,
|
|
319
|
+
"detail": (
|
|
320
|
+
f"{pending} of {total} items are missing or stale in the vector index"
|
|
321
|
+
),
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
"status": "ready",
|
|
325
|
+
"pending_items": 0,
|
|
326
|
+
"total_items": total,
|
|
327
|
+
"detail": "vector index is up to date",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return _unavailable("this knowledge store does not expose a vector index")
|
|
331
|
+
|
|
260
332
|
# ── insights digest ──────────────────────────────────────────────────
|
|
261
333
|
|
|
262
334
|
def insights(
|