supervaizer 0.9.6__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 (50) hide show
  1. supervaizer/__init__.py +88 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +304 -0
  4. supervaizer/account_service.py +87 -0
  5. supervaizer/admin/routes.py +1254 -0
  6. supervaizer/admin/templates/agent_detail.html +145 -0
  7. supervaizer/admin/templates/agents.html +175 -0
  8. supervaizer/admin/templates/agents_grid.html +80 -0
  9. supervaizer/admin/templates/base.html +233 -0
  10. supervaizer/admin/templates/case_detail.html +230 -0
  11. supervaizer/admin/templates/cases_list.html +182 -0
  12. supervaizer/admin/templates/cases_table.html +134 -0
  13. supervaizer/admin/templates/console.html +389 -0
  14. supervaizer/admin/templates/dashboard.html +153 -0
  15. supervaizer/admin/templates/job_detail.html +192 -0
  16. supervaizer/admin/templates/jobs_list.html +180 -0
  17. supervaizer/admin/templates/jobs_table.html +122 -0
  18. supervaizer/admin/templates/navigation.html +153 -0
  19. supervaizer/admin/templates/recent_activity.html +81 -0
  20. supervaizer/admin/templates/server.html +105 -0
  21. supervaizer/admin/templates/server_status_cards.html +121 -0
  22. supervaizer/agent.py +816 -0
  23. supervaizer/case.py +400 -0
  24. supervaizer/cli.py +135 -0
  25. supervaizer/common.py +283 -0
  26. supervaizer/event.py +181 -0
  27. supervaizer/examples/controller-template.py +195 -0
  28. supervaizer/instructions.py +145 -0
  29. supervaizer/job.py +379 -0
  30. supervaizer/job_service.py +155 -0
  31. supervaizer/lifecycle.py +417 -0
  32. supervaizer/parameter.py +173 -0
  33. supervaizer/protocol/__init__.py +11 -0
  34. supervaizer/protocol/a2a/__init__.py +21 -0
  35. supervaizer/protocol/a2a/model.py +227 -0
  36. supervaizer/protocol/a2a/routes.py +99 -0
  37. supervaizer/protocol/acp/__init__.py +21 -0
  38. supervaizer/protocol/acp/model.py +198 -0
  39. supervaizer/protocol/acp/routes.py +74 -0
  40. supervaizer/py.typed +1 -0
  41. supervaizer/routes.py +667 -0
  42. supervaizer/server.py +554 -0
  43. supervaizer/server_utils.py +54 -0
  44. supervaizer/storage.py +436 -0
  45. supervaizer/telemetry.py +81 -0
  46. supervaizer-0.9.6.dist-info/METADATA +245 -0
  47. supervaizer-0.9.6.dist-info/RECORD +50 -0
  48. supervaizer-0.9.6.dist-info/WHEEL +4 -0
  49. supervaizer-0.9.6.dist-info/entry_points.txt +2 -0
  50. supervaizer-0.9.6.dist-info/licenses/LICENSE.md +346 -0
