ltcai 9.2.0 → 9.4.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 +34 -22
- package/docs/CHANGELOG.md +49 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/docs/mcp-tools.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/automation_intelligence.py +129 -0
- package/latticeai/api/brain_intelligence.py +68 -0
- package/latticeai/app_factory.py +17 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/persistence_runtime.py +15 -0
- package/latticeai/runtime/router_registration.py +16 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +450 -0
- package/latticeai/services/brain_intelligence.py +439 -0
- package/latticeai/services/memory_service.py +65 -5
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/router_context.py +1 -0
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- 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 +11 -11
- package/static/app/assets/Act-t9oveJO7.js +1 -0
- package/static/app/assets/{Brain-DBYgdcjt.js → Brain-DP-gpcEJ.js} +1 -1
- package/static/app/assets/{Capture-Cf3hqRtN.js → Capture-DYknDKy8.js} +1 -1
- package/static/app/assets/{Library-CFfkNn3s.js → Library-DLyc_g8c.js} +1 -1
- package/static/app/assets/{System-BOurbT-v.js → System-BZgJ7tGu.js} +1 -1
- package/static/app/assets/index-BeQ77vPs.js +17 -0
- package/static/app/assets/index-Cl4S_9Id.css +2 -0
- package/static/app/assets/{primitives-DcUUmhdC.js → primitives-DawfkPR4.js} +1 -1
- package/static/app/assets/{textarea-BklR6zN4.js → textarea-a4Ir3SZS.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/Act-C3dBrWE-.js +0 -1
- package/static/app/assets/index-A3M9sElj.js +0 -17
- package/static/app/assets/index-Bmx9rzTc.css +0 -2
|
@@ -36,6 +36,8 @@ def build_persistence_runtime(
|
|
|
36
36
|
from latticeai.core.plugins import PluginRegistry
|
|
37
37
|
from latticeai.core.realtime import RealtimeBus
|
|
38
38
|
from latticeai.core.workspace_os import WorkspaceOSStore
|
|
39
|
+
from latticeai.services.automation_intelligence import AutomationIntelligenceService
|
|
40
|
+
from latticeai.services.brain_intelligence import BrainIntelligenceService
|
|
39
41
|
from latticeai.services.memory_service import MemoryService
|
|
40
42
|
from latticeai.services.workspace_service import WorkspaceService
|
|
41
43
|
|
|
@@ -57,6 +59,17 @@ def build_persistence_runtime(
|
|
|
57
59
|
history_file=history_file,
|
|
58
60
|
conversation_store=conversations,
|
|
59
61
|
)
|
|
62
|
+
brain_intelligence = BrainIntelligenceService(
|
|
63
|
+
knowledge_graph=knowledge_graph,
|
|
64
|
+
memory_service=memory_service,
|
|
65
|
+
enable_graph=enable_graph,
|
|
66
|
+
)
|
|
67
|
+
automation_intelligence = AutomationIntelligenceService(
|
|
68
|
+
conversation_store=conversations,
|
|
69
|
+
knowledge_graph=knowledge_graph,
|
|
70
|
+
store=workspace_os,
|
|
71
|
+
enable_graph=enable_graph,
|
|
72
|
+
)
|
|
60
73
|
ingestion_pipeline = IngestionPipeline(
|
|
61
74
|
knowledge_graph,
|
|
62
75
|
hooks=hooks_registry,
|
|
@@ -81,6 +94,8 @@ def build_persistence_runtime(
|
|
|
81
94
|
"TEMPLATE_CATALOG": template_catalog,
|
|
82
95
|
"AGENT_REGISTRY": agent_registry,
|
|
83
96
|
"MEMORY_SERVICE": memory_service,
|
|
97
|
+
"BRAIN_INTELLIGENCE": brain_intelligence,
|
|
98
|
+
"AUTOMATION_INTELLIGENCE": automation_intelligence,
|
|
84
99
|
"INGESTION_PIPELINE": ingestion_pipeline,
|
|
85
100
|
"DEVICE_IDENTITY": device_identity,
|
|
86
101
|
"KG_PORTABILITY": kg_portability,
|
|
@@ -485,6 +485,8 @@ def register_interaction_routers(
|
|
|
485
485
|
memory_service: Any = None,
|
|
486
486
|
platform: Any = None,
|
|
487
487
|
active_model_getter: Any = None,
|
|
488
|
+
create_brain_intelligence_router: Any = None,
|
|
489
|
+
brain_intelligence: Any = None,
|
|
488
490
|
) -> tuple[Any, ...]:
|
|
489
491
|
"""Register chat/search/tools/hooks/registry/memory routes in order."""
|
|
490
492
|
|
|
@@ -504,6 +506,7 @@ def register_interaction_routers(
|
|
|
504
506
|
memory_service = interaction_context.memory_service
|
|
505
507
|
platform = interaction_context.platform
|
|
506
508
|
active_model_getter = interaction_context.active_model_getter
|
|
509
|
+
brain_intelligence = interaction_context.brain_intelligence
|
|
507
510
|
|
|
508
511
|
return register_routers(
|
|
509
512
|
app,
|
|
@@ -561,6 +564,19 @@ def register_interaction_routers(
|
|
|
561
564
|
append_audit_event=append_audit_event,
|
|
562
565
|
active_model_getter=active_model_getter,
|
|
563
566
|
),
|
|
567
|
+
*(
|
|
568
|
+
(
|
|
569
|
+
create_brain_intelligence_router(
|
|
570
|
+
service=brain_intelligence,
|
|
571
|
+
require_user=require_user,
|
|
572
|
+
gate_read=platform.gate_read,
|
|
573
|
+
gate_write=platform.gate_write,
|
|
574
|
+
append_audit_event=append_audit_event,
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
if create_brain_intelligence_router is not None and brain_intelligence is not None
|
|
578
|
+
else ()
|
|
579
|
+
),
|
|
564
580
|
)
|
|
565
581
|
|
|
566
582
|
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Question-driven everyday automation intelligence (v9.4.0).
|
|
2
|
+
|
|
3
|
+
Users repeat themselves: the same "오늘 뭐 해야 하지?", "프로젝트 상태 정리해줘",
|
|
4
|
+
"그 폴더에 새 문서 뭐 들어왔어?" questions come back every day. This service
|
|
5
|
+
watches for those patterns and turns them into one-click, consent-first
|
|
6
|
+
automation suggestions:
|
|
7
|
+
|
|
8
|
+
* **question_patterns** — mines the user's own chat history for recurring
|
|
9
|
+
question intents. Clustering is deterministic and local (token-signature
|
|
10
|
+
similarity, no model call), so the evidence shown to the user is their own
|
|
11
|
+
literal questions.
|
|
12
|
+
* **suggestions** — converts recurring patterns and active local knowledge
|
|
13
|
+
sources (folders the user connected to the Brain) into concrete automation
|
|
14
|
+
proposals: a scheduled answer for a recurring question, a digest for a busy
|
|
15
|
+
knowledge folder, or one of the starter recipes when the intent matches.
|
|
16
|
+
* **build_suggestion_workflow** — turns an accepted suggestion into a
|
|
17
|
+
workflow definition using the same consent-first shape as the starter
|
|
18
|
+
recipes: installed as a disabled draft, review-queue gated, local-only,
|
|
19
|
+
no external actions.
|
|
20
|
+
* **overview** — one payload for the automation surface: suggestions,
|
|
21
|
+
installed automations with their enable state, and pattern evidence.
|
|
22
|
+
|
|
23
|
+
Every suggestion id is deterministic, so install calls are idempotent and the
|
|
24
|
+
UI can safely re-request suggestions without duplicates.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import logging
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any, Dict, List, Optional
|
|
34
|
+
|
|
35
|
+
from latticeai.core.timeutil import now_iso as _now
|
|
36
|
+
|
|
37
|
+
LOGGER = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
_MIN_PATTERN_COUNT = 2
|
|
40
|
+
_MAX_HISTORY = 4000
|
|
41
|
+
_SIGNATURE_SIMILARITY = 0.6
|
|
42
|
+
|
|
43
|
+
_QUESTION_HINT_RE = re.compile(
|
|
44
|
+
r"(\?|어때|뭐야|뭐가|뭘까|알려줘|보여줘|정리해|요약해|정리 좀|요약 좀|해줘"
|
|
45
|
+
r"|what|how|why|when|where|status|summar|remind|list|show me|tell me)",
|
|
46
|
+
re.IGNORECASE,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_STOPWORDS = {
|
|
50
|
+
"the", "a", "an", "is", "are", "was", "to", "of", "in", "on", "for", "and",
|
|
51
|
+
"or", "me", "my", "you", "please", "can", "could", "would", "it", "this",
|
|
52
|
+
"that", "do", "does", "what", "how", "about",
|
|
53
|
+
"좀", "그", "이", "저", "것", "거", "게", "내", "제", "나", "너", "우리",
|
|
54
|
+
"해줘", "해주세요", "주세요", "합니다", "있어", "있나", "있는지", "어떻게",
|
|
55
|
+
"뭐야", "뭐가", "알려줘", "보여줘",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Intent buckets map recurring question language onto the starter recipes.
|
|
59
|
+
_INTENT_RULES = (
|
|
60
|
+
(
|
|
61
|
+
"digest",
|
|
62
|
+
re.compile(r"(오늘|하루|today|daily).{0,12}(정리|요약|digest|summary|기억|메모)|"
|
|
63
|
+
r"(정리|요약).{0,8}(해줘|해 줘|부탁)|summar(y|ize)", re.IGNORECASE),
|
|
64
|
+
"daily-memory-digest",
|
|
65
|
+
),
|
|
66
|
+
(
|
|
67
|
+
"project_review",
|
|
68
|
+
re.compile(r"(프로젝트|project|진행|progress|status|상태|주간|weekly|이번 주)", re.IGNORECASE),
|
|
69
|
+
"weekly-project-review",
|
|
70
|
+
),
|
|
71
|
+
(
|
|
72
|
+
"follow_up",
|
|
73
|
+
re.compile(r"(리마인드|remind|챙겨|잊지|deadline|마감|follow.?up|나중에|까먹)", re.IGNORECASE),
|
|
74
|
+
"follow-up-radar",
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _tokens(text: str) -> List[str]:
|
|
80
|
+
return [
|
|
81
|
+
token
|
|
82
|
+
for token in re.findall(r"[\w가-힣]+", str(text or "").lower())
|
|
83
|
+
if len(token) > 1 and token not in _STOPWORDS
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _signature(tokens: List[str]) -> frozenset:
|
|
88
|
+
return frozenset(tokens)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _similarity(left: frozenset, right: frozenset) -> float:
|
|
92
|
+
if not left or not right:
|
|
93
|
+
return 0.0
|
|
94
|
+
return len(left & right) / len(left | right)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _stable_id(prefix: str, seed: str) -> str:
|
|
98
|
+
return f"{prefix}-{hashlib.sha256(seed.encode('utf-8')).hexdigest()[:10]}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class _Pattern:
|
|
103
|
+
representative: str
|
|
104
|
+
signature: frozenset
|
|
105
|
+
count: int = 1
|
|
106
|
+
last_asked: str = ""
|
|
107
|
+
intent: str = "recurring_question"
|
|
108
|
+
recipe_id: Optional[str] = None
|
|
109
|
+
examples: List[str] = field(default_factory=list)
|
|
110
|
+
|
|
111
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
112
|
+
return {
|
|
113
|
+
"id": _stable_id("pat", " ".join(sorted(self.signature))),
|
|
114
|
+
"representative": self.representative[:160],
|
|
115
|
+
"count": self.count,
|
|
116
|
+
"last_asked": self.last_asked,
|
|
117
|
+
"intent": self.intent,
|
|
118
|
+
"recipe_id": self.recipe_id,
|
|
119
|
+
"examples": self.examples[:3],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AutomationIntelligenceService:
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
conversation_store: Any = None,
|
|
128
|
+
knowledge_graph: Any = None,
|
|
129
|
+
store: Any = None,
|
|
130
|
+
enable_graph: bool = True,
|
|
131
|
+
) -> None:
|
|
132
|
+
self._conversations = conversation_store
|
|
133
|
+
self._kg = knowledge_graph
|
|
134
|
+
self._store = store
|
|
135
|
+
self._enable_graph = bool(enable_graph and knowledge_graph is not None)
|
|
136
|
+
|
|
137
|
+
# ── question pattern mining ──────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def _user_questions(
|
|
140
|
+
self, *, user_email: Optional[str], workspace_id: Optional[str]
|
|
141
|
+
) -> List[Dict[str, Any]]:
|
|
142
|
+
if self._conversations is None:
|
|
143
|
+
return []
|
|
144
|
+
try:
|
|
145
|
+
items = self._conversations.history(
|
|
146
|
+
user_email=user_email,
|
|
147
|
+
allowed_workspaces={workspace_id} if workspace_id is not None else None,
|
|
148
|
+
include_legacy_global=workspace_id is None,
|
|
149
|
+
limit=_MAX_HISTORY,
|
|
150
|
+
)
|
|
151
|
+
except Exception:
|
|
152
|
+
LOGGER.exception("automation intelligence history read failed")
|
|
153
|
+
return []
|
|
154
|
+
questions = []
|
|
155
|
+
for item in items:
|
|
156
|
+
if item.get("role") != "user":
|
|
157
|
+
continue
|
|
158
|
+
content = str(item.get("content") or "").strip()
|
|
159
|
+
if not content or len(content) < 6 or content.startswith("/"):
|
|
160
|
+
continue
|
|
161
|
+
if not _QUESTION_HINT_RE.search(content):
|
|
162
|
+
continue
|
|
163
|
+
questions.append({"content": content, "timestamp": str(item.get("timestamp") or "")})
|
|
164
|
+
return questions
|
|
165
|
+
|
|
166
|
+
def question_patterns(
|
|
167
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
168
|
+
) -> Dict[str, Any]:
|
|
169
|
+
questions = self._user_questions(user_email=user_email, workspace_id=workspace_id)
|
|
170
|
+
patterns: List[_Pattern] = []
|
|
171
|
+
for question in questions:
|
|
172
|
+
tokens = _tokens(question["content"])
|
|
173
|
+
if len(tokens) < 2:
|
|
174
|
+
continue
|
|
175
|
+
signature = _signature(tokens)
|
|
176
|
+
match = None
|
|
177
|
+
for pattern in patterns:
|
|
178
|
+
if _similarity(pattern.signature, signature) >= _SIGNATURE_SIMILARITY:
|
|
179
|
+
match = pattern
|
|
180
|
+
break
|
|
181
|
+
if match is None:
|
|
182
|
+
patterns.append(
|
|
183
|
+
_Pattern(
|
|
184
|
+
representative=question["content"],
|
|
185
|
+
signature=signature,
|
|
186
|
+
last_asked=question["timestamp"],
|
|
187
|
+
examples=[question["content"]],
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
continue
|
|
191
|
+
match.count += 1
|
|
192
|
+
# Union keeps the cluster stable as phrasing drifts.
|
|
193
|
+
match.signature = match.signature | signature
|
|
194
|
+
if question["timestamp"] >= match.last_asked:
|
|
195
|
+
match.last_asked = question["timestamp"]
|
|
196
|
+
match.representative = question["content"]
|
|
197
|
+
if question["content"] not in match.examples:
|
|
198
|
+
match.examples.append(question["content"])
|
|
199
|
+
|
|
200
|
+
recurring = [p for p in patterns if p.count >= _MIN_PATTERN_COUNT]
|
|
201
|
+
for pattern in recurring:
|
|
202
|
+
for intent, rule, recipe_id in _INTENT_RULES:
|
|
203
|
+
if rule.search(pattern.representative):
|
|
204
|
+
pattern.intent = intent
|
|
205
|
+
pattern.recipe_id = recipe_id
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
recurring.sort(key=lambda p: (p.count, p.last_asked), reverse=True)
|
|
209
|
+
return {
|
|
210
|
+
"questions_scanned": len(questions),
|
|
211
|
+
"patterns": [p.as_dict() for p in recurring[:20]],
|
|
212
|
+
"generated_at": _now(),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# ── knowledge sources ────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
def _knowledge_sources(self) -> List[Dict[str, Any]]:
|
|
218
|
+
if not self._enable_graph or not hasattr(self._kg, "local_sources"):
|
|
219
|
+
return []
|
|
220
|
+
try:
|
|
221
|
+
return list(self._kg.local_sources().get("sources") or [])
|
|
222
|
+
except Exception:
|
|
223
|
+
LOGGER.exception("automation intelligence source read failed")
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
# ── suggestions ──────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def _installed_suggestion_ids(self, *, workspace_id: Optional[str]) -> Dict[str, Dict[str, Any]]:
|
|
229
|
+
installed: Dict[str, Dict[str, Any]] = {}
|
|
230
|
+
if self._store is None:
|
|
231
|
+
return installed
|
|
232
|
+
try:
|
|
233
|
+
workflows = self._store.list_workflows(workspace_id=workspace_id).get("workflows") or []
|
|
234
|
+
except Exception:
|
|
235
|
+
LOGGER.exception("automation intelligence workflow read failed")
|
|
236
|
+
return installed
|
|
237
|
+
for workflow in workflows:
|
|
238
|
+
metadata = (workflow or {}).get("metadata") or {}
|
|
239
|
+
suggestion_id = metadata.get("suggestion_id")
|
|
240
|
+
if metadata.get("created_from") == "automation_suggestion" and suggestion_id:
|
|
241
|
+
installed[str(suggestion_id)] = workflow
|
|
242
|
+
return installed
|
|
243
|
+
|
|
244
|
+
def suggestions(
|
|
245
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
246
|
+
) -> Dict[str, Any]:
|
|
247
|
+
pattern_report = self.question_patterns(user_email=user_email, workspace_id=workspace_id)
|
|
248
|
+
installed = self._installed_suggestion_ids(workspace_id=workspace_id)
|
|
249
|
+
|
|
250
|
+
items: List[Dict[str, Any]] = []
|
|
251
|
+
for pattern in pattern_report["patterns"]:
|
|
252
|
+
suggestion_id = _stable_id("sug-q", pattern["id"])
|
|
253
|
+
items.append({
|
|
254
|
+
"id": suggestion_id,
|
|
255
|
+
"kind": "recurring_question",
|
|
256
|
+
"intent": pattern["intent"],
|
|
257
|
+
"recipe_id": pattern["recipe_id"],
|
|
258
|
+
"title": pattern["representative"],
|
|
259
|
+
"reason": {
|
|
260
|
+
"type": "repeated_question",
|
|
261
|
+
"count": pattern["count"],
|
|
262
|
+
"last_asked": pattern["last_asked"],
|
|
263
|
+
"examples": pattern["examples"],
|
|
264
|
+
},
|
|
265
|
+
"cadence": "daily",
|
|
266
|
+
"installed": suggestion_id in installed,
|
|
267
|
+
"workflow_id": (installed.get(suggestion_id) or {}).get("id"),
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
for source in self._knowledge_sources():
|
|
271
|
+
file_status = source.get("file_status") or {}
|
|
272
|
+
indexed = sum(int(v or 0) for v in file_status.values())
|
|
273
|
+
if indexed <= 0:
|
|
274
|
+
continue
|
|
275
|
+
suggestion_id = _stable_id("sug-src", str(source.get("id") or source.get("root_path") or ""))
|
|
276
|
+
items.append({
|
|
277
|
+
"id": suggestion_id,
|
|
278
|
+
"kind": "knowledge_source",
|
|
279
|
+
"intent": "source_digest",
|
|
280
|
+
"recipe_id": None,
|
|
281
|
+
"title": str(source.get("label") or source.get("root_path") or "knowledge folder"),
|
|
282
|
+
"reason": {
|
|
283
|
+
"type": "connected_source",
|
|
284
|
+
"root_path": source.get("root_path"),
|
|
285
|
+
"indexed_files": indexed,
|
|
286
|
+
"watch_enabled": bool(source.get("watch_enabled")),
|
|
287
|
+
},
|
|
288
|
+
"cadence": "when new knowledge arrives",
|
|
289
|
+
"installed": suggestion_id in installed,
|
|
290
|
+
"workflow_id": (installed.get(suggestion_id) or {}).get("id"),
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
"suggestions": items,
|
|
295
|
+
"questions_scanned": pattern_report["questions_scanned"],
|
|
296
|
+
"consent": {
|
|
297
|
+
"default_state": "draft_disabled",
|
|
298
|
+
"local_only": True,
|
|
299
|
+
"external_actions": False,
|
|
300
|
+
"requires_user_enable": True,
|
|
301
|
+
"review_before_run": True,
|
|
302
|
+
},
|
|
303
|
+
"generated_at": _now(),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
def find_suggestion(
|
|
307
|
+
self,
|
|
308
|
+
suggestion_id: str,
|
|
309
|
+
*,
|
|
310
|
+
user_email: Optional[str] = None,
|
|
311
|
+
workspace_id: Optional[str] = None,
|
|
312
|
+
) -> Optional[Dict[str, Any]]:
|
|
313
|
+
for suggestion in self.suggestions(user_email=user_email, workspace_id=workspace_id)["suggestions"]:
|
|
314
|
+
if suggestion["id"] == suggestion_id:
|
|
315
|
+
return suggestion
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
# ── workflow building ────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
def build_suggestion_workflow(
|
|
321
|
+
self, suggestion: Dict[str, Any], *, enabled: bool = False
|
|
322
|
+
) -> Dict[str, Any]:
|
|
323
|
+
"""Build a consent-first workflow definition from a suggestion.
|
|
324
|
+
|
|
325
|
+
Mirrors the starter-recipe shape: trigger → draft agent → review
|
|
326
|
+
output, installed disabled by default, review-queue gated, local
|
|
327
|
+
only, and stamped with provenance so installs stay idempotent.
|
|
328
|
+
"""
|
|
329
|
+
kind = suggestion.get("kind")
|
|
330
|
+
title = str(suggestion.get("title") or "automation")[:80]
|
|
331
|
+
if kind == "knowledge_source":
|
|
332
|
+
trigger: Dict[str, Any] = {"trigger": "brain_event"}
|
|
333
|
+
trigger_name = "New knowledge in connected folder"
|
|
334
|
+
name = f"Folder digest: {title}"
|
|
335
|
+
reason = suggestion.get("reason") or {}
|
|
336
|
+
prompt = (
|
|
337
|
+
"New knowledge arrived from the connected folder "
|
|
338
|
+
f"'{reason.get('root_path') or title}'. Draft a short digest of what "
|
|
339
|
+
"was added, what changed, and any follow-ups it suggests. Use only "
|
|
340
|
+
"the local Brain; do not contact external services."
|
|
341
|
+
)
|
|
342
|
+
creates = ["folder digest", "change summary", "follow-up suggestions"]
|
|
343
|
+
else:
|
|
344
|
+
trigger = {"trigger": "interval", "interval_seconds": 86_400}
|
|
345
|
+
trigger_name = "User-enabled schedule"
|
|
346
|
+
name = f"Scheduled answer: {title}"
|
|
347
|
+
prompt = (
|
|
348
|
+
"The user repeatedly asks this question:\n"
|
|
349
|
+
f"“{suggestion.get('title')}”\n"
|
|
350
|
+
"Answer it from the current Brain (memories, knowledge graph, "
|
|
351
|
+
"recent activity) as a concise draft the user can review. "
|
|
352
|
+
"Use only local knowledge; do not contact external services."
|
|
353
|
+
)
|
|
354
|
+
creates = ["scheduled answer draft", "supporting evidence", "suggested follow-ups"]
|
|
355
|
+
|
|
356
|
+
trigger_config = {
|
|
357
|
+
**trigger,
|
|
358
|
+
"enabled": bool(enabled),
|
|
359
|
+
"review_queue": True,
|
|
360
|
+
"consent_required": True,
|
|
361
|
+
"local_only": True,
|
|
362
|
+
"external_actions": False,
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
"name": name,
|
|
366
|
+
"nodes": [
|
|
367
|
+
{
|
|
368
|
+
"id": "trigger",
|
|
369
|
+
"type": "trigger",
|
|
370
|
+
"name": trigger_name,
|
|
371
|
+
"config": trigger_config,
|
|
372
|
+
"next": "draft",
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
"id": "draft",
|
|
376
|
+
"type": "agent",
|
|
377
|
+
"name": "Draft for review",
|
|
378
|
+
"config": {
|
|
379
|
+
"agent": "agent:planner",
|
|
380
|
+
"goal": prompt,
|
|
381
|
+
"prompt": prompt,
|
|
382
|
+
"roles": ["researcher", "planner", "executor", "reviewer"],
|
|
383
|
+
"mode": "draft",
|
|
384
|
+
"local_only": True,
|
|
385
|
+
"external_actions": False,
|
|
386
|
+
"requires_review": True,
|
|
387
|
+
},
|
|
388
|
+
"next": "output",
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
"id": "output",
|
|
392
|
+
"type": "output",
|
|
393
|
+
"name": "Review before saving",
|
|
394
|
+
"config": {
|
|
395
|
+
"value": "Draft ready for review. Save, edit, or discard it before it becomes durable memory.",
|
|
396
|
+
},
|
|
397
|
+
"next": None,
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
"metadata": {
|
|
401
|
+
"created_from": "automation_suggestion",
|
|
402
|
+
"suggestion_id": suggestion.get("id"),
|
|
403
|
+
"suggestion_kind": kind,
|
|
404
|
+
"suggestion_title": title,
|
|
405
|
+
"suggestion_reason": suggestion.get("reason"),
|
|
406
|
+
"automation_state": "enabled" if enabled else "draft_disabled",
|
|
407
|
+
"local_only": True,
|
|
408
|
+
"external_actions": False,
|
|
409
|
+
"requires_user_enable": not enabled,
|
|
410
|
+
"creates": creates,
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
# ── overview for the automation surface ──────────────────────────────
|
|
415
|
+
|
|
416
|
+
def overview(
|
|
417
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
418
|
+
) -> Dict[str, Any]:
|
|
419
|
+
suggestion_report = self.suggestions(user_email=user_email, workspace_id=workspace_id)
|
|
420
|
+
installed: List[Dict[str, Any]] = []
|
|
421
|
+
if self._store is not None:
|
|
422
|
+
try:
|
|
423
|
+
workflows = self._store.list_workflows(workspace_id=workspace_id).get("workflows") or []
|
|
424
|
+
except Exception:
|
|
425
|
+
LOGGER.exception("automation intelligence workflow read failed")
|
|
426
|
+
workflows = []
|
|
427
|
+
for workflow in workflows:
|
|
428
|
+
metadata = (workflow or {}).get("metadata") or {}
|
|
429
|
+
if metadata.get("created_from") not in {"automation_suggestion", "brain_automation_recipe"}:
|
|
430
|
+
continue
|
|
431
|
+
installed.append({
|
|
432
|
+
"id": workflow.get("id"),
|
|
433
|
+
"name": workflow.get("name"),
|
|
434
|
+
"created_from": metadata.get("created_from"),
|
|
435
|
+
"suggestion_id": metadata.get("suggestion_id"),
|
|
436
|
+
"recipe_id": metadata.get("recipe_id"),
|
|
437
|
+
"enabled": metadata.get("automation_state") == "enabled",
|
|
438
|
+
"requires_user_enable": bool(metadata.get("requires_user_enable", True)),
|
|
439
|
+
"creates": metadata.get("creates") or [],
|
|
440
|
+
})
|
|
441
|
+
return {
|
|
442
|
+
"suggestions": suggestion_report["suggestions"],
|
|
443
|
+
"questions_scanned": suggestion_report["questions_scanned"],
|
|
444
|
+
"installed": installed,
|
|
445
|
+
"consent": suggestion_report["consent"],
|
|
446
|
+
"generated_at": _now(),
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
__all__ = ["AutomationIntelligenceService"]
|