fuli-context 0.7.3 → 0.7.4-beta.1
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 +170 -18
- package/README.zh-CN.md +146 -12
- package/compose.graphiti.yml +2 -0
- package/dist/web/assets/{AboutPage-qeO_vm0T.js → AboutPage-CftRexSY.js} +1 -1
- package/dist/web/assets/{ConnectionsPage-DO0GR9Xg.js → ConnectionsPage-D5J91x6d.js} +1 -1
- package/dist/web/assets/{useMinimumLoadingDisplay-BpCyBeko.css → KnowledgeEditDialog-DWUV_NPp.css} +1 -1
- package/dist/web/assets/KnowledgeEditDialog-wotVahvr.js +1 -0
- package/dist/web/assets/{KnowledgeInspector-Bjhdu2mf.css → KnowledgeInspector--1NujtXW.css} +1 -1
- package/dist/web/assets/KnowledgeInspector-BzkW0mVs.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-BebUDiHt.js +1 -0
- package/dist/web/assets/KnowledgePage-OUT07aKH.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-1pHJ0MNZ.js +4 -0
- package/dist/web/assets/{OverviewPage-CmCcDCAs.js → OverviewPage-CHE6XhdS.js} +1 -1
- package/dist/web/assets/PersonalProfilePage-B7T09bow.css +1 -0
- package/dist/web/assets/PersonalProfilePage-CXXiabUy.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-d2wYGW8n.js +1 -0
- package/dist/web/assets/PublicProjectsPage-hxLShtnZ.js +1 -0
- package/dist/web/assets/{ReviewPage-B3vnZ6gn.js → ReviewPage-DUeyu7E8.js} +1 -1
- package/dist/web/assets/{SearchableSelect-ih69qbcS.js → SearchableSelect-C5RiB5F8.js} +1 -1
- package/dist/web/assets/SettingsPage-BMuASSzQ.js +1 -0
- package/dist/web/assets/SettingsPage-CAfz4qiV.css +1 -0
- package/dist/web/assets/WritingTastePage-B9BCYIwQ.js +1 -0
- package/dist/web/assets/WritingTastePage-DSgvTH6x.css +1 -0
- package/dist/web/assets/{console-DE40hXGC.js → console-DMvHxKf9.js} +1 -1
- package/dist/web/assets/conversation-launcher-settings-Dnlcw7Rj.js +1 -0
- package/dist/web/assets/{i18n-CDxBs1sz.js → i18n-CWfd8Dtt.js} +1 -1
- package/dist/web/assets/{identity-BCxb1mcy.js → identity-C9mLdR8L.js} +1 -1
- package/dist/web/assets/index-F5e2sQ7p.js +2 -0
- package/dist/web/assets/{index-CCOGfZzE.css → index-X2j29j8i.css} +1 -1
- package/dist/web/assets/{model-CCMdt10Q.js → model-CsYAR8O3.js} +1 -1
- package/dist/web/assets/paths-CDREouhi.js +1 -0
- package/dist/web/assets/useApi-CROJJdhE-BLQiPVw0.js +1 -0
- package/dist/web/assets/vue-router-DN8x6iY1.js +1 -0
- package/dist/web/index.html +7 -6
- package/graph-provider/fuli_graph/app.py +238 -2
- package/graph-provider/fuli_graph/auth.py +18 -0
- package/graph-provider/fuli_graph/collaboration_context.py +253 -17
- package/graph-provider/fuli_graph/common_knowledge.py +6 -2
- package/graph-provider/fuli_graph/config.py +5 -0
- package/graph-provider/fuli_graph/graph_models.py +6 -0
- package/graph-provider/fuli_graph/graph_projection.py +14 -0
- package/graph-provider/fuli_graph/graph_query.py +27 -3
- package/graph-provider/fuli_graph/knowledge_management.py +19 -1
- package/graph-provider/fuli_graph/knowledge_search.py +11 -0
- package/graph-provider/fuli_graph/models.py +15 -2
- package/graph-provider/fuli_graph/personal_global_preference_models.py +234 -0
- package/graph-provider/fuli_graph/personal_global_preferences.py +1247 -0
- package/graph-provider/fuli_graph/project_action_models.py +22 -0
- package/graph-provider/fuli_graph/project_knowledge.py +121 -2
- package/graph-provider/fuli_graph/provider_values.py +24 -0
- package/graph-provider/fuli_graph/runtime.py +78 -0
- package/graph-provider/fuli_graph/search_projection.py +14 -1
- package/graph-provider/fuli_graph/store_knowledge.py +116 -2
- package/graph-provider/fuli_graph/workflow_candidate_models.py +168 -0
- package/graph-provider/fuli_graph/workflow_candidates.py +996 -0
- package/graph-provider/fuli_graph/workflow_observation_models.py +30 -0
- package/graph-provider/fuli_graph/workflow_observations.py +84 -0
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +18 -18
- package/package.json +4 -4
- package/skills/capturing-session-knowledge/SKILL.md +32 -5
- package/skills/flreview/SKILL.md +4 -3
- package/src/agent-tools/graph-definitions.js +136 -20
- package/src/agent-tools/graph-handlers.js +10 -4
- package/src/cli/command-registry.js +1 -1
- package/src/cli/local-runtime-command.js +69 -2
- package/src/graphiti/agent-knowledge-workflows.js +497 -16
- package/src/graphiti/collaboration-preference-projection.js +162 -0
- package/src/graphiti/federated-application.js +155 -161
- package/src/graphiti/federated-search-support.js +102 -0
- package/src/graphiti/knowledge-provider-mapping.js +15 -0
- package/src/graphiti/provider-client.js +162 -10
- package/src/graphiti/related-project-suggestions.js +109 -0
- package/src/graphiti/runtime-config.js +14 -0
- package/src/graphiti/source-marker.js +2 -2
- package/src/graphiti/user-taste-skill.js +241 -0
- package/src/graphiti/workflow-candidate-mapping.js +87 -0
- package/src/graphiti/workflow-observation.js +82 -0
- package/src/graphiti/writing-taste-profile-workflow.js +25 -0
- package/src/graphiti/writing-taste-profile.js +490 -0
- package/src/http/graph-api-router.js +15 -2
- package/src/mcp/context-resources.js +158 -0
- package/src/mcp/create-mcp-server.js +39 -5
- package/src/mcp/instructions.js +5 -7
- package/src/mcp/session-id.js +60 -0
- package/src/mcp/task-context-registry.js +15 -3
- package/src/mcp/tool-annotations.js +8 -1
- package/src/mcp/tool-result.js +28 -4
- package/src/server.js +0 -1
- package/src/setup/claude-code-config.js +17 -6
- package/src/setup/graph-runtime.js +20 -2
- package/src/system/runtime-settings.js +121 -2
- package/src/system/system-service.js +5 -1
- package/dist/web/assets/KnowledgeInspector-DEW5lA1m.js +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-BkM568Ld.js +0 -1
- package/dist/web/assets/KnowledgePage-BAT4aZIm.js +0 -1
- package/dist/web/assets/KnowledgeWorkspace-DyGtBz--.js +0 -4
- package/dist/web/assets/PersonalProfilePage-C42mwKN8.js +0 -1
- package/dist/web/assets/PersonalProfilePage-CdmEzyvZ.css +0 -1
- package/dist/web/assets/PersonalProjectsPage-BwaXR62K.js +0 -1
- package/dist/web/assets/PublicProjectsPage-egKBulOq.js +0 -1
- package/dist/web/assets/SettingsPage-C6ouU0sR.css +0 -1
- package/dist/web/assets/SettingsPage-oLE8mNs5.js +0 -1
- package/dist/web/assets/index-BzhNfzU_.js +0 -2
- package/dist/web/assets/paths-DwHeiGMT.js +0 -1
- package/dist/web/assets/useMinimumLoadingDisplay-HdI7XAmu.js +0 -1
- package/dist/web/assets/vue-router-Drfl1F8i.js +0 -1
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import secrets
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
from fastapi import HTTPException
|
|
7
|
+
|
|
8
|
+
from .provider_values import native_datetime, now_utc, stable_uuid
|
|
9
|
+
from .workflow_candidate_models import (
|
|
10
|
+
WorkflowAuthorization,
|
|
11
|
+
WorkflowCandidate,
|
|
12
|
+
WorkflowCandidatePage,
|
|
13
|
+
WorkflowCandidateReview,
|
|
14
|
+
WorkflowCandidateReviewIntent,
|
|
15
|
+
WorkflowCandidateReviewPreview,
|
|
16
|
+
WorkflowCandidateSearch,
|
|
17
|
+
WorkflowRecommendation,
|
|
18
|
+
WorkflowRecommendationPage,
|
|
19
|
+
WorkflowRecommendationPolicy,
|
|
20
|
+
WorkflowRecommendationWeights,
|
|
21
|
+
WorkflowRecency,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
MINIMUM_OCCURRENCES = 3
|
|
25
|
+
MINIMUM_DISTINCT_SESSIONS = 3
|
|
26
|
+
RECOMMENDATION_THRESHOLD = 0.7
|
|
27
|
+
RECOMMENDATION_WEIGHTS = WorkflowRecommendationWeights(
|
|
28
|
+
occurrences=0.45,
|
|
29
|
+
distinct_sessions=0.25,
|
|
30
|
+
recency=0.2,
|
|
31
|
+
confirmation_authority=0.1,
|
|
32
|
+
)
|
|
33
|
+
DECLINE_PENALTY = 0.25
|
|
34
|
+
NEGATIVE_EVIDENCE_PENALTY = 0.1
|
|
35
|
+
HIGH_RISK_ACTION_CATEGORIES = [
|
|
36
|
+
'send', 'delete', 'publish', 'payment', 'external_write'
|
|
37
|
+
]
|
|
38
|
+
REVIEW_PREVIEW_TTL = timedelta(minutes=10)
|
|
39
|
+
RECOMMENDATION_POLICY = WorkflowRecommendationPolicy(
|
|
40
|
+
minimum_occurrences=MINIMUM_OCCURRENCES,
|
|
41
|
+
minimum_distinct_sessions=MINIMUM_DISTINCT_SESSIONS,
|
|
42
|
+
recommendation_threshold=RECOMMENDATION_THRESHOLD,
|
|
43
|
+
weights=RECOMMENDATION_WEIGHTS,
|
|
44
|
+
decline_penalty=DECLINE_PENALTY,
|
|
45
|
+
negative_evidence_penalty=NEGATIVE_EVIDENCE_PENALTY,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def recommend_workflow_candidates(
|
|
50
|
+
store,
|
|
51
|
+
actor: dict,
|
|
52
|
+
request: WorkflowCandidateSearch,
|
|
53
|
+
*,
|
|
54
|
+
evaluated_at: datetime | None = None,
|
|
55
|
+
) -> WorkflowRecommendationPage:
|
|
56
|
+
candidates = await _read_workflow_candidates(
|
|
57
|
+
store, actor, request, evaluated_at=evaluated_at
|
|
58
|
+
)
|
|
59
|
+
return WorkflowRecommendationPage(
|
|
60
|
+
policy=RECOMMENDATION_POLICY,
|
|
61
|
+
candidates=[
|
|
62
|
+
candidate
|
|
63
|
+
for candidate in candidates
|
|
64
|
+
if candidate.recommendation.recommended
|
|
65
|
+
or candidate.execution_authorized
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def search_workflow_candidates(
|
|
71
|
+
store,
|
|
72
|
+
actor: dict,
|
|
73
|
+
request: WorkflowCandidateSearch,
|
|
74
|
+
*,
|
|
75
|
+
evaluated_at: datetime | None = None,
|
|
76
|
+
) -> WorkflowCandidatePage:
|
|
77
|
+
return WorkflowCandidatePage(
|
|
78
|
+
policy=RECOMMENDATION_POLICY,
|
|
79
|
+
candidates=await _read_workflow_candidates(
|
|
80
|
+
store, actor, request, evaluated_at=evaluated_at
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def preview_workflow_candidate_review(
|
|
86
|
+
store,
|
|
87
|
+
actor: dict,
|
|
88
|
+
candidate_id: str,
|
|
89
|
+
request: WorkflowCandidateReviewIntent,
|
|
90
|
+
*,
|
|
91
|
+
created_at: datetime | None = None,
|
|
92
|
+
token_factory=secrets.token_urlsafe,
|
|
93
|
+
) -> WorkflowCandidateReviewPreview:
|
|
94
|
+
space = await _personal_space(
|
|
95
|
+
store, actor, request.personal_space_id, 'maintainer'
|
|
96
|
+
)
|
|
97
|
+
target_rows, _, _ = await store.runtime.driver.execute_query(
|
|
98
|
+
_WORKFLOW_CANDIDATE_REVIEW_TARGET_QUERY,
|
|
99
|
+
space_id=space['id'],
|
|
100
|
+
candidate_id=candidate_id,
|
|
101
|
+
routing_='r',
|
|
102
|
+
)
|
|
103
|
+
if not target_rows:
|
|
104
|
+
raise HTTPException(status_code=404, detail='workflow candidate not found')
|
|
105
|
+
current_version = int(target_rows[0]['candidate_version'])
|
|
106
|
+
current_evidence_revision = int(target_rows[0]['evidence_revision'])
|
|
107
|
+
current_decision_revision = int(target_rows[0]['decision_revision'])
|
|
108
|
+
if (
|
|
109
|
+
current_version != request.candidate_version
|
|
110
|
+
or current_evidence_revision != request.evidence_revision
|
|
111
|
+
or current_decision_revision != request.decision_revision
|
|
112
|
+
):
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=409,
|
|
115
|
+
detail='workflow candidate changed; refresh before reviewing',
|
|
116
|
+
)
|
|
117
|
+
issued_at = created_at or now_utc()
|
|
118
|
+
expires_at = issued_at + REVIEW_PREVIEW_TTL
|
|
119
|
+
approval_token = token_factory(32)
|
|
120
|
+
token_hash = _sha256(approval_token)
|
|
121
|
+
payload_fingerprint = _review_payload_fingerprint(candidate_id, request)
|
|
122
|
+
preview_id = stable_uuid(
|
|
123
|
+
space['id'], 'workflow-review-preview', token_hash
|
|
124
|
+
)
|
|
125
|
+
records, _, _ = await store.runtime.driver.execute_query(
|
|
126
|
+
_CREATE_WORKFLOW_REVIEW_PREVIEW_QUERY,
|
|
127
|
+
space_id=space['id'],
|
|
128
|
+
candidate_id=candidate_id,
|
|
129
|
+
candidate_version=request.candidate_version,
|
|
130
|
+
evidence_revision=request.evidence_revision,
|
|
131
|
+
decision_revision=request.decision_revision,
|
|
132
|
+
rule_fingerprint=(
|
|
133
|
+
target_rows[0].get('rule_fingerprint') or candidate_id
|
|
134
|
+
),
|
|
135
|
+
preview_id=preview_id,
|
|
136
|
+
token_hash=token_hash,
|
|
137
|
+
payload_fingerprint=payload_fingerprint,
|
|
138
|
+
issued_to_actor_id=actor['id'],
|
|
139
|
+
issued_channel='provider_http_human_review',
|
|
140
|
+
created_at=issued_at,
|
|
141
|
+
expires_at=expires_at,
|
|
142
|
+
)
|
|
143
|
+
if not records:
|
|
144
|
+
raise HTTPException(
|
|
145
|
+
status_code=409,
|
|
146
|
+
detail='workflow candidate changed before review preview was created',
|
|
147
|
+
)
|
|
148
|
+
return WorkflowCandidateReviewPreview(
|
|
149
|
+
preview_id=preview_id,
|
|
150
|
+
candidate_id=candidate_id,
|
|
151
|
+
candidate_version=request.candidate_version,
|
|
152
|
+
evidence_revision=request.evidence_revision,
|
|
153
|
+
decision_revision=request.decision_revision,
|
|
154
|
+
payload_fingerprint=payload_fingerprint,
|
|
155
|
+
approval_token=approval_token,
|
|
156
|
+
expires_at=expires_at,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def review_workflow_candidate(
|
|
161
|
+
store,
|
|
162
|
+
actor: dict,
|
|
163
|
+
candidate_id: str,
|
|
164
|
+
request: WorkflowCandidateReview,
|
|
165
|
+
*,
|
|
166
|
+
reviewed_at: datetime | None = None,
|
|
167
|
+
) -> WorkflowCandidate:
|
|
168
|
+
space = await _personal_space(
|
|
169
|
+
store, actor, request.personal_space_id, 'maintainer'
|
|
170
|
+
)
|
|
171
|
+
changed_at = reviewed_at or now_utc()
|
|
172
|
+
payload_fingerprint = _review_payload_fingerprint(candidate_id, request)
|
|
173
|
+
event_id = stable_uuid(
|
|
174
|
+
space['id'],
|
|
175
|
+
'workflow-review-event',
|
|
176
|
+
candidate_id,
|
|
177
|
+
str(request.candidate_version),
|
|
178
|
+
request.idempotency_key,
|
|
179
|
+
)
|
|
180
|
+
existing_rows, _, _ = await store.runtime.driver.execute_query(
|
|
181
|
+
_WORKFLOW_REVIEW_EVENT_QUERY,
|
|
182
|
+
space_id=space['id'],
|
|
183
|
+
event_id=event_id,
|
|
184
|
+
routing_='r',
|
|
185
|
+
)
|
|
186
|
+
if existing_rows:
|
|
187
|
+
if existing_rows[0].get('payload_fingerprint') != payload_fingerprint:
|
|
188
|
+
raise HTTPException(
|
|
189
|
+
status_code=409,
|
|
190
|
+
detail='workflow review idempotency key was used for another payload',
|
|
191
|
+
)
|
|
192
|
+
return await _read_workflow_candidate(
|
|
193
|
+
store, actor, space['id'], candidate_id, changed_at
|
|
194
|
+
)
|
|
195
|
+
token_hash = _sha256(request.approval_token)
|
|
196
|
+
if request.decision == 'reject':
|
|
197
|
+
records, _, _ = await store.runtime.driver.execute_query(
|
|
198
|
+
_REJECT_WORKFLOW_CANDIDATE_QUERY,
|
|
199
|
+
space_id=space['id'],
|
|
200
|
+
candidate_id=candidate_id,
|
|
201
|
+
candidate_version=request.candidate_version,
|
|
202
|
+
evidence_revision=request.evidence_revision,
|
|
203
|
+
decision_revision=request.decision_revision,
|
|
204
|
+
token_hash=token_hash,
|
|
205
|
+
payload_fingerprint=payload_fingerprint,
|
|
206
|
+
event_id=event_id,
|
|
207
|
+
actor_id=actor['id'],
|
|
208
|
+
reason=request.reason,
|
|
209
|
+
authority=request.authority.kind,
|
|
210
|
+
authority_label=request.authority.label,
|
|
211
|
+
decision_source=request.decision_source,
|
|
212
|
+
reviewed_at=changed_at,
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
rule_id = stable_uuid(
|
|
216
|
+
space['id'],
|
|
217
|
+
'workflow-rule',
|
|
218
|
+
candidate_id,
|
|
219
|
+
str(request.candidate_version),
|
|
220
|
+
)
|
|
221
|
+
authorization_id = stable_uuid(
|
|
222
|
+
space['id'],
|
|
223
|
+
'workflow-authorization',
|
|
224
|
+
event_id,
|
|
225
|
+
)
|
|
226
|
+
records, _, _ = await store.runtime.driver.execute_query(
|
|
227
|
+
_APPROVE_WORKFLOW_CANDIDATE_QUERY,
|
|
228
|
+
space_id=space['id'],
|
|
229
|
+
candidate_id=candidate_id,
|
|
230
|
+
candidate_version=request.candidate_version,
|
|
231
|
+
evidence_revision=request.evidence_revision,
|
|
232
|
+
decision_revision=request.decision_revision,
|
|
233
|
+
token_hash=token_hash,
|
|
234
|
+
payload_fingerprint=payload_fingerprint,
|
|
235
|
+
rule_id=rule_id,
|
|
236
|
+
authorization_id=authorization_id,
|
|
237
|
+
event_id=event_id,
|
|
238
|
+
actor_id=actor['id'],
|
|
239
|
+
reason=request.reason,
|
|
240
|
+
authority=request.authority.kind,
|
|
241
|
+
authority_label=request.authority.label,
|
|
242
|
+
decision_source=request.decision_source,
|
|
243
|
+
high_risk_action_categories=HIGH_RISK_ACTION_CATEGORIES,
|
|
244
|
+
reviewed_at=changed_at,
|
|
245
|
+
)
|
|
246
|
+
if not records:
|
|
247
|
+
raise HTTPException(
|
|
248
|
+
status_code=409,
|
|
249
|
+
detail=(
|
|
250
|
+
'workflow review token is invalid, expired, used, or bound '
|
|
251
|
+
'to another candidate version'
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
return await _read_workflow_candidate(
|
|
255
|
+
store, actor, space['id'], candidate_id, changed_at
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def _read_workflow_candidate(
|
|
260
|
+
store,
|
|
261
|
+
actor: dict,
|
|
262
|
+
space_id: str,
|
|
263
|
+
candidate_id: str,
|
|
264
|
+
evaluated_at: datetime,
|
|
265
|
+
) -> WorkflowCandidate:
|
|
266
|
+
candidates = await _read_workflow_candidates(
|
|
267
|
+
store,
|
|
268
|
+
actor,
|
|
269
|
+
WorkflowCandidateSearch(
|
|
270
|
+
personal_space_id=space_id,
|
|
271
|
+
limit=1,
|
|
272
|
+
),
|
|
273
|
+
evaluated_at=evaluated_at,
|
|
274
|
+
candidate_id=candidate_id,
|
|
275
|
+
)
|
|
276
|
+
if not candidates:
|
|
277
|
+
raise HTTPException(status_code=404, detail='workflow candidate not found')
|
|
278
|
+
return candidates[0]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def _read_workflow_candidates(
|
|
282
|
+
store,
|
|
283
|
+
actor: dict,
|
|
284
|
+
request: WorkflowCandidateSearch,
|
|
285
|
+
*,
|
|
286
|
+
evaluated_at: datetime | None,
|
|
287
|
+
candidate_id: str | None = None,
|
|
288
|
+
) -> list[WorkflowCandidate]:
|
|
289
|
+
space = await _personal_space(store, actor, request.personal_space_id, 'reader')
|
|
290
|
+
rows, _, _ = await store.runtime.driver.execute_query(
|
|
291
|
+
_WORKFLOW_CANDIDATE_QUERY,
|
|
292
|
+
space_id=space['id'],
|
|
293
|
+
personal_project_id=request.personal_project_id,
|
|
294
|
+
after_step_key=request.after_step_key,
|
|
295
|
+
candidate_id=candidate_id,
|
|
296
|
+
minimum_occurrences=MINIMUM_OCCURRENCES,
|
|
297
|
+
limit=request.limit,
|
|
298
|
+
routing_='r',
|
|
299
|
+
)
|
|
300
|
+
return [
|
|
301
|
+
_candidate(dict(row), evaluated_at=evaluated_at)
|
|
302
|
+
for row in rows
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def _personal_space(store, actor: dict, space_id: str, role: str) -> dict:
|
|
307
|
+
space = await store.authorize(actor, space_id, role)
|
|
308
|
+
if space['kind'] != 'personal':
|
|
309
|
+
raise HTTPException(status_code=422, detail='workflow candidates are personal-only')
|
|
310
|
+
return space
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def materialize_workflow_candidates(
|
|
314
|
+
store,
|
|
315
|
+
space: dict,
|
|
316
|
+
*,
|
|
317
|
+
personal_project_id: str | None,
|
|
318
|
+
pairs: list[dict],
|
|
319
|
+
) -> list[str]:
|
|
320
|
+
if not pairs:
|
|
321
|
+
return []
|
|
322
|
+
rows, _, _ = await store.runtime.driver.execute_query(
|
|
323
|
+
_WORKFLOW_CANDIDATE_EVIDENCE_QUERY,
|
|
324
|
+
group_id=space['group_id'],
|
|
325
|
+
personal_project_id=personal_project_id,
|
|
326
|
+
pairs=pairs,
|
|
327
|
+
routing_='r',
|
|
328
|
+
)
|
|
329
|
+
changed_at = now_utc()
|
|
330
|
+
candidates = []
|
|
331
|
+
for value in (dict(row) for row in rows):
|
|
332
|
+
occurrence_count = int(value.get('occurrence_count') or 0)
|
|
333
|
+
if occurrence_count < MINIMUM_OCCURRENCES:
|
|
334
|
+
continue
|
|
335
|
+
condition_json = value.get('condition_json') or '{}'
|
|
336
|
+
candidate_id = stable_uuid(
|
|
337
|
+
space['id'],
|
|
338
|
+
'workflow-candidate',
|
|
339
|
+
personal_project_id or 'personal-global',
|
|
340
|
+
value['workflow_key'],
|
|
341
|
+
value['source_step_id'],
|
|
342
|
+
value['target_step_id'],
|
|
343
|
+
)
|
|
344
|
+
evidence_ids = sorted(value.get('evidence_ids') or [])
|
|
345
|
+
evidence_fingerprint = stable_uuid(
|
|
346
|
+
'workflow-evidence',
|
|
347
|
+
json.dumps(
|
|
348
|
+
{
|
|
349
|
+
'occurrence_count': occurrence_count,
|
|
350
|
+
'distinct_session_count': int(
|
|
351
|
+
value.get('distinct_session_count') or 0
|
|
352
|
+
),
|
|
353
|
+
'first_observed_at': native_datetime(
|
|
354
|
+
value['first_observed_at']
|
|
355
|
+
).isoformat(),
|
|
356
|
+
'last_observed_at': native_datetime(
|
|
357
|
+
value['last_observed_at']
|
|
358
|
+
).isoformat(),
|
|
359
|
+
'confirmation_authority': (
|
|
360
|
+
value.get('confirmation_authority') or 'none'
|
|
361
|
+
),
|
|
362
|
+
'negative_evidence_count': int(
|
|
363
|
+
value.get('negative_evidence_count') or 0
|
|
364
|
+
),
|
|
365
|
+
'evidence_ids': evidence_ids,
|
|
366
|
+
},
|
|
367
|
+
ensure_ascii=False,
|
|
368
|
+
separators=(',', ':'),
|
|
369
|
+
sort_keys=True,
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
candidates.append({
|
|
373
|
+
'candidate_id': candidate_id,
|
|
374
|
+
'personal_space_id': space['id'],
|
|
375
|
+
'personal_project_id': personal_project_id,
|
|
376
|
+
'workflow_key': value['workflow_key'],
|
|
377
|
+
'source_step_id': value['source_step_id'],
|
|
378
|
+
'source_step_key': value['source_step_key'],
|
|
379
|
+
'source_step_name': value['source_step_name'],
|
|
380
|
+
'target_step_id': value['target_step_id'],
|
|
381
|
+
'target_step_key': value['target_step_key'],
|
|
382
|
+
'target_step_name': value['target_step_name'],
|
|
383
|
+
'condition_json': condition_json,
|
|
384
|
+
'rule_fingerprint': stable_uuid(
|
|
385
|
+
'workflow-rule',
|
|
386
|
+
value['workflow_key'],
|
|
387
|
+
value['source_step_id'],
|
|
388
|
+
value['target_step_id'],
|
|
389
|
+
condition_json,
|
|
390
|
+
),
|
|
391
|
+
'evidence_fingerprint': evidence_fingerprint,
|
|
392
|
+
'occurrence_count': occurrence_count,
|
|
393
|
+
'distinct_session_count': int(
|
|
394
|
+
value.get('distinct_session_count') or 0
|
|
395
|
+
),
|
|
396
|
+
'first_observed_at': value['first_observed_at'],
|
|
397
|
+
'last_observed_at': value['last_observed_at'],
|
|
398
|
+
'confirmation_authority': (
|
|
399
|
+
value.get('confirmation_authority') or 'none'
|
|
400
|
+
),
|
|
401
|
+
'negative_evidence_count': int(
|
|
402
|
+
value.get('negative_evidence_count') or 0
|
|
403
|
+
),
|
|
404
|
+
'evidence_ids': evidence_ids,
|
|
405
|
+
'changed_at': changed_at,
|
|
406
|
+
})
|
|
407
|
+
if not candidates:
|
|
408
|
+
return []
|
|
409
|
+
records, _, _ = await store.runtime.driver.execute_query(
|
|
410
|
+
_MATERIALIZE_WORKFLOW_CANDIDATE_QUERY,
|
|
411
|
+
space_id=space['id'],
|
|
412
|
+
candidates=candidates,
|
|
413
|
+
)
|
|
414
|
+
return [record['candidate_id'] for record in records]
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _candidate(row: dict, *, evaluated_at: datetime | None) -> WorkflowCandidate:
|
|
418
|
+
now = evaluated_at or datetime.now(timezone.utc)
|
|
419
|
+
first_observed_at = native_datetime(row['first_observed_at'])
|
|
420
|
+
last_observed_at = native_datetime(row['last_observed_at'])
|
|
421
|
+
age_days = max((now - last_observed_at).total_seconds() / 86_400, 0)
|
|
422
|
+
recency_score = _recency_score(age_days)
|
|
423
|
+
occurrence_count = int(row.get('occurrence_count') or 0)
|
|
424
|
+
distinct_session_count = int(row.get('distinct_session_count') or 0)
|
|
425
|
+
negative_evidence_count = int(row.get('negative_evidence_count') or 0)
|
|
426
|
+
decline_count = int(row.get('decline_count') or 0)
|
|
427
|
+
authority = row.get('confirmation_authority') or 'none'
|
|
428
|
+
candidate_version = int(row.get('candidate_version') or 1)
|
|
429
|
+
evidence_revision = int(row.get('evidence_revision') or 1)
|
|
430
|
+
decision_revision = int(row.get('decision_revision') or 0)
|
|
431
|
+
rule_fingerprint = row.get('rule_fingerprint') or row['candidate_id']
|
|
432
|
+
authorization = _authorization(row)
|
|
433
|
+
execution_authorized = bool(
|
|
434
|
+
authorization
|
|
435
|
+
and authorization.active
|
|
436
|
+
and authorization.candidate_version == candidate_version
|
|
437
|
+
and authorization.rule_fingerprint == rule_fingerprint
|
|
438
|
+
)
|
|
439
|
+
score = _recommendation_score(
|
|
440
|
+
occurrence_count=occurrence_count,
|
|
441
|
+
distinct_session_count=distinct_session_count,
|
|
442
|
+
recency_score=recency_score,
|
|
443
|
+
authority=authority,
|
|
444
|
+
negative_evidence_count=negative_evidence_count,
|
|
445
|
+
decline_count=decline_count,
|
|
446
|
+
)
|
|
447
|
+
recommended = (
|
|
448
|
+
score >= RECOMMENDATION_THRESHOLD
|
|
449
|
+
and distinct_session_count >= MINIMUM_DISTINCT_SESSIONS
|
|
450
|
+
)
|
|
451
|
+
action = (
|
|
452
|
+
'authorized_rule_available'
|
|
453
|
+
if execution_authorized
|
|
454
|
+
else 'ask_user'
|
|
455
|
+
if recommended
|
|
456
|
+
else 'none'
|
|
457
|
+
)
|
|
458
|
+
return WorkflowCandidate(
|
|
459
|
+
candidate_id=row['candidate_id'],
|
|
460
|
+
candidate_version=candidate_version,
|
|
461
|
+
evidence_revision=evidence_revision,
|
|
462
|
+
decision_revision=decision_revision,
|
|
463
|
+
rule_fingerprint=rule_fingerprint,
|
|
464
|
+
workflow_key=(
|
|
465
|
+
row.get('workflow_key')
|
|
466
|
+
or f"{row['source_step_key']}->{row['target_step_key']}"
|
|
467
|
+
),
|
|
468
|
+
condition=_workflow_condition(row.get('condition_json')),
|
|
469
|
+
personal_space_id=row['personal_space_id'],
|
|
470
|
+
personal_project_id=row.get('personal_project_id'),
|
|
471
|
+
source_step_id=row['source_step_id'],
|
|
472
|
+
source_step_key=row['source_step_key'],
|
|
473
|
+
source_step_name=row['source_step_name'],
|
|
474
|
+
target_step_id=row['target_step_id'],
|
|
475
|
+
target_step_key=row['target_step_key'],
|
|
476
|
+
target_step_name=row['target_step_name'],
|
|
477
|
+
status=row.get('status') or 'pending',
|
|
478
|
+
occurrence_count=occurrence_count,
|
|
479
|
+
distinct_session_count=distinct_session_count,
|
|
480
|
+
recency=WorkflowRecency(
|
|
481
|
+
first_observed_at=first_observed_at,
|
|
482
|
+
last_observed_at=last_observed_at,
|
|
483
|
+
age_days=round(age_days, 4),
|
|
484
|
+
score=recency_score,
|
|
485
|
+
),
|
|
486
|
+
confirmation_authority=authority,
|
|
487
|
+
negative_evidence_count=negative_evidence_count,
|
|
488
|
+
decline_count=decline_count,
|
|
489
|
+
reviewed_at=native_datetime(row.get('reviewed_at')),
|
|
490
|
+
review_reason=row.get('review_reason'),
|
|
491
|
+
recommendation=WorkflowRecommendation(
|
|
492
|
+
recommended=recommended,
|
|
493
|
+
score=score,
|
|
494
|
+
threshold=RECOMMENDATION_THRESHOLD,
|
|
495
|
+
action=action,
|
|
496
|
+
),
|
|
497
|
+
execution_authorized=execution_authorized,
|
|
498
|
+
authorization=authorization,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _authorization(row: dict) -> WorkflowAuthorization | None:
|
|
503
|
+
if not row.get('authorization_id'):
|
|
504
|
+
return None
|
|
505
|
+
return WorkflowAuthorization(
|
|
506
|
+
authorization_id=row['authorization_id'],
|
|
507
|
+
candidate_id=row.get('authorization_candidate_id') or row['candidate_id'],
|
|
508
|
+
candidate_version=int(
|
|
509
|
+
row.get('authorization_candidate_version')
|
|
510
|
+
or row.get('candidate_version')
|
|
511
|
+
or 1
|
|
512
|
+
),
|
|
513
|
+
rule_id=row.get('authorization_rule_id') or row['authorization_id'],
|
|
514
|
+
rule_fingerprint=(
|
|
515
|
+
row.get('authorization_rule_fingerprint')
|
|
516
|
+
or row.get('rule_fingerprint')
|
|
517
|
+
or row['candidate_id']
|
|
518
|
+
),
|
|
519
|
+
scope='durable',
|
|
520
|
+
active=row.get('authorization_active') is True,
|
|
521
|
+
authority=row['authorization_authority'],
|
|
522
|
+
created_at=native_datetime(row['authorization_created_at']),
|
|
523
|
+
high_risk_per_call_approval_required=True,
|
|
524
|
+
high_risk_action_categories=(
|
|
525
|
+
row.get('high_risk_action_categories')
|
|
526
|
+
or HIGH_RISK_ACTION_CATEGORIES
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _recommendation_score(
|
|
532
|
+
*,
|
|
533
|
+
occurrence_count: int,
|
|
534
|
+
distinct_session_count: int,
|
|
535
|
+
recency_score: float,
|
|
536
|
+
authority: str,
|
|
537
|
+
negative_evidence_count: int,
|
|
538
|
+
decline_count: int,
|
|
539
|
+
) -> float:
|
|
540
|
+
occurrence_signal = min(occurrence_count / MINIMUM_OCCURRENCES, 1)
|
|
541
|
+
session_signal = min(
|
|
542
|
+
distinct_session_count / MINIMUM_DISTINCT_SESSIONS,
|
|
543
|
+
1,
|
|
544
|
+
)
|
|
545
|
+
authority_signal = 1 if authority in {'user', 'authoritative_source'} else 0
|
|
546
|
+
weighted = (
|
|
547
|
+
occurrence_signal * RECOMMENDATION_WEIGHTS.occurrences
|
|
548
|
+
+ session_signal * RECOMMENDATION_WEIGHTS.distinct_sessions
|
|
549
|
+
+ recency_score * RECOMMENDATION_WEIGHTS.recency
|
|
550
|
+
+ authority_signal * RECOMMENDATION_WEIGHTS.confirmation_authority
|
|
551
|
+
)
|
|
552
|
+
penalty = (
|
|
553
|
+
decline_count * DECLINE_PENALTY
|
|
554
|
+
+ negative_evidence_count * NEGATIVE_EVIDENCE_PENALTY
|
|
555
|
+
)
|
|
556
|
+
return round(min(max(weighted - penalty, 0), 1), 4)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _recency_score(age_days: float) -> float:
|
|
560
|
+
if age_days <= 7:
|
|
561
|
+
return 1
|
|
562
|
+
if age_days <= 30:
|
|
563
|
+
return 0.75
|
|
564
|
+
if age_days <= 90:
|
|
565
|
+
return 0.5
|
|
566
|
+
return 0.25
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _review_payload_fingerprint(
|
|
570
|
+
candidate_id: str,
|
|
571
|
+
request: WorkflowCandidateReviewIntent,
|
|
572
|
+
) -> str:
|
|
573
|
+
payload = {
|
|
574
|
+
'candidate_id': candidate_id,
|
|
575
|
+
**request.model_dump(mode='json', exclude={'approval_token'}),
|
|
576
|
+
}
|
|
577
|
+
return _sha256(json.dumps(
|
|
578
|
+
payload,
|
|
579
|
+
ensure_ascii=False,
|
|
580
|
+
separators=(',', ':'),
|
|
581
|
+
sort_keys=True,
|
|
582
|
+
))
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _sha256(value: str) -> str:
|
|
586
|
+
return hashlib.sha256(value.encode('utf-8')).hexdigest()
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _workflow_condition(value: str | None) -> dict[str, object]:
|
|
590
|
+
try:
|
|
591
|
+
parsed = json.loads(value or '{}')
|
|
592
|
+
except (TypeError, ValueError):
|
|
593
|
+
return {}
|
|
594
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
_WORKFLOW_CANDIDATE_QUERY = '''
|
|
598
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
599
|
+
[:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate)
|
|
600
|
+
WHERE candidate.occurrence_count >= $minimum_occurrences
|
|
601
|
+
AND ($candidate_id IS NULL OR candidate.id = $candidate_id)
|
|
602
|
+
AND ($personal_project_id IS NULL OR
|
|
603
|
+
candidate.personal_project_id = $personal_project_id)
|
|
604
|
+
AND ($after_step_key IS NULL OR candidate.source_step_key = $after_step_key)
|
|
605
|
+
OPTIONAL MATCH (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->
|
|
606
|
+
(authorization:FuliWorkflowAuthorization {active: true})
|
|
607
|
+
WITH candidate, authorization
|
|
608
|
+
ORDER BY authorization.created_at DESC
|
|
609
|
+
WITH candidate, head(collect(authorization)) AS authorization
|
|
610
|
+
RETURN candidate.id AS candidate_id,
|
|
611
|
+
coalesce(candidate.candidate_version, 1) AS candidate_version,
|
|
612
|
+
coalesce(candidate.evidence_revision, 1) AS evidence_revision,
|
|
613
|
+
coalesce(candidate.decision_revision, 0) AS decision_revision,
|
|
614
|
+
coalesce(candidate.rule_fingerprint, candidate.id) AS rule_fingerprint,
|
|
615
|
+
candidate.workflow_key AS workflow_key,
|
|
616
|
+
coalesce(candidate.condition_json, '{}') AS condition_json,
|
|
617
|
+
candidate.personal_space_id AS personal_space_id,
|
|
618
|
+
candidate.personal_project_id AS personal_project_id,
|
|
619
|
+
candidate.source_step_id AS source_step_id,
|
|
620
|
+
candidate.source_step_key AS source_step_key,
|
|
621
|
+
candidate.source_step_name AS source_step_name,
|
|
622
|
+
candidate.target_step_id AS target_step_id,
|
|
623
|
+
candidate.target_step_key AS target_step_key,
|
|
624
|
+
candidate.target_step_name AS target_step_name,
|
|
625
|
+
coalesce(candidate.status, 'pending') AS status,
|
|
626
|
+
candidate.occurrence_count AS occurrence_count,
|
|
627
|
+
candidate.distinct_session_count AS distinct_session_count,
|
|
628
|
+
candidate.first_observed_at AS first_observed_at,
|
|
629
|
+
candidate.last_observed_at AS last_observed_at,
|
|
630
|
+
coalesce(candidate.confirmation_authority, 'none') AS confirmation_authority,
|
|
631
|
+
coalesce(candidate.negative_evidence_count, 0) AS negative_evidence_count,
|
|
632
|
+
coalesce(candidate.decline_count, 0) AS decline_count,
|
|
633
|
+
candidate.reviewed_at AS reviewed_at,
|
|
634
|
+
candidate.review_reason AS review_reason,
|
|
635
|
+
authorization.id AS authorization_id,
|
|
636
|
+
coalesce(authorization.active, false) AS authorization_active,
|
|
637
|
+
authorization.candidate_id AS authorization_candidate_id,
|
|
638
|
+
authorization.candidate_version AS authorization_candidate_version,
|
|
639
|
+
authorization.rule_id AS authorization_rule_id,
|
|
640
|
+
authorization.rule_fingerprint AS authorization_rule_fingerprint,
|
|
641
|
+
authorization.authority AS authorization_authority,
|
|
642
|
+
authorization.created_at AS authorization_created_at,
|
|
643
|
+
authorization.high_risk_action_categories AS high_risk_action_categories
|
|
644
|
+
ORDER BY candidate.last_observed_at DESC, candidate.id
|
|
645
|
+
LIMIT $limit
|
|
646
|
+
'''
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
_WORKFLOW_CANDIDATE_REVIEW_TARGET_QUERY = '''
|
|
650
|
+
/* WORKFLOW_CANDIDATE_REVIEW_TARGET */
|
|
651
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
652
|
+
[:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate {
|
|
653
|
+
id: $candidate_id
|
|
654
|
+
})
|
|
655
|
+
RETURN coalesce(candidate.candidate_version, 1) AS candidate_version,
|
|
656
|
+
coalesce(candidate.evidence_revision, 1) AS evidence_revision,
|
|
657
|
+
coalesce(candidate.decision_revision, 0) AS decision_revision,
|
|
658
|
+
coalesce(candidate.rule_fingerprint, candidate.id) AS rule_fingerprint
|
|
659
|
+
'''
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
_CREATE_WORKFLOW_REVIEW_PREVIEW_QUERY = '''
|
|
663
|
+
/* WORKFLOW_REVIEW_PREVIEW_CREATE */
|
|
664
|
+
MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
665
|
+
[:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate {
|
|
666
|
+
id: $candidate_id,
|
|
667
|
+
candidate_version: $candidate_version,
|
|
668
|
+
evidence_revision: $evidence_revision,
|
|
669
|
+
decision_revision: $decision_revision,
|
|
670
|
+
rule_fingerprint: $rule_fingerprint
|
|
671
|
+
})
|
|
672
|
+
CREATE (preview:FuliWorkflowReviewPreview {
|
|
673
|
+
id: $preview_id,
|
|
674
|
+
candidate_id: $candidate_id,
|
|
675
|
+
candidate_version: $candidate_version,
|
|
676
|
+
evidence_revision: $evidence_revision,
|
|
677
|
+
decision_revision: $decision_revision,
|
|
678
|
+
rule_fingerprint: $rule_fingerprint,
|
|
679
|
+
token_hash: $token_hash,
|
|
680
|
+
payload_fingerprint: $payload_fingerprint,
|
|
681
|
+
issued_to_actor_id: $issued_to_actor_id,
|
|
682
|
+
issued_channel: $issued_channel,
|
|
683
|
+
created_at: $created_at,
|
|
684
|
+
expires_at: $expires_at,
|
|
685
|
+
used_at: null
|
|
686
|
+
})
|
|
687
|
+
MERGE (candidate)-[:HAS_WORKFLOW_REVIEW_PREVIEW]->(preview)
|
|
688
|
+
RETURN preview.id AS preview_id
|
|
689
|
+
'''
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
_WORKFLOW_REVIEW_EVENT_QUERY = '''
|
|
693
|
+
/* WORKFLOW_REVIEW_EVENT_LOOKUP */
|
|
694
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
695
|
+
[:HAS_WORKFLOW_CANDIDATE]->(:FuliWorkflowCandidate)-
|
|
696
|
+
[:HAS_WORKFLOW_REVIEW_EVENT]->(event:FuliWorkflowReviewEvent {
|
|
697
|
+
id: $event_id
|
|
698
|
+
})
|
|
699
|
+
RETURN event.payload_fingerprint AS payload_fingerprint
|
|
700
|
+
'''
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
_REJECT_WORKFLOW_CANDIDATE_QUERY = '''
|
|
704
|
+
/* WORKFLOW_CANDIDATE_REJECT */
|
|
705
|
+
MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
706
|
+
[:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate {
|
|
707
|
+
id: $candidate_id
|
|
708
|
+
})
|
|
709
|
+
MATCH (candidate)-[:HAS_WORKFLOW_REVIEW_PREVIEW]->
|
|
710
|
+
(preview:FuliWorkflowReviewPreview {
|
|
711
|
+
token_hash: $token_hash
|
|
712
|
+
})
|
|
713
|
+
WHERE preview.used_at IS NULL
|
|
714
|
+
AND preview.expires_at >= $reviewed_at
|
|
715
|
+
AND preview.payload_fingerprint = $payload_fingerprint
|
|
716
|
+
AND preview.candidate_version = $candidate_version
|
|
717
|
+
AND preview.evidence_revision = $evidence_revision
|
|
718
|
+
AND preview.decision_revision = $decision_revision
|
|
719
|
+
AND preview.issued_to_actor_id = $actor_id
|
|
720
|
+
AND preview.rule_fingerprint = candidate.rule_fingerprint
|
|
721
|
+
SET candidate.review_lock_nonce =
|
|
722
|
+
coalesce(candidate.review_lock_nonce, 0) + 1
|
|
723
|
+
WITH candidate, preview
|
|
724
|
+
WHERE preview.used_at IS NULL
|
|
725
|
+
AND candidate.candidate_version = $candidate_version
|
|
726
|
+
AND candidate.evidence_revision = $evidence_revision
|
|
727
|
+
AND candidate.decision_revision = $decision_revision
|
|
728
|
+
AND candidate.rule_fingerprint = preview.rule_fingerprint
|
|
729
|
+
MERGE (event:FuliWorkflowReviewEvent {id: $event_id})
|
|
730
|
+
ON CREATE SET event.candidate_id = $candidate_id,
|
|
731
|
+
event.candidate_version = $candidate_version,
|
|
732
|
+
event.evidence_revision = $evidence_revision,
|
|
733
|
+
event.decision_revision = $decision_revision,
|
|
734
|
+
event.payload_fingerprint = $payload_fingerprint,
|
|
735
|
+
event.decision = 'reject',
|
|
736
|
+
event.reason = $reason,
|
|
737
|
+
event.authority = $authority,
|
|
738
|
+
event.authority_label = $authority_label,
|
|
739
|
+
event.decision_source = $decision_source,
|
|
740
|
+
event.created_at = $reviewed_at,
|
|
741
|
+
preview.used_at = $reviewed_at,
|
|
742
|
+
preview.used_by_event_id = $event_id,
|
|
743
|
+
candidate.status = 'rejected',
|
|
744
|
+
candidate.decision_revision = $decision_revision + 1,
|
|
745
|
+
candidate.decline_count =
|
|
746
|
+
coalesce(candidate.decline_count, 0) + 1,
|
|
747
|
+
candidate.reviewed_at = $reviewed_at,
|
|
748
|
+
candidate.review_reason = $reason,
|
|
749
|
+
candidate.review_authority = $authority
|
|
750
|
+
MERGE (candidate)-[:HAS_WORKFLOW_REVIEW_EVENT]->(event)
|
|
751
|
+
WITH candidate, event
|
|
752
|
+
OPTIONAL MATCH (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->
|
|
753
|
+
(authorization:FuliWorkflowAuthorization {active: true})
|
|
754
|
+
WITH candidate, event, collect(authorization) AS active_authorizations
|
|
755
|
+
FOREACH (authorization IN active_authorizations |
|
|
756
|
+
SET authorization.active = false,
|
|
757
|
+
authorization.revoked_at = $reviewed_at,
|
|
758
|
+
authorization.revocation_reason = $reason
|
|
759
|
+
)
|
|
760
|
+
RETURN event.id AS event_id
|
|
761
|
+
'''
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
_APPROVE_WORKFLOW_CANDIDATE_QUERY = '''
|
|
765
|
+
/* WORKFLOW_CANDIDATE_APPROVE */
|
|
766
|
+
MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
767
|
+
[:HAS_WORKFLOW_CANDIDATE]->(candidate:FuliWorkflowCandidate {
|
|
768
|
+
id: $candidate_id
|
|
769
|
+
})
|
|
770
|
+
MATCH (candidate)-[:HAS_WORKFLOW_REVIEW_PREVIEW]->
|
|
771
|
+
(preview:FuliWorkflowReviewPreview {
|
|
772
|
+
token_hash: $token_hash
|
|
773
|
+
})
|
|
774
|
+
WHERE preview.used_at IS NULL
|
|
775
|
+
AND preview.expires_at >= $reviewed_at
|
|
776
|
+
AND preview.payload_fingerprint = $payload_fingerprint
|
|
777
|
+
AND preview.candidate_version = $candidate_version
|
|
778
|
+
AND preview.evidence_revision = $evidence_revision
|
|
779
|
+
AND preview.decision_revision = $decision_revision
|
|
780
|
+
AND preview.issued_to_actor_id = $actor_id
|
|
781
|
+
AND preview.rule_fingerprint = candidate.rule_fingerprint
|
|
782
|
+
SET candidate.review_lock_nonce =
|
|
783
|
+
coalesce(candidate.review_lock_nonce, 0) + 1
|
|
784
|
+
WITH candidate, preview
|
|
785
|
+
WHERE preview.used_at IS NULL
|
|
786
|
+
AND candidate.candidate_version = $candidate_version
|
|
787
|
+
AND candidate.evidence_revision = $evidence_revision
|
|
788
|
+
AND candidate.decision_revision = $decision_revision
|
|
789
|
+
AND candidate.rule_fingerprint = preview.rule_fingerprint
|
|
790
|
+
MERGE (event:FuliWorkflowReviewEvent {id: $event_id})
|
|
791
|
+
ON CREATE SET event.candidate_id = $candidate_id,
|
|
792
|
+
event.candidate_version = $candidate_version,
|
|
793
|
+
event.evidence_revision = $evidence_revision,
|
|
794
|
+
event.decision_revision = $decision_revision,
|
|
795
|
+
event.payload_fingerprint = $payload_fingerprint,
|
|
796
|
+
event.decision = 'approve',
|
|
797
|
+
event.reason = $reason,
|
|
798
|
+
event.authority = $authority,
|
|
799
|
+
event.authority_label = $authority_label,
|
|
800
|
+
event.decision_source = $decision_source,
|
|
801
|
+
event.created_at = $reviewed_at,
|
|
802
|
+
preview.used_at = $reviewed_at,
|
|
803
|
+
preview.used_by_event_id = $event_id,
|
|
804
|
+
candidate.status = 'approved',
|
|
805
|
+
candidate.decision_revision = $decision_revision + 1,
|
|
806
|
+
candidate.reviewed_at = $reviewed_at,
|
|
807
|
+
candidate.review_reason = $reason,
|
|
808
|
+
candidate.review_authority = $authority
|
|
809
|
+
MERGE (candidate)-[:HAS_WORKFLOW_REVIEW_EVENT]->(event)
|
|
810
|
+
WITH candidate, event
|
|
811
|
+
OPTIONAL MATCH (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->
|
|
812
|
+
(previous:FuliWorkflowAuthorization {active: true})
|
|
813
|
+
WHERE previous IS NULL OR previous.id <> $authorization_id
|
|
814
|
+
WITH candidate, event, collect(previous) AS previous_authorizations
|
|
815
|
+
FOREACH (previous IN previous_authorizations |
|
|
816
|
+
SET previous.active = false,
|
|
817
|
+
previous.revoked_at = $reviewed_at,
|
|
818
|
+
previous.revocation_reason = 'superseded by a later workflow approval'
|
|
819
|
+
)
|
|
820
|
+
MERGE (rule:FuliWorkflowRule {id: $rule_id})
|
|
821
|
+
ON CREATE SET rule.candidate_id = $candidate_id,
|
|
822
|
+
rule.candidate_version = $candidate_version,
|
|
823
|
+
rule.rule_fingerprint = candidate.rule_fingerprint,
|
|
824
|
+
rule.source_step_id = candidate.source_step_id,
|
|
825
|
+
rule.source_step_key = candidate.source_step_key,
|
|
826
|
+
rule.target_step_id = candidate.target_step_id,
|
|
827
|
+
rule.target_step_key = candidate.target_step_key,
|
|
828
|
+
rule.condition_json = candidate.condition_json,
|
|
829
|
+
rule.created_at = $reviewed_at
|
|
830
|
+
MERGE (candidate)-[:MATERIALIZED_AS_WORKFLOW_RULE]->(rule)
|
|
831
|
+
MERGE (authorization:FuliWorkflowAuthorization {id: $authorization_id})
|
|
832
|
+
ON CREATE SET authorization.candidate_id = $candidate_id,
|
|
833
|
+
authorization.candidate_version = $candidate_version,
|
|
834
|
+
authorization.rule_id = $rule_id,
|
|
835
|
+
authorization.rule_fingerprint = candidate.rule_fingerprint,
|
|
836
|
+
authorization.scope = 'durable',
|
|
837
|
+
authorization.active = true,
|
|
838
|
+
authorization.authority = $authority,
|
|
839
|
+
authorization.authority_label = $authority_label,
|
|
840
|
+
authorization.decision_source = $decision_source,
|
|
841
|
+
authorization.created_at = $reviewed_at,
|
|
842
|
+
authorization.high_risk_per_call_approval_required = true,
|
|
843
|
+
authorization.high_risk_action_categories =
|
|
844
|
+
$high_risk_action_categories
|
|
845
|
+
MERGE (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->(authorization)
|
|
846
|
+
MERGE (authorization)-[:AUTHORIZES_WORKFLOW_RULE]->(rule)
|
|
847
|
+
RETURN event.id AS event_id
|
|
848
|
+
'''
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
_WORKFLOW_CANDIDATE_EVIDENCE_QUERY = '''
|
|
852
|
+
/* WORKFLOW_CANDIDATE_EVIDENCE_AGGREGATION */
|
|
853
|
+
UNWIND $pairs AS pair
|
|
854
|
+
MATCH (source:Entity {uuid: pair.source_step_id})-
|
|
855
|
+
[evidence:RELATES_TO {
|
|
856
|
+
group_id: $group_id,
|
|
857
|
+
name: 'RECOMMENDS_NEXT'
|
|
858
|
+
}]->(target:Entity {uuid: pair.target_step_id})
|
|
859
|
+
WHERE evidence.invalid_at IS NULL
|
|
860
|
+
AND evidence.fuli_key = pair.workflow_key
|
|
861
|
+
AND coalesce(evidence.fuli_workflow_condition_json, '{}') = pair.condition_json
|
|
862
|
+
AND evidence.fuli_workflow_session_authority = 'mcp_host'
|
|
863
|
+
WITH pair, source, target, collect(DISTINCT evidence) AS evidence_items
|
|
864
|
+
UNWIND evidence_items AS evidence
|
|
865
|
+
UNWIND coalesce(evidence.episodes, []) AS episode_id
|
|
866
|
+
MATCH (episode:Episodic {uuid: episode_id, group_id: $group_id})
|
|
867
|
+
WHERE episode.fuli_workflow_session_authority = 'mcp_host'
|
|
868
|
+
AND (
|
|
869
|
+
episode.fuli_personal_project_id = $personal_project_id
|
|
870
|
+
OR (
|
|
871
|
+
episode.fuli_personal_project_id IS NULL
|
|
872
|
+
AND $personal_project_id IS NULL
|
|
873
|
+
)
|
|
874
|
+
)
|
|
875
|
+
WITH pair, source, target,
|
|
876
|
+
collect(DISTINCT evidence) AS evidence_items,
|
|
877
|
+
collect(DISTINCT episode) AS episodes
|
|
878
|
+
WITH pair, source, target, episodes,
|
|
879
|
+
CASE
|
|
880
|
+
WHEN any(evidence IN evidence_items
|
|
881
|
+
WHERE evidence.fuli_workflow_confirmation_authority = 'user')
|
|
882
|
+
THEN 'user'
|
|
883
|
+
WHEN any(evidence IN evidence_items
|
|
884
|
+
WHERE evidence.fuli_workflow_confirmation_authority =
|
|
885
|
+
'authoritative_source')
|
|
886
|
+
THEN 'authoritative_source'
|
|
887
|
+
WHEN any(evidence IN evidence_items
|
|
888
|
+
WHERE evidence.fuli_workflow_confirmation_authority =
|
|
889
|
+
'agent_proposed')
|
|
890
|
+
THEN 'agent_proposed'
|
|
891
|
+
WHEN any(evidence IN evidence_items
|
|
892
|
+
WHERE evidence.fuli_workflow_confirmation_authority =
|
|
893
|
+
'import_proposed')
|
|
894
|
+
THEN 'import_proposed'
|
|
895
|
+
ELSE 'none'
|
|
896
|
+
END AS confirmation_authority,
|
|
897
|
+
reduce(
|
|
898
|
+
total = 0,
|
|
899
|
+
evidence IN evidence_items |
|
|
900
|
+
total + coalesce(evidence.fuli_negative_evidence_count, 0)
|
|
901
|
+
) AS negative_evidence_count,
|
|
902
|
+
[evidence IN evidence_items | evidence.uuid] AS evidence_ids
|
|
903
|
+
UNWIND episodes AS episode
|
|
904
|
+
RETURN source.uuid AS source_step_id,
|
|
905
|
+
pair.workflow_key AS workflow_key,
|
|
906
|
+
coalesce(source.fuli_key, pair.source_step_key) AS source_step_key,
|
|
907
|
+
source.name AS source_step_name,
|
|
908
|
+
target.uuid AS target_step_id,
|
|
909
|
+
coalesce(target.fuli_key, pair.target_step_key) AS target_step_key,
|
|
910
|
+
target.name AS target_step_name,
|
|
911
|
+
pair.condition_json AS condition_json,
|
|
912
|
+
count(DISTINCT episode.uuid) AS occurrence_count,
|
|
913
|
+
count(DISTINCT episode.fuli_session_id) AS distinct_session_count,
|
|
914
|
+
min(episode.valid_at) AS first_observed_at,
|
|
915
|
+
max(episode.valid_at) AS last_observed_at,
|
|
916
|
+
confirmation_authority,
|
|
917
|
+
negative_evidence_count,
|
|
918
|
+
evidence_ids
|
|
919
|
+
'''
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
_MATERIALIZE_WORKFLOW_CANDIDATE_QUERY = '''
|
|
923
|
+
UNWIND $candidates AS row
|
|
924
|
+
MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
|
|
925
|
+
MATCH (source:Entity {uuid: row.source_step_id})
|
|
926
|
+
MATCH (target:Entity {uuid: row.target_step_id})
|
|
927
|
+
MERGE (candidate:FuliWorkflowCandidate {id: row.candidate_id})
|
|
928
|
+
ON CREATE SET candidate.status = 'pending',
|
|
929
|
+
candidate.decline_count = 0,
|
|
930
|
+
candidate.candidate_version = 1,
|
|
931
|
+
candidate.evidence_revision = 1,
|
|
932
|
+
candidate.decision_revision = 0,
|
|
933
|
+
candidate.rule_fingerprint = row.rule_fingerprint,
|
|
934
|
+
candidate.evidence_fingerprint = row.evidence_fingerprint,
|
|
935
|
+
candidate.created_at = row.changed_at
|
|
936
|
+
WITH space, source, target, candidate, row,
|
|
937
|
+
candidate.rule_fingerprint <> row.rule_fingerprint AS rule_changed,
|
|
938
|
+
candidate.evidence_fingerprint <> row.evidence_fingerprint
|
|
939
|
+
AS evidence_changed
|
|
940
|
+
SET candidate.candidate_version =
|
|
941
|
+
CASE WHEN rule_changed
|
|
942
|
+
THEN coalesce(candidate.candidate_version, 1) + 1
|
|
943
|
+
ELSE coalesce(candidate.candidate_version, 1) END,
|
|
944
|
+
candidate.evidence_revision =
|
|
945
|
+
CASE WHEN evidence_changed
|
|
946
|
+
THEN coalesce(candidate.evidence_revision, 1) + 1
|
|
947
|
+
ELSE coalesce(candidate.evidence_revision, 1) END,
|
|
948
|
+
candidate.decision_revision =
|
|
949
|
+
CASE WHEN rule_changed
|
|
950
|
+
THEN coalesce(candidate.decision_revision, 0) + 1
|
|
951
|
+
ELSE coalesce(candidate.decision_revision, 0) END,
|
|
952
|
+
candidate.status =
|
|
953
|
+
CASE WHEN rule_changed THEN 'pending'
|
|
954
|
+
ELSE coalesce(candidate.status, 'pending') END,
|
|
955
|
+
candidate.reviewed_at =
|
|
956
|
+
CASE WHEN rule_changed THEN null ELSE candidate.reviewed_at END,
|
|
957
|
+
candidate.review_reason =
|
|
958
|
+
CASE WHEN rule_changed THEN null ELSE candidate.review_reason END,
|
|
959
|
+
candidate.personal_space_id = row.personal_space_id,
|
|
960
|
+
candidate.personal_project_id = row.personal_project_id,
|
|
961
|
+
candidate.workflow_key = row.workflow_key,
|
|
962
|
+
candidate.source_step_id = row.source_step_id,
|
|
963
|
+
candidate.source_step_key = row.source_step_key,
|
|
964
|
+
candidate.source_step_name = row.source_step_name,
|
|
965
|
+
candidate.target_step_id = row.target_step_id,
|
|
966
|
+
candidate.target_step_key = row.target_step_key,
|
|
967
|
+
candidate.target_step_name = row.target_step_name,
|
|
968
|
+
candidate.condition_json = row.condition_json,
|
|
969
|
+
candidate.rule_fingerprint = row.rule_fingerprint,
|
|
970
|
+
candidate.evidence_fingerprint = row.evidence_fingerprint,
|
|
971
|
+
candidate.occurrence_count = row.occurrence_count,
|
|
972
|
+
candidate.distinct_session_count = row.distinct_session_count,
|
|
973
|
+
candidate.first_observed_at = row.first_observed_at,
|
|
974
|
+
candidate.last_observed_at = row.last_observed_at,
|
|
975
|
+
candidate.confirmation_authority = row.confirmation_authority,
|
|
976
|
+
candidate.negative_evidence_count = row.negative_evidence_count,
|
|
977
|
+
candidate.evidence_ids = row.evidence_ids,
|
|
978
|
+
candidate.updated_at = row.changed_at
|
|
979
|
+
WITH space, source, target, candidate, row, rule_changed
|
|
980
|
+
OPTIONAL MATCH (candidate)-[:HAS_WORKFLOW_AUTHORIZATION]->
|
|
981
|
+
(authorization:FuliWorkflowAuthorization {active: true})
|
|
982
|
+
WITH space, source, target, candidate, row, rule_changed,
|
|
983
|
+
collect(authorization) AS active_authorizations
|
|
984
|
+
FOREACH (authorization IN
|
|
985
|
+
CASE WHEN rule_changed THEN active_authorizations ELSE [] END |
|
|
986
|
+
SET authorization.active = false,
|
|
987
|
+
authorization.revoked_at = row.changed_at,
|
|
988
|
+
authorization.revocation_reason = 'workflow rule fingerprint changed'
|
|
989
|
+
)
|
|
990
|
+
MERGE (space)-[:HAS_WORKFLOW_CANDIDATE]->(candidate)
|
|
991
|
+
MERGE (candidate)-[:FROM_WORKFLOW_STEP]->(source)
|
|
992
|
+
MERGE (candidate)-[:TO_WORKFLOW_STEP]->(target)
|
|
993
|
+
RETURN candidate.id AS candidate_id,
|
|
994
|
+
candidate.candidate_version AS candidate_version,
|
|
995
|
+
candidate.evidence_revision AS evidence_revision
|
|
996
|
+
'''
|