supervaizer 0.10.5__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.
Files changed (76) hide show
  1. supervaizer/__init__.py +97 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +308 -0
  4. supervaizer/account_service.py +93 -0
  5. supervaizer/admin/routes.py +1293 -0
  6. supervaizer/admin/static/js/job-start-form.js +373 -0
  7. supervaizer/admin/templates/agent_detail.html +145 -0
  8. supervaizer/admin/templates/agents.html +249 -0
  9. supervaizer/admin/templates/agents_grid.html +82 -0
  10. supervaizer/admin/templates/base.html +233 -0
  11. supervaizer/admin/templates/case_detail.html +230 -0
  12. supervaizer/admin/templates/cases_list.html +182 -0
  13. supervaizer/admin/templates/cases_table.html +134 -0
  14. supervaizer/admin/templates/console.html +389 -0
  15. supervaizer/admin/templates/dashboard.html +153 -0
  16. supervaizer/admin/templates/job_detail.html +192 -0
  17. supervaizer/admin/templates/job_start_test.html +109 -0
  18. supervaizer/admin/templates/jobs_list.html +180 -0
  19. supervaizer/admin/templates/jobs_table.html +122 -0
  20. supervaizer/admin/templates/navigation.html +163 -0
  21. supervaizer/admin/templates/recent_activity.html +81 -0
  22. supervaizer/admin/templates/server.html +105 -0
  23. supervaizer/admin/templates/server_status_cards.html +121 -0
  24. supervaizer/admin/templates/supervaize_instructions.html +212 -0
  25. supervaizer/agent.py +956 -0
  26. supervaizer/case.py +432 -0
  27. supervaizer/cli.py +395 -0
  28. supervaizer/common.py +324 -0
  29. supervaizer/deploy/__init__.py +16 -0
  30. supervaizer/deploy/cli.py +305 -0
  31. supervaizer/deploy/commands/__init__.py +9 -0
  32. supervaizer/deploy/commands/clean.py +294 -0
  33. supervaizer/deploy/commands/down.py +119 -0
  34. supervaizer/deploy/commands/local.py +460 -0
  35. supervaizer/deploy/commands/plan.py +167 -0
  36. supervaizer/deploy/commands/status.py +169 -0
  37. supervaizer/deploy/commands/up.py +281 -0
  38. supervaizer/deploy/docker.py +377 -0
  39. supervaizer/deploy/driver_factory.py +42 -0
  40. supervaizer/deploy/drivers/__init__.py +39 -0
  41. supervaizer/deploy/drivers/aws_app_runner.py +607 -0
  42. supervaizer/deploy/drivers/base.py +196 -0
  43. supervaizer/deploy/drivers/cloud_run.py +570 -0
  44. supervaizer/deploy/drivers/do_app_platform.py +504 -0
  45. supervaizer/deploy/health.py +404 -0
  46. supervaizer/deploy/state.py +210 -0
  47. supervaizer/deploy/templates/Dockerfile.template +44 -0
  48. supervaizer/deploy/templates/debug_env.py +69 -0
  49. supervaizer/deploy/templates/docker-compose.yml.template +37 -0
  50. supervaizer/deploy/templates/dockerignore.template +66 -0
  51. supervaizer/deploy/templates/entrypoint.sh +20 -0
  52. supervaizer/deploy/utils.py +52 -0
  53. supervaizer/event.py +181 -0
  54. supervaizer/examples/controller_template.py +196 -0
  55. supervaizer/instructions.py +145 -0
  56. supervaizer/job.py +392 -0
  57. supervaizer/job_service.py +156 -0
  58. supervaizer/lifecycle.py +417 -0
  59. supervaizer/parameter.py +233 -0
  60. supervaizer/protocol/__init__.py +11 -0
  61. supervaizer/protocol/a2a/__init__.py +21 -0
  62. supervaizer/protocol/a2a/model.py +227 -0
  63. supervaizer/protocol/a2a/routes.py +99 -0
  64. supervaizer/py.typed +1 -0
  65. supervaizer/routes.py +917 -0
  66. supervaizer/server.py +553 -0
  67. supervaizer/server_utils.py +54 -0
  68. supervaizer/storage.py +462 -0
  69. supervaizer/telemetry.py +81 -0
  70. supervaizer/utils/__init__.py +16 -0
  71. supervaizer/utils/version_check.py +56 -0
  72. supervaizer-0.10.5.dist-info/METADATA +317 -0
  73. supervaizer-0.10.5.dist-info/RECORD +76 -0
  74. supervaizer-0.10.5.dist-info/WHEEL +4 -0
  75. supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
  76. supervaizer-0.10.5.dist-info/licenses/LICENSE.md +346 -0
