tallyfy 1.0.0__py3-none-any.whl
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.
Potentially problematic release.
This version of tallyfy might be problematic. Click here for more details.
- tallyfy/README.md +636 -0
- tallyfy/__init__.py +20 -0
- tallyfy/core.py +333 -0
- tallyfy/form_fields_management.py +582 -0
- tallyfy/models.py +1013 -0
- tallyfy/task_management.py +356 -0
- tallyfy/template_management.py +2580 -0
- tallyfy/user_management.py +235 -0
- tallyfy-1.0.0.dist-info/METADATA +680 -0
- tallyfy-1.0.0.dist-info/RECORD +13 -0
- tallyfy-1.0.0.dist-info/WHEEL +5 -0
- tallyfy-1.0.0.dist-info/licenses/LICENSE +21 -0
- tallyfy-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2580 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Template management functionality for Tallyfy SDK
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional, Dict, Any
|
|
6
|
+
from .models import Template, Step, AutomatedAction, PrerunField, TallyfyError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TemplateManagement:
|
|
10
|
+
"""Handles template and step management operations"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, sdk):
|
|
13
|
+
self.sdk = sdk
|
|
14
|
+
|
|
15
|
+
def search_templates_by_name(self, org_id: str, template_name: str) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Search for template by name using the search endpoint.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
org_id: Organization ID
|
|
21
|
+
template_name: Name or partial name of the template to search for
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Template ID of the found template
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
TallyfyError: If no template found, multiple matches, or search fails
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
search_endpoint = f"organizations/{org_id}/search"
|
|
31
|
+
search_params = {
|
|
32
|
+
'on': 'blueprint',
|
|
33
|
+
'per_page': '20',
|
|
34
|
+
'search': template_name
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
search_response = self.sdk._make_request('GET', search_endpoint, params=search_params)
|
|
38
|
+
|
|
39
|
+
if isinstance(search_response, dict) and 'blueprint' in search_response:
|
|
40
|
+
template_data = search_response['blueprint']
|
|
41
|
+
if 'data' in template_data and template_data['data']:
|
|
42
|
+
templates = template_data['data']
|
|
43
|
+
|
|
44
|
+
# First try exact match (case-insensitive)
|
|
45
|
+
exact_matches = [p for p in templates if p['title'].lower() == template_name.lower()]
|
|
46
|
+
if exact_matches:
|
|
47
|
+
return exact_matches[0]['id']
|
|
48
|
+
elif len(templates) == 1:
|
|
49
|
+
# Single search result, use it
|
|
50
|
+
return templates[0]['id']
|
|
51
|
+
else:
|
|
52
|
+
# Multiple matches found, provide helpful error with options
|
|
53
|
+
match_names = [f"'{p['title']}'" for p in templates[:5]] # Show max 5
|
|
54
|
+
raise TallyfyError(
|
|
55
|
+
f"Multiple templates found matching '{template_name}': {', '.join(match_names)}. Please be more specific.")
|
|
56
|
+
else:
|
|
57
|
+
raise TallyfyError(f"No template found matching name: {template_name}")
|
|
58
|
+
else:
|
|
59
|
+
raise TallyfyError(f"Search failed for template name: {template_name}")
|
|
60
|
+
|
|
61
|
+
except TallyfyError as e:
|
|
62
|
+
self.sdk.logger.error(f"Failed to search for template '{template_name}': {e}")
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
def get_template(self, org_id: str, template_id: Optional[str] = None, template_name: Optional[str] = None) -> Optional[Template]:
|
|
66
|
+
"""
|
|
67
|
+
Get a template (checklist) by its ID or name with full details including prerun fields,
|
|
68
|
+
automated actions, linked tasks, and metadata.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
org_id: Organization ID
|
|
72
|
+
template_id: Template (checklist) ID
|
|
73
|
+
template_name: Template (checklist) name
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Template object with complete template data
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
TallyfyError: If the request fails
|
|
80
|
+
"""
|
|
81
|
+
if not template_id and not template_name:
|
|
82
|
+
raise ValueError("Either template_id or template_name must be provided")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
# If template_name is provided but not template_id, search for the template first
|
|
86
|
+
if template_name and not template_id:
|
|
87
|
+
template_id = self.search_templates_by_name(org_id, template_name)
|
|
88
|
+
|
|
89
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}"
|
|
90
|
+
response_data = self.sdk._make_request('GET', endpoint)
|
|
91
|
+
|
|
92
|
+
if isinstance(response_data, dict) and 'data' in response_data:
|
|
93
|
+
template_data = response_data['data']
|
|
94
|
+
return Template.from_dict(template_data)
|
|
95
|
+
else:
|
|
96
|
+
self.sdk.logger.warning("Unexpected response format for template")
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
except TallyfyError as e:
|
|
100
|
+
self.sdk.logger.error(f"Failed to get template {template_id} for org {org_id}: {e}")
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
def update_template_metadata(self, org_id: str, template_id: str, **kwargs) -> Optional[Template]:
|
|
104
|
+
"""
|
|
105
|
+
Update template metadata like title, summary, guidance, icons, etc.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
org_id: Organization ID
|
|
109
|
+
template_id: Template ID to update
|
|
110
|
+
**kwargs: Template metadata fields to update (title, summary, guidance, icon, etc.)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Updated Template object
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
TallyfyError: If the request fails
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}"
|
|
120
|
+
|
|
121
|
+
# Build update data from kwargs
|
|
122
|
+
update_data = {}
|
|
123
|
+
allowed_fields = [
|
|
124
|
+
'title', 'summary', 'guidance', 'icon', 'alias', 'webhook',
|
|
125
|
+
'explanation_video', 'kickoff_title', 'kickoff_description',
|
|
126
|
+
'is_public', 'is_featured', 'auto_naming', 'folderize_process',
|
|
127
|
+
'tag_process', 'allow_launcher_change_name', 'is_pinned',
|
|
128
|
+
'default_folder', 'folder_changeable_by_launcher'
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
for field, value in kwargs.items():
|
|
132
|
+
if field in allowed_fields:
|
|
133
|
+
update_data[field] = value
|
|
134
|
+
else:
|
|
135
|
+
self.sdk.logger.warning(f"Ignoring unknown template field: {field}")
|
|
136
|
+
|
|
137
|
+
if not update_data:
|
|
138
|
+
raise ValueError("No valid template fields provided for update")
|
|
139
|
+
|
|
140
|
+
response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
|
|
141
|
+
|
|
142
|
+
if isinstance(response_data, dict) and 'data' in response_data:
|
|
143
|
+
template_data = response_data['data']
|
|
144
|
+
return Template.from_dict(template_data)
|
|
145
|
+
else:
|
|
146
|
+
self.sdk.logger.warning("Unexpected response format for template update")
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
except TallyfyError as e:
|
|
150
|
+
self.sdk.logger.error(f"Failed to update template {template_id} for org {org_id}: {e}")
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
def get_template_with_steps(self, org_id: str, template_id: Optional[str] = None, template_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
154
|
+
"""
|
|
155
|
+
Get template with full step details and structure.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
org_id: Organization ID
|
|
159
|
+
template_id: Template ID to retrieve
|
|
160
|
+
template_name: Template name to retrieve (alternative to template_id)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Dictionary containing template data with full step details
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
TallyfyError: If the request fails
|
|
167
|
+
"""
|
|
168
|
+
if not template_id and not template_name:
|
|
169
|
+
raise ValueError("Either template_id or template_name must be provided")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# If template_name is provided but not template_id, search for the template first
|
|
173
|
+
if template_name and not template_id:
|
|
174
|
+
template_id = self.search_templates_by_name(org_id, template_name)
|
|
175
|
+
|
|
176
|
+
# Get template with steps included
|
|
177
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}"
|
|
178
|
+
params = {'with': 'steps,automated_actions,prerun'}
|
|
179
|
+
|
|
180
|
+
response_data = self.sdk._make_request('GET', endpoint, params=params)
|
|
181
|
+
|
|
182
|
+
if isinstance(response_data, dict) and 'data' in response_data:
|
|
183
|
+
template_data = response_data['data']
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
'template': Template.from_dict(template_data),
|
|
187
|
+
'raw_data': template_data,
|
|
188
|
+
'step_count': len(template_data.get('steps', [])),
|
|
189
|
+
'steps': template_data.get('steps', []),
|
|
190
|
+
'automation_count': len(template_data.get('automated_actions', [])),
|
|
191
|
+
'prerun_field_count': len(template_data.get('prerun', []))
|
|
192
|
+
}
|
|
193
|
+
else:
|
|
194
|
+
self.sdk.logger.warning("Unexpected response format for template with steps")
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
except TallyfyError as e:
|
|
198
|
+
self.sdk.logger.error(f"Failed to get template with steps {template_id or template_name} for org {org_id}: {e}")
|
|
199
|
+
raise
|
|
200
|
+
|
|
201
|
+
def duplicate_template(self, org_id: str, template_id: str, new_name: str, copy_permissions: bool = False) -> Optional[Template]:
|
|
202
|
+
"""
|
|
203
|
+
Create a copy of a template for safe editing.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
org_id: Organization ID
|
|
207
|
+
template_id: Template ID to duplicate
|
|
208
|
+
new_name: Name for the new template copy
|
|
209
|
+
copy_permissions: Whether to copy template permissions (default: False)
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
New Template object
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
TallyfyError: If the request fails
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}/duplicate"
|
|
219
|
+
|
|
220
|
+
duplicate_data = {
|
|
221
|
+
'title': new_name,
|
|
222
|
+
'copy_permissions': copy_permissions
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
response_data = self.sdk._make_request('POST', endpoint, data=duplicate_data)
|
|
226
|
+
|
|
227
|
+
if isinstance(response_data, dict) and 'data' in response_data:
|
|
228
|
+
template_data = response_data['data']
|
|
229
|
+
return Template.from_dict(template_data)
|
|
230
|
+
else:
|
|
231
|
+
self.sdk.logger.warning("Unexpected response format for template duplication")
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
except TallyfyError as e:
|
|
235
|
+
self.sdk.logger.error(f"Failed to duplicate template {template_id} for org {org_id}: {e}")
|
|
236
|
+
raise
|
|
237
|
+
|
|
238
|
+
def get_template_steps(self, org_id: str, template_id: str) -> List[Step]:
|
|
239
|
+
"""
|
|
240
|
+
Get all steps of a template.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
org_id: Organization ID
|
|
244
|
+
template_id: Template ID
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of Step objects
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
TallyfyError: If the request fails
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}/steps"
|
|
254
|
+
response_data = self.sdk._make_request('GET', endpoint)
|
|
255
|
+
|
|
256
|
+
if isinstance(response_data, dict) and 'data' in response_data:
|
|
257
|
+
steps_data = response_data['data']
|
|
258
|
+
return [Step.from_dict(step_data) for step_data in steps_data]
|
|
259
|
+
else:
|
|
260
|
+
if isinstance(response_data, list):
|
|
261
|
+
return [Step.from_dict(step_data) for step_data in response_data]
|
|
262
|
+
else:
|
|
263
|
+
self.sdk.logger.warning("Unexpected response format for template steps")
|
|
264
|
+
return []
|
|
265
|
+
|
|
266
|
+
except TallyfyError as e:
|
|
267
|
+
self.sdk.logger.error(f"Failed to get template steps for template {template_id}: {e}")
|
|
268
|
+
raise
|
|
269
|
+
|
|
270
|
+
def get_step_dependencies(self, org_id: str, template_id: str, step_id: str) -> Dict[str, Any]:
|
|
271
|
+
"""
|
|
272
|
+
Analyze which automations affect when this step appears.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
org_id: Organization ID
|
|
276
|
+
template_id: Template ID
|
|
277
|
+
step_id: Step ID to analyze
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Dictionary containing dependency analysis with:
|
|
281
|
+
- step_info: Basic step information
|
|
282
|
+
- automation_rules: List of automations that affect this step
|
|
283
|
+
- dependencies: List of conditions that must be met for step to show
|
|
284
|
+
- affected_by: List of other steps/fields that influence this step's visibility
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
# Get template with automations
|
|
288
|
+
template_data = self.get_template_with_steps(org_id, template_id)
|
|
289
|
+
if not template_data:
|
|
290
|
+
raise TallyfyError(f"Could not retrieve template {template_id}")
|
|
291
|
+
|
|
292
|
+
# Find the target step
|
|
293
|
+
target_step = None
|
|
294
|
+
for step_data in template_data['steps']:
|
|
295
|
+
if step_data.get('id') == step_id:
|
|
296
|
+
target_step = step_data
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
if not target_step:
|
|
300
|
+
raise TallyfyError(f"Step {step_id} not found in template {template_id}")
|
|
301
|
+
|
|
302
|
+
# Analyze automation rules that affect this step
|
|
303
|
+
automation_rules = []
|
|
304
|
+
dependencies = []
|
|
305
|
+
affected_by = []
|
|
306
|
+
|
|
307
|
+
template = template_data['template']
|
|
308
|
+
if hasattr(template, 'automated_actions') and template.automated_actions:
|
|
309
|
+
for automation in template.automated_actions:
|
|
310
|
+
# Check if this automation affects our target step
|
|
311
|
+
step_affected = False
|
|
312
|
+
for action in automation.then_actions:
|
|
313
|
+
if (action.target_step_id == step_id or
|
|
314
|
+
action.actionable_id == step_id):
|
|
315
|
+
step_affected = True
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
if step_affected:
|
|
319
|
+
automation_rules.append({
|
|
320
|
+
'automation_id': automation.id,
|
|
321
|
+
'alias': automation.automated_alias,
|
|
322
|
+
'conditions': [
|
|
323
|
+
{
|
|
324
|
+
'id': cond.id,
|
|
325
|
+
'type': cond.conditionable_type,
|
|
326
|
+
'target_id': cond.conditionable_id,
|
|
327
|
+
'operation': cond.operation,
|
|
328
|
+
'statement': cond.statement,
|
|
329
|
+
'logic': cond.logic
|
|
330
|
+
} for cond in automation.conditions
|
|
331
|
+
],
|
|
332
|
+
'actions': [
|
|
333
|
+
{
|
|
334
|
+
'id': action.id,
|
|
335
|
+
'type': action.action_type,
|
|
336
|
+
'verb': action.action_verb,
|
|
337
|
+
'target_step_id': action.target_step_id
|
|
338
|
+
} for action in automation.then_actions
|
|
339
|
+
]
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
# Extract dependencies from conditions
|
|
343
|
+
for condition in automation.conditions:
|
|
344
|
+
if condition.conditionable_type in ['Step', 'Capture']:
|
|
345
|
+
dependencies.append({
|
|
346
|
+
'type': condition.conditionable_type.lower(),
|
|
347
|
+
'id': condition.conditionable_id,
|
|
348
|
+
'requirement': f"{condition.operation} {condition.statement}",
|
|
349
|
+
'logic': condition.logic
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
affected_by.append({
|
|
353
|
+
'type': condition.conditionable_type.lower(),
|
|
354
|
+
'id': condition.conditionable_id,
|
|
355
|
+
'influence': f"Step visibility depends on this {condition.conditionable_type.lower()}"
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
'step_info': {
|
|
360
|
+
'id': target_step.get('id'),
|
|
361
|
+
'title': target_step.get('title'),
|
|
362
|
+
'position': target_step.get('position'),
|
|
363
|
+
'step_type': target_step.get('step_type')
|
|
364
|
+
},
|
|
365
|
+
'automation_rules': automation_rules,
|
|
366
|
+
'dependencies': dependencies,
|
|
367
|
+
'affected_by': affected_by,
|
|
368
|
+
'dependency_count': len(dependencies),
|
|
369
|
+
'has_conditional_visibility': len(automation_rules) > 0
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
except TallyfyError as e:
|
|
373
|
+
self.sdk.logger.error(f"Failed to analyze step dependencies for step {step_id}: {e}")
|
|
374
|
+
raise
|
|
375
|
+
|
|
376
|
+
def suggest_step_deadline(self, org_id: str, template_id: str, step_id: str) -> Dict[str, Any]:
|
|
377
|
+
"""
|
|
378
|
+
Suggest reasonable deadline based on step type and complexity.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
org_id: Organization ID
|
|
382
|
+
template_id: Template ID
|
|
383
|
+
step_id: Step ID to analyze
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Dictionary containing deadline suggestions with:
|
|
387
|
+
- step_info: Basic step information
|
|
388
|
+
- suggested_deadline: Recommended deadline configuration
|
|
389
|
+
- reasoning: Explanation for the suggestion
|
|
390
|
+
- alternatives: Other deadline options
|
|
391
|
+
"""
|
|
392
|
+
try:
|
|
393
|
+
# Get template and step details
|
|
394
|
+
template_data = self.get_template_with_steps(org_id, template_id)
|
|
395
|
+
if not template_data:
|
|
396
|
+
raise TallyfyError(f"Could not retrieve template {template_id}")
|
|
397
|
+
|
|
398
|
+
# Find the target step
|
|
399
|
+
target_step = None
|
|
400
|
+
for step_data in template_data['steps']:
|
|
401
|
+
if step_data.get('id') == step_id:
|
|
402
|
+
target_step = step_data
|
|
403
|
+
break
|
|
404
|
+
|
|
405
|
+
if not target_step:
|
|
406
|
+
raise TallyfyError(f"Step {step_id} not found in template {template_id}")
|
|
407
|
+
|
|
408
|
+
# Analyze step characteristics
|
|
409
|
+
step_title = target_step.get('title', '').lower()
|
|
410
|
+
step_summary = target_step.get('summary', '').lower()
|
|
411
|
+
step_type = target_step.get('step_type', '')
|
|
412
|
+
captures = target_step.get('captures', [])
|
|
413
|
+
|
|
414
|
+
# Default suggestion
|
|
415
|
+
suggested_deadline = {
|
|
416
|
+
'value': 2,
|
|
417
|
+
'unit': 'business_days',
|
|
418
|
+
'option': 'from',
|
|
419
|
+
'step': 'start_run'
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
reasoning = "Standard deadline for typical workflow steps"
|
|
423
|
+
alternatives = []
|
|
424
|
+
|
|
425
|
+
# Adjust based on step characteristics
|
|
426
|
+
content = f"{step_title} {step_summary}"
|
|
427
|
+
|
|
428
|
+
# Quick tasks (1 business day)
|
|
429
|
+
if any(word in content for word in ['approve', 'review', 'check', 'verify', 'confirm', 'acknowledge']):
|
|
430
|
+
suggested_deadline.update({'value': 1, 'unit': 'business_days'})
|
|
431
|
+
reasoning = "Quick approval/review tasks typically need fast turnaround"
|
|
432
|
+
alternatives = [
|
|
433
|
+
{'value': 2, 'unit': 'business_days', 'description': 'Standard timeline'},
|
|
434
|
+
{'value': 4, 'unit': 'hours', 'description': 'Urgent approval'},
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
# Complex tasks (1 week)
|
|
438
|
+
elif any(word in content for word in ['develop', 'create', 'design', 'write', 'prepare', 'plan', 'analyze']):
|
|
439
|
+
suggested_deadline.update({'value': 1, 'unit': 'weeks'})
|
|
440
|
+
reasoning = "Creative/development work requires adequate time for quality output"
|
|
441
|
+
alternatives = [
|
|
442
|
+
{'value': 3, 'unit': 'business_days', 'description': 'Accelerated timeline'},
|
|
443
|
+
{'value': 2, 'unit': 'weeks', 'description': 'Extended timeline for complex work'},
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
# External coordination (3-5 days)
|
|
447
|
+
elif any(word in content for word in ['coordinate', 'schedule', 'contact', 'notify', 'communicate']):
|
|
448
|
+
suggested_deadline.update({'value': 3, 'unit': 'business_days'})
|
|
449
|
+
reasoning = "External coordination requires time for responses and scheduling"
|
|
450
|
+
alternatives = [
|
|
451
|
+
{'value': 1, 'unit': 'business_days', 'description': 'Internal coordination only'},
|
|
452
|
+
{'value': 1, 'unit': 'weeks', 'description': 'Complex external coordination'},
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
# Legal/compliance (2 weeks)
|
|
456
|
+
elif any(word in content for word in ['legal', 'compliance', 'audit', 'regulation', 'contract']):
|
|
457
|
+
suggested_deadline.update({'value': 2, 'unit': 'weeks'})
|
|
458
|
+
reasoning = "Legal and compliance work requires thorough review and documentation"
|
|
459
|
+
alternatives = [
|
|
460
|
+
{'value': 1, 'unit': 'weeks', 'description': 'Simple legal review'},
|
|
461
|
+
{'value': 1, 'unit': 'months', 'description': 'Complex legal/regulatory work'},
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
# Adjust based on form complexity
|
|
465
|
+
if captures:
|
|
466
|
+
complex_fields = len([c for c in captures if c.get('field_type') in ['file', 'wysiwyg', 'table']])
|
|
467
|
+
if complex_fields > 2:
|
|
468
|
+
# Add extra time for complex forms
|
|
469
|
+
if suggested_deadline['unit'] == 'business_days':
|
|
470
|
+
suggested_deadline['value'] += 1
|
|
471
|
+
reasoning += " (adjusted for complex form fields)"
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
'step_info': {
|
|
475
|
+
'id': target_step.get('id'),
|
|
476
|
+
'title': target_step.get('title'),
|
|
477
|
+
'step_type': step_type,
|
|
478
|
+
'form_field_count': len(captures),
|
|
479
|
+
'position': target_step.get('position')
|
|
480
|
+
},
|
|
481
|
+
'suggested_deadline': suggested_deadline,
|
|
482
|
+
'reasoning': reasoning,
|
|
483
|
+
'alternatives': alternatives,
|
|
484
|
+
'confidence': 'medium', # Could be calculated based on keyword matches
|
|
485
|
+
'factors_considered': [
|
|
486
|
+
'Step title keywords',
|
|
487
|
+
'Step summary content',
|
|
488
|
+
'Form field complexity',
|
|
489
|
+
'Step type classification'
|
|
490
|
+
]
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
except TallyfyError as e:
|
|
494
|
+
self.sdk.logger.error(f"Failed to suggest deadline for step {step_id}: {e}")
|
|
495
|
+
raise
|
|
496
|
+
|
|
497
|
+
def create_automation_rule(self, org_id: str, template_id: str, automation_data: Dict[str, Any]) -> AutomatedAction:
|
|
498
|
+
"""
|
|
499
|
+
Create conditional automation (if-then rules).
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
org_id: Organization ID
|
|
503
|
+
template_id: Template ID
|
|
504
|
+
automation_data: Dictionary containing automation rule data with conditions and actions
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
AutomatedAction object
|
|
508
|
+
|
|
509
|
+
Raises:
|
|
510
|
+
TallyfyError: If the request fails
|
|
511
|
+
"""
|
|
512
|
+
try:
|
|
513
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}/automated_actions"
|
|
514
|
+
response_data = self.sdk._make_request('POST', endpoint, data=automation_data)
|
|
515
|
+
|
|
516
|
+
if isinstance(response_data, dict) and 'data' in response_data:
|
|
517
|
+
automation_data = response_data['data']
|
|
518
|
+
return AutomatedAction.from_dict(automation_data)
|
|
519
|
+
else:
|
|
520
|
+
self.sdk.logger.warning("Unexpected response format for automation creation")
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
except TallyfyError as e:
|
|
524
|
+
self.sdk.logger.error(f"Failed to create automation rule for template {template_id}: {e}")
|
|
525
|
+
raise
|
|
526
|
+
|
|
527
|
+
def update_automation_rule(self, org_id: str, template_id: str, automation_id: str, **kwargs) -> AutomatedAction:
|
|
528
|
+
"""
|
|
529
|
+
Modify automation conditions and actions.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
org_id: Organization ID
|
|
533
|
+
template_id: Template ID
|
|
534
|
+
automation_id: Automation rule ID
|
|
535
|
+
**kwargs: Automation fields to update
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
Updated AutomatedAction object
|
|
539
|
+
|
|
540
|
+
Raises:
|
|
541
|
+
TallyfyError: If the request fails
|
|
542
|
+
"""
|
|
543
|
+
try:
|
|
544
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}/automated_actions/{automation_id}"
|
|
545
|
+
|
|
546
|
+
# Build update data from kwargs
|
|
547
|
+
update_data = {}
|
|
548
|
+
allowed_fields = [
|
|
549
|
+
'automated_alias', 'conditions', 'then_actions'
|
|
550
|
+
]
|
|
551
|
+
|
|
552
|
+
for field, value in kwargs.items():
|
|
553
|
+
if field in allowed_fields:
|
|
554
|
+
update_data[field] = value
|
|
555
|
+
else:
|
|
556
|
+
self.sdk.logger.warning(f"Ignoring unknown automation field: {field}")
|
|
557
|
+
|
|
558
|
+
if not update_data:
|
|
559
|
+
raise ValueError("No valid automation fields provided for update")
|
|
560
|
+
|
|
561
|
+
response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
|
|
562
|
+
|
|
563
|
+
if isinstance(response_data, dict) and 'data' in response_data:
|
|
564
|
+
automation_data = response_data['data']
|
|
565
|
+
return AutomatedAction.from_dict(automation_data)
|
|
566
|
+
else:
|
|
567
|
+
self.sdk.logger.warning("Unexpected response format for automation update")
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
except TallyfyError as e:
|
|
571
|
+
self.sdk.logger.error(f"Failed to update automation rule {automation_id}: {e}")
|
|
572
|
+
raise
|
|
573
|
+
|
|
574
|
+
def delete_automation_rule(self, org_id: str, template_id: str, automation_id: str) -> bool:
|
|
575
|
+
"""
|
|
576
|
+
Remove an automation rule.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
org_id: Organization ID
|
|
580
|
+
template_id: Template ID
|
|
581
|
+
automation_id: Automation rule ID
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
True if deletion was successful
|
|
585
|
+
|
|
586
|
+
Raises:
|
|
587
|
+
TallyfyError: If the request fails
|
|
588
|
+
"""
|
|
589
|
+
try:
|
|
590
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}/automated_actions/{automation_id}"
|
|
591
|
+
response_data = self.sdk._make_request('DELETE', endpoint)
|
|
592
|
+
|
|
593
|
+
return True
|
|
594
|
+
|
|
595
|
+
except TallyfyError as e:
|
|
596
|
+
self.sdk.logger.error(f"Failed to delete automation rule {automation_id}: {e}")
|
|
597
|
+
raise
|
|
598
|
+
|
|
599
|
+
def analyze_template_automations(self, org_id: str, template_id: str) -> Dict[str, Any]:
|
|
600
|
+
"""
|
|
601
|
+
Analyze all automations for conflicts, redundancies, and optimization opportunities.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
org_id: Organization ID
|
|
605
|
+
template_id: Template ID
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
Dictionary containing automation analysis with:
|
|
609
|
+
- total_automations: Count of automation rules
|
|
610
|
+
- conflicts: List of conflicting rules
|
|
611
|
+
- redundancies: List of redundant rules
|
|
612
|
+
- optimization_opportunities: Suggested improvements
|
|
613
|
+
- complexity_score: Overall complexity rating
|
|
614
|
+
"""
|
|
615
|
+
try:
|
|
616
|
+
template_data = self.get_template_with_steps(org_id, template_id)
|
|
617
|
+
if not template_data:
|
|
618
|
+
raise TallyfyError(f"Could not retrieve template {template_id}")
|
|
619
|
+
template = template_data['template']
|
|
620
|
+
automations = template.automated_actions or []
|
|
621
|
+
conflicts = []
|
|
622
|
+
redundancies = []
|
|
623
|
+
optimization_opportunities = []
|
|
624
|
+
|
|
625
|
+
# Analyze conflicts and redundancies
|
|
626
|
+
for i, automation1 in enumerate(automations):
|
|
627
|
+
for j, automation2 in enumerate(automations[i+1:], i+1):
|
|
628
|
+
# Check for conflicting actions on same targets
|
|
629
|
+
targets1 = set()
|
|
630
|
+
targets2 = set()
|
|
631
|
+
|
|
632
|
+
for action in automation1.then_actions:
|
|
633
|
+
if hasattr(action, 'target_step_id') and action.target_step_id:
|
|
634
|
+
targets1.add(action.target_step_id)
|
|
635
|
+
if hasattr(action, 'actionable_id') and action.actionable_id:
|
|
636
|
+
targets1.add(action.actionable_id)
|
|
637
|
+
|
|
638
|
+
for action in automation2.then_actions:
|
|
639
|
+
if hasattr(action, 'target_step_id') and action.target_step_id:
|
|
640
|
+
targets2.add(action.target_step_id)
|
|
641
|
+
if hasattr(action, 'actionable_id') and action.actionable_id:
|
|
642
|
+
targets2.add(action.actionable_id)
|
|
643
|
+
|
|
644
|
+
common_targets = targets1.intersection(targets2)
|
|
645
|
+
if common_targets:
|
|
646
|
+
# Check if conditions could trigger simultaneously
|
|
647
|
+
similar_conditions = self._analyze_condition_similarity(automation1.conditions, automation2.conditions)
|
|
648
|
+
|
|
649
|
+
if similar_conditions['similarity_score'] > 0.7:
|
|
650
|
+
conflicts.append({
|
|
651
|
+
'automation1_id': automation1.id,
|
|
652
|
+
'automation1_alias': automation1.automated_alias,
|
|
653
|
+
'automation2_id': automation2.id,
|
|
654
|
+
'automation2_alias': automation2.automated_alias,
|
|
655
|
+
'common_targets': list(common_targets),
|
|
656
|
+
'conflict_type': 'overlapping_conditions',
|
|
657
|
+
'similarity_score': similar_conditions['similarity_score'],
|
|
658
|
+
'risk_level': 'high' if similar_conditions['similarity_score'] > 0.9 else 'medium'
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
# Check for exact condition duplicates (redundancies)
|
|
662
|
+
if similar_conditions['similarity_score'] == 1.0:
|
|
663
|
+
redundancies.append({
|
|
664
|
+
'automation1_id': automation1.id,
|
|
665
|
+
'automation2_id': automation2.id,
|
|
666
|
+
'redundancy_type': 'identical_conditions',
|
|
667
|
+
'recommendation': 'Consider consolidating these rules'
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
# Identify optimization opportunities
|
|
671
|
+
|
|
672
|
+
# 1. Single-condition automations that could be merged
|
|
673
|
+
single_condition_automations = [a for a in automations if len(a.conditions) == 1]
|
|
674
|
+
if len(single_condition_automations) > 3:
|
|
675
|
+
optimization_opportunities.append({
|
|
676
|
+
'type': 'merge_simple_rules',
|
|
677
|
+
'description': f"Found {len(single_condition_automations)} single-condition rules that could potentially be consolidated",
|
|
678
|
+
'impact': 'medium',
|
|
679
|
+
'automation_ids': [a.id for a in single_condition_automations]
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
# 2. Unused or ineffective automations
|
|
683
|
+
steps = template_data.get('steps', [])
|
|
684
|
+
if len(steps) > 0:
|
|
685
|
+
steps = steps['data']
|
|
686
|
+
step_ids = set(step['id'] for step in steps)
|
|
687
|
+
for automation in automations:
|
|
688
|
+
has_valid_targets = False
|
|
689
|
+
for action in automation.then_actions:
|
|
690
|
+
target_id = getattr(action, 'target_step_id', None) or getattr(action, 'actionable_id', None)
|
|
691
|
+
if target_id and target_id in step_ids:
|
|
692
|
+
has_valid_targets = True
|
|
693
|
+
break
|
|
694
|
+
|
|
695
|
+
if not has_valid_targets:
|
|
696
|
+
optimization_opportunities.append({
|
|
697
|
+
'type': 'orphaned_automation',
|
|
698
|
+
'description': f"Automation '{automation.automated_alias}' targets non-existent steps",
|
|
699
|
+
'impact': 'high',
|
|
700
|
+
'automation_id': automation.id,
|
|
701
|
+
'recommendation': 'Review and update target steps or remove this automation'
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
# 3. Overly complex condition chains
|
|
705
|
+
for automation in automations:
|
|
706
|
+
if len(automation.conditions) > 5:
|
|
707
|
+
optimization_opportunities.append({
|
|
708
|
+
'type': 'complex_conditions',
|
|
709
|
+
'description': f"Automation '{automation.automated_alias}' has {len(automation.conditions)} conditions",
|
|
710
|
+
'impact': 'medium',
|
|
711
|
+
'automation_id': automation.id,
|
|
712
|
+
'recommendation': 'Consider breaking into simpler rules for better maintainability'
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
# Calculate complexity score
|
|
716
|
+
complexity_factors = {
|
|
717
|
+
'total_automations': len(automations),
|
|
718
|
+
'total_conditions': sum(len(a.conditions) for a in automations),
|
|
719
|
+
'total_actions': sum(len(a.then_actions) for a in automations),
|
|
720
|
+
'conflicts': len(conflicts),
|
|
721
|
+
'redundancies': len(redundancies)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
complexity_score = min(100, (
|
|
725
|
+
complexity_factors['total_automations'] * 5 +
|
|
726
|
+
complexity_factors['total_conditions'] * 2 +
|
|
727
|
+
complexity_factors['total_actions'] * 2 +
|
|
728
|
+
complexity_factors['conflicts'] * 15 +
|
|
729
|
+
complexity_factors['redundancies'] * 10
|
|
730
|
+
))
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
'total_automations': len(automations),
|
|
734
|
+
'conflicts': conflicts,
|
|
735
|
+
'redundancies': redundancies,
|
|
736
|
+
'optimization_opportunities': optimization_opportunities,
|
|
737
|
+
'complexity_score': complexity_score,
|
|
738
|
+
'complexity_rating': (
|
|
739
|
+
'low' if complexity_score < 30 else
|
|
740
|
+
'medium' if complexity_score < 70 else
|
|
741
|
+
'high'
|
|
742
|
+
),
|
|
743
|
+
'analysis_summary': {
|
|
744
|
+
'conflict_count': len(conflicts),
|
|
745
|
+
'redundancy_count': len(redundancies),
|
|
746
|
+
'optimization_count': len(optimization_opportunities),
|
|
747
|
+
'health_status': 'good' if not conflicts and not redundancies else 'needs_attention'
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
except TallyfyError as e:
|
|
752
|
+
self.sdk.logger.error(f"Failed to analyze template automations for template {template_id}: {e}")
|
|
753
|
+
raise
|
|
754
|
+
|
|
755
|
+
def consolidate_automation_rules(self, org_id: str, template_id: str, preview: bool = True) -> Dict[str, Any]:
|
|
756
|
+
"""
|
|
757
|
+
Suggest and optionally implement automation consolidation.
|
|
758
|
+
|
|
759
|
+
Args:
|
|
760
|
+
org_id: Organization ID
|
|
761
|
+
template_id: Template ID
|
|
762
|
+
preview: If True, only suggest changes without implementing (default: True)
|
|
763
|
+
|
|
764
|
+
Returns:
|
|
765
|
+
Dictionary containing consolidation suggestions and results
|
|
766
|
+
"""
|
|
767
|
+
try:
|
|
768
|
+
analysis = self.analyze_template_automations(org_id, template_id)
|
|
769
|
+
|
|
770
|
+
consolidation_plan = []
|
|
771
|
+
savings_estimate = {
|
|
772
|
+
'rules_reduced': 0,
|
|
773
|
+
'conditions_simplified': 0,
|
|
774
|
+
'maintenance_improvement': 0
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
# Process redundancies
|
|
778
|
+
for redundancy in analysis['redundancies']:
|
|
779
|
+
consolidation_plan.append({
|
|
780
|
+
'type': 'merge_redundant',
|
|
781
|
+
'automation_ids': [redundancy['automation1_id'], redundancy['automation2_id']],
|
|
782
|
+
'action': 'merge_into_single_rule',
|
|
783
|
+
'benefit': 'Eliminates duplicate logic',
|
|
784
|
+
'implementation': 'automated' if not preview else 'preview_only'
|
|
785
|
+
})
|
|
786
|
+
savings_estimate['rules_reduced'] += 1
|
|
787
|
+
|
|
788
|
+
# Process optimization opportunities
|
|
789
|
+
for opportunity in analysis['optimization_opportunities']:
|
|
790
|
+
if opportunity['type'] == 'merge_simple_rules':
|
|
791
|
+
consolidation_plan.append({
|
|
792
|
+
'type': 'group_simple_rules',
|
|
793
|
+
'automation_ids': opportunity['automation_ids'],
|
|
794
|
+
'action': 'create_multi_condition_rule',
|
|
795
|
+
'benefit': 'Reduces rule count while maintaining functionality',
|
|
796
|
+
'implementation': 'manual_review_required'
|
|
797
|
+
})
|
|
798
|
+
savings_estimate['rules_reduced'] += len(opportunity['automation_ids']) - 1
|
|
799
|
+
|
|
800
|
+
elif opportunity['type'] == 'orphaned_automation':
|
|
801
|
+
consolidation_plan.append({
|
|
802
|
+
'type': 'remove_orphaned',
|
|
803
|
+
'automation_ids': [opportunity['automation_id']],
|
|
804
|
+
'action': 'remove_unused_automation',
|
|
805
|
+
'benefit': 'Eliminates dead code',
|
|
806
|
+
'implementation': 'automated' if not preview else 'preview_only'
|
|
807
|
+
})
|
|
808
|
+
savings_estimate['rules_reduced'] += 1
|
|
809
|
+
|
|
810
|
+
# If not preview mode, implement automatic consolidations
|
|
811
|
+
implemented_changes = []
|
|
812
|
+
if not preview:
|
|
813
|
+
for plan_item in consolidation_plan:
|
|
814
|
+
if plan_item['implementation'] == 'automated':
|
|
815
|
+
try:
|
|
816
|
+
if plan_item['type'] == 'remove_orphaned':
|
|
817
|
+
for automation_id in plan_item['automation_ids']:
|
|
818
|
+
self.delete_automation_rule(org_id, template_id, automation_id)
|
|
819
|
+
implemented_changes.append({
|
|
820
|
+
'action': 'deleted_automation',
|
|
821
|
+
'automation_id': automation_id,
|
|
822
|
+
'status': 'success'
|
|
823
|
+
})
|
|
824
|
+
except Exception as e:
|
|
825
|
+
implemented_changes.append({
|
|
826
|
+
'action': 'failed_automation_deletion',
|
|
827
|
+
'automation_id': automation_id,
|
|
828
|
+
'status': 'error',
|
|
829
|
+
'error': str(e)
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
'preview_mode': preview,
|
|
834
|
+
'consolidation_plan': consolidation_plan,
|
|
835
|
+
'savings_estimate': savings_estimate,
|
|
836
|
+
'implemented_changes': implemented_changes,
|
|
837
|
+
'summary': {
|
|
838
|
+
'total_suggestions': len(consolidation_plan),
|
|
839
|
+
'estimated_rule_reduction': savings_estimate['rules_reduced'],
|
|
840
|
+
'complexity_improvement': min(30, savings_estimate['rules_reduced'] * 5),
|
|
841
|
+
'requires_manual_review': len([p for p in consolidation_plan if 'manual' in p['implementation']])
|
|
842
|
+
},
|
|
843
|
+
'next_steps': [
|
|
844
|
+
'Review manual consolidation suggestions',
|
|
845
|
+
'Test consolidated rules in staging environment',
|
|
846
|
+
'Monitor automation performance after changes'
|
|
847
|
+
] if not preview else [
|
|
848
|
+
'Run with preview=False to implement automatic consolidations',
|
|
849
|
+
'Review manual suggestions and implement carefully',
|
|
850
|
+
'Re-analyze after changes to measure improvement'
|
|
851
|
+
]
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
except TallyfyError as e:
|
|
855
|
+
self.sdk.logger.error(f"Failed to consolidate automation rules for template {template_id}: {e}")
|
|
856
|
+
raise
|
|
857
|
+
|
|
858
|
+
def get_step_visibility_conditions(self, org_id: str, template_id: str, step_id: str) -> Dict[str, Any]:
|
|
859
|
+
"""
|
|
860
|
+
Analyze when/how a step becomes visible based on all automations.
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
org_id: Organization ID
|
|
864
|
+
template_id: Template ID
|
|
865
|
+
step_id: Step ID to analyze
|
|
866
|
+
|
|
867
|
+
Returns:
|
|
868
|
+
Dictionary containing step visibility analysis
|
|
869
|
+
"""
|
|
870
|
+
try:
|
|
871
|
+
template_data = self.get_template_with_steps(org_id, template_id)
|
|
872
|
+
if not template_data:
|
|
873
|
+
raise TallyfyError(f"Could not retrieve template {template_id}")
|
|
874
|
+
|
|
875
|
+
# Find the target step
|
|
876
|
+
target_step = None
|
|
877
|
+
for step_data in template_data['steps']:
|
|
878
|
+
if step_data.get('id') == step_id:
|
|
879
|
+
target_step = step_data
|
|
880
|
+
break
|
|
881
|
+
|
|
882
|
+
if not target_step:
|
|
883
|
+
raise TallyfyError(f"Step {step_id} not found in template {template_id}")
|
|
884
|
+
|
|
885
|
+
visibility_rules = []
|
|
886
|
+
always_visible = True
|
|
887
|
+
visibility_logic = []
|
|
888
|
+
|
|
889
|
+
template = template_data['template']
|
|
890
|
+
if hasattr(template, 'automated_actions') and template.automated_actions:
|
|
891
|
+
for automation in template.automated_actions:
|
|
892
|
+
affects_visibility = False
|
|
893
|
+
show_actions = []
|
|
894
|
+
hide_actions = []
|
|
895
|
+
|
|
896
|
+
for action in automation.then_actions:
|
|
897
|
+
if (hasattr(action, 'target_step_id') and action.target_step_id == step_id and
|
|
898
|
+
hasattr(action, 'action_verb')):
|
|
899
|
+
if action.action_verb in ['show', 'reveal', 'display']:
|
|
900
|
+
affects_visibility = True
|
|
901
|
+
show_actions.append(action)
|
|
902
|
+
always_visible = False
|
|
903
|
+
elif action.action_verb in ['hide', 'conceal', 'skip']:
|
|
904
|
+
affects_visibility = True
|
|
905
|
+
hide_actions.append(action)
|
|
906
|
+
|
|
907
|
+
if affects_visibility:
|
|
908
|
+
condition_summary = self._summarize_conditions(automation.conditions)
|
|
909
|
+
|
|
910
|
+
visibility_rules.append({
|
|
911
|
+
'automation_id': automation.id,
|
|
912
|
+
'automation_alias': automation.automated_alias,
|
|
913
|
+
'condition_summary': condition_summary,
|
|
914
|
+
'show_actions': len(show_actions),
|
|
915
|
+
'hide_actions': len(hide_actions),
|
|
916
|
+
'net_effect': 'show' if len(show_actions) > len(hide_actions) else 'hide',
|
|
917
|
+
'conditions': [
|
|
918
|
+
{
|
|
919
|
+
'type': cond.conditionable_type,
|
|
920
|
+
'target': cond.conditionable_id,
|
|
921
|
+
'operation': cond.operation,
|
|
922
|
+
'value': cond.statement,
|
|
923
|
+
'logic': cond.logic
|
|
924
|
+
} for cond in automation.conditions
|
|
925
|
+
]
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
visibility_logic.append({
|
|
929
|
+
'rule': f"IF {condition_summary} THEN {show_actions[0].action_verb if show_actions else hide_actions[0].action_verb} step",
|
|
930
|
+
'effect': 'show' if show_actions else 'hide'
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
# Determine overall visibility behavior
|
|
934
|
+
show_rules = [r for r in visibility_rules if r['net_effect'] == 'show']
|
|
935
|
+
hide_rules = [r for r in visibility_rules if r['net_effect'] == 'hide']
|
|
936
|
+
|
|
937
|
+
visibility_behavior = {
|
|
938
|
+
'default_state': 'visible' if always_visible else 'hidden',
|
|
939
|
+
'conditional_visibility': len(visibility_rules) > 0,
|
|
940
|
+
'show_rule_count': len(show_rules),
|
|
941
|
+
'hide_rule_count': len(hide_rules),
|
|
942
|
+
'complexity': 'simple' if len(visibility_rules) <= 1 else 'complex'
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
'step_info': {
|
|
947
|
+
'id': step_id,
|
|
948
|
+
'title': target_step.get('title'),
|
|
949
|
+
'position': target_step.get('position')
|
|
950
|
+
},
|
|
951
|
+
'visibility_behavior': visibility_behavior,
|
|
952
|
+
'visibility_rules': visibility_rules,
|
|
953
|
+
'visibility_logic': visibility_logic,
|
|
954
|
+
'summary': {
|
|
955
|
+
'always_visible': always_visible,
|
|
956
|
+
'has_show_conditions': len(show_rules) > 0,
|
|
957
|
+
'has_hide_conditions': len(hide_rules) > 0,
|
|
958
|
+
'total_rules_affecting': len(visibility_rules),
|
|
959
|
+
'predictability': 'high' if len(visibility_rules) <= 2 else 'medium' if len(visibility_rules) <= 5 else 'low'
|
|
960
|
+
},
|
|
961
|
+
'recommendations': self._generate_visibility_recommendations(visibility_rules, always_visible)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
except TallyfyError as e:
|
|
965
|
+
self.sdk.logger.error(f"Failed to analyze step visibility for step {step_id}: {e}")
|
|
966
|
+
raise
|
|
967
|
+
|
|
968
|
+
def suggest_automation_consolidation(self, org_id: str, template_id: str) -> List[Dict[str, Any]]:
|
|
969
|
+
"""
|
|
970
|
+
AI analysis of automation rules with consolidation recommendations.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
org_id: Organization ID
|
|
974
|
+
template_id: Template ID
|
|
975
|
+
|
|
976
|
+
Returns:
|
|
977
|
+
List of consolidation recommendations with detailed analysis
|
|
978
|
+
"""
|
|
979
|
+
try:
|
|
980
|
+
analysis = self.analyze_template_automations(org_id, template_id)
|
|
981
|
+
template_data = self.get_template_with_steps(org_id, template_id)
|
|
982
|
+
|
|
983
|
+
recommendations = []
|
|
984
|
+
|
|
985
|
+
# Priority 1: Critical issues
|
|
986
|
+
for conflict in analysis['conflicts']:
|
|
987
|
+
recommendations.append({
|
|
988
|
+
'priority': 'critical',
|
|
989
|
+
'type': 'resolve_conflict',
|
|
990
|
+
'title': f"Resolve automation conflict between '{conflict['automation1_alias']}' and '{conflict['automation2_alias']}'",
|
|
991
|
+
'description': f"These automations have overlapping conditions (similarity: {conflict['similarity_score']:.1%}) but target the same elements",
|
|
992
|
+
'impact': 'high',
|
|
993
|
+
'effort': 'medium',
|
|
994
|
+
'automation_ids': [conflict['automation1_id'], conflict['automation2_id']],
|
|
995
|
+
'common_targets': conflict['common_targets'],
|
|
996
|
+
'recommended_action': 'Review conditions and merge or differentiate the rules',
|
|
997
|
+
'risk_if_ignored': 'Unpredictable behavior, potential process failures'
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
# Priority 2: Redundancies
|
|
1001
|
+
for redundancy in analysis['redundancies']:
|
|
1002
|
+
recommendations.append({
|
|
1003
|
+
'priority': 'high',
|
|
1004
|
+
'type': 'eliminate_redundancy',
|
|
1005
|
+
'title': f"Merge redundant automation rules",
|
|
1006
|
+
'description': f"Two automations have identical conditions but separate implementations",
|
|
1007
|
+
'impact': 'medium',
|
|
1008
|
+
'effort': 'low',
|
|
1009
|
+
'automation_ids': [redundancy['automation1_id'], redundancy['automation2_id']],
|
|
1010
|
+
'recommended_action': 'Consolidate into a single rule with combined actions',
|
|
1011
|
+
'expected_benefit': 'Reduced maintenance overhead, clearer logic flow'
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
# Priority 3: Optimization opportunities
|
|
1015
|
+
for opportunity in analysis['optimization_opportunities']:
|
|
1016
|
+
if opportunity['type'] == 'merge_simple_rules':
|
|
1017
|
+
recommendations.append({
|
|
1018
|
+
'priority': 'medium',
|
|
1019
|
+
'type': 'consolidate_simple_rules',
|
|
1020
|
+
'title': f"Group {len(opportunity['automation_ids'])} simple automation rules",
|
|
1021
|
+
'description': "Multiple single-condition rules could be combined into fewer, more efficient rules",
|
|
1022
|
+
'impact': 'medium',
|
|
1023
|
+
'effort': 'medium',
|
|
1024
|
+
'automation_ids': opportunity['automation_ids'],
|
|
1025
|
+
'recommended_action': 'Create multi-condition rules grouped by similar actions',
|
|
1026
|
+
'expected_benefit': f"Reduce rule count by approximately {len(opportunity['automation_ids']) - 2}"
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
elif opportunity['type'] == 'orphaned_automation':
|
|
1030
|
+
recommendations.append({
|
|
1031
|
+
'priority': 'high',
|
|
1032
|
+
'type': 'remove_orphaned',
|
|
1033
|
+
'title': f"Remove unused automation: {opportunity.get('automation_alias', 'Unknown')}",
|
|
1034
|
+
'description': opportunity['description'],
|
|
1035
|
+
'impact': 'low',
|
|
1036
|
+
'effort': 'low',
|
|
1037
|
+
'automation_ids': [opportunity['automation_id']],
|
|
1038
|
+
'recommended_action': opportunity['recommendation'],
|
|
1039
|
+
'expected_benefit': 'Cleaner automation setup, reduced confusion'
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
elif opportunity['type'] == 'complex_conditions':
|
|
1043
|
+
recommendations.append({
|
|
1044
|
+
'priority': 'medium',
|
|
1045
|
+
'type': 'simplify_complex',
|
|
1046
|
+
'title': f"Simplify complex automation",
|
|
1047
|
+
'description': opportunity['description'],
|
|
1048
|
+
'impact': 'medium',
|
|
1049
|
+
'effort': 'high',
|
|
1050
|
+
'automation_ids': [opportunity['automation_id']],
|
|
1051
|
+
'recommended_action': opportunity['recommendation'],
|
|
1052
|
+
'expected_benefit': 'Improved maintainability and reliability'
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
# Priority 4: General improvements
|
|
1056
|
+
if analysis['complexity_score'] > 70:
|
|
1057
|
+
recommendations.append({
|
|
1058
|
+
'priority': 'low',
|
|
1059
|
+
'type': 'reduce_complexity',
|
|
1060
|
+
'title': f"Overall automation complexity is high (score: {analysis['complexity_score']})",
|
|
1061
|
+
'description': "The template has complex automation setup that may be difficult to maintain",
|
|
1062
|
+
'impact': 'medium',
|
|
1063
|
+
'effort': 'high',
|
|
1064
|
+
'recommended_action': 'Consider redesigning automation flow with fewer, more focused rules',
|
|
1065
|
+
'expected_benefit': 'Easier maintenance, better performance, reduced errors'
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
# Sort by priority
|
|
1069
|
+
priority_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
|
1070
|
+
recommendations.sort(key=lambda x: priority_order.get(x['priority'], 4))
|
|
1071
|
+
|
|
1072
|
+
return recommendations
|
|
1073
|
+
|
|
1074
|
+
except TallyfyError as e:
|
|
1075
|
+
self.sdk.logger.error(f"Failed to suggest automation consolidation for template {template_id}: {e}")
|
|
1076
|
+
raise
|
|
1077
|
+
|
|
1078
|
+
def _analyze_condition_similarity(self, conditions1: List, conditions2: List) -> Dict[str, Any]:
|
|
1079
|
+
"""Helper method to analyze similarity between condition sets"""
|
|
1080
|
+
if not conditions1 or not conditions2:
|
|
1081
|
+
return {'similarity_score': 0.0, 'common_elements': []}
|
|
1082
|
+
|
|
1083
|
+
# Compare condition elements
|
|
1084
|
+
elements1 = set()
|
|
1085
|
+
elements2 = set()
|
|
1086
|
+
|
|
1087
|
+
for cond in conditions1:
|
|
1088
|
+
if hasattr(cond, 'conditionable_type') and hasattr(cond, 'conditionable_id'):
|
|
1089
|
+
elements1.add(f"{cond.conditionable_type}:{cond.conditionable_id}")
|
|
1090
|
+
|
|
1091
|
+
for cond in conditions2:
|
|
1092
|
+
if hasattr(cond, 'conditionable_type') and hasattr(cond, 'conditionable_id'):
|
|
1093
|
+
elements2.add(f"{cond.conditionable_type}:{cond.conditionable_id}")
|
|
1094
|
+
|
|
1095
|
+
if not elements1 or not elements2:
|
|
1096
|
+
return {'similarity_score': 0.0, 'common_elements': []}
|
|
1097
|
+
|
|
1098
|
+
common_elements = elements1.intersection(elements2)
|
|
1099
|
+
similarity_score = len(common_elements) / max(len(elements1), len(elements2))
|
|
1100
|
+
|
|
1101
|
+
return {
|
|
1102
|
+
'similarity_score': similarity_score,
|
|
1103
|
+
'common_elements': list(common_elements)
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
def _summarize_conditions(self, conditions: List) -> str:
|
|
1107
|
+
"""Helper method to create human-readable condition summary"""
|
|
1108
|
+
if not conditions:
|
|
1109
|
+
return "Always"
|
|
1110
|
+
|
|
1111
|
+
condition_parts = []
|
|
1112
|
+
for cond in conditions:
|
|
1113
|
+
if hasattr(cond, 'conditionable_type') and hasattr(cond, 'operation') and hasattr(cond, 'statement'):
|
|
1114
|
+
part = f"{cond.conditionable_type} {cond.operation} '{cond.statement}'"
|
|
1115
|
+
condition_parts.append(part)
|
|
1116
|
+
|
|
1117
|
+
if len(condition_parts) == 1:
|
|
1118
|
+
return condition_parts[0]
|
|
1119
|
+
elif len(condition_parts) <= 3:
|
|
1120
|
+
return " AND ".join(condition_parts)
|
|
1121
|
+
else:
|
|
1122
|
+
return f"{condition_parts[0]} AND {len(condition_parts)-1} more conditions"
|
|
1123
|
+
|
|
1124
|
+
def _generate_visibility_recommendations(self, visibility_rules: List, always_visible: bool) -> List[str]:
|
|
1125
|
+
"""Helper method to generate visibility recommendations"""
|
|
1126
|
+
recommendations = []
|
|
1127
|
+
|
|
1128
|
+
if len(visibility_rules) == 0:
|
|
1129
|
+
if always_visible:
|
|
1130
|
+
recommendations.append("Step is always visible - no optimization needed")
|
|
1131
|
+
else:
|
|
1132
|
+
recommendations.append("Step is never visible - check if this is intentional")
|
|
1133
|
+
|
|
1134
|
+
elif len(visibility_rules) == 1:
|
|
1135
|
+
recommendations.append("Simple visibility logic - good maintainability")
|
|
1136
|
+
|
|
1137
|
+
elif len(visibility_rules) > 5:
|
|
1138
|
+
recommendations.append("Complex visibility logic - consider simplifying conditions")
|
|
1139
|
+
recommendations.append("Review if all visibility rules are necessary")
|
|
1140
|
+
|
|
1141
|
+
show_rules = [r for r in visibility_rules if r['net_effect'] == 'show']
|
|
1142
|
+
hide_rules = [r for r in visibility_rules if r['net_effect'] == 'hide']
|
|
1143
|
+
|
|
1144
|
+
if len(show_rules) > 0 and len(hide_rules) > 0:
|
|
1145
|
+
recommendations.append("Step has both show and hide conditions - verify intended behavior")
|
|
1146
|
+
recommendations.append("Consider testing edge cases where multiple rules might trigger")
|
|
1147
|
+
|
|
1148
|
+
return recommendations
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
# TODO after implementing the new functions on API side
|
|
1152
|
+
# def add_kickoff_field(self, org_id: str, template_id: str, field_data: Dict[str, Any]) -> PrerunField:
|
|
1153
|
+
# """
|
|
1154
|
+
# Add kickoff/prerun fields to template.
|
|
1155
|
+
#
|
|
1156
|
+
# Args:
|
|
1157
|
+
# org_id: Organization ID
|
|
1158
|
+
# template_id: Template ID
|
|
1159
|
+
# field_data: Dictionary containing prerun field data including field_type, label, required, etc.
|
|
1160
|
+
#
|
|
1161
|
+
# Returns:
|
|
1162
|
+
# PrerunField object
|
|
1163
|
+
#
|
|
1164
|
+
# Raises:
|
|
1165
|
+
# TallyfyError: If the request fails
|
|
1166
|
+
# """
|
|
1167
|
+
# try:
|
|
1168
|
+
# endpoint = f"organizations/{org_id}/checklists/{template_id}/prerun"
|
|
1169
|
+
# response_data = self.sdk._make_request('POST', endpoint, data=field_data)
|
|
1170
|
+
#
|
|
1171
|
+
# if isinstance(response_data, dict) and 'data' in response_data:
|
|
1172
|
+
# prerun_data = response_data['data']
|
|
1173
|
+
# return PrerunField.from_dict(prerun_data)
|
|
1174
|
+
# else:
|
|
1175
|
+
# self.sdk.logger.warning("Unexpected response format for kickoff field creation")
|
|
1176
|
+
# return None
|
|
1177
|
+
#
|
|
1178
|
+
# except TallyfyError as e:
|
|
1179
|
+
# self.sdk.logger.error(f"Failed to add kickoff field to template {template_id}: {e}")
|
|
1180
|
+
# raise
|
|
1181
|
+
#
|
|
1182
|
+
# def update_kickoff_field(self, org_id: str, template_id: str, field_id: str, **kwargs) -> PrerunField:
|
|
1183
|
+
# """
|
|
1184
|
+
# Update kickoff field properties.
|
|
1185
|
+
#
|
|
1186
|
+
# Args:
|
|
1187
|
+
# org_id: Organization ID
|
|
1188
|
+
# template_id: Template ID
|
|
1189
|
+
# field_id: Prerun field ID
|
|
1190
|
+
# **kwargs: Prerun field properties to update
|
|
1191
|
+
#
|
|
1192
|
+
# Returns:
|
|
1193
|
+
# Updated PrerunField object
|
|
1194
|
+
#
|
|
1195
|
+
# Raises:
|
|
1196
|
+
# TallyfyError: If the request fails
|
|
1197
|
+
# """
|
|
1198
|
+
# try:
|
|
1199
|
+
# endpoint = f"organizations/{org_id}/checklists/{template_id}/prerun/{field_id}"
|
|
1200
|
+
#
|
|
1201
|
+
# # Build update data from kwargs
|
|
1202
|
+
# update_data = {}
|
|
1203
|
+
# allowed_fields = [
|
|
1204
|
+
# 'field_type', 'label', 'guidance', 'required', 'position', 'options',
|
|
1205
|
+
# 'validation_rules', 'default_value', 'placeholder', 'max_length',
|
|
1206
|
+
# 'min_length', 'regex_pattern', 'help_text'
|
|
1207
|
+
# ]
|
|
1208
|
+
#
|
|
1209
|
+
# for field, value in kwargs.items():
|
|
1210
|
+
# if field in allowed_fields:
|
|
1211
|
+
# update_data[field] = value
|
|
1212
|
+
# else:
|
|
1213
|
+
# self.sdk.logger.warning(f"Ignoring unknown prerun field: {field}")
|
|
1214
|
+
#
|
|
1215
|
+
# if not update_data:
|
|
1216
|
+
# raise ValueError("No valid prerun field properties provided for update")
|
|
1217
|
+
#
|
|
1218
|
+
# response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
|
|
1219
|
+
#
|
|
1220
|
+
# if isinstance(response_data, dict) and 'data' in response_data:
|
|
1221
|
+
# prerun_data = response_data['data']
|
|
1222
|
+
# return PrerunField.from_dict(prerun_data)
|
|
1223
|
+
# else:
|
|
1224
|
+
# self.sdk.logger.warning("Unexpected response format for kickoff field update")
|
|
1225
|
+
# return None
|
|
1226
|
+
#
|
|
1227
|
+
# except TallyfyError as e:
|
|
1228
|
+
# self.sdk.logger.error(f"Failed to update kickoff field {field_id}: {e}")
|
|
1229
|
+
# raise
|
|
1230
|
+
|
|
1231
|
+
def suggest_kickoff_fields(self, org_id: str, template_id: str) -> List[Dict[str, Any]]:
|
|
1232
|
+
"""
|
|
1233
|
+
Suggest relevant kickoff fields based on template analysis.
|
|
1234
|
+
|
|
1235
|
+
Args:
|
|
1236
|
+
org_id: Organization ID
|
|
1237
|
+
template_id: Template ID
|
|
1238
|
+
|
|
1239
|
+
Returns:
|
|
1240
|
+
List of suggested kickoff field configurations with reasoning
|
|
1241
|
+
"""
|
|
1242
|
+
try:
|
|
1243
|
+
# Get template details for analysis
|
|
1244
|
+
template_data = self.get_template_with_steps(org_id, template_id)
|
|
1245
|
+
if not template_data:
|
|
1246
|
+
raise TallyfyError(f"Could not retrieve template {template_id}")
|
|
1247
|
+
|
|
1248
|
+
template = template_data['template']
|
|
1249
|
+
steps = template_data.get('steps', [])
|
|
1250
|
+
existing_prerun = template.prerun or []
|
|
1251
|
+
|
|
1252
|
+
# Analyze template content for suggestions
|
|
1253
|
+
suggestions = []
|
|
1254
|
+
confidence_scores = {}
|
|
1255
|
+
|
|
1256
|
+
# Common fields based on template type and content
|
|
1257
|
+
template_title = template.title.lower()
|
|
1258
|
+
template_summary = (template.summary or '').lower()
|
|
1259
|
+
template_content = f"{template_title} {template_summary}"
|
|
1260
|
+
|
|
1261
|
+
# Basic project information fields
|
|
1262
|
+
if any(word in template_content for word in ['project', 'initiative', 'campaign', 'launch']):
|
|
1263
|
+
suggestions.append({
|
|
1264
|
+
'field_type': 'text',
|
|
1265
|
+
'label': 'Project Name',
|
|
1266
|
+
'guidance': 'Enter the name of this project or initiative',
|
|
1267
|
+
'required': True,
|
|
1268
|
+
'position': 1,
|
|
1269
|
+
'reasoning': 'Project-related templates benefit from capturing the specific project name',
|
|
1270
|
+
'confidence': 'high'
|
|
1271
|
+
})
|
|
1272
|
+
confidence_scores['project_name'] = 0.9
|
|
1273
|
+
|
|
1274
|
+
suggestions.append({
|
|
1275
|
+
'field_type': 'date',
|
|
1276
|
+
'label': 'Target Completion Date',
|
|
1277
|
+
'guidance': 'When should this project be completed?',
|
|
1278
|
+
'required': False,
|
|
1279
|
+
'position': 2,
|
|
1280
|
+
'reasoning': 'Project templates often need target completion dates for planning',
|
|
1281
|
+
'confidence': 'medium'
|
|
1282
|
+
})
|
|
1283
|
+
confidence_scores['target_date'] = 0.7
|
|
1284
|
+
|
|
1285
|
+
# Client/customer information
|
|
1286
|
+
if any(word in template_content for word in ['client', 'customer', 'vendor', 'supplier', 'partner']):
|
|
1287
|
+
suggestions.append({
|
|
1288
|
+
'field_type': 'text',
|
|
1289
|
+
'label': 'Client/Customer Name',
|
|
1290
|
+
'guidance': 'Name of the client or customer for this process',
|
|
1291
|
+
'required': True,
|
|
1292
|
+
'position': len(suggestions) + 1,
|
|
1293
|
+
'reasoning': 'Client-focused templates need client identification',
|
|
1294
|
+
'confidence': 'high'
|
|
1295
|
+
})
|
|
1296
|
+
confidence_scores['client_name'] = 0.9
|
|
1297
|
+
|
|
1298
|
+
suggestions.append({
|
|
1299
|
+
'field_type': 'email',
|
|
1300
|
+
'label': 'Primary Contact Email',
|
|
1301
|
+
'guidance': 'Main contact person for this engagement',
|
|
1302
|
+
'required': False,
|
|
1303
|
+
'position': len(suggestions) + 1,
|
|
1304
|
+
'reasoning': 'Client processes benefit from capturing contact information',
|
|
1305
|
+
'confidence': 'medium'
|
|
1306
|
+
})
|
|
1307
|
+
confidence_scores['contact_email'] = 0.7
|
|
1308
|
+
|
|
1309
|
+
# Budget/financial fields
|
|
1310
|
+
if any(word in template_content for word in ['budget', 'cost', 'expense', 'financial', 'price', 'payment']):
|
|
1311
|
+
suggestions.append({
|
|
1312
|
+
'field_type': 'number',
|
|
1313
|
+
'label': 'Budget Amount',
|
|
1314
|
+
'guidance': 'Total budget allocated for this initiative',
|
|
1315
|
+
'required': False,
|
|
1316
|
+
'position': len(suggestions) + 1,
|
|
1317
|
+
'validation_rules': {'min': 0},
|
|
1318
|
+
'reasoning': 'Financial templates should capture budget information upfront',
|
|
1319
|
+
'confidence': 'high'
|
|
1320
|
+
})
|
|
1321
|
+
confidence_scores['budget'] = 0.85
|
|
1322
|
+
|
|
1323
|
+
# Priority/urgency fields
|
|
1324
|
+
if any(word in template_content for word in ['urgent', 'priority', 'critical', 'important', 'emergency']):
|
|
1325
|
+
suggestions.append({
|
|
1326
|
+
'field_type': 'dropdown',
|
|
1327
|
+
'label': 'Priority Level',
|
|
1328
|
+
'guidance': 'How urgent is this request?',
|
|
1329
|
+
'required': True,
|
|
1330
|
+
'position': len(suggestions) + 1,
|
|
1331
|
+
'options': [
|
|
1332
|
+
{'value': 'low', 'label': 'Low Priority'},
|
|
1333
|
+
{'value': 'medium', 'label': 'Medium Priority'},
|
|
1334
|
+
{'value': 'high', 'label': 'High Priority'},
|
|
1335
|
+
{'value': 'urgent', 'label': 'Urgent'}
|
|
1336
|
+
],
|
|
1337
|
+
'reasoning': 'Templates with urgency keywords benefit from priority classification',
|
|
1338
|
+
'confidence': 'medium'
|
|
1339
|
+
})
|
|
1340
|
+
confidence_scores['priority'] = 0.75
|
|
1341
|
+
|
|
1342
|
+
# Department/team fields
|
|
1343
|
+
if any(word in template_content for word in ['department', 'team', 'division', 'group', 'unit']):
|
|
1344
|
+
suggestions.append({
|
|
1345
|
+
'field_type': 'text',
|
|
1346
|
+
'label': 'Department/Team',
|
|
1347
|
+
'guidance': 'Which department or team is this for?',
|
|
1348
|
+
'required': False,
|
|
1349
|
+
'position': len(suggestions) + 1,
|
|
1350
|
+
'reasoning': 'Organizational templates often need department identification',
|
|
1351
|
+
'confidence': 'medium'
|
|
1352
|
+
})
|
|
1353
|
+
confidence_scores['department'] = 0.6
|
|
1354
|
+
|
|
1355
|
+
# Description field for complex processes
|
|
1356
|
+
if len(steps) > 5:
|
|
1357
|
+
suggestions.append({
|
|
1358
|
+
'field_type': 'wysiwyg',
|
|
1359
|
+
'label': 'Detailed Description',
|
|
1360
|
+
'guidance': 'Provide detailed context and requirements for this process',
|
|
1361
|
+
'required': False,
|
|
1362
|
+
'position': len(suggestions) + 1,
|
|
1363
|
+
'reasoning': 'Complex templates with many steps benefit from detailed upfront descriptions',
|
|
1364
|
+
'confidence': 'medium'
|
|
1365
|
+
})
|
|
1366
|
+
confidence_scores['description'] = 0.65
|
|
1367
|
+
|
|
1368
|
+
# Check for steps that might benefit from kickoff information
|
|
1369
|
+
step_analysis = {
|
|
1370
|
+
'requires_approval': False,
|
|
1371
|
+
'has_external_dependencies': False,
|
|
1372
|
+
'requires_assets': False
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
for step in steps['data']:
|
|
1376
|
+
step_title = step.get('title', '')
|
|
1377
|
+
step_summary = step.get('summary', '')
|
|
1378
|
+
|
|
1379
|
+
if step_title:
|
|
1380
|
+
step_title = step_title.lower()
|
|
1381
|
+
if step_summary:
|
|
1382
|
+
step_summary = step_summary.lower()
|
|
1383
|
+
|
|
1384
|
+
step_content = f"{step_title} {step_summary}"
|
|
1385
|
+
|
|
1386
|
+
if any(word in step_content for word in ['approve', 'review', 'authorize', 'sign-off']):
|
|
1387
|
+
step_analysis['requires_approval'] = True
|
|
1388
|
+
|
|
1389
|
+
if any(word in step_content for word in ['external', 'vendor', 'third-party', 'client']):
|
|
1390
|
+
step_analysis['has_external_dependencies'] = True
|
|
1391
|
+
|
|
1392
|
+
if any(word in step_content for word in ['upload', 'attach', 'document', 'file', 'image']):
|
|
1393
|
+
step_analysis['requires_assets'] = True
|
|
1394
|
+
|
|
1395
|
+
# Add suggestions based on step analysis
|
|
1396
|
+
if step_analysis['requires_approval']:
|
|
1397
|
+
suggestions.append({
|
|
1398
|
+
'field_type': 'dropdown',
|
|
1399
|
+
'label': 'Approval Required',
|
|
1400
|
+
'guidance': 'Does this process require special approval?',
|
|
1401
|
+
'required': False,
|
|
1402
|
+
'position': len(suggestions) + 1,
|
|
1403
|
+
'options': [
|
|
1404
|
+
{'value': 'none', 'label': 'No special approval needed'},
|
|
1405
|
+
{'value': 'manager', 'label': 'Manager approval required'},
|
|
1406
|
+
{'value': 'director', 'label': 'Director approval required'},
|
|
1407
|
+
{'value': 'executive', 'label': 'Executive approval required'}
|
|
1408
|
+
],
|
|
1409
|
+
'reasoning': 'Template contains approval steps that may need upfront specification',
|
|
1410
|
+
'confidence': 'medium'
|
|
1411
|
+
})
|
|
1412
|
+
confidence_scores['approval_type'] = 0.7
|
|
1413
|
+
|
|
1414
|
+
if step_analysis['has_external_dependencies']:
|
|
1415
|
+
suggestions.append({
|
|
1416
|
+
'field_type': 'text',
|
|
1417
|
+
'label': 'External Dependencies',
|
|
1418
|
+
'guidance': 'List any external parties or dependencies involved',
|
|
1419
|
+
'required': False,
|
|
1420
|
+
'position': len(suggestions) + 1,
|
|
1421
|
+
'reasoning': 'Template has external dependencies that should be identified upfront',
|
|
1422
|
+
'confidence': 'medium'
|
|
1423
|
+
})
|
|
1424
|
+
confidence_scores['external_deps'] = 0.65
|
|
1425
|
+
|
|
1426
|
+
# Filter out suggestions that would duplicate existing fields
|
|
1427
|
+
existing_labels = set((field.label or '').lower() for field in existing_prerun)
|
|
1428
|
+
filtered_suggestions = []
|
|
1429
|
+
|
|
1430
|
+
for suggestion in suggestions:
|
|
1431
|
+
if suggestion['label'].lower() not in existing_labels:
|
|
1432
|
+
# Add similarity check for existing fields
|
|
1433
|
+
is_similar = False
|
|
1434
|
+
for existing_field in existing_prerun:
|
|
1435
|
+
if existing_field.label and self._calculate_field_similarity(suggestion['label'], existing_field.label) > 0.8:
|
|
1436
|
+
is_similar = True
|
|
1437
|
+
break
|
|
1438
|
+
|
|
1439
|
+
if not is_similar:
|
|
1440
|
+
filtered_suggestions.append(suggestion)
|
|
1441
|
+
|
|
1442
|
+
# Sort by confidence score
|
|
1443
|
+
filtered_suggestions.sort(key=lambda x: confidence_scores.get(x.get('_key', ''), 0.5), reverse=True)
|
|
1444
|
+
|
|
1445
|
+
# Limit to top 5 suggestions
|
|
1446
|
+
return filtered_suggestions[:5]
|
|
1447
|
+
|
|
1448
|
+
except TallyfyError as e:
|
|
1449
|
+
self.sdk.logger.error(f"Failed to suggest kickoff fields for template {template_id}: {e}")
|
|
1450
|
+
raise
|
|
1451
|
+
|
|
1452
|
+
def _calculate_field_similarity(self, label1: str, label2: str) -> float:
|
|
1453
|
+
"""Helper method to calculate similarity between field labels"""
|
|
1454
|
+
label1_words = set(label1.lower().split())
|
|
1455
|
+
label2_words = set(label2.lower().split())
|
|
1456
|
+
|
|
1457
|
+
if not label1_words or not label2_words:
|
|
1458
|
+
return 0.0
|
|
1459
|
+
|
|
1460
|
+
intersection = label1_words.intersection(label2_words)
|
|
1461
|
+
union = label1_words.union(label2_words)
|
|
1462
|
+
|
|
1463
|
+
return len(intersection) / len(union) if union else 0.0
|
|
1464
|
+
|
|
1465
|
+
def assess_template_health(self, org_id: str, template_id: str) -> Dict[str, Any]:
|
|
1466
|
+
"""
|
|
1467
|
+
Comprehensive template health check analyzing multiple aspects.
|
|
1468
|
+
|
|
1469
|
+
Args:
|
|
1470
|
+
org_id: Organization ID
|
|
1471
|
+
template_id: Template ID
|
|
1472
|
+
|
|
1473
|
+
Returns:
|
|
1474
|
+
Dictionary containing:
|
|
1475
|
+
- overall_health_score: Score from 0-100
|
|
1476
|
+
- health_categories: Breakdown by category
|
|
1477
|
+
- issues: List of identified problems
|
|
1478
|
+
- recommendations: Improvement suggestions
|
|
1479
|
+
- improvement_plan: Prioritized action items
|
|
1480
|
+
"""
|
|
1481
|
+
try:
|
|
1482
|
+
# Get comprehensive template data
|
|
1483
|
+
template_data = self.get_template_with_steps(org_id, template_id)
|
|
1484
|
+
if not template_data:
|
|
1485
|
+
raise TallyfyError(f"Could not retrieve template {template_id}")
|
|
1486
|
+
|
|
1487
|
+
template = template_data['template']
|
|
1488
|
+
steps = template_data.get('steps', [])
|
|
1489
|
+
steps = steps['data']
|
|
1490
|
+
# Initialize health assessment
|
|
1491
|
+
health_assessment = {
|
|
1492
|
+
'overall_health_score': 0,
|
|
1493
|
+
'health_categories': {},
|
|
1494
|
+
'issues': [],
|
|
1495
|
+
'recommendations': [],
|
|
1496
|
+
'improvement_plan': [],
|
|
1497
|
+
'assessment_details': {
|
|
1498
|
+
'template_info': {
|
|
1499
|
+
'id': template.id,
|
|
1500
|
+
'title': template.title,
|
|
1501
|
+
'step_count': len(steps),
|
|
1502
|
+
'automation_count': len(template.automated_actions or []),
|
|
1503
|
+
'prerun_field_count': len(template.prerun or [])
|
|
1504
|
+
},
|
|
1505
|
+
'analysis_timestamp': self._get_current_timestamp()
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
# 1. Template Metadata Health (15 points)
|
|
1510
|
+
metadata_score, metadata_issues, metadata_recommendations = self._assess_template_metadata(template)
|
|
1511
|
+
health_assessment['health_categories']['metadata'] = {
|
|
1512
|
+
'score': metadata_score,
|
|
1513
|
+
'max_score': 15,
|
|
1514
|
+
'description': 'Template title, summary, and basic configuration'
|
|
1515
|
+
}
|
|
1516
|
+
health_assessment['issues'].extend(metadata_issues)
|
|
1517
|
+
health_assessment['recommendations'].extend(metadata_recommendations)
|
|
1518
|
+
|
|
1519
|
+
# 2. Step Title Clarity (20 points)
|
|
1520
|
+
clarity_score, clarity_issues, clarity_recommendations = self._assess_step_clarity(steps)
|
|
1521
|
+
health_assessment['health_categories']['step_clarity'] = {
|
|
1522
|
+
'score': clarity_score,
|
|
1523
|
+
'max_score': 20,
|
|
1524
|
+
'description': 'Clarity and descriptiveness of step titles'
|
|
1525
|
+
}
|
|
1526
|
+
health_assessment['issues'].extend(clarity_issues)
|
|
1527
|
+
health_assessment['recommendations'].extend(clarity_recommendations)
|
|
1528
|
+
|
|
1529
|
+
# 3. Form Field Completeness (15 points)
|
|
1530
|
+
form_score, form_issues, form_recommendations = self._assess_form_completeness(steps)
|
|
1531
|
+
health_assessment['health_categories']['form_fields'] = {
|
|
1532
|
+
'score': form_score,
|
|
1533
|
+
'max_score': 15,
|
|
1534
|
+
'description': 'Quality and completeness of form fields'
|
|
1535
|
+
}
|
|
1536
|
+
health_assessment['issues'].extend(form_issues)
|
|
1537
|
+
health_assessment['recommendations'].extend(form_recommendations)
|
|
1538
|
+
|
|
1539
|
+
# 4. Automation Efficiency (20 points)
|
|
1540
|
+
automation_score, automation_issues, automation_recommendations = self._assess_automation_efficiency(template, template_id, org_id)
|
|
1541
|
+
health_assessment['health_categories']['automation'] = {
|
|
1542
|
+
'score': automation_score,
|
|
1543
|
+
'max_score': 20,
|
|
1544
|
+
'description': 'Automation rules efficiency and conflicts'
|
|
1545
|
+
}
|
|
1546
|
+
health_assessment['issues'].extend(automation_issues)
|
|
1547
|
+
health_assessment['recommendations'].extend(automation_recommendations)
|
|
1548
|
+
|
|
1549
|
+
# 5. Deadline Reasonableness (15 points)
|
|
1550
|
+
deadline_score, deadline_issues, deadline_recommendations = self._assess_deadline_reasonableness(steps)
|
|
1551
|
+
health_assessment['health_categories']['deadlines'] = {
|
|
1552
|
+
'score': deadline_score,
|
|
1553
|
+
'max_score': 15,
|
|
1554
|
+
'description': 'Appropriateness of step deadlines'
|
|
1555
|
+
}
|
|
1556
|
+
health_assessment['issues'].extend(deadline_issues)
|
|
1557
|
+
health_assessment['recommendations'].extend(deadline_recommendations)
|
|
1558
|
+
|
|
1559
|
+
# 6. Workflow Logic (15 points)
|
|
1560
|
+
workflow_score, workflow_issues, workflow_recommendations = self._assess_workflow_logic(steps, template.automated_actions or [])
|
|
1561
|
+
health_assessment['health_categories']['workflow_logic'] = {
|
|
1562
|
+
'score': workflow_score,
|
|
1563
|
+
'max_score': 15,
|
|
1564
|
+
'description': 'Overall workflow structure and logic'
|
|
1565
|
+
}
|
|
1566
|
+
health_assessment['issues'].extend(workflow_issues)
|
|
1567
|
+
health_assessment['recommendations'].extend(workflow_recommendations)
|
|
1568
|
+
|
|
1569
|
+
# Calculate overall health score
|
|
1570
|
+
total_score = sum(cat['score'] for cat in health_assessment['health_categories'].values())
|
|
1571
|
+
max_total_score = sum(cat['max_score'] for cat in health_assessment['health_categories'].values())
|
|
1572
|
+
health_assessment['overall_health_score'] = round((total_score / max_total_score) * 100, 1)
|
|
1573
|
+
|
|
1574
|
+
# Generate improvement plan
|
|
1575
|
+
health_assessment['improvement_plan'] = self._generate_improvement_plan(
|
|
1576
|
+
health_assessment['issues'],
|
|
1577
|
+
health_assessment['recommendations'],
|
|
1578
|
+
health_assessment['health_categories']
|
|
1579
|
+
)
|
|
1580
|
+
|
|
1581
|
+
# Add health rating
|
|
1582
|
+
health_assessment['health_rating'] = self._get_health_rating(health_assessment['overall_health_score'])
|
|
1583
|
+
|
|
1584
|
+
return health_assessment
|
|
1585
|
+
|
|
1586
|
+
except TallyfyError as e:
|
|
1587
|
+
self.sdk.logger.error(f"Failed to assess template health for template {template_id}: {e}")
|
|
1588
|
+
raise
|
|
1589
|
+
|
|
1590
|
+
def _assess_template_metadata(self, template) -> tuple:
|
|
1591
|
+
"""Assess template metadata quality"""
|
|
1592
|
+
score = 0
|
|
1593
|
+
issues = []
|
|
1594
|
+
recommendations = []
|
|
1595
|
+
|
|
1596
|
+
# Title quality (5 points)
|
|
1597
|
+
if template.title and len(template.title.strip()) > 0:
|
|
1598
|
+
if len(template.title.strip()) >= 10:
|
|
1599
|
+
score += 5
|
|
1600
|
+
elif len(template.title.strip()) >= 5:
|
|
1601
|
+
score += 3
|
|
1602
|
+
issues.append({
|
|
1603
|
+
'category': 'metadata',
|
|
1604
|
+
'severity': 'medium',
|
|
1605
|
+
'issue': 'Template title is quite short',
|
|
1606
|
+
'description': f"Title '{template.title}' could be more descriptive"
|
|
1607
|
+
})
|
|
1608
|
+
recommendations.append({
|
|
1609
|
+
'category': 'metadata',
|
|
1610
|
+
'priority': 'medium',
|
|
1611
|
+
'action': 'Expand template title',
|
|
1612
|
+
'description': 'Consider adding more descriptive words to clarify the template purpose'
|
|
1613
|
+
})
|
|
1614
|
+
else:
|
|
1615
|
+
score += 1
|
|
1616
|
+
issues.append({
|
|
1617
|
+
'category': 'metadata',
|
|
1618
|
+
'severity': 'high',
|
|
1619
|
+
'issue': 'Template title is very short',
|
|
1620
|
+
'description': f"Title '{template.title}' is too brief and unclear"
|
|
1621
|
+
})
|
|
1622
|
+
recommendations.append({
|
|
1623
|
+
'category': 'metadata',
|
|
1624
|
+
'priority': 'high',
|
|
1625
|
+
'action': 'Rewrite template title',
|
|
1626
|
+
'description': 'Create a clear, descriptive title that explains what this template accomplishes'
|
|
1627
|
+
})
|
|
1628
|
+
else:
|
|
1629
|
+
issues.append({
|
|
1630
|
+
'category': 'metadata',
|
|
1631
|
+
'severity': 'critical',
|
|
1632
|
+
'issue': 'Missing template title',
|
|
1633
|
+
'description': 'Template has no title or empty title'
|
|
1634
|
+
})
|
|
1635
|
+
recommendations.append({
|
|
1636
|
+
'category': 'metadata',
|
|
1637
|
+
'priority': 'critical',
|
|
1638
|
+
'action': 'Add template title',
|
|
1639
|
+
'description': 'Every template must have a clear, descriptive title'
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
# Summary quality (5 points)
|
|
1643
|
+
if template.summary and len(template.summary.strip()) > 0:
|
|
1644
|
+
if len(template.summary.strip()) >= 50:
|
|
1645
|
+
score += 5
|
|
1646
|
+
elif len(template.summary.strip()) >= 20:
|
|
1647
|
+
score += 3
|
|
1648
|
+
recommendations.append({
|
|
1649
|
+
'category': 'metadata',
|
|
1650
|
+
'priority': 'low',
|
|
1651
|
+
'action': 'Expand template summary',
|
|
1652
|
+
'description': 'Consider adding more detail to help users understand the template purpose'
|
|
1653
|
+
})
|
|
1654
|
+
else:
|
|
1655
|
+
score += 2
|
|
1656
|
+
issues.append({
|
|
1657
|
+
'category': 'metadata',
|
|
1658
|
+
'severity': 'medium',
|
|
1659
|
+
'issue': 'Template summary is very brief',
|
|
1660
|
+
'description': 'Summary should provide more context about the template'
|
|
1661
|
+
})
|
|
1662
|
+
recommendations.append({
|
|
1663
|
+
'category': 'metadata',
|
|
1664
|
+
'priority': 'medium',
|
|
1665
|
+
'action': 'Improve template summary',
|
|
1666
|
+
'description': 'Write a more comprehensive summary explaining when and how to use this template'
|
|
1667
|
+
})
|
|
1668
|
+
else:
|
|
1669
|
+
issues.append({
|
|
1670
|
+
'category': 'metadata',
|
|
1671
|
+
'severity': 'high',
|
|
1672
|
+
'issue': 'Missing template summary',
|
|
1673
|
+
'description': 'Template lacks a summary description'
|
|
1674
|
+
})
|
|
1675
|
+
recommendations.append({
|
|
1676
|
+
'category': 'metadata',
|
|
1677
|
+
'priority': 'high',
|
|
1678
|
+
'action': 'Add template summary',
|
|
1679
|
+
'description': 'Write a clear summary explaining the template purpose and scope'
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
# Guidance quality (5 points)
|
|
1683
|
+
if template.guidance and len(template.guidance.strip()) > 0:
|
|
1684
|
+
score += 5
|
|
1685
|
+
else:
|
|
1686
|
+
score += 2
|
|
1687
|
+
recommendations.append({
|
|
1688
|
+
'category': 'metadata',
|
|
1689
|
+
'priority': 'low',
|
|
1690
|
+
'action': 'Add template guidance',
|
|
1691
|
+
'description': 'Consider adding guidance to help users understand how to use this template effectively'
|
|
1692
|
+
})
|
|
1693
|
+
|
|
1694
|
+
return score, issues, recommendations
|
|
1695
|
+
|
|
1696
|
+
def _assess_step_clarity(self, steps) -> tuple:
|
|
1697
|
+
"""Assess step title clarity and descriptiveness"""
|
|
1698
|
+
score = 0
|
|
1699
|
+
issues = []
|
|
1700
|
+
recommendations = []
|
|
1701
|
+
|
|
1702
|
+
if not steps:
|
|
1703
|
+
issues.append({
|
|
1704
|
+
'category': 'step_clarity',
|
|
1705
|
+
'severity': 'critical',
|
|
1706
|
+
'issue': 'Template has no steps',
|
|
1707
|
+
'description': 'Template must have at least one step to be functional'
|
|
1708
|
+
})
|
|
1709
|
+
recommendations.append({
|
|
1710
|
+
'category': 'step_clarity',
|
|
1711
|
+
'priority': 'critical',
|
|
1712
|
+
'action': 'Add steps to template',
|
|
1713
|
+
'description': 'Create workflow steps that define the process'
|
|
1714
|
+
})
|
|
1715
|
+
return 0, issues, recommendations
|
|
1716
|
+
|
|
1717
|
+
total_possible = len(steps) * 4 # 4 points per step max
|
|
1718
|
+
step_scores = []
|
|
1719
|
+
|
|
1720
|
+
for i, step in enumerate(steps):
|
|
1721
|
+
step_score = 0
|
|
1722
|
+
step_title = step.get('title', '')
|
|
1723
|
+
step_summary = step.get('summary', '')
|
|
1724
|
+
if step_title:
|
|
1725
|
+
step_title = step_title.strip()
|
|
1726
|
+
if step_summary:
|
|
1727
|
+
step_summary = step_summary.strip()
|
|
1728
|
+
|
|
1729
|
+
# Title existence and quality (3 points)
|
|
1730
|
+
if step_title:
|
|
1731
|
+
if len(step_title) >= 15:
|
|
1732
|
+
step_score += 3
|
|
1733
|
+
elif len(step_title) >= 8:
|
|
1734
|
+
step_score += 2
|
|
1735
|
+
elif len(step_title) >= 3:
|
|
1736
|
+
step_score += 1
|
|
1737
|
+
issues.append({
|
|
1738
|
+
'category': 'step_clarity',
|
|
1739
|
+
'severity': 'medium',
|
|
1740
|
+
'issue': f'Step {i+1} title is too brief',
|
|
1741
|
+
'description': f"Step title '{step_title}' could be more descriptive"
|
|
1742
|
+
})
|
|
1743
|
+
recommendations.append({
|
|
1744
|
+
'category': 'step_clarity',
|
|
1745
|
+
'priority': 'medium',
|
|
1746
|
+
'action': f'Improve step {i+1} title',
|
|
1747
|
+
'description': 'Make the title more descriptive and action-oriented'
|
|
1748
|
+
})
|
|
1749
|
+
else:
|
|
1750
|
+
issues.append({
|
|
1751
|
+
'category': 'step_clarity',
|
|
1752
|
+
'severity': 'high',
|
|
1753
|
+
'issue': f'Step {i+1} title is very unclear',
|
|
1754
|
+
'description': f"Step title '{step_title}' is too short to be meaningful"
|
|
1755
|
+
})
|
|
1756
|
+
recommendations.append({
|
|
1757
|
+
'category': 'step_clarity',
|
|
1758
|
+
'priority': 'high',
|
|
1759
|
+
'action': f'Rewrite step {i+1} title',
|
|
1760
|
+
'description': 'Create a clear, action-oriented title that explains what needs to be done'
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1763
|
+
# Check for action words
|
|
1764
|
+
action_words = ['create', 'review', 'approve', 'complete', 'submit', 'verify', 'analyze', 'prepare', 'send', 'update']
|
|
1765
|
+
if any(word in step_title.lower() for word in action_words):
|
|
1766
|
+
# Bonus for action-oriented titles
|
|
1767
|
+
step_score += 0.5
|
|
1768
|
+
else:
|
|
1769
|
+
issues.append({
|
|
1770
|
+
'category': 'step_clarity',
|
|
1771
|
+
'severity': 'critical',
|
|
1772
|
+
'issue': f'Step {i+1} has no title',
|
|
1773
|
+
'description': 'Every step must have a clear title'
|
|
1774
|
+
})
|
|
1775
|
+
recommendations.append({
|
|
1776
|
+
'category': 'step_clarity',
|
|
1777
|
+
'priority': 'critical',
|
|
1778
|
+
'action': f'Add title to step {i+1}',
|
|
1779
|
+
'description': 'Write a clear, action-oriented title for this step'
|
|
1780
|
+
})
|
|
1781
|
+
|
|
1782
|
+
# Summary quality (1 point)
|
|
1783
|
+
if step_summary and len(step_summary) >= 20:
|
|
1784
|
+
step_score += 1
|
|
1785
|
+
elif not step_summary:
|
|
1786
|
+
recommendations.append({
|
|
1787
|
+
'category': 'step_clarity',
|
|
1788
|
+
'priority': 'low',
|
|
1789
|
+
'action': f'Add summary to step {i+1}',
|
|
1790
|
+
'description': 'Consider adding a summary to provide additional context'
|
|
1791
|
+
})
|
|
1792
|
+
|
|
1793
|
+
step_scores.append(min(step_score, 4)) # Cap at 4 points per step
|
|
1794
|
+
|
|
1795
|
+
# Calculate score as percentage of maximum, scaled to 20 points
|
|
1796
|
+
if total_possible > 0:
|
|
1797
|
+
score = round((sum(step_scores) / total_possible) * 20)
|
|
1798
|
+
|
|
1799
|
+
return score, issues, recommendations
|
|
1800
|
+
|
|
1801
|
+
def _assess_form_completeness(self, steps) -> tuple:
|
|
1802
|
+
"""Assess form field quality and completeness"""
|
|
1803
|
+
score = 0
|
|
1804
|
+
issues = []
|
|
1805
|
+
recommendations = []
|
|
1806
|
+
|
|
1807
|
+
total_fields = 0
|
|
1808
|
+
well_configured_fields = 0
|
|
1809
|
+
steps_with_forms = 0
|
|
1810
|
+
|
|
1811
|
+
for i, step in enumerate(steps):
|
|
1812
|
+
captures = step.get('captures', [])
|
|
1813
|
+
if captures:
|
|
1814
|
+
steps_with_forms += 1
|
|
1815
|
+
|
|
1816
|
+
for j, capture in enumerate(captures):
|
|
1817
|
+
total_fields += 1
|
|
1818
|
+
field_score = 0
|
|
1819
|
+
|
|
1820
|
+
# Field has label (required)
|
|
1821
|
+
label = capture.get('label', '').strip()
|
|
1822
|
+
if label:
|
|
1823
|
+
if len(label) >= 3:
|
|
1824
|
+
field_score += 2
|
|
1825
|
+
else:
|
|
1826
|
+
issues.append({
|
|
1827
|
+
'category': 'form_fields',
|
|
1828
|
+
'severity': 'medium',
|
|
1829
|
+
'issue': f'Step {i+1} field {j+1} has very short label',
|
|
1830
|
+
'description': f"Field label '{label}' is too brief"
|
|
1831
|
+
})
|
|
1832
|
+
field_score += 1
|
|
1833
|
+
else:
|
|
1834
|
+
issues.append({
|
|
1835
|
+
'category': 'form_fields',
|
|
1836
|
+
'severity': 'high',
|
|
1837
|
+
'issue': f'Step {i+1} field {j+1} missing label',
|
|
1838
|
+
'description': 'Form field must have a descriptive label'
|
|
1839
|
+
})
|
|
1840
|
+
recommendations.append({
|
|
1841
|
+
'category': 'form_fields',
|
|
1842
|
+
'priority': 'high',
|
|
1843
|
+
'action': f'Add label to step {i+1} field {j+1}',
|
|
1844
|
+
'description': 'Every form field needs a clear, descriptive label'
|
|
1845
|
+
})
|
|
1846
|
+
|
|
1847
|
+
# Field has guidance
|
|
1848
|
+
guidance = capture.get('guidance', '')
|
|
1849
|
+
if guidance and len(guidance) >= 10:
|
|
1850
|
+
field_score += 1
|
|
1851
|
+
elif not guidance:
|
|
1852
|
+
recommendations.append({
|
|
1853
|
+
'category': 'form_fields',
|
|
1854
|
+
'priority': 'low',
|
|
1855
|
+
'action': f'Add guidance to step {i+1} field {j+1}',
|
|
1856
|
+
'description': 'Consider adding guidance to help users understand what to enter'
|
|
1857
|
+
})
|
|
1858
|
+
|
|
1859
|
+
# Required field properly marked
|
|
1860
|
+
field_type = capture.get('field_type', '')
|
|
1861
|
+
if capture.get('required') is not None:
|
|
1862
|
+
field_score += 1
|
|
1863
|
+
|
|
1864
|
+
# Field type specific checks
|
|
1865
|
+
if field_type == 'dropdown':
|
|
1866
|
+
options = capture.get('options', [])
|
|
1867
|
+
if options and len(options) >= 2:
|
|
1868
|
+
field_score += 1
|
|
1869
|
+
else:
|
|
1870
|
+
issues.append({
|
|
1871
|
+
'category': 'form_fields',
|
|
1872
|
+
'severity': 'high',
|
|
1873
|
+
'issue': f'Step {i+1} dropdown field has insufficient options',
|
|
1874
|
+
'description': 'Dropdown fields should have at least 2 options'
|
|
1875
|
+
})
|
|
1876
|
+
recommendations.append({
|
|
1877
|
+
'category': 'form_fields',
|
|
1878
|
+
'priority': 'high',
|
|
1879
|
+
'action': f'Add options to step {i+1} dropdown field',
|
|
1880
|
+
'description': 'Configure appropriate dropdown options for user selection'
|
|
1881
|
+
})
|
|
1882
|
+
elif field_type in ['text', 'wysiwyg']:
|
|
1883
|
+
field_score += 1 # Text fields are generally well-configured by default
|
|
1884
|
+
|
|
1885
|
+
if field_score >= 4:
|
|
1886
|
+
well_configured_fields += 1
|
|
1887
|
+
|
|
1888
|
+
# Calculate score
|
|
1889
|
+
if total_fields > 0:
|
|
1890
|
+
field_quality_ratio = well_configured_fields / total_fields
|
|
1891
|
+
score = round(field_quality_ratio * 15)
|
|
1892
|
+
else:
|
|
1893
|
+
score = 10 # No form fields is not necessarily bad
|
|
1894
|
+
if len(steps) > 2: # But if there are many steps, might expect some forms
|
|
1895
|
+
recommendations.append({
|
|
1896
|
+
'category': 'form_fields',
|
|
1897
|
+
'priority': 'medium',
|
|
1898
|
+
'action': 'Consider adding form fields',
|
|
1899
|
+
'description': 'Templates with multiple steps often benefit from data collection forms'
|
|
1900
|
+
})
|
|
1901
|
+
|
|
1902
|
+
return score, issues, recommendations
|
|
1903
|
+
|
|
1904
|
+
def _assess_automation_efficiency(self, template, template_id, org_id) -> tuple:
|
|
1905
|
+
"""Assess automation rules for efficiency and conflicts"""
|
|
1906
|
+
score = 0
|
|
1907
|
+
issues = []
|
|
1908
|
+
recommendations = []
|
|
1909
|
+
|
|
1910
|
+
automations = template.automated_actions or []
|
|
1911
|
+
|
|
1912
|
+
if not automations:
|
|
1913
|
+
score = 15 # No automations is fine for simple templates
|
|
1914
|
+
recommendations.append({
|
|
1915
|
+
'category': 'automation',
|
|
1916
|
+
'priority': 'low',
|
|
1917
|
+
'action': 'Consider adding automation',
|
|
1918
|
+
'description': 'Automation can improve efficiency for repetitive workflows'
|
|
1919
|
+
})
|
|
1920
|
+
return score, issues, recommendations
|
|
1921
|
+
|
|
1922
|
+
try:
|
|
1923
|
+
# Use existing automation analysis
|
|
1924
|
+
analysis = self.analyze_template_automations(org_id, template_id)
|
|
1925
|
+
base_score = 20
|
|
1926
|
+
|
|
1927
|
+
# Deduct for conflicts
|
|
1928
|
+
conflicts = analysis.get('conflicts', [])
|
|
1929
|
+
if conflicts:
|
|
1930
|
+
deduction = min(len(conflicts) * 3, 10)
|
|
1931
|
+
base_score -= deduction
|
|
1932
|
+
for conflict in conflicts:
|
|
1933
|
+
issues.append({
|
|
1934
|
+
'category': 'automation',
|
|
1935
|
+
'severity': 'high',
|
|
1936
|
+
'issue': 'Automation conflict detected',
|
|
1937
|
+
'description': f"Conflicting automations: {conflict.get('automation1_alias')} and {conflict.get('automation2_alias')}"
|
|
1938
|
+
})
|
|
1939
|
+
recommendations.append({
|
|
1940
|
+
'category': 'automation',
|
|
1941
|
+
'priority': 'high',
|
|
1942
|
+
'action': 'Resolve automation conflicts',
|
|
1943
|
+
'description': 'Review and merge or differentiate conflicting automation rules'
|
|
1944
|
+
})
|
|
1945
|
+
|
|
1946
|
+
# Deduct for redundancies
|
|
1947
|
+
redundancies = analysis.get('redundancies', [])
|
|
1948
|
+
if redundancies:
|
|
1949
|
+
deduction = min(len(redundancies) * 2, 5)
|
|
1950
|
+
base_score -= deduction
|
|
1951
|
+
for redundancy in redundancies:
|
|
1952
|
+
issues.append({
|
|
1953
|
+
'category': 'automation',
|
|
1954
|
+
'severity': 'medium',
|
|
1955
|
+
'issue': 'Redundant automation rules',
|
|
1956
|
+
'description': 'Multiple automation rules with identical conditions'
|
|
1957
|
+
})
|
|
1958
|
+
recommendations.append({
|
|
1959
|
+
'category': 'automation',
|
|
1960
|
+
'priority': 'medium',
|
|
1961
|
+
'action': 'Consolidate redundant automations',
|
|
1962
|
+
'description': 'Merge automation rules with identical conditions'
|
|
1963
|
+
})
|
|
1964
|
+
|
|
1965
|
+
# Check complexity
|
|
1966
|
+
complexity_score = analysis.get('complexity_score', 0)
|
|
1967
|
+
if complexity_score > 80:
|
|
1968
|
+
base_score -= 3
|
|
1969
|
+
issues.append({
|
|
1970
|
+
'category': 'automation',
|
|
1971
|
+
'severity': 'medium',
|
|
1972
|
+
'issue': 'High automation complexity',
|
|
1973
|
+
'description': f'Complexity score of {complexity_score} may be difficult to maintain'
|
|
1974
|
+
})
|
|
1975
|
+
recommendations.append({
|
|
1976
|
+
'category': 'automation',
|
|
1977
|
+
'priority': 'medium',
|
|
1978
|
+
'action': 'Simplify automation rules',
|
|
1979
|
+
'description': 'Consider breaking complex rules into simpler, more focused automations'
|
|
1980
|
+
})
|
|
1981
|
+
|
|
1982
|
+
score = max(base_score, 0000)
|
|
1983
|
+
|
|
1984
|
+
except Exception as e:
|
|
1985
|
+
# If automation analysis fails, give a neutral score
|
|
1986
|
+
score = 10
|
|
1987
|
+
self.sdk.logger.warning(f"Could not analyze automation efficiency: {e}")
|
|
1988
|
+
|
|
1989
|
+
return score, issues, recommendations
|
|
1990
|
+
|
|
1991
|
+
def _assess_deadline_reasonableness(self, steps) -> tuple:
|
|
1992
|
+
"""Assess whether step deadlines are reasonable"""
|
|
1993
|
+
score = 0
|
|
1994
|
+
issues = []
|
|
1995
|
+
recommendations = []
|
|
1996
|
+
|
|
1997
|
+
steps_with_deadlines = 0
|
|
1998
|
+
reasonable_deadlines = 0
|
|
1999
|
+
|
|
2000
|
+
for i, step in enumerate(steps):
|
|
2001
|
+
deadline = step.get('deadline')
|
|
2002
|
+
if deadline and isinstance(deadline, dict):
|
|
2003
|
+
steps_with_deadlines += 1
|
|
2004
|
+
|
|
2005
|
+
value = deadline.get('value', 0)
|
|
2006
|
+
unit = deadline.get('unit', 'days')
|
|
2007
|
+
|
|
2008
|
+
# Convert to hours for comparison
|
|
2009
|
+
hours = value
|
|
2010
|
+
if unit == 'days':
|
|
2011
|
+
hours = value * 24
|
|
2012
|
+
elif unit == 'weeks':
|
|
2013
|
+
hours = value * 24 * 7
|
|
2014
|
+
elif unit == 'months':
|
|
2015
|
+
hours = value * 24 * 30
|
|
2016
|
+
elif unit == 'business_days':
|
|
2017
|
+
hours = value * 8 # 8 hour work days
|
|
2018
|
+
|
|
2019
|
+
# Assess reasonableness based on step content
|
|
2020
|
+
step_title = step.get('title', '')
|
|
2021
|
+
step_summary = step.get('summary', '')
|
|
2022
|
+
if step_summary:
|
|
2023
|
+
step_summary = step_summary.lower()
|
|
2024
|
+
if step_title:
|
|
2025
|
+
step_title = step_title.lower()
|
|
2026
|
+
content = f"{step_title} {step_summary}"
|
|
2027
|
+
|
|
2028
|
+
is_reasonable = True
|
|
2029
|
+
|
|
2030
|
+
# Quick tasks should have short deadlines
|
|
2031
|
+
if any(word in content for word in ['approve', 'review', 'check', 'verify', 'confirm']):
|
|
2032
|
+
if hours > 72: # More than 3 days
|
|
2033
|
+
is_reasonable = False
|
|
2034
|
+
issues.append({
|
|
2035
|
+
'category': 'deadlines',
|
|
2036
|
+
'severity': 'medium',
|
|
2037
|
+
'issue': f'Step {i+1} deadline may be too long',
|
|
2038
|
+
'description': f'Quick approval tasks typically need shorter deadlines than {value} {unit}'
|
|
2039
|
+
})
|
|
2040
|
+
recommendations.append({
|
|
2041
|
+
'category': 'deadlines',
|
|
2042
|
+
'priority': 'medium',
|
|
2043
|
+
'action': f'Shorten deadline for step {i+1}',
|
|
2044
|
+
'description': 'Consider 1-2 business days for approval tasks'
|
|
2045
|
+
})
|
|
2046
|
+
|
|
2047
|
+
# Complex tasks should have adequate time
|
|
2048
|
+
elif any(word in content for word in ['develop', 'create', 'design', 'write', 'prepare', 'analyze']):
|
|
2049
|
+
if hours < 16: # Less than 2 days
|
|
2050
|
+
is_reasonable = False
|
|
2051
|
+
issues.append({
|
|
2052
|
+
'category': 'deadlines',
|
|
2053
|
+
'severity': 'medium',
|
|
2054
|
+
'issue': f'Step {i+1} deadline may be too short',
|
|
2055
|
+
'description': f'Creative/development work may need more time than {value} {unit}'
|
|
2056
|
+
})
|
|
2057
|
+
recommendations.append({
|
|
2058
|
+
'category': 'deadlines',
|
|
2059
|
+
'priority': 'medium',
|
|
2060
|
+
'action': f'Extend deadline for step {i+1}',
|
|
2061
|
+
'description': 'Consider 3-5 days or 1 week for complex creative work'
|
|
2062
|
+
})
|
|
2063
|
+
|
|
2064
|
+
# Extremely short or long deadlines
|
|
2065
|
+
if hours < 1:
|
|
2066
|
+
is_reasonable = False
|
|
2067
|
+
issues.append({
|
|
2068
|
+
'category': 'deadlines',
|
|
2069
|
+
'severity': 'high',
|
|
2070
|
+
'issue': f'Step {i+1} deadline is extremely short',
|
|
2071
|
+
'description': f'Deadline of {value} {unit} is likely unrealistic'
|
|
2072
|
+
})
|
|
2073
|
+
elif hours > 720: # More than 30 days
|
|
2074
|
+
is_reasonable = False
|
|
2075
|
+
issues.append({
|
|
2076
|
+
'category': 'deadlines',
|
|
2077
|
+
'severity': 'medium',
|
|
2078
|
+
'issue': f'Step {i+1} deadline is very long',
|
|
2079
|
+
'description': f'Consider if {value} {unit} is appropriate for maintaining momentum'
|
|
2080
|
+
})
|
|
2081
|
+
recommendations.append({
|
|
2082
|
+
'category': 'deadlines',
|
|
2083
|
+
'priority': 'low',
|
|
2084
|
+
'action': f'Review long deadline for step {i+1}',
|
|
2085
|
+
'description': 'Very long deadlines can reduce urgency and momentum'
|
|
2086
|
+
})
|
|
2087
|
+
|
|
2088
|
+
if is_reasonable:
|
|
2089
|
+
reasonable_deadlines += 1
|
|
2090
|
+
|
|
2091
|
+
# Calculate score
|
|
2092
|
+
if steps_with_deadlines > 0:
|
|
2093
|
+
ratio = reasonable_deadlines / steps_with_deadlines
|
|
2094
|
+
score = round(ratio * 15)
|
|
2095
|
+
else:
|
|
2096
|
+
score = 10 # Neutral score if no deadlines set
|
|
2097
|
+
if len(steps) > 1:
|
|
2098
|
+
recommendations.append({
|
|
2099
|
+
'category': 'deadlines',
|
|
2100
|
+
'priority': 'medium',
|
|
2101
|
+
'action': 'Consider adding deadlines',
|
|
2102
|
+
'description': 'Deadlines help ensure timely completion of workflow steps'
|
|
2103
|
+
})
|
|
2104
|
+
|
|
2105
|
+
return score, issues, recommendations
|
|
2106
|
+
|
|
2107
|
+
def _assess_workflow_logic(self, steps, automations) -> tuple:
|
|
2108
|
+
"""Assess overall workflow structure and logic"""
|
|
2109
|
+
score = 0
|
|
2110
|
+
issues = []
|
|
2111
|
+
recommendations = []
|
|
2112
|
+
|
|
2113
|
+
# Base score
|
|
2114
|
+
base_score = 15
|
|
2115
|
+
|
|
2116
|
+
# Check step count
|
|
2117
|
+
step_count = len(steps)
|
|
2118
|
+
if step_count == 0:
|
|
2119
|
+
issues.append({
|
|
2120
|
+
'category': 'workflow_logic',
|
|
2121
|
+
'severity': 'critical',
|
|
2122
|
+
'issue': 'No workflow steps defined',
|
|
2123
|
+
'description': 'Template must have at least one step'
|
|
2124
|
+
})
|
|
2125
|
+
return 0, issues, recommendations
|
|
2126
|
+
elif step_count == 1:
|
|
2127
|
+
base_score -= 2
|
|
2128
|
+
recommendations.append({
|
|
2129
|
+
'category': 'workflow_logic',
|
|
2130
|
+
'priority': 'low',
|
|
2131
|
+
'action': 'Consider multi-step workflow',
|
|
2132
|
+
'description': 'Single-step templates may benefit from being broken into multiple steps'
|
|
2133
|
+
})
|
|
2134
|
+
elif step_count > 20:
|
|
2135
|
+
base_score -= 3
|
|
2136
|
+
issues.append({
|
|
2137
|
+
'category': 'workflow_logic',
|
|
2138
|
+
'severity': 'medium',
|
|
2139
|
+
'issue': 'Very complex workflow',
|
|
2140
|
+
'description': f'Template has {step_count} steps, which may be overwhelming'
|
|
2141
|
+
})
|
|
2142
|
+
recommendations.append({
|
|
2143
|
+
'category': 'workflow_logic',
|
|
2144
|
+
'priority': 'medium',
|
|
2145
|
+
'action': 'Consider breaking into sub-workflows',
|
|
2146
|
+
'description': 'Large workflows can be divided into smaller, focused templates'
|
|
2147
|
+
})
|
|
2148
|
+
|
|
2149
|
+
# Check for logical step progression
|
|
2150
|
+
step_titles = [step.get('title', '').lower() for step in steps]
|
|
2151
|
+
|
|
2152
|
+
# Look for logical patterns
|
|
2153
|
+
has_start_step = any('start' in title or 'begin' in title or 'initiate' in title for title in step_titles)
|
|
2154
|
+
has_end_step = any('complete' in title or 'finish' in title or 'close' in title or 'final' in title for title in step_titles)
|
|
2155
|
+
|
|
2156
|
+
if step_count >= 3:
|
|
2157
|
+
if has_start_step:
|
|
2158
|
+
score += 1
|
|
2159
|
+
if has_end_step:
|
|
2160
|
+
score += 1
|
|
2161
|
+
|
|
2162
|
+
if not has_start_step and not has_end_step:
|
|
2163
|
+
recommendations.append({
|
|
2164
|
+
'category': 'workflow_logic',
|
|
2165
|
+
'priority': 'low',
|
|
2166
|
+
'action': 'Consider clear start/end steps',
|
|
2167
|
+
'description': 'Clear initiation and completion steps improve workflow clarity'
|
|
2168
|
+
})
|
|
2169
|
+
|
|
2170
|
+
# Check automation alignment with workflow
|
|
2171
|
+
if automations:
|
|
2172
|
+
automation_targets = set()
|
|
2173
|
+
for automation in automations:
|
|
2174
|
+
for action in automation.then_actions:
|
|
2175
|
+
if hasattr(action, 'target_step_id') and action.target_step_id:
|
|
2176
|
+
automation_targets.add(action.target_step_id)
|
|
2177
|
+
|
|
2178
|
+
step_ids = set(step.get('id') for step in steps if step.get('id'))
|
|
2179
|
+
orphaned_automations = automation_targets - step_ids
|
|
2180
|
+
|
|
2181
|
+
if orphaned_automations:
|
|
2182
|
+
base_score -= 2
|
|
2183
|
+
issues.append({
|
|
2184
|
+
'category': 'workflow_logic',
|
|
2185
|
+
'severity': 'high',
|
|
2186
|
+
'issue': 'Automations target non-existent steps',
|
|
2187
|
+
'description': f'{len(orphaned_automations)} automation(s) target deleted or missing steps'
|
|
2188
|
+
})
|
|
2189
|
+
recommendations.append({
|
|
2190
|
+
'category': 'workflow_logic',
|
|
2191
|
+
'priority': 'high',
|
|
2192
|
+
'action': 'Fix automation targets',
|
|
2193
|
+
'description': 'Update or remove automations that target non-existent steps'
|
|
2194
|
+
})
|
|
2195
|
+
|
|
2196
|
+
# Check for balanced step distribution
|
|
2197
|
+
steps_with_content = sum(1 for step in steps if step.get('summary') or step.get('captures'))
|
|
2198
|
+
if step_count > 3 and steps_with_content < step_count * 0.5:
|
|
2199
|
+
base_score -= 2
|
|
2200
|
+
recommendations.append({
|
|
2201
|
+
'category': 'workflow_logic',
|
|
2202
|
+
'priority': 'medium',
|
|
2203
|
+
'action': 'Add content to workflow steps',
|
|
2204
|
+
'description': 'Many steps lack descriptions or form fields that could help users'
|
|
2205
|
+
})
|
|
2206
|
+
|
|
2207
|
+
score = max(base_score, 0)
|
|
2208
|
+
return score, issues, recommendations
|
|
2209
|
+
|
|
2210
|
+
def _generate_improvement_plan(self, issues, recommendations, health_categories) -> List[Dict[str, Any]]:
|
|
2211
|
+
"""Generate prioritized improvement plan"""
|
|
2212
|
+
plan_items = []
|
|
2213
|
+
|
|
2214
|
+
# Critical issues first
|
|
2215
|
+
critical_items = [item for item in issues if item.get('severity') == 'critical']
|
|
2216
|
+
for item in critical_items:
|
|
2217
|
+
plan_items.append({
|
|
2218
|
+
'priority': 1,
|
|
2219
|
+
'category': item['category'],
|
|
2220
|
+
'action': f"CRITICAL: {item['issue']}",
|
|
2221
|
+
'description': item['description'],
|
|
2222
|
+
'impact': 'high',
|
|
2223
|
+
'effort': 'medium'
|
|
2224
|
+
})
|
|
2225
|
+
|
|
2226
|
+
# High priority recommendations
|
|
2227
|
+
high_priority_recs = [item for item in recommendations if item.get('priority') == 'critical' or item.get('priority') == 'high']
|
|
2228
|
+
for item in high_priority_recs:
|
|
2229
|
+
plan_items.append({
|
|
2230
|
+
'priority': 2,
|
|
2231
|
+
'category': item['category'],
|
|
2232
|
+
'action': item['action'],
|
|
2233
|
+
'description': item['description'],
|
|
2234
|
+
'impact': 'high' if item.get('priority') == 'critical' else 'medium',
|
|
2235
|
+
'effort': 'medium'
|
|
2236
|
+
})
|
|
2237
|
+
|
|
2238
|
+
# Focus on lowest scoring categories
|
|
2239
|
+
sorted_categories = sorted(health_categories.items(), key=lambda x: x[1]['score'])
|
|
2240
|
+
for category_name, category_data in sorted_categories[:2]: # Top 2 lowest scoring
|
|
2241
|
+
if category_data['score'] < category_data['max_score'] * 0.7: # Less than 70%
|
|
2242
|
+
category_recs = [item for item in recommendations if item.get('category') == category_name and item.get('priority') in ['medium', 'low']]
|
|
2243
|
+
for item in category_recs[:2]: # Top 2 recommendations per category
|
|
2244
|
+
plan_items.append({
|
|
2245
|
+
'priority': 3,
|
|
2246
|
+
'category': item['category'],
|
|
2247
|
+
'action': item['action'],
|
|
2248
|
+
'description': item['description'],
|
|
2249
|
+
'impact': 'medium',
|
|
2250
|
+
'effort': 'low' if item.get('priority') == 'low' else 'medium'
|
|
2251
|
+
})
|
|
2252
|
+
|
|
2253
|
+
# Limit to top 8 items to keep focused
|
|
2254
|
+
return plan_items[:8]
|
|
2255
|
+
|
|
2256
|
+
def _get_health_rating(self, score) -> str:
|
|
2257
|
+
"""Convert numeric score to health rating"""
|
|
2258
|
+
if score >= 90:
|
|
2259
|
+
return 'excellent'
|
|
2260
|
+
elif score >= 80:
|
|
2261
|
+
return 'good'
|
|
2262
|
+
elif score >= 70:
|
|
2263
|
+
return 'fair'
|
|
2264
|
+
elif score >= 60:
|
|
2265
|
+
return 'poor'
|
|
2266
|
+
else:
|
|
2267
|
+
return 'critical'
|
|
2268
|
+
|
|
2269
|
+
def _get_current_timestamp(self) -> str:
|
|
2270
|
+
"""Get current timestamp for assessment"""
|
|
2271
|
+
from datetime import datetime
|
|
2272
|
+
return datetime.now().isoformat()
|
|
2273
|
+
|
|
2274
|
+
def add_assignees_to_step(self, org_id: str, template_id: str, step_id: str, assignees: Dict[str, Any]) -> Dict[str, Any]:
|
|
2275
|
+
"""
|
|
2276
|
+
Add assignees to a specific step in a template.
|
|
2277
|
+
|
|
2278
|
+
Args:
|
|
2279
|
+
org_id: Organization ID
|
|
2280
|
+
template_id: Template ID
|
|
2281
|
+
step_id: Step ID to add assignees to
|
|
2282
|
+
assignees: Dictionary containing assignee data with users and guests
|
|
2283
|
+
Expected format: {
|
|
2284
|
+
'assignees': [user_id1, user_id2, ...], # List of user IDs
|
|
2285
|
+
'guests': [guest_email1, guest_email2, ...] # List of guest emails
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
Returns:
|
|
2289
|
+
Dictionary containing updated step information
|
|
2290
|
+
|
|
2291
|
+
Raises:
|
|
2292
|
+
TallyfyError: If the request fails
|
|
2293
|
+
"""
|
|
2294
|
+
try:
|
|
2295
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}/steps/{step_id}"
|
|
2296
|
+
|
|
2297
|
+
# Validate assignees data
|
|
2298
|
+
if not isinstance(assignees, dict):
|
|
2299
|
+
raise ValueError("Assignees must be a dictionary")
|
|
2300
|
+
|
|
2301
|
+
# Build update data with proper structure
|
|
2302
|
+
update_data = {}
|
|
2303
|
+
|
|
2304
|
+
# Add user assignees if provided
|
|
2305
|
+
if 'assignees' in assignees and assignees['assignees']:
|
|
2306
|
+
user_ids = assignees['assignees']
|
|
2307
|
+
if not isinstance(user_ids, list):
|
|
2308
|
+
raise ValueError("Assignees must be a list of user IDs")
|
|
2309
|
+
|
|
2310
|
+
# Validate user IDs are integers
|
|
2311
|
+
for user_id in user_ids:
|
|
2312
|
+
if not isinstance(user_id, int):
|
|
2313
|
+
raise ValueError(f"User ID {user_id} must be an integer")
|
|
2314
|
+
|
|
2315
|
+
update_data['assignees'] = user_ids
|
|
2316
|
+
|
|
2317
|
+
# Add guest assignees if provided
|
|
2318
|
+
if 'guests' in assignees and assignees['guests']:
|
|
2319
|
+
guest_emails = assignees['guests']
|
|
2320
|
+
if not isinstance(guest_emails, list):
|
|
2321
|
+
raise ValueError("Guests must be a list of email addresses")
|
|
2322
|
+
|
|
2323
|
+
# Validate guest emails
|
|
2324
|
+
import re
|
|
2325
|
+
email_pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
|
|
2326
|
+
for guest_email in guest_emails:
|
|
2327
|
+
if not isinstance(guest_email, str) or not re.match(email_pattern, guest_email):
|
|
2328
|
+
raise ValueError(f"Guest email {guest_email} is not a valid email address")
|
|
2329
|
+
|
|
2330
|
+
update_data['guests'] = guest_emails
|
|
2331
|
+
|
|
2332
|
+
# Validate that at least one assignee type is provided
|
|
2333
|
+
if not update_data:
|
|
2334
|
+
raise ValueError("At least one assignee (user or guest) must be specified")
|
|
2335
|
+
|
|
2336
|
+
response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
|
|
2337
|
+
|
|
2338
|
+
if isinstance(response_data, dict):
|
|
2339
|
+
return response_data
|
|
2340
|
+
else:
|
|
2341
|
+
self.sdk.logger.warning("Unexpected response format for step assignee addition")
|
|
2342
|
+
return {'success': True, 'assignees_added': update_data}
|
|
2343
|
+
|
|
2344
|
+
except TallyfyError as e:
|
|
2345
|
+
self.sdk.logger.error(f"Failed to add assignees to step {step_id}: {e}")
|
|
2346
|
+
raise
|
|
2347
|
+
except ValueError as e:
|
|
2348
|
+
self.sdk.logger.error(f"Invalid assignee data: {e}")
|
|
2349
|
+
raise TallyfyError(f"Invalid assignee data: {e}")
|
|
2350
|
+
|
|
2351
|
+
def edit_description_on_step(self, org_id: str, template_id: str, step_id: str, description: str) -> Dict[str, Any]:
|
|
2352
|
+
"""
|
|
2353
|
+
Edit the description/summary of a specific step in a template.
|
|
2354
|
+
|
|
2355
|
+
Args:
|
|
2356
|
+
org_id: Organization ID
|
|
2357
|
+
template_id: Template ID
|
|
2358
|
+
step_id: Step ID to edit description for
|
|
2359
|
+
description: New description/summary text for the step
|
|
2360
|
+
|
|
2361
|
+
Returns:
|
|
2362
|
+
Dictionary containing updated step information
|
|
2363
|
+
|
|
2364
|
+
Raises:
|
|
2365
|
+
TallyfyError: If the request fails
|
|
2366
|
+
"""
|
|
2367
|
+
try:
|
|
2368
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}/steps/{step_id}"
|
|
2369
|
+
|
|
2370
|
+
# Validate description
|
|
2371
|
+
if not isinstance(description, str):
|
|
2372
|
+
raise ValueError("Description must be a string")
|
|
2373
|
+
|
|
2374
|
+
description = description.strip()
|
|
2375
|
+
if not description:
|
|
2376
|
+
raise ValueError("Description cannot be empty")
|
|
2377
|
+
|
|
2378
|
+
# Update data with correct payload structure
|
|
2379
|
+
update_data = {
|
|
2380
|
+
'summary': description
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
|
|
2384
|
+
|
|
2385
|
+
if isinstance(response_data, dict):
|
|
2386
|
+
if 'data' in response_data:
|
|
2387
|
+
return response_data['data']
|
|
2388
|
+
return response_data
|
|
2389
|
+
else:
|
|
2390
|
+
self.sdk.logger.warning("Unexpected response format for step description update")
|
|
2391
|
+
return {'success': True, 'updated_summary': description}
|
|
2392
|
+
|
|
2393
|
+
except TallyfyError as e:
|
|
2394
|
+
self.sdk.logger.error(f"Failed to update step description for step {step_id}: {e}")
|
|
2395
|
+
raise
|
|
2396
|
+
except ValueError as e:
|
|
2397
|
+
self.sdk.logger.error(f"Invalid description data: {e}")
|
|
2398
|
+
raise TallyfyError(f"Invalid description data: {e}")
|
|
2399
|
+
|
|
2400
|
+
def add_step_to_template(self, org_id: str, template_id: str, step_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
2401
|
+
"""
|
|
2402
|
+
Add a new step to a template.
|
|
2403
|
+
|
|
2404
|
+
Args:
|
|
2405
|
+
org_id: Organization ID
|
|
2406
|
+
template_id: Template ID
|
|
2407
|
+
step_data: Dictionary containing step data including title, summary, position, etc.
|
|
2408
|
+
Expected format: {
|
|
2409
|
+
'title': 'Step title',
|
|
2410
|
+
'prevent_guest_comment': False, # prevent guest comments
|
|
2411
|
+
'allow_guest_owners': False, # allow guest assignees
|
|
2412
|
+
'can_complete_only_assignees': False, # only assignees can complete
|
|
2413
|
+
'checklist_id': 'Template ID',
|
|
2414
|
+
'is_soft_start_date': True, # soft start date
|
|
2415
|
+
'everyone_must_complete': False, # all assignees must complete
|
|
2416
|
+
'skip_start_process': False, # skip when starting process
|
|
2417
|
+
'summary': 'Step description (optional)',
|
|
2418
|
+
'position': 1, # Position in workflow (optional, defaults to end)
|
|
2419
|
+
'step_type': 'task', # Optional: 'task', 'decision', 'form', etc.
|
|
2420
|
+
'max_assignable': 1, # Optional: max number of assignees
|
|
2421
|
+
'webhook': 'url', # Optional: webhook URL
|
|
2422
|
+
'assignees': [123, 456], # Optional: list of user IDs
|
|
2423
|
+
'guests': ['email@example.com'], # Optional: list of guest emails
|
|
2424
|
+
'roles': ['Project Manager'], # Optional: list of roles
|
|
2425
|
+
'role_changes_every_time': True # Optional: role changes each time
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
Returns:
|
|
2429
|
+
Dictionary containing created step information
|
|
2430
|
+
|
|
2431
|
+
Raises:
|
|
2432
|
+
TallyfyError: If the request fails
|
|
2433
|
+
"""
|
|
2434
|
+
try:
|
|
2435
|
+
endpoint = f"organizations/{org_id}/checklists/{template_id}/steps"
|
|
2436
|
+
|
|
2437
|
+
# Validate step data
|
|
2438
|
+
if not isinstance(step_data, dict):
|
|
2439
|
+
raise ValueError("Step data must be a dictionary")
|
|
2440
|
+
|
|
2441
|
+
# Validate required fields
|
|
2442
|
+
if 'title' not in step_data or not step_data['title']:
|
|
2443
|
+
raise ValueError("Step title is required")
|
|
2444
|
+
|
|
2445
|
+
title = step_data['title'].strip()
|
|
2446
|
+
if not title:
|
|
2447
|
+
raise ValueError("Step title cannot be empty")
|
|
2448
|
+
|
|
2449
|
+
# Build step creation data with defaults based on the payload structure
|
|
2450
|
+
create_data = {
|
|
2451
|
+
'title': title
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
# Add optional string fields
|
|
2455
|
+
optional_string_fields = ['summary', 'step_type', 'alias', 'webhook', 'checklist_id']
|
|
2456
|
+
for field in optional_string_fields:
|
|
2457
|
+
if field in step_data and step_data[field]:
|
|
2458
|
+
create_data[field] = str(step_data[field]).strip()
|
|
2459
|
+
|
|
2460
|
+
# Add optional integer fields
|
|
2461
|
+
if 'position' in step_data:
|
|
2462
|
+
position = step_data['position']
|
|
2463
|
+
if isinstance(position, int) and position > 0:
|
|
2464
|
+
create_data['position'] = position
|
|
2465
|
+
else:
|
|
2466
|
+
raise ValueError("Position must be a positive integer")
|
|
2467
|
+
|
|
2468
|
+
if 'max_assignable' in step_data:
|
|
2469
|
+
max_assignable = step_data['max_assignable']
|
|
2470
|
+
if isinstance(max_assignable, int) and max_assignable > 0:
|
|
2471
|
+
create_data['max_assignable'] = max_assignable
|
|
2472
|
+
elif max_assignable is not None:
|
|
2473
|
+
raise ValueError("max_assignable must be a positive integer or None")
|
|
2474
|
+
|
|
2475
|
+
# Add optional boolean fields with proper string conversion
|
|
2476
|
+
boolean_fields = [
|
|
2477
|
+
'allow_guest_owners', 'skip_start_process', 'can_complete_only_assignees',
|
|
2478
|
+
'everyone_must_complete', 'prevent_guest_comment', 'is_soft_start_date',
|
|
2479
|
+
'role_changes_every_time'
|
|
2480
|
+
]
|
|
2481
|
+
for field in boolean_fields:
|
|
2482
|
+
if field in step_data:
|
|
2483
|
+
create_data[field] = True if step_data[field] else False
|
|
2484
|
+
|
|
2485
|
+
# Add assignees if provided
|
|
2486
|
+
if 'assignees' in step_data and step_data['assignees']:
|
|
2487
|
+
assignees_list = step_data['assignees']
|
|
2488
|
+
if isinstance(assignees_list, list):
|
|
2489
|
+
# Validate user IDs are integers
|
|
2490
|
+
for user_id in assignees_list:
|
|
2491
|
+
if not isinstance(user_id, int):
|
|
2492
|
+
raise ValueError(f"User ID {user_id} must be an integer")
|
|
2493
|
+
create_data['assignees'] = assignees_list
|
|
2494
|
+
else:
|
|
2495
|
+
raise ValueError("Assignees must be a list of user IDs")
|
|
2496
|
+
|
|
2497
|
+
# Add guests if provided
|
|
2498
|
+
if 'guests' in step_data and step_data['guests']:
|
|
2499
|
+
guests_list = step_data['guests']
|
|
2500
|
+
if isinstance(guests_list, list):
|
|
2501
|
+
# Validate guest emails
|
|
2502
|
+
import re
|
|
2503
|
+
email_pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
|
|
2504
|
+
for guest_email in guests_list:
|
|
2505
|
+
if not isinstance(guest_email, str) or not re.match(email_pattern, guest_email):
|
|
2506
|
+
raise ValueError(f"Guest email {guest_email} is not a valid email address")
|
|
2507
|
+
create_data['guests'] = guests_list
|
|
2508
|
+
else:
|
|
2509
|
+
raise ValueError("Guests must be a list of email addresses")
|
|
2510
|
+
|
|
2511
|
+
# Add roles if provided
|
|
2512
|
+
if 'roles' in step_data and step_data['roles']:
|
|
2513
|
+
roles_list = step_data['roles']
|
|
2514
|
+
if isinstance(roles_list, list):
|
|
2515
|
+
create_data['roles'] = [str(role) for role in roles_list]
|
|
2516
|
+
else:
|
|
2517
|
+
raise ValueError("Roles must be a list of role names")
|
|
2518
|
+
|
|
2519
|
+
# Add deadline if provided (complex object)
|
|
2520
|
+
if 'deadline' in step_data and step_data['deadline']:
|
|
2521
|
+
deadline = step_data['deadline']
|
|
2522
|
+
if isinstance(deadline, dict):
|
|
2523
|
+
deadline_data = {}
|
|
2524
|
+
if 'value' in deadline:
|
|
2525
|
+
deadline_data['value'] = int(deadline['value'])
|
|
2526
|
+
if 'unit' in deadline:
|
|
2527
|
+
valid_units = ['minutes', 'hours', 'days', 'weeks', 'months']
|
|
2528
|
+
if deadline['unit'] in valid_units:
|
|
2529
|
+
deadline_data['unit'] = deadline['unit']
|
|
2530
|
+
else:
|
|
2531
|
+
raise ValueError(f"Deadline unit must be one of: {', '.join(valid_units)}")
|
|
2532
|
+
if 'option' in deadline:
|
|
2533
|
+
valid_options = ['from', 'prior_to']
|
|
2534
|
+
if deadline['option'] in valid_options:
|
|
2535
|
+
deadline_data['option'] = deadline['option']
|
|
2536
|
+
else:
|
|
2537
|
+
raise ValueError(f"Deadline option must be one of: {', '.join(valid_options)}")
|
|
2538
|
+
if 'step' in deadline:
|
|
2539
|
+
deadline_data['step'] = deadline['step']
|
|
2540
|
+
|
|
2541
|
+
if deadline_data:
|
|
2542
|
+
create_data['deadline'] = deadline_data
|
|
2543
|
+
else:
|
|
2544
|
+
raise ValueError("Deadline must be a dictionary with value, unit, option, and step")
|
|
2545
|
+
|
|
2546
|
+
# Add start_date if provided (similar structure to deadline)
|
|
2547
|
+
if 'start_date' in step_data and step_data['start_date']:
|
|
2548
|
+
start_date = step_data['start_date']
|
|
2549
|
+
if isinstance(start_date, dict):
|
|
2550
|
+
start_date_data = {}
|
|
2551
|
+
if 'value' in start_date:
|
|
2552
|
+
start_date_data['value'] = int(start_date['value'])
|
|
2553
|
+
if 'unit' in start_date:
|
|
2554
|
+
valid_units = ['minutes', 'hours', 'days', 'weeks', 'months']
|
|
2555
|
+
if start_date['unit'] in valid_units:
|
|
2556
|
+
start_date_data['unit'] = start_date['unit']
|
|
2557
|
+
else:
|
|
2558
|
+
raise ValueError(f"Start date unit must be one of: {', '.join(valid_units)}")
|
|
2559
|
+
|
|
2560
|
+
if start_date_data:
|
|
2561
|
+
create_data['start_date'] = start_date_data
|
|
2562
|
+
else:
|
|
2563
|
+
raise ValueError("Start date must be a dictionary with value and unit")
|
|
2564
|
+
|
|
2565
|
+
response_data = self.sdk._make_request('POST', endpoint, data=create_data)
|
|
2566
|
+
|
|
2567
|
+
if isinstance(response_data, dict):
|
|
2568
|
+
if 'data' in response_data:
|
|
2569
|
+
return response_data['data']
|
|
2570
|
+
return response_data
|
|
2571
|
+
else:
|
|
2572
|
+
self.sdk.logger.warning("Unexpected response format for step creation")
|
|
2573
|
+
return {'success': True, 'created_step': create_data}
|
|
2574
|
+
|
|
2575
|
+
except TallyfyError as e:
|
|
2576
|
+
self.sdk.logger.error(f"Failed to add step to template {template_id}: {e}")
|
|
2577
|
+
raise
|
|
2578
|
+
except ValueError as e:
|
|
2579
|
+
self.sdk.logger.error(f"Invalid step data: {e}")
|
|
2580
|
+
raise TallyfyError(f"Invalid step data: {e}")
|