supervaizer/case.py ADDED
@@ -0,0 +1,400 @@
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
14
+ from typing_extensions import deprecated
15
+
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
+
59
+ error (Optional[str]): Error message if any. Default to None
60
+
61
+ When payload contains a question (supervaizer_form):
62
+ payload = {
63
+ "supervaizer_form": {
64
+ "question": str, # The question to ask
65
+ "answer": {
66
+ "fields": [
67
+ {
68
+ "name": str, # Field name
69
+ "description": str, # Field description
70
+ "type": type, # Field type (e.g. bool)
71
+ "field_type": str, # Field type name (e.g. "BooleanField")
72
+ "required": bool # Whether field is required
73
+ },
74
+ # ... additional fields
75
+ ]
76
+ }
77
+ }
78
+ }
79
+
80
+ Returns:
81
+ CaseNodeUpdate: CaseNodeUpdate object
82
+ """
83
+ # Use model_construct rather than passing arguments to __init__
84
+ values = {
85
+ "cost": cost,
86
+ "name": name,
87
+ "payload": payload,
88
+ "is_final": is_final,
89
+ "index": index,
90
+ "error": error,
91
+ }
92
+ object.__setattr__(self, "__dict__", {})
93
+ object.__setattr__(self, "__pydantic_fields_set__", set())
94
+ object.__setattr__(self, "__pydantic_extra__", None)
95
+ object.__setattr__(self, "__pydantic_private__", None)
96
+
97
+ # Update the model fields without calling the SvBaseModel.__init__
98
+ for key, value in values.items():
99
+ setattr(self, key, value)
100
+
101
+ @property
102
+ def registration_info(self) -> Dict[str, Any]:
103
+ """Returns registration info for the case node update"""
104
+ return {
105
+ "index": self.index,
106
+ "name": self.name,
107
+ "error": self.error,
108
+ "cost": self.cost,
109
+ "payload": self.payload,
110
+ "is_final": self.is_final,
111
+ }
112
+
113
+
114
+ class CaseNoteType(Enum):
115
+ """
116
+ CaseNoteType is an enum that represents the type of a case note.
117
+ """
118
+
119
+ CHAT = "chat"
120
+ TRIGGER = "trigger"
121
+ NOTIFICATION = "notification"
122
+ VALIDATION = "validation"
123
+ DELIVERY = "delivery"
124
+ ERROR = "error"
125
+ WARNING = "warning"
126
+ INFO = "info"
127
+
128
+
129
+ @deprecated("Not used")
130
+ class CaseNode(SvBaseModel):
131
+ name: str
132
+ description: str
133
+ type: CaseNoteType
134
+
135
+ class Config:
136
+ arbitrary_types_allowed = True
137
+
138
+ @property
139
+ def registration_info(self) -> Dict[str, Any]:
140
+ """Returns registration info for the case node"""
141
+ return {
142
+ "name": self.name,
143
+ "description": self.description,
144
+ "type": self.type.value,
145
+ }
146
+
147
+
148
+ class CaseAbstractModel(SvBaseModel):
149
+ model_config = ConfigDict(arbitrary_types_allowed=True)
150
+ id: str
151
+ job_id: str
152
+ name: str
153
+ account: "Account"
154
+ description: str
155
+ status: EntityStatus
156
+ updates: List[CaseNodeUpdate] = []
157
+ total_cost: float = 0.0
158
+ final_delivery: Optional[Dict[str, Any]] = None
159
+ finished_at: Optional[datetime] = None
160
+
161
+
162
+ class Case(CaseAbstractModel):
163
+ def __init__(self, **kwargs: Any) -> None:
164
+ super().__init__(**kwargs)
165
+ # Register the case in the global registry
166
+ Cases().add_case(self)
167
+ # Persist case to storage
168
+ from supervaizer.storage import StorageManager
169
+
170
+ storage = StorageManager()
171
+ storage.save_object("Case", self.to_dict)
172
+
173
+ @property
174
+ def uri(self) -> str:
175
+ return f"case:{self.id}"
176
+
177
+ @property
178
+ def case_ref(self) -> str:
179
+ return f"{self.job_id}-{self.id}"
180
+
181
+ @property
182
+ def calculated_cost(self) -> float:
183
+ return sum(update.cost or 0.0 for update in self.updates)
184
+
185
+ def update(self, updateCaseNode: CaseNodeUpdate, **kwargs: Any) -> None:
186
+ updateCaseNode.index = len(self.updates) + 1
187
+ if updateCaseNode.error:
188
+ success, error = PersistentEntityLifecycle.handle_event(
189
+ self, EntityEvents.ERROR_ENCOUNTERED
190
+ )
191
+ log.warning(
192
+ f"[Case update] CaseRef {self.case_ref} has error {updateCaseNode.error}"
193
+ )
194
+ assert self.status == EntityStatus.FAILED # Just to be sure
195
+ self.account.send_update_case(self, updateCaseNode)
196
+ self.updates.append(updateCaseNode)
197
+
198
+ storage = StorageManager()
199
+ storage.save_object("Case", self.to_dict)
200
+
201
+ def request_human_input(
202
+ self, updateCaseNode: CaseNodeUpdate, message: str, **kwargs: Any
203
+ ) -> None:
204
+ updateCaseNode.index = len(self.updates) + 1
205
+ log.info(
206
+ f"[Update case human_input] CaseRef {self.case_ref} with update {updateCaseNode}"
207
+ )
208
+ self.account.send_update_case(self, updateCaseNode)
209
+ from supervaizer.storage import PersistentEntityLifecycle
210
+
211
+ PersistentEntityLifecycle.handle_event(self, EntityEvents.AWAITING_ON_INPUT)
212
+ self.updates.append(updateCaseNode)
213
+
214
+ # Persist updated case to storage (for the updates list change)
215
+
216
+ storage = StorageManager()
217
+ storage.save_object("Case", self.to_dict)
218
+
219
+ def receive_human_input(self, **kwargs: Any) -> None:
220
+ # Transition from AWAITING to IN_PROGRESS
221
+ from supervaizer.storage import PersistentEntityLifecycle
222
+
223
+ PersistentEntityLifecycle.handle_event(self, EntityEvents.INPUT_RECEIVED)
224
+
225
+ def close(
226
+ self,
227
+ case_result: Dict[str, Any],
228
+ final_cost: Optional[float] = None,
229
+ **kwargs: Any,
230
+ ) -> None:
231
+ """
232
+ Close the case and send the final update to the account.
233
+ """
234
+ if final_cost:
235
+ self.total_cost = final_cost
236
+ else:
237
+ self.total_cost = self.calculated_cost
238
+ log.info(
239
+ f"[Close case] CaseRef {self.case_ref} with result {case_result} - Case cost is {self.total_cost}"
240
+ )
241
+ # Transition from IN_PROGRESS to COMPLETED
242
+ from supervaizer.storage import PersistentEntityLifecycle
243
+
244
+ PersistentEntityLifecycle.handle_event(self, EntityEvents.SUCCESSFULLY_DONE)
245
+
246
+ update = CaseNodeUpdate(
247
+ payload=case_result,
248
+ is_final=True,
249
+ )
250
+ update.index = len(self.updates) + 1
251
+
252
+ self.final_delivery = case_result
253
+ self.finished_at = datetime.now()
254
+ self.account.send_update_case(self, update)
255
+
256
+ # Persist updated case to storage
257
+ from supervaizer.storage import StorageManager
258
+
259
+ storage = StorageManager()
260
+ storage.save_object("Case", self.to_dict)
261
+
262
+ @property
263
+ def registration_info(self) -> Dict[str, Any]:
264
+ """Returns registration info for the case"""
265
+ return {
266
+ "case_id": self.id,
267
+ "job_id": self.job_id,
268
+ "case_ref": self.case_ref,
269
+ "name": self.name,
270
+ "description": self.description,
271
+ "status": self.status.value,
272
+ "updates": [update.registration_info for update in self.updates],
273
+ "total_cost": self.total_cost,
274
+ "final_delivery": self.final_delivery,
275
+ }
276
+
277
+ @classmethod
278
+ def start(
279
+ cls,
280
+ job_id: str,
281
+ name: str,
282
+ account: "Account",
283
+ description: str,
284
+ case_id: Optional[str] = None,
285
+ ) -> "Case":
286
+ """
287
+ Start a new case
288
+
289
+ Args:
290
+ case_id (str): The id of the case - should be unique for the job. If not provided, a shortuuid will be generated.
291
+ job_id (str): The id of the job
292
+ name (str): The name of the case
293
+ account (Account): The account
294
+ description (str): The description of the case
295
+
296
+ Returns:
297
+ Case: The case
298
+ """
299
+
300
+ case = cls(
301
+ id=case_id or shortuuid.uuid(),
302
+ job_id=job_id,
303
+ account=account,
304
+ name=name,
305
+ description=description,
306
+ status=EntityStatus.STOPPED,
307
+ )
308
+ log.info(f"[Case created] {case.id}")
309
+
310
+ # Add case to job's case_ids for foreign key relationship
311
+ from supervaizer.job import Jobs
312
+
313
+ job = Jobs().get_job(job_id)
314
+ if job:
315
+ job.add_case_id(case.id)
316
+
317
+ # Transition from STOPPED to IN_PROGRESS
318
+
319
+ PersistentEntityLifecycle.handle_event(case, EntityEvents.START_WORK)
320
+
321
+ # Send case start event to Supervaize SaaS.
322
+ result = account.send_start_case(case=case)
323
+ if result:
324
+ log.debug(
325
+ f"[Case start] Case {case.id} send to Supervaize with result {result}"
326
+ )
327
+ else:
328
+ log.error(
329
+ f"[Case start] §SCCS01 Case {case.id} failed to send to Supervaize"
330
+ )
331
+
332
+ return case
333
+
334
+
335
+ @singleton
336
+ class Cases:
337
+ """Global registry for all cases, organized by job."""
338
+
339
+ def __init__(self) -> None:
340
+ # Structure: {job_id: {case_id: Case}}
341
+ self.cases_by_job: dict[str, dict[str, "Case"]] = {}
342
+
343
+ def reset(self) -> None:
344
+ self.cases_by_job.clear()
345
+
346
+ def add_case(self, case: "Case") -> None:
347
+ """Add a case to the registry under its job
348
+
349
+ Args:
350
+ case (Case): The case to add
351
+
352
+ Raises:
353
+ ValueError: If case with same ID already exists for this job
354
+ """
355
+ job_id = case.job_id
356
+
357
+ # Initialize job's case dict if not exists
358
+ if job_id not in self.cases_by_job:
359
+ self.cases_by_job[job_id] = {}
360
+
361
+ # Check if case already exists for this job
362
+ if case.id in self.cases_by_job[job_id]:
363
+ raise ValueError(f"Case ID '{case.id}' already exists for job {job_id}")
364
+
365
+ self.cases_by_job[job_id][case.id] = case
366
+
367
+ def get_case(self, case_id: str, job_id: str | None = None) -> "Case | None":
368
+ """Get a case by its ID and optionally job ID
369
+
370
+ Args:
371
+ case_id (str): The ID of the case to get
372
+ job_id (str | None): The ID of the job. If None, searches all jobs.
373
+
374
+ Returns:
375
+ Case | None: The case if found, None otherwise
376
+ """
377
+ if job_id:
378
+ # Search in specific job's cases
379
+ return self.cases_by_job.get(job_id, {}).get(case_id)
380
+
381
+ # Search across all jobs
382
+ for job_cases in self.cases_by_job.values():
383
+ if case_id in job_cases:
384
+ return job_cases[case_id]
385
+ return None
386
+
387
+ def get_job_cases(self, job_id: str) -> dict[str, "Case"]:
388
+ """Get all cases for a specific job
389
+
390
+ Args:
391
+ job_id (str): The ID of the job
392
+
393
+ Returns:
394
+ dict[str, Case]: Dictionary of cases for this job, empty if job not found
395
+ """
396
+ return self.cases_by_job.get(job_id, {})
397
+
398
+ def __contains__(self, case_id: str) -> bool:
399
+ """Check if case exists in any job's registry"""
400
+ return any(case_id in cases for cases in self.cases_by_job.values())
supervaizer/cli.py ADDED
@@ -0,0 +1,135 @@
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
+ import os
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from supervaizer.__version__ import VERSION
17
+
18
+ app = typer.Typer(
19
+ help=f"Supervaizer Controller CLI v{VERSION} - Documentation @ https://docs.supervaize.com"
20
+ )
21
+ console = Console()
22
+
23
+
24
+ @app.command()
25
+ def start(
26
+ public_url: Optional[str] = typer.Option(
27
+ os.environ.get("SUPERVAIZER_PUBLIC_URL") or None,
28
+ help="Public URL to use for inbound connections",
29
+ ),
30
+ host: str = typer.Option(
31
+ os.environ.get("SUPERVAIZER_HOST", "0.0.0.0"), help="Host to bind the server to"
32
+ ),
33
+ port: int = typer.Option(
34
+ int(os.environ.get("SUPERVAIZER_PORT") or "8000"),
35
+ help="Port to bind the server to",
36
+ ),
37
+ log_level: str = typer.Option(
38
+ os.environ.get("SUPERVAIZER_LOG_LEVEL", "INFO"),
39
+ help="Log level (DEBUG, INFO, WARNING, ERROR)",
40
+ ),
41
+ debug: bool = typer.Option(
42
+ (os.environ.get("SUPERVAIZER_DEBUG") or "False").lower() == "true",
43
+ help="Enable debug mode",
44
+ ),
45
+ reload: bool = typer.Option(
46
+ (os.environ.get("SUPERVAIZER_RELOAD") or "False").lower() == "true",
47
+ help="Enable auto-reload",
48
+ ),
49
+ environment: str = typer.Option(
50
+ os.environ.get("SUPERVAIZER_ENVIRONMENT", "dev"), help="Environment name"
51
+ ),
52
+ script_path: Optional[str] = typer.Argument(
53
+ None,
54
+ help="Path to the supervaizer_control.py script",
55
+ ),
56
+ ) -> None:
57
+ """Start the Supervaizer Controller server."""
58
+ if script_path is None:
59
+ # Try to get from environment variable first, then default
60
+ script_path = (
61
+ os.environ.get("SUPERVAIZER_SCRIPT_PATH") or "supervaizer_control.py"
62
+ )
63
+
64
+ if not os.path.exists(script_path):
65
+ console.print(f"[bold red]Error:[/] {script_path} not found")
66
+ console.print("Run [bold]supervaizer scaffold[/] to create a default script")
67
+ sys.exit(1)
68
+
69
+ # Set environment variables for the server configuration
70
+ os.environ["SUPERVAIZER_HOST"] = host
71
+ os.environ["SUPERVAIZER_PORT"] = str(port)
72
+ os.environ["SUPERVAIZER_ENVIRONMENT"] = environment
73
+ os.environ["SUPERVAIZER_LOG_LEVEL"] = log_level
74
+ os.environ["SUPERVAIZER_DEBUG"] = str(debug)
75
+ os.environ["SUPERVAIZER_RELOAD"] = str(reload)
76
+ if public_url is not None:
77
+ os.environ["SUPERVAIZER_PUBLIC_URL"] = public_url
78
+
79
+ console.print(f"[bold green]Starting Supervaizer Controller v{VERSION}[/]")
80
+ console.print(f"Loading configuration from [bold]{script_path}[/]")
81
+
82
+ # Execute the script
83
+ with open(script_path, "r") as f:
84
+ script_content = f.read()
85
+
86
+ # Execute the script in the current global namespace
87
+ exec(script_content, globals())
88
+
89
+
90
+ @app.command()
91
+ def scaffold(
92
+ output_path: str = typer.Option(
93
+ os.environ.get("SUPERVAIZER_OUTPUT_PATH", "supervaizer_control.py"),
94
+ help="Path to save the script",
95
+ ),
96
+ force: bool = typer.Option(
97
+ (os.environ.get("SUPERVAIZER_FORCE_INSTALL") or "False").lower() == "true",
98
+ help="Overwrite existing file",
99
+ ),
100
+ ) -> None:
101
+ """Create a draft supervaizer_control.py script."""
102
+ # Check if file already exists
103
+ if os.path.exists(output_path) and not force:
104
+ console.print(f"[bold red]Error:[/] {output_path} already exists")
105
+ console.print("Use [bold]--force[/] to overwrite it")
106
+ sys.exit(1)
107
+
108
+ # Get the path to the examples directory
109
+ examples_dir = Path(__file__).parent / "examples"
110
+ example_file = examples_dir / "controller-template.py"
111
+
112
+ if not example_file.exists():
113
+ console.print("[bold red]Error:[/] Example file not found")
114
+ sys.exit(1)
115
+
116
+ # Copy the example file to the output path
117
+ shutil.copy(example_file, output_path)
118
+ console.print(
119
+ f"[bold green]Success:[/] Created an example file at [bold blue]{output_path}[/]"
120
+ )
121
+ console.print(
122
+ "1. Copy this file to [bold]supervaizer_control.py[/] and edit it to configure your agent(s)"
123
+ )
124
+ console.print(
125
+ "2. (Optional) Get your API from [bold]supervaizer.com and setup your environment variables"
126
+ )
127
+ console.print(
128
+ "3. Run [bold]supervaizer start[/] to start the supervaizer controller"
129
+ )
130
+ console.print("4. Open [bold]http://localhost:8000/docs[/] to explore the API")
131
+ sys.exit(0)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ app()