supervaizer/case.py ADDED
@@ -0,0 +1,432 @@
1
+ # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
2
+ #
3
+ # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4
+ # If a copy of the MPL was not distributed with this file, you can obtain one at
5
+ # https://mozilla.org/MPL/2.0/.
6
+
7
+
8
+ from datetime import datetime
9
+ from enum import Enum
10
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
11
+
12
+ import shortuuid
13
+ from pydantic import ConfigDict, Field
14
+ from pydantic.json_schema import SkipJsonSchema
15
+ from typing import Callable
16
+ from supervaizer.common import SvBaseModel, log, singleton
17
+ from supervaizer.lifecycle import EntityEvents, EntityStatus
18
+ from supervaizer.storage import PersistentEntityLifecycle, StorageManager
19
+
20
+ if TYPE_CHECKING:
21
+ from supervaizer.account import Account
22
+
23
+
24
+ class CaseNodeUpdate(SvBaseModel):
25
+ """
26
+ CaseNodeUpdate is a class that represents an update to a case node.
27
+
28
+
29
+ Returns:
30
+ CaseNodeUpdate: CaseNodeUpdate object
31
+ """
32
+
33
+ index: int | None = None # added in Case.update
34
+ cost: float | None = None
35
+ name: str | None = None
36
+ # Todo: test with non-serializable objects. Make sure it works.
37
+ payload: Optional[Dict[str, Any]] = None
38
+ is_final: bool = False
39
+ error: Optional[str] = None
40
+
41
+ def __init__(
42
+ self,
43
+ cost: float | None = None,
44
+ name: str | None = None,
45
+ payload: Dict[str, Any] | None = None,
46
+ is_final: bool = False,
47
+ index: int | None = None,
48
+ error: Optional[str] = None,
49
+ ) -> None:
50
+ """Initialize a CaseNodeUpdate.
51
+
52
+ Args:
53
+ cost (float): Cost of the update
54
+ name (str): Name of the update
55
+ payload (Dict[str, Any]): Additional data for the update - when a question is requested to the user, the payload is the question
56
+ is_final (bool): Whether this is the final update. Default to False
57
+ index (int): Index of the node to update. This is set by Case.update()
58
+ error (Optional[str]): Error message if any. Default to None
59
+
60
+ When payload contains a question (supervaizer_form):
61
+ payload = {
62
+ "supervaizer_form": {
63
+ "question": str, # The question to ask
64
+ "answer": {
65
+ "fields": [
66
+ {
67
+ "name": str, # Field name
68
+ "description": str, # Field description
69
+ "type": type, # Field type (e.g. bool)
70
+ "field_type": str, # Field type name (e.g. "BooleanField")
71
+ "required": bool # Whether field is required
72
+ },
73
+ # ... additional fields
74
+ ]
75
+ }
76
+ }
77
+ }
78
+
79
+ Returns:
80
+ CaseNodeUpdate: CaseNodeUpdate object
81
+ """
82
+ # Use model_construct rather than passing arguments to __init__
83
+ values = {
84
+ "cost": cost,
85
+ "name": name,
86
+ "payload": payload,
87
+ "is_final": is_final,
88
+ "index": index,
89
+ "error": error,
90
+ }
91
+ object.__setattr__(self, "__dict__", {})
92
+ object.__setattr__(self, "__pydantic_fields_set__", set())
93
+ object.__setattr__(self, "__pydantic_extra__", None)
94
+ object.__setattr__(self, "__pydantic_private__", None)
95
+
96
+ # Update the model fields without calling the SvBaseModel.__init__
97
+ for key, value in values.items():
98
+ setattr(self, key, value)
99
+
100
+ @property
101
+ def registration_info(self) -> Dict[str, Any]:
102
+ """Returns registration info for the case node update"""
103
+ # Serialize payload to convert type objects to strings for JSON serialization
104
+ serialized_payload = (
105
+ self.serialize_value(self.payload) if self.payload else None
106
+ )
107
+ return {
108
+ "index": self.index,
109
+ "name": self.name,
110
+ "error": self.error,
111
+ "cost": self.cost,
112
+ "payload": serialized_payload,
113
+ "is_final": self.is_final,
114
+ }
115
+
116
+
117
+ class CaseNodeType(Enum):
118
+ """
119
+ CaseNodeType is an enum that represents the type of a case note.
120
+ """
121
+
122
+ CHAT = "chat"
123
+ TRIGGER = "trigger"
124
+ NOTIFICATION = "notification"
125
+ STATUS_UPDATE = "status_update"
126
+ INTERMEDIARY_DELIVERY = "intermediary_delivery"
127
+ HITL = "human_in_the_loop"
128
+ DELIVERABLE = "deliverable"
129
+ VALIDATION = "validation"
130
+ DELIVERY = "delivery"
131
+ ERROR = "error"
132
+ WARNING = "warning"
133
+ INFO = "info"
134
+
135
+
136
+ class CaseNode(SvBaseModel):
137
+ model_config = ConfigDict(arbitrary_types_allowed=True)
138
+
139
+ name: str
140
+ type: CaseNodeType
141
+ factory: SkipJsonSchema[Callable[..., CaseNodeUpdate]] = Field(
142
+ exclude=True, repr=False
143
+ ) # Exclude from JSON schema generation and representation
144
+ description: str | None = None
145
+ can_be_confirmed: bool = False # Whether the user can decide that this node needs to be confirmed. This must be set in the job definition.
146
+
147
+ def __call__(self, *args: Any, **kwargs: Any) -> CaseNodeUpdate:
148
+ """Make it callable directly."""
149
+ return self.factory(*args, **kwargs)
150
+
151
+ @property
152
+ def registration_info(self) -> Dict[str, Any]:
153
+ """Returns registration info for the case node"""
154
+ return {
155
+ "name": self.name,
156
+ "type": self.type.value,
157
+ "description": self.description,
158
+ "can_be_confirmed": self.can_be_confirmed,
159
+ }
160
+
161
+
162
+ class CaseNodes(SvBaseModel):
163
+ nodes: List[CaseNode] = []
164
+
165
+ def get(self, name: str) -> CaseNode | None:
166
+ return next((node for node in self.nodes if node.name == name), None)
167
+
168
+ @property
169
+ def registration_info(self) -> Dict[str, Any]:
170
+ """Returns registration info for the case nodes"""
171
+ return {
172
+ "nodes": [node.registration_info for node in self.nodes],
173
+ }
174
+
175
+
176
+ class CaseAbstractModel(SvBaseModel):
177
+ model_config = ConfigDict(arbitrary_types_allowed=True)
178
+ id: str
179
+ job_id: str
180
+ name: str
181
+ account: "Account"
182
+ description: str
183
+ status: EntityStatus
184
+ updates: List[CaseNodeUpdate] = []
185
+ total_cost: float = 0.0
186
+ final_delivery: Optional[Dict[str, Any]] = None
187
+ finished_at: Optional[datetime] = None
188
+
189
+
190
+ class Case(CaseAbstractModel):
191
+ def __init__(self, **kwargs: Any) -> None:
192
+ super().__init__(**kwargs)
193
+ # Register the case in the global registry
194
+ Cases().add_case(self)
195
+ # Persist case to storage
196
+ from supervaizer.storage import StorageManager
197
+
198
+ storage = StorageManager()
199
+ storage.save_object("Case", self.to_dict)
200
+
201
+ @property
202
+ def uri(self) -> str:
203
+ return f"case:{self.id}"
204
+
205
+ @property
206
+ def case_ref(self) -> str:
207
+ return f"{self.job_id}-{self.id}"
208
+
209
+ @property
210
+ def calculated_cost(self) -> float:
211
+ return sum(update.cost or 0.0 for update in self.updates)
212
+
213
+ def update(self, updateCaseNode: CaseNodeUpdate, **kwargs: Any) -> None:
214
+ updateCaseNode.index = len(self.updates) + 1
215
+ if updateCaseNode.error:
216
+ success, error = PersistentEntityLifecycle.handle_event(
217
+ self, EntityEvents.ERROR_ENCOUNTERED
218
+ )
219
+ log.warning(
220
+ f"[Case update] CaseRef {self.case_ref} has error {updateCaseNode.error}"
221
+ )
222
+ assert self.status == EntityStatus.FAILED # Just to be sure
223
+ self.account.send_update_case(self, updateCaseNode)
224
+ self.updates.append(updateCaseNode)
225
+
226
+ storage = StorageManager()
227
+ storage.save_object("Case", self.to_dict)
228
+
229
+ def request_human_input(
230
+ self, updateCaseNode: CaseNodeUpdate, message: str, **kwargs: Any
231
+ ) -> None:
232
+ updateCaseNode.index = len(self.updates) + 1
233
+ log.info(
234
+ f"[Update case human_input] CaseRef {self.case_ref} with update {updateCaseNode}"
235
+ )
236
+ self.account.send_update_case(self, updateCaseNode)
237
+ from supervaizer.storage import PersistentEntityLifecycle
238
+
239
+ PersistentEntityLifecycle.handle_event(self, EntityEvents.AWAITING_ON_INPUT)
240
+ self.updates.append(updateCaseNode)
241
+
242
+ # Persist updated case to storage (for the updates list change)
243
+
244
+ storage = StorageManager()
245
+ storage.save_object("Case", self.to_dict)
246
+
247
+ def receive_human_input(
248
+ self, updateCaseNode: CaseNodeUpdate, **kwargs: Any
249
+ ) -> None:
250
+ # Add the update to the case (this handles index, send_update_case, and persistence)
251
+ self.update(updateCaseNode)
252
+ # Transition from AWAITING to IN_PROGRESS
253
+ from supervaizer.storage import PersistentEntityLifecycle
254
+
255
+ PersistentEntityLifecycle.handle_event(self, EntityEvents.INPUT_RECEIVED)
256
+
257
+ def close(
258
+ self,
259
+ case_result: Dict[str, Any],
260
+ final_cost: Optional[float] = None,
261
+ **kwargs: Any,
262
+ ) -> None:
263
+ """
264
+ Close the case and send the final update to the account.
265
+ """
266
+ if final_cost:
267
+ self.total_cost = final_cost
268
+ else:
269
+ self.total_cost = self.calculated_cost
270
+ log.info(
271
+ f"[Close case] CaseRef {self.case_ref} with result {case_result} - Case cost is {self.total_cost}"
272
+ )
273
+ # Transition from IN_PROGRESS to COMPLETED
274
+ from supervaizer.storage import PersistentEntityLifecycle
275
+
276
+ PersistentEntityLifecycle.handle_event(self, EntityEvents.SUCCESSFULLY_DONE)
277
+
278
+ update = CaseNodeUpdate(
279
+ payload=case_result,
280
+ is_final=True,
281
+ )
282
+ update.index = len(self.updates) + 1
283
+
284
+ self.final_delivery = case_result
285
+ self.finished_at = datetime.now()
286
+ self.account.send_update_case(self, update)
287
+
288
+ # Persist updated case to storage
289
+ from supervaizer.storage import StorageManager
290
+
291
+ storage = StorageManager()
292
+ storage.save_object("Case", self.to_dict)
293
+
294
+ @property
295
+ def registration_info(self) -> Dict[str, Any]:
296
+ """Returns registration info for the case"""
297
+ return {
298
+ "case_id": self.id,
299
+ "job_id": self.job_id,
300
+ "case_ref": self.case_ref,
301
+ "name": self.name,
302
+ "description": self.description,
303
+ "status": self.status.value,
304
+ "updates": [update.registration_info for update in self.updates],
305
+ "total_cost": self.total_cost,
306
+ "final_delivery": self.final_delivery,
307
+ }
308
+
309
+ @classmethod
310
+ def start(
311
+ cls,
312
+ job_id: str,
313
+ name: str,
314
+ account: "Account",
315
+ description: str,
316
+ case_id: Optional[str] = None,
317
+ ) -> "Case":
318
+ """
319
+ Start a new case
320
+
321
+ Args:
322
+ case_id (str): The id of the case - should be unique for the job. If not provided, a shortuuid will be generated.
323
+ job_id (str): The id of the job
324
+ name (str): The name of the case
325
+ account (Account): The account
326
+ description (str): The description of the case
327
+
328
+ Returns:
329
+ Case: The case
330
+ """
331
+
332
+ case = cls(
333
+ id=case_id or shortuuid.uuid(),
334
+ job_id=job_id,
335
+ account=account,
336
+ name=name,
337
+ description=description,
338
+ status=EntityStatus.STOPPED,
339
+ )
340
+ log.info(f"[Case created] {case.id}")
341
+
342
+ # Add case to job's case_ids for foreign key relationship
343
+ from supervaizer.job import Jobs
344
+
345
+ job = Jobs().get_job(job_id)
346
+ if job:
347
+ job.add_case_id(case.id)
348
+
349
+ # Transition from STOPPED to IN_PROGRESS
350
+
351
+ PersistentEntityLifecycle.handle_event(case, EntityEvents.START_WORK)
352
+
353
+ # Send case start event to Supervaize SaaS.
354
+ result = account.send_start_case(case=case)
355
+ if result:
356
+ log.debug(
357
+ f"[Case start] Case {case.id} send to Supervaize with result {result}"
358
+ )
359
+ else:
360
+ log.error(
361
+ f"[Case start] §SCCS01 Case {case.id} failed to send to Supervaize"
362
+ )
363
+
364
+ return case
365
+
366
+
367
+ @singleton
368
+ class Cases:
369
+ """Global registry for all cases, organized by job."""
370
+
371
+ def __init__(self) -> None:
372
+ # Structure: {job_id: {case_id: Case}}
373
+ self.cases_by_job: dict[str, dict[str, "Case"]] = {}
374
+
375
+ def reset(self) -> None:
376
+ self.cases_by_job.clear()
377
+
378
+ def add_case(self, case: "Case") -> None:
379
+ """Add a case to the registry under its job
380
+
381
+ Args:
382
+ case (Case): The case to add
383
+
384
+ Raises:
385
+ ValueError: If case with same ID already exists for this job
386
+ """
387
+ job_id = case.job_id
388
+
389
+ # Initialize job's case dict if not exists
390
+ if job_id not in self.cases_by_job:
391
+ self.cases_by_job[job_id] = {}
392
+
393
+ # Check if case already exists for this job
394
+ if case.id in self.cases_by_job[job_id]:
395
+ raise ValueError(f"Case ID '{case.id}' already exists for job {job_id}")
396
+
397
+ self.cases_by_job[job_id][case.id] = case
398
+
399
+ def get_case(self, case_id: str, job_id: str | None = None) -> "Case | None":
400
+ """Get a case by its ID and optionally job ID
401
+
402
+ Args:
403
+ case_id (str): The ID of the case to get
404
+ job_id (str | None): The ID of the job. If None, searches all jobs.
405
+
406
+ Returns:
407
+ Case | None: The case if found, None otherwise
408
+ """
409
+ if job_id:
410
+ # Search in specific job's cases
411
+ return self.cases_by_job.get(job_id, {}).get(case_id)
412
+
413
+ # Search across all jobs
414
+ for job_cases in self.cases_by_job.values():
415
+ if case_id in job_cases:
416
+ return job_cases[case_id]
417
+ return None
418
+
419
+ def get_job_cases(self, job_id: str) -> dict[str, "Case"]:
420
+ """Get all cases for a specific job
421
+
422
+ Args:
423
+ job_id (str): The ID of the job
424
+
425
+ Returns:
426
+ dict[str, Case]: Dictionary of cases for this job, empty if job not found
427
+ """
428
+ return self.cases_by_job.get(job_id, {})
429
+
430
+ def __contains__(self, case_id: str) -> bool:
431
+ """Check if case exists in any job's registry"""
432
+ return any(case_id in cases for cases in self.cases_by_job.values())