better-notion 2.2.0__py3-none-any.whl → 2.3.2__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.
@@ -0,0 +1,503 @@
1
+ """AI-aware agent workflow utilities for the agents SDK.
2
+
3
+ This module provides intelligent task selection, incident triage, and batch
4
+ operations optimized for AI agent workflows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from better_notion._sdk.client import NotionClient
15
+
16
+
17
+ @dataclass
18
+ class TaskRecommendation:
19
+ """Represents a task recommendation for an agent.
20
+
21
+ Attributes:
22
+ task: The Task entity
23
+ match_score: Score from 0-100 indicating how well the task matches
24
+ match_reason: Human-readable explanation of why this task was recommended
25
+ """
26
+
27
+ task: Any
28
+ match_score: float
29
+ match_reason: str
30
+
31
+ def to_dict(self) -> dict[str, Any]:
32
+ """Convert recommendation to dictionary.
33
+
34
+ Returns:
35
+ Dictionary with task details and recommendation metadata
36
+ """
37
+ return {
38
+ "id": self.task.id,
39
+ "title": self.task.title,
40
+ "priority": getattr(self.task, "priority", None),
41
+ "status": getattr(self.task, "status", None),
42
+ "match_score": self.match_score,
43
+ "match_reason": self.match_reason,
44
+ }
45
+
46
+
47
+ class TaskSelector:
48
+ """Intelligent task selection for AI agents.
49
+
50
+ Provides scoring algorithms to pick the best tasks for an agent to work on
51
+ based on skills, priority, age, and other factors.
52
+ """
53
+
54
+ def __init__(self, client: NotionClient) -> None:
55
+ """Initialize the TaskSelector.
56
+
57
+ Args:
58
+ client: Notion API client
59
+ """
60
+ self._client = client
61
+
62
+ async def pick_best_tasks(
63
+ self,
64
+ skills: list[str] | None = None,
65
+ max_priority: str | None = None,
66
+ exclude_patterns: list[str] | None = None,
67
+ count: int = 5,
68
+ project_id: str | None = None,
69
+ version_id: str | None = None,
70
+ ) -> list[TaskRecommendation]:
71
+ """Pick the best tasks for an agent to work on.
72
+
73
+ Tasks are scored based on:
74
+ - Priority (0-40 points): Critical > High > Medium > Low
75
+ - Skills (0-30 points): Matching skills in title/description
76
+ - Age (0-20 points): Older tasks get higher priority
77
+ - Ready bonus (10 points): No blocking dependencies
78
+
79
+ Args:
80
+ skills: List of skills the agent has (e.g., ["python", "database"])
81
+ max_priority: Maximum priority level to consider (e.g., "High")
82
+ exclude_patterns: Regex patterns to exclude from task titles
83
+ count: Maximum number of recommendations to return
84
+ project_id: Filter to specific project
85
+ version_id: Filter to specific version
86
+
87
+ Returns:
88
+ List of task recommendations sorted by match score (descending)
89
+ """
90
+ from better_notion.plugins.official.agents_sdk.managers import TaskManager
91
+
92
+ task_mgr = TaskManager(self._client)
93
+
94
+ # Get candidate tasks - filter to backlog only
95
+ candidates = await task_mgr.list(
96
+ status="Backlog",
97
+ project_id=project_id,
98
+ version_id=version_id,
99
+ )
100
+
101
+ # Filter and score candidates
102
+ recommendations = []
103
+
104
+ for task in candidates:
105
+ # Apply filters
106
+ if max_priority:
107
+ priority_order = {"Critical": 4, "High": 3, "Medium": 2, "Low": 1}
108
+ task_prio = getattr(task, "priority", "Low")
109
+ if priority_order.get(task_prio, 0) > priority_order.get(max_priority, 0):
110
+ continue
111
+
112
+ if exclude_patterns:
113
+ title_lower = task.title.lower()
114
+ if any(re.search(pattern, title_lower) for pattern in exclude_patterns):
115
+ continue
116
+
117
+ # Calculate match score
118
+ score, reason = self._calculate_score(task, skills)
119
+
120
+ recommendations.append(
121
+ TaskRecommendation(
122
+ task=task,
123
+ match_score=score,
124
+ match_reason=reason,
125
+ )
126
+ )
127
+
128
+ # Sort by score (descending)
129
+ recommendations.sort(key=lambda r: r.match_score, reverse=True)
130
+
131
+ return recommendations[:count]
132
+
133
+ def _calculate_score(
134
+ self,
135
+ task: Any,
136
+ skills: list[str] | None,
137
+ ) -> tuple[float, str]:
138
+ """Calculate a match score for a task.
139
+
140
+ Args:
141
+ task: The task entity to score
142
+ skills: List of agent's skills
143
+
144
+ Returns:
145
+ Tuple of (score, reason) where score is 0-100 and reason explains the score
146
+ """
147
+ score = 0.0
148
+ reasons = []
149
+
150
+ # Priority scoring (0-40 points)
151
+ priority_scores = {
152
+ "Critical": 40,
153
+ "High": 30,
154
+ "Medium": 20,
155
+ "Low": 10,
156
+ }
157
+ task_prio = getattr(task, "priority", "Low")
158
+ prio_score = priority_scores.get(task_prio, 0)
159
+ score += prio_score
160
+ if prio_score > 0:
161
+ reasons.append(f"{task_prio} priority")
162
+
163
+ # Skill matching (0-30 points)
164
+ if skills:
165
+ task_title_lower = task.title.lower()
166
+ task_desc_lower = getattr(task, "description", "") or ""
167
+ if isinstance(task_desc_lower, str):
168
+ task_desc_lower = task_desc_lower.lower()
169
+ else:
170
+ task_desc_lower = ""
171
+
172
+ matched_skills = []
173
+ for skill in skills:
174
+ skill_lower = skill.lower()
175
+ if skill_lower in task_title_lower or skill_lower in task_desc_lower:
176
+ matched_skills.append(skill)
177
+
178
+ if matched_skills:
179
+ skill_points = min(30, len(matched_skills) * 10)
180
+ score += skill_points
181
+ reasons.append(f"matches skills: {', '.join(matched_skills)}")
182
+
183
+ # Age scoring (0-20 points) - older tasks get higher priority
184
+ # Note: Not implemented in v1 due to limited created_at access
185
+ # if hasattr(task, 'created_at') and task.created_at:
186
+ # days_old = (datetime.now(timezone.utc) - task.created_at).days
187
+ # age_points = min(20, days_old)
188
+ # score += age_points
189
+ # if age_points > 0:
190
+ # reasons.append(f"{days_old} days old")
191
+
192
+ # Bonus for ready tasks (no blockers) - 10 points
193
+ # Note: Not implemented in v1 - requires dependency resolution from issue #040
194
+ # if hasattr(task, 'can_start') and await task.can_start():
195
+ # score += 10
196
+ # reasons.append("no blocking dependencies")
197
+
198
+ reason = "; ".join(reasons) if reasons else "no specific match criteria"
199
+ return score, reason
200
+
201
+
202
+ class IncidentTriager:
203
+ """Automatic incident triage and classification.
204
+
205
+ Uses keyword-based heuristics to classify incidents by severity and type,
206
+ suggest team assignments, and provide confidence scores.
207
+ """
208
+
209
+ # Severity classification keywords
210
+ CRITICAL_KEYWORDS = ["down", "outage", "critical", "emergency", "production down", "crash"]
211
+ HIGH_KEYWORDS = ["slow", "degraded", "error", "bug", "broken", "failure"]
212
+ MEDIUM_KEYWORDS = ["issue", "problem", "glitch"]
213
+
214
+ # Type classification keywords
215
+ TYPE_KEYWORDS: dict[str, list[str]] = {
216
+ "Performance": ["slow", "latency", "performance", "timeout", "lag"],
217
+ "Security": ["security", "auth", "unauthorized", "vulnerability", "injection", "exploit"],
218
+ "Bug": ["bug", "error", "broken", "crash", "exception", "fault"],
219
+ "Service Disruption": ["down", "outage", "unavailable", "can't access", "500"],
220
+ "Data": ["data", "database", "corruption", "loss", "leak", "inconsistent"],
221
+ }
222
+
223
+ # Team assignment suggestions
224
+ TEAM_MAPPING: dict[str, str] = {
225
+ "Performance": "Performance Team",
226
+ "Security": "Security Team",
227
+ "Bug": "Engineering Team",
228
+ "Service Disruption": "Operations Team",
229
+ "Data": "Data Team",
230
+ "General": "Engineering Team",
231
+ }
232
+
233
+ # Keywords for confidence calculation
234
+ SPECIFIC_KEYWORDS = [
235
+ "down",
236
+ "outage",
237
+ "slow",
238
+ "security",
239
+ "bug",
240
+ "performance",
241
+ "database",
242
+ "api",
243
+ "critical",
244
+ "crash",
245
+ "error",
246
+ "timeout",
247
+ "latency",
248
+ "unauthorized",
249
+ ]
250
+
251
+ def __init__(self, client: NotionClient) -> None:
252
+ """Initialize the IncidentTriager.
253
+
254
+ Args:
255
+ client: Notion API client
256
+ """
257
+ self._client = client
258
+
259
+ async def triage_incident(self, incident: Any) -> dict[str, Any]:
260
+ """Triage and classify an incident.
261
+
262
+ Args:
263
+ incident: The Incident entity to triage
264
+
265
+ Returns:
266
+ Dictionary with classification results:
267
+ - severity: Classified severity level
268
+ - type: Incident type
269
+ - suggested_assignment: Team that should handle this
270
+ - confidence: Confidence score (0-1)
271
+ - reasoning: Explanation of the classification
272
+ """
273
+ title_lower = incident.title.lower()
274
+ description = getattr(incident, "description", None) or ""
275
+ if isinstance(description, str):
276
+ description_lower = description.lower()
277
+ else:
278
+ description_lower = ""
279
+ text = f"{title_lower} {description_lower}"
280
+
281
+ # Classify severity
282
+ severity = self._classify_severity(text)
283
+
284
+ # Classify type
285
+ incident_type = self._classify_type(text)
286
+
287
+ # Suggest assignment
288
+ suggested_team = self._suggest_assignment(incident_type, severity)
289
+
290
+ # Calculate confidence
291
+ confidence = self._calculate_confidence(text)
292
+
293
+ return {
294
+ "severity": severity,
295
+ "type": incident_type,
296
+ "suggested_assignment": suggested_team,
297
+ "confidence": confidence,
298
+ "reasoning": self._get_reasoning(text, severity, incident_type),
299
+ }
300
+
301
+ def _classify_severity(self, text: str) -> str:
302
+ """Classify incident severity based on keywords.
303
+
304
+ Args:
305
+ text: Lowercase text to analyze
306
+
307
+ Returns:
308
+ Severity level: Critical, High, Medium, or Low
309
+ """
310
+ if any(kw in text for kw in self.CRITICAL_KEYWORDS):
311
+ return "Critical"
312
+ elif any(kw in text for kw in self.HIGH_KEYWORDS):
313
+ return "High"
314
+ elif any(kw in text for kw in self.MEDIUM_KEYWORDS):
315
+ return "Medium"
316
+ else:
317
+ return "Low"
318
+
319
+ def _classify_type(self, text: str) -> str:
320
+ """Classify incident type based on keywords.
321
+
322
+ Args:
323
+ text: Lowercase text to analyze
324
+
325
+ Returns:
326
+ Incident type (e.g., Performance, Security, Bug, etc.)
327
+ """
328
+ for incident_type, keywords in self.TYPE_KEYWORDS.items():
329
+ if any(kw in text for kw in keywords):
330
+ return incident_type
331
+
332
+ return "General"
333
+
334
+ def _suggest_assignment(self, incident_type: str, severity: str) -> str:
335
+ """Suggest team assignment based on type and severity.
336
+
337
+ Args:
338
+ incident_type: The classified incident type
339
+ severity: The classified severity level
340
+
341
+ Returns:
342
+ Name of the team that should handle this incident
343
+ """
344
+ return self.TEAM_MAPPING.get(incident_type, "Engineering Team")
345
+
346
+ def _calculate_confidence(self, text: str) -> float:
347
+ """Calculate confidence in classification.
348
+
349
+ More specific keywords = higher confidence.
350
+
351
+ Args:
352
+ text: Lowercase text to analyze
353
+
354
+ Returns:
355
+ Confidence score from 0.0 to 1.0
356
+ """
357
+ matches = sum(1 for kw in self.SPECIFIC_KEYWORDS if kw in text)
358
+
359
+ # Base confidence 0.5, +0.1 for each matching keyword, max 0.95
360
+ confidence = min(0.95, 0.5 + (matches * 0.1))
361
+
362
+ return round(confidence, 2)
363
+
364
+ def _get_reasoning(self, text: str, severity: str, incident_type: str) -> str:
365
+ """Get reasoning for classification.
366
+
367
+ Args:
368
+ text: The analyzed text
369
+ severity: Classified severity
370
+ incident_type: Classified type
371
+
372
+ Returns:
373
+ Human-readable explanation of the classification
374
+ """
375
+ # Find matching keywords for reasoning
376
+ matched_keywords = []
377
+ for kw in self.SPECIFIC_KEYWORDS:
378
+ if kw in text:
379
+ matched_keywords.append(kw)
380
+
381
+ if matched_keywords:
382
+ keywords_str = ", ".join(matched_keywords[:3]) # Show top 3
383
+ return (
384
+ f"Classified as '{severity}' severity and '{incident_type}' type "
385
+ f"based on keywords: {keywords_str}"
386
+ )
387
+ else:
388
+ return (
389
+ f"Classified as '{severity}' severity and '{incident_type}' type "
390
+ "(low confidence - no specific keywords found)"
391
+ )
392
+
393
+
394
+ class BatchOperationManager:
395
+ """Manager for executing batch operations.
396
+
397
+ Allows execution of multiple operations in sequence with error handling
398
+ and summary statistics.
399
+ """
400
+
401
+ def __init__(self, client: NotionClient) -> None:
402
+ """Initialize the BatchOperationManager.
403
+
404
+ Args:
405
+ client: Notion API client
406
+ """
407
+ self._client = client
408
+
409
+ async def execute_batch(
410
+ self,
411
+ operations: list[dict[str, Any]],
412
+ continue_on_error: bool = False,
413
+ ) -> dict[str, Any]:
414
+ """Execute a batch of operations.
415
+
416
+ Args:
417
+ operations: List of operation dicts with 'command' and 'args' keys
418
+ continue_on_error: If True, continue after errors; if False, stop on first error
419
+
420
+ Returns:
421
+ Dictionary with results and summary:
422
+ - results: List of operation results
423
+ - summary: Total, succeeded, and failed counts
424
+ """
425
+ results = []
426
+ succeeded = 0
427
+ failed = 0
428
+
429
+ for i, op in enumerate(operations):
430
+ try:
431
+ result = await self._execute_operation(op)
432
+ results.append(
433
+ {
434
+ "operation": i + 1,
435
+ "status": "success",
436
+ "result": result,
437
+ }
438
+ )
439
+ succeeded += 1
440
+ except Exception as e:
441
+ results.append(
442
+ {
443
+ "operation": i + 1,
444
+ "status": "error",
445
+ "error": str(e),
446
+ }
447
+ )
448
+ failed += 1
449
+
450
+ if not continue_on_error:
451
+ break
452
+
453
+ return {
454
+ "results": results,
455
+ "summary": {
456
+ "total": len(operations),
457
+ "succeeded": succeeded,
458
+ "failed": failed,
459
+ },
460
+ }
461
+
462
+ async def _execute_operation(self, operation: dict[str, Any]) -> Any:
463
+ """Execute a single operation.
464
+
465
+ Args:
466
+ operation: Dict with 'command' and 'args' keys
467
+
468
+ Returns:
469
+ Result from the operation
470
+
471
+ Raises:
472
+ ValueError: If command is unknown
473
+ """
474
+ command = operation.get("command")
475
+ args = operation.get("args", {})
476
+
477
+ # Map commands to manager methods
478
+ if command == "tasks claim":
479
+ from better_notion.plugins.official.agents_sdk.managers import TaskManager
480
+
481
+ mgr = TaskManager(self._client, self._client.workspace_config)
482
+ return await mgr.claim(args["task_id"])
483
+
484
+ elif command == "tasks start":
485
+ from better_notion.plugins.official.agents_sdk.managers import TaskManager
486
+
487
+ mgr = TaskManager(self._client, self._client.workspace_config)
488
+ return await mgr.start(args["task_id"])
489
+
490
+ elif command == "tasks complete":
491
+ from better_notion.plugins.official.agents_sdk.managers import TaskManager
492
+
493
+ mgr = TaskManager(self._client, self._client.workspace_config)
494
+ return await mgr.complete(args["task_id"])
495
+
496
+ elif command == "tasks create":
497
+ from better_notion.plugins.official.agents_sdk.managers import TaskManager
498
+
499
+ mgr = TaskManager(self._client, self._client.workspace_config)
500
+ return await mgr.create(**args)
501
+
502
+ else:
503
+ raise ValueError(f"Unknown command: {command}")