actions.py 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369
  1. """
  2. Action System for Trixy Scheduler
  3. This module provides various action types that define what happens when schedule
  4. entries are executed. Actions integrate with the Trixy event system, ML training
  5. components, and support custom function execution.
  6. Action Types:
  7. - EventAction: Trigger events through the event system
  8. - MLTrainingAction: Start ML model training processes
  9. - FunctionAction: Execute custom functions or methods
  10. - MultiAction: Execute multiple actions in sequence or parallel
  11. - ConditionalAction: Execute actions based on conditions
  12. Key Features:
  13. - Thread-safe implementations
  14. - Comprehensive error handling and validation
  15. - Integration with Trixy event system and ML training
  16. - Serialization support for persistence
  17. - Factory pattern for dynamic creation
  18. - Conditional and multi-action support
  19. - Retry mechanisms and timeout handling
  20. Usage:
  21. from trixy_core.scheduler.actions import EventAction, MLTrainingAction, FunctionAction
  22. # Event action - trigger system events
  23. event_action = EventAction("system_backup", {"type": "full", "location": "/backup"})
  24. # ML training action - start training process
  25. ml_action = MLTrainingAction("voice_recognition", {"epochs": 100, "batch_size": 32})
  26. # Function action - execute custom function
  27. func_action = FunctionAction(my_custom_function, param1="value1", param2="value2")
  28. # Multi action - execute multiple actions
  29. multi_action = MultiAction([event_action, ml_action], parallel=False)
  30. """
  31. import time
  32. import threading
  33. import inspect
  34. import importlib
  35. import traceback
  36. from abc import ABC, abstractmethod
  37. from concurrent.futures import ThreadPoolExecutor, as_completed, Future
  38. from typing import Dict, Any, Optional, List, Union, Callable, Tuple
  39. from dataclasses import dataclass
  40. from enum import Enum
  41. import json
  42. def pprint(message: str) -> None:
  43. """
  44. Action logging function that adapts based on mode.
  45. Uses the same pattern as specified in CLAUDE.md.
  46. """
  47. print(f"[SCHEDULER.ACTIONS] {message}")
  48. class ActionError(Exception):
  49. """Base exception for action-related errors."""
  50. pass
  51. class ActionValidationError(ActionError):
  52. """Raised when action validation fails."""
  53. pass
  54. class ActionExecutionError(ActionError):
  55. """Raised when action execution fails."""
  56. pass
  57. class ActionTimeoutError(ActionError):
  58. """Raised when action execution times out."""
  59. pass
  60. class ActionStatus(Enum):
  61. """Status of action execution."""
  62. PENDING = "pending"
  63. RUNNING = "running"
  64. COMPLETED = "completed"
  65. FAILED = "failed"
  66. TIMEOUT = "timeout"
  67. CANCELLED = "cancelled"
  68. @dataclass
  69. class ActionResult:
  70. """Result of action execution."""
  71. status: ActionStatus
  72. start_time: float
  73. end_time: Optional[float] = None
  74. result_data: Optional[Any] = None
  75. error_message: Optional[str] = None
  76. exception: Optional[Exception] = None
  77. @property
  78. def duration_seconds(self) -> float:
  79. """Get execution duration in seconds."""
  80. if self.end_time is None:
  81. return time.time() - self.start_time
  82. return self.end_time - self.start_time
  83. def to_dict(self) -> Dict[str, Any]:
  84. """Convert result to dictionary."""
  85. return {
  86. 'status': self.status.value,
  87. 'start_time': self.start_time,
  88. 'end_time': self.end_time,
  89. 'duration_seconds': self.duration_seconds,
  90. 'result_data': self.result_data,
  91. 'error_message': self.error_message,
  92. 'exception_type': type(self.exception).__name__ if self.exception else None
  93. }
  94. class BaseAction(ABC):
  95. """
  96. Abstract base class for all actions.
  97. All action implementations must inherit from this class and implement
  98. the required methods for execution and validation.
  99. """
  100. def __init__(
  101. self,
  102. name: Optional[str] = None,
  103. enabled: bool = True,
  104. timeout_seconds: Optional[float] = None,
  105. retry_count: int = 0,
  106. retry_delay_seconds: float = 1.0,
  107. is_critical: bool = False,
  108. **kwargs
  109. ):
  110. """
  111. Initialize base action.
  112. Args:
  113. name: Optional name for the action
  114. enabled: Whether the action is enabled
  115. timeout_seconds: Maximum execution time (None for no timeout)
  116. retry_count: Number of retries on failure
  117. retry_delay_seconds: Delay between retries
  118. is_critical: Whether action failure should stop schedule execution
  119. **kwargs: Additional action-specific parameters
  120. """
  121. self.name = name or f"{self.__class__.__name__}_{id(self)}"
  122. self.enabled = enabled
  123. self.timeout_seconds = timeout_seconds
  124. self.retry_count = retry_count
  125. self.retry_delay_seconds = retry_delay_seconds
  126. self.is_critical = is_critical
  127. # Execution tracking
  128. self.execution_count = 0
  129. self.success_count = 0
  130. self.failure_count = 0
  131. self.last_executed = None
  132. self.last_result = None
  133. # Thread safety
  134. self._lock = threading.RLock()
  135. # Store additional parameters
  136. self.parameters = kwargs
  137. pprint(f"Created action: {self.name} ({self.__class__.__name__})")
  138. @abstractmethod
  139. def _execute_action(self, context: Dict[str, Any]) -> Any:
  140. """
  141. Execute the actual action logic.
  142. Args:
  143. context: Execution context from schedule
  144. Returns:
  145. Any: Action result data
  146. Raises:
  147. ActionExecutionError: If execution fails
  148. """
  149. pass
  150. def execute(self, context: Optional[Dict[str, Any]] = None) -> ActionResult:
  151. """
  152. Execute the action with retry logic and error handling.
  153. Args:
  154. context: Optional execution context
  155. Returns:
  156. ActionResult: Result of action execution
  157. """
  158. if not self.enabled:
  159. result = ActionResult(
  160. status=ActionStatus.CANCELLED,
  161. start_time=time.time(),
  162. end_time=time.time(),
  163. error_message="Action is disabled"
  164. )
  165. return result
  166. context = context or {}
  167. start_time = time.time()
  168. with self._lock:
  169. self.execution_count += 1
  170. self.last_executed = start_time
  171. pprint(f"Executing action '{self.name}'")
  172. # Execute with retries
  173. last_exception = None
  174. for attempt in range(self.retry_count + 1):
  175. if attempt > 0:
  176. pprint(f"Retrying action '{self.name}' (attempt {attempt + 1}/{self.retry_count + 1})")
  177. time.sleep(self.retry_delay_seconds)
  178. result = self._execute_with_timeout(context, start_time)
  179. if result.status == ActionStatus.COMPLETED:
  180. with self._lock:
  181. self.success_count += 1
  182. self.last_result = result
  183. pprint(f"Action '{self.name}' completed successfully")
  184. return result
  185. last_exception = result.exception
  186. if result.status == ActionStatus.TIMEOUT:
  187. pprint(f"Action '{self.name}' timed out")
  188. break # Don't retry timeouts
  189. # All attempts failed
  190. with self._lock:
  191. self.failure_count += 1
  192. self.last_result = result
  193. pprint(f"Action '{self.name}' failed after {self.retry_count + 1} attempts")
  194. return result
  195. def _execute_with_timeout(self, context: Dict[str, Any], start_time: float) -> ActionResult:
  196. """
  197. Execute action with timeout handling.
  198. Args:
  199. context: Execution context
  200. start_time: Start time of execution
  201. Returns:
  202. ActionResult: Execution result
  203. """
  204. if self.timeout_seconds is None:
  205. # No timeout - execute directly
  206. try:
  207. result_data = self._execute_action(context)
  208. return ActionResult(
  209. status=ActionStatus.COMPLETED,
  210. start_time=start_time,
  211. end_time=time.time(),
  212. result_data=result_data
  213. )
  214. except Exception as e:
  215. return ActionResult(
  216. status=ActionStatus.FAILED,
  217. start_time=start_time,
  218. end_time=time.time(),
  219. error_message=str(e),
  220. exception=e
  221. )
  222. # Execute with timeout using ThreadPoolExecutor
  223. with ThreadPoolExecutor(max_workers=1) as executor:
  224. future = executor.submit(self._execute_action, context)
  225. try:
  226. result_data = future.result(timeout=self.timeout_seconds)
  227. return ActionResult(
  228. status=ActionStatus.COMPLETED,
  229. start_time=start_time,
  230. end_time=time.time(),
  231. result_data=result_data
  232. )
  233. except Exception as e:
  234. if isinstance(e, TimeoutError):
  235. future.cancel()
  236. return ActionResult(
  237. status=ActionStatus.TIMEOUT,
  238. start_time=start_time,
  239. end_time=time.time(),
  240. error_message=f"Action timed out after {self.timeout_seconds} seconds",
  241. exception=ActionTimeoutError(f"Timeout after {self.timeout_seconds} seconds")
  242. )
  243. else:
  244. return ActionResult(
  245. status=ActionStatus.FAILED,
  246. start_time=start_time,
  247. end_time=time.time(),
  248. error_message=str(e),
  249. exception=e
  250. )
  251. def enable(self) -> None:
  252. """Enable the action."""
  253. with self._lock:
  254. self.enabled = True
  255. pprint(f"Action '{self.name}' enabled")
  256. def disable(self) -> None:
  257. """Disable the action."""
  258. with self._lock:
  259. self.enabled = False
  260. pprint(f"Action '{self.name}' disabled")
  261. def reset_statistics(self) -> None:
  262. """Reset execution statistics."""
  263. with self._lock:
  264. self.execution_count = 0
  265. self.success_count = 0
  266. self.failure_count = 0
  267. self.last_executed = None
  268. self.last_result = None
  269. pprint(f"Action '{self.name}' statistics reset")
  270. def validate(self) -> List[str]:
  271. """
  272. Validate the action configuration.
  273. Returns:
  274. List[str]: List of validation errors (empty if valid)
  275. """
  276. errors = []
  277. if not self.name:
  278. errors.append("Action name is required")
  279. if self.timeout_seconds is not None and self.timeout_seconds <= 0:
  280. errors.append("Timeout seconds must be positive")
  281. if self.retry_count < 0:
  282. errors.append("Retry count cannot be negative")
  283. if self.retry_delay_seconds < 0:
  284. errors.append("Retry delay cannot be negative")
  285. return errors
  286. def get_info(self) -> Dict[str, Any]:
  287. """
  288. Get information about the action.
  289. Returns:
  290. Dict[str, Any]: Action information
  291. """
  292. with self._lock:
  293. success_rate = 0.0
  294. if self.execution_count > 0:
  295. success_rate = (self.success_count / self.execution_count) * 100
  296. return {
  297. 'name': self.name,
  298. 'type': self.__class__.__name__,
  299. 'enabled': self.enabled,
  300. 'is_critical': self.is_critical,
  301. 'timeout_seconds': self.timeout_seconds,
  302. 'retry_count': self.retry_count,
  303. 'execution_count': self.execution_count,
  304. 'success_count': self.success_count,
  305. 'failure_count': self.failure_count,
  306. 'success_rate_percent': round(success_rate, 2),
  307. 'last_executed': self.last_executed,
  308. 'last_status': self.last_result.status.value if self.last_result else None,
  309. 'parameters': self.parameters.copy()
  310. }
  311. def to_dict(self) -> Dict[str, Any]:
  312. """
  313. Convert action to dictionary for serialization.
  314. Returns:
  315. Dict[str, Any]: Dictionary representation
  316. """
  317. base_dict = {
  318. 'type': self.__class__.__name__,
  319. 'name': self.name,
  320. 'enabled': self.enabled,
  321. 'timeout_seconds': self.timeout_seconds,
  322. 'retry_count': self.retry_count,
  323. 'retry_delay_seconds': self.retry_delay_seconds,
  324. 'is_critical': self.is_critical,
  325. 'execution_count': self.execution_count,
  326. 'success_count': self.success_count,
  327. 'failure_count': self.failure_count,
  328. 'last_executed': self.last_executed
  329. }
  330. # Add action-specific data
  331. base_dict.update(self._get_specific_dict())
  332. return base_dict
  333. @abstractmethod
  334. def _get_specific_dict(self) -> Dict[str, Any]:
  335. """
  336. Get action-specific dictionary data.
  337. Returns:
  338. Dict[str, Any]: Action-specific data
  339. """
  340. pass
  341. @classmethod
  342. @abstractmethod
  343. def from_dict(cls, data: Dict[str, Any]) -> 'BaseAction':
  344. """
  345. Create action from dictionary data.
  346. Args:
  347. data: Dictionary data
  348. Returns:
  349. BaseAction: Created action instance
  350. """
  351. pass
  352. def __str__(self) -> str:
  353. """String representation of the action."""
  354. return f"{self.__class__.__name__}(name='{self.name}', enabled={self.enabled})"
  355. def __repr__(self) -> str:
  356. """Detailed representation of the action."""
  357. return (
  358. f"{self.__class__.__name__}(name='{self.name}', enabled={self.enabled}, "
  359. f"executions={self.execution_count}, success_rate={self.success_count}/{self.execution_count})"
  360. )
  361. class EventAction(BaseAction):
  362. """
  363. Action that triggers events through the Trixy event system.
  364. Integrates with the application's event handler to trigger
  365. events with specified data.
  366. """
  367. def __init__(
  368. self,
  369. event_name: str,
  370. event_data: Optional[Dict[str, Any]] = None,
  371. wait_for_completion: bool = False,
  372. completion_timeout: float = 30.0,
  373. **kwargs
  374. ):
  375. """
  376. Initialize event action.
  377. Args:
  378. event_name: Name of event to trigger
  379. event_data: Optional data to pass with event
  380. wait_for_completion: Whether to wait for event processing completion
  381. completion_timeout: Timeout for waiting for completion
  382. **kwargs: Additional parameters
  383. """
  384. super().__init__(**kwargs)
  385. self.event_name = event_name
  386. self.event_data = event_data or {}
  387. self.wait_for_completion = wait_for_completion
  388. self.completion_timeout = completion_timeout
  389. # Event system integration
  390. self._event_handler = None
  391. self._validate_parameters()
  392. def _validate_parameters(self) -> None:
  393. """Validate event action parameters."""
  394. if not self.event_name or not isinstance(self.event_name, str):
  395. raise ActionValidationError("Event name must be a non-empty string")
  396. if not isinstance(self.event_data, dict):
  397. raise ActionValidationError("Event data must be a dictionary")
  398. if self.completion_timeout <= 0:
  399. raise ActionValidationError("Completion timeout must be positive")
  400. def set_event_handler(self, event_handler) -> None:
  401. """Set the event handler for integration."""
  402. self._event_handler = event_handler
  403. pprint(f"EventAction '{self.name}' connected to event system")
  404. def _execute_action(self, context: Dict[str, Any]) -> Any:
  405. """Execute the event action."""
  406. if not self._event_handler:
  407. raise ActionExecutionError("Event handler not available")
  408. # Merge context data with event data
  409. combined_data = self.event_data.copy()
  410. combined_data.update(context)
  411. pprint(f"Triggering event '{self.event_name}' from action '{self.name}'")
  412. if self.wait_for_completion:
  413. # Trigger event and wait for completion
  414. event_id = self._event_handler.trigger_event(self.event_name, **combined_data)
  415. if event_id:
  416. # Wait for event completion
  417. history_entry = self._event_handler.wait_for_event(event_id, self.completion_timeout)
  418. if history_entry:
  419. if history_entry.errors:
  420. raise ActionExecutionError(f"Event processing failed: {'; '.join(history_entry.errors)}")
  421. return {
  422. 'event_id': event_id,
  423. 'event_name': self.event_name,
  424. 'execution_time_ms': history_entry.execution_time_ms,
  425. 'handlers_called': len(history_entry.handlers_called)
  426. }
  427. else:
  428. raise ActionExecutionError(f"Event processing timed out after {self.completion_timeout} seconds")
  429. else:
  430. raise ActionExecutionError("Failed to trigger event")
  431. else:
  432. # Fire and forget
  433. event_id = self._event_handler.trigger_event(self.event_name, **combined_data)
  434. return {
  435. 'event_id': event_id,
  436. 'event_name': self.event_name,
  437. 'wait_for_completion': False
  438. }
  439. def _get_specific_dict(self) -> Dict[str, Any]:
  440. """Get event action specific data."""
  441. return {
  442. 'event_name': self.event_name,
  443. 'event_data': self.event_data,
  444. 'wait_for_completion': self.wait_for_completion,
  445. 'completion_timeout': self.completion_timeout
  446. }
  447. @classmethod
  448. def from_dict(cls, data: Dict[str, Any]) -> 'EventAction':
  449. """Create EventAction from dictionary."""
  450. action = cls(
  451. event_name=data['event_name'],
  452. event_data=data.get('event_data', {}),
  453. wait_for_completion=data.get('wait_for_completion', False),
  454. completion_timeout=data.get('completion_timeout', 30.0),
  455. name=data.get('name'),
  456. enabled=data.get('enabled', True),
  457. timeout_seconds=data.get('timeout_seconds'),
  458. retry_count=data.get('retry_count', 0),
  459. retry_delay_seconds=data.get('retry_delay_seconds', 1.0),
  460. is_critical=data.get('is_critical', False)
  461. )
  462. # Restore state
  463. action.execution_count = data.get('execution_count', 0)
  464. action.success_count = data.get('success_count', 0)
  465. action.failure_count = data.get('failure_count', 0)
  466. action.last_executed = data.get('last_executed')
  467. return action
  468. class MLTrainingAction(BaseAction):
  469. """
  470. Action that starts ML model training processes.
  471. Integrates with the Trixy ML training system to start
  472. training for voice recognition, wakeword detection, etc.
  473. """
  474. def __init__(
  475. self,
  476. training_type: str,
  477. training_config: Optional[Dict[str, Any]] = None,
  478. wait_for_completion: bool = True,
  479. **kwargs
  480. ):
  481. """
  482. Initialize ML training action.
  483. Args:
  484. training_type: Type of training (e.g., 'voice_recognition', 'wakeword')
  485. training_config: Configuration for training process
  486. wait_for_completion: Whether to wait for training completion
  487. **kwargs: Additional parameters
  488. """
  489. super().__init__(**kwargs)
  490. self.training_type = training_type
  491. self.training_config = training_config or {}
  492. self.wait_for_completion = wait_for_completion
  493. # Training system integration
  494. self._training_manager = None
  495. self._validate_parameters()
  496. def _validate_parameters(self) -> None:
  497. """Validate ML training action parameters."""
  498. if not self.training_type or not isinstance(self.training_type, str):
  499. raise ActionValidationError("Training type must be a non-empty string")
  500. if not isinstance(self.training_config, dict):
  501. raise ActionValidationError("Training config must be a dictionary")
  502. valid_training_types = ['voice_recognition', 'wakeword', 'custom']
  503. if self.training_type not in valid_training_types:
  504. pprint(f"Warning: Unknown training type '{self.training_type}'. Valid types: {valid_training_types}")
  505. def set_training_manager(self, training_manager) -> None:
  506. """Set the training manager for integration."""
  507. self._training_manager = training_manager
  508. pprint(f"MLTrainingAction '{self.name}' connected to training system")
  509. def _execute_action(self, context: Dict[str, Any]) -> Any:
  510. """Execute the ML training action."""
  511. # Note: This is a placeholder implementation
  512. # In a real system, this would integrate with the actual ML training pipeline
  513. pprint(f"Starting ML training: {self.training_type}")
  514. # Merge context with training config
  515. combined_config = self.training_config.copy()
  516. combined_config.update(context)
  517. # Simulate training process
  518. if self.training_type == 'voice_recognition':
  519. return self._start_voice_recognition_training(combined_config)
  520. elif self.training_type == 'wakeword':
  521. return self._start_wakeword_training(combined_config)
  522. else:
  523. return self._start_custom_training(combined_config)
  524. def _start_voice_recognition_training(self, config: Dict[str, Any]) -> Dict[str, Any]:
  525. """Start voice recognition training."""
  526. pprint(f"Starting voice recognition training with config: {config}")
  527. # Placeholder implementation
  528. # In reality, this would:
  529. # 1. Load training data
  530. # 2. Initialize model
  531. # 3. Start training process
  532. # 4. Monitor progress
  533. # 5. Save trained model
  534. if self.wait_for_completion:
  535. # Simulate training time
  536. time.sleep(1) # Placeholder for actual training
  537. return {
  538. 'training_type': 'voice_recognition',
  539. 'status': 'completed',
  540. 'model_path': f'/models/voice_recognition/model_{int(time.time())}.pth',
  541. 'accuracy': 0.95,
  542. 'training_time_seconds': 1,
  543. 'epochs_completed': config.get('epochs', 100)
  544. }
  545. else:
  546. # Start training in background
  547. return {
  548. 'training_type': 'voice_recognition',
  549. 'status': 'started',
  550. 'training_id': f'vr_{int(time.time())}',
  551. 'wait_for_completion': False
  552. }
  553. def _start_wakeword_training(self, config: Dict[str, Any]) -> Dict[str, Any]:
  554. """Start wakeword training."""
  555. pprint(f"Starting wakeword training with config: {config}")
  556. # Placeholder implementation
  557. if self.wait_for_completion:
  558. time.sleep(0.5) # Placeholder for actual training
  559. return {
  560. 'training_type': 'wakeword',
  561. 'status': 'completed',
  562. 'model_path': f'/models/wakeword/model_{int(time.time())}.pth',
  563. 'accuracy': 0.92,
  564. 'training_time_seconds': 0.5,
  565. 'epochs_completed': config.get('epochs', 50)
  566. }
  567. else:
  568. return {
  569. 'training_type': 'wakeword',
  570. 'status': 'started',
  571. 'training_id': f'ww_{int(time.time())}',
  572. 'wait_for_completion': False
  573. }
  574. def _start_custom_training(self, config: Dict[str, Any]) -> Dict[str, Any]:
  575. """Start custom training."""
  576. pprint(f"Starting custom training with config: {config}")
  577. # Placeholder implementation
  578. return {
  579. 'training_type': 'custom',
  580. 'status': 'started',
  581. 'config': config,
  582. 'message': 'Custom training would be implemented based on specific requirements'
  583. }
  584. def _get_specific_dict(self) -> Dict[str, Any]:
  585. """Get ML training action specific data."""
  586. return {
  587. 'training_type': self.training_type,
  588. 'training_config': self.training_config,
  589. 'wait_for_completion': self.wait_for_completion
  590. }
  591. @classmethod
  592. def from_dict(cls, data: Dict[str, Any]) -> 'MLTrainingAction':
  593. """Create MLTrainingAction from dictionary."""
  594. action = cls(
  595. training_type=data['training_type'],
  596. training_config=data.get('training_config', {}),
  597. wait_for_completion=data.get('wait_for_completion', True),
  598. name=data.get('name'),
  599. enabled=data.get('enabled', True),
  600. timeout_seconds=data.get('timeout_seconds'),
  601. retry_count=data.get('retry_count', 0),
  602. retry_delay_seconds=data.get('retry_delay_seconds', 1.0),
  603. is_critical=data.get('is_critical', False)
  604. )
  605. # Restore state
  606. action.execution_count = data.get('execution_count', 0)
  607. action.success_count = data.get('success_count', 0)
  608. action.failure_count = data.get('failure_count', 0)
  609. action.last_executed = data.get('last_executed')
  610. return action
  611. class FunctionAction(BaseAction):
  612. """
  613. Action that executes custom functions or methods.
  614. Supports calling Python functions, methods, or importable callables
  615. with specified parameters.
  616. """
  617. def __init__(
  618. self,
  619. function: Union[Callable, str],
  620. function_args: Optional[List[Any]] = None,
  621. function_kwargs: Optional[Dict[str, Any]] = None,
  622. import_path: Optional[str] = None,
  623. **kwargs
  624. ):
  625. """
  626. Initialize function action.
  627. Args:
  628. function: Function to call or string name for import
  629. function_args: Positional arguments for function
  630. function_kwargs: Keyword arguments for function
  631. import_path: Import path for function (e.g., 'module.submodule')
  632. **kwargs: Additional parameters
  633. """
  634. super().__init__(**kwargs)
  635. self.function_args = function_args or []
  636. self.function_kwargs = function_kwargs or {}
  637. self.import_path = import_path
  638. # Store function reference or name
  639. if callable(function):
  640. self.function = function
  641. self.function_name = getattr(function, '__name__', str(function))
  642. elif isinstance(function, str):
  643. self.function = None
  644. self.function_name = function
  645. else:
  646. raise ActionValidationError("Function must be callable or string name")
  647. self._validate_parameters()
  648. def _validate_parameters(self) -> None:
  649. """Validate function action parameters."""
  650. if not isinstance(self.function_args, list):
  651. raise ActionValidationError("Function args must be a list")
  652. if not isinstance(self.function_kwargs, dict):
  653. raise ActionValidationError("Function kwargs must be a dictionary")
  654. if self.function is None and not self.import_path:
  655. raise ActionValidationError("Import path required when function is specified as string")
  656. def _resolve_function(self) -> Callable:
  657. """Resolve function reference for execution."""
  658. if self.function is not None:
  659. return self.function
  660. # Import the function
  661. try:
  662. if '.' in self.import_path:
  663. module_path, function_name = self.import_path.rsplit('.', 1)
  664. module = importlib.import_module(module_path)
  665. function = getattr(module, function_name)
  666. else:
  667. # Function in current module
  668. module = importlib.import_module(self.import_path)
  669. function = getattr(module, self.function_name)
  670. if not callable(function):
  671. raise ActionExecutionError(f"'{self.function_name}' is not callable")
  672. return function
  673. except ImportError as e:
  674. raise ActionExecutionError(f"Failed to import function: {e}")
  675. except AttributeError as e:
  676. raise ActionExecutionError(f"Function not found: {e}")
  677. def _execute_action(self, context: Dict[str, Any]) -> Any:
  678. """Execute the function action."""
  679. function = self._resolve_function()
  680. # Merge context with function kwargs
  681. combined_kwargs = self.function_kwargs.copy()
  682. combined_kwargs.update(context)
  683. pprint(f"Executing function '{self.function_name}' from action '{self.name}'")
  684. try:
  685. # Call the function
  686. result = function(*self.function_args, **combined_kwargs)
  687. return {
  688. 'function_name': self.function_name,
  689. 'result': result,
  690. 'args_count': len(self.function_args),
  691. 'kwargs_count': len(combined_kwargs)
  692. }
  693. except Exception as e:
  694. raise ActionExecutionError(f"Function execution failed: {e}")
  695. def _get_specific_dict(self) -> Dict[str, Any]:
  696. """Get function action specific data."""
  697. return {
  698. 'function_name': self.function_name,
  699. 'function_args': self.function_args,
  700. 'function_kwargs': self.function_kwargs,
  701. 'import_path': self.import_path
  702. }
  703. @classmethod
  704. def from_dict(cls, data: Dict[str, Any]) -> 'FunctionAction':
  705. """Create FunctionAction from dictionary."""
  706. action = cls(
  707. function=data['function_name'],
  708. function_args=data.get('function_args', []),
  709. function_kwargs=data.get('function_kwargs', {}),
  710. import_path=data.get('import_path'),
  711. name=data.get('name'),
  712. enabled=data.get('enabled', True),
  713. timeout_seconds=data.get('timeout_seconds'),
  714. retry_count=data.get('retry_count', 0),
  715. retry_delay_seconds=data.get('retry_delay_seconds', 1.0),
  716. is_critical=data.get('is_critical', False)
  717. )
  718. # Restore state
  719. action.execution_count = data.get('execution_count', 0)
  720. action.success_count = data.get('success_count', 0)
  721. action.failure_count = data.get('failure_count', 0)
  722. action.last_executed = data.get('last_executed')
  723. return action
  724. class MultiAction(BaseAction):
  725. """
  726. Action that executes multiple actions in sequence or parallel.
  727. Supports executing a list of actions either sequentially or in parallel,
  728. with configurable failure handling.
  729. """
  730. def __init__(
  731. self,
  732. actions: List[BaseAction],
  733. parallel: bool = False,
  734. stop_on_first_failure: bool = True,
  735. max_parallel_workers: int = 5,
  736. **kwargs
  737. ):
  738. """
  739. Initialize multi action.
  740. Args:
  741. actions: List of actions to execute
  742. parallel: Whether to execute actions in parallel
  743. stop_on_first_failure: Whether to stop on first action failure
  744. max_parallel_workers: Maximum parallel workers for parallel execution
  745. **kwargs: Additional parameters
  746. """
  747. super().__init__(**kwargs)
  748. self.actions = actions
  749. self.parallel = parallel
  750. self.stop_on_first_failure = stop_on_first_failure
  751. self.max_parallel_workers = max_parallel_workers
  752. self._validate_parameters()
  753. def _validate_parameters(self) -> None:
  754. """Validate multi action parameters."""
  755. if not self.actions:
  756. raise ActionValidationError("At least one action must be specified")
  757. for i, action in enumerate(self.actions):
  758. if not isinstance(action, BaseAction):
  759. raise ActionValidationError(f"Action {i} is not a BaseAction instance")
  760. if self.max_parallel_workers <= 0:
  761. raise ActionValidationError("Max parallel workers must be positive")
  762. def _execute_action(self, context: Dict[str, Any]) -> Any:
  763. """Execute the multi action."""
  764. if self.parallel:
  765. return self._execute_parallel(context)
  766. else:
  767. return self._execute_sequential(context)
  768. def _execute_sequential(self, context: Dict[str, Any]) -> Dict[str, Any]:
  769. """Execute actions sequentially."""
  770. results = []
  771. successful_count = 0
  772. failed_count = 0
  773. for i, action in enumerate(self.actions):
  774. pprint(f"Executing action {i+1}/{len(self.actions)}: {action.name}")
  775. try:
  776. result = action.execute(context)
  777. results.append({
  778. 'action_name': action.name,
  779. 'action_index': i,
  780. 'result': result.to_dict()
  781. })
  782. if result.status == ActionStatus.COMPLETED:
  783. successful_count += 1
  784. else:
  785. failed_count += 1
  786. if self.stop_on_first_failure:
  787. pprint(f"Stopping multi-action due to failure in action '{action.name}'")
  788. break
  789. except Exception as e:
  790. failed_count += 1
  791. results.append({
  792. 'action_name': action.name,
  793. 'action_index': i,
  794. 'error': str(e)
  795. })
  796. if self.stop_on_first_failure:
  797. pprint(f"Stopping multi-action due to exception in action '{action.name}': {e}")
  798. break
  799. return {
  800. 'execution_type': 'sequential',
  801. 'total_actions': len(self.actions),
  802. 'executed_actions': len(results),
  803. 'successful_count': successful_count,
  804. 'failed_count': failed_count,
  805. 'results': results
  806. }
  807. def _execute_parallel(self, context: Dict[str, Any]) -> Dict[str, Any]:
  808. """Execute actions in parallel."""
  809. results = []
  810. successful_count = 0
  811. failed_count = 0
  812. with ThreadPoolExecutor(max_workers=self.max_parallel_workers) as executor:
  813. # Submit all actions
  814. future_to_action = {
  815. executor.submit(action.execute, context): (i, action)
  816. for i, action in enumerate(self.actions)
  817. }
  818. # Collect results
  819. for future in as_completed(future_to_action):
  820. action_index, action = future_to_action[future]
  821. try:
  822. result = future.result()
  823. results.append({
  824. 'action_name': action.name,
  825. 'action_index': action_index,
  826. 'result': result.to_dict()
  827. })
  828. if result.status == ActionStatus.COMPLETED:
  829. successful_count += 1
  830. else:
  831. failed_count += 1
  832. except Exception as e:
  833. failed_count += 1
  834. results.append({
  835. 'action_name': action.name,
  836. 'action_index': action_index,
  837. 'error': str(e)
  838. })
  839. # Sort results by action index to maintain order
  840. results.sort(key=lambda x: x['action_index'])
  841. return {
  842. 'execution_type': 'parallel',
  843. 'total_actions': len(self.actions),
  844. 'executed_actions': len(results),
  845. 'successful_count': successful_count,
  846. 'failed_count': failed_count,
  847. 'max_workers': self.max_parallel_workers,
  848. 'results': results
  849. }
  850. def _get_specific_dict(self) -> Dict[str, Any]:
  851. """Get multi action specific data."""
  852. return {
  853. 'actions': [action.to_dict() for action in self.actions],
  854. 'parallel': self.parallel,
  855. 'stop_on_first_failure': self.stop_on_first_failure,
  856. 'max_parallel_workers': self.max_parallel_workers
  857. }
  858. @classmethod
  859. def from_dict(cls, data: Dict[str, Any]) -> 'MultiAction':
  860. """Create MultiAction from dictionary."""
  861. # Recreate actions from their dictionaries
  862. actions = []
  863. for action_data in data.get('actions', []):
  864. action = ActionFactory.from_dict(action_data)
  865. actions.append(action)
  866. action = cls(
  867. actions=actions,
  868. parallel=data.get('parallel', False),
  869. stop_on_first_failure=data.get('stop_on_first_failure', True),
  870. max_parallel_workers=data.get('max_parallel_workers', 5),
  871. name=data.get('name'),
  872. enabled=data.get('enabled', True),
  873. timeout_seconds=data.get('timeout_seconds'),
  874. retry_count=data.get('retry_count', 0),
  875. retry_delay_seconds=data.get('retry_delay_seconds', 1.0),
  876. is_critical=data.get('is_critical', False)
  877. )
  878. # Restore state
  879. action.execution_count = data.get('execution_count', 0)
  880. action.success_count = data.get('success_count', 0)
  881. action.failure_count = data.get('failure_count', 0)
  882. action.last_executed = data.get('last_executed')
  883. return action
  884. class ConditionalAction(BaseAction):
  885. """
  886. Action that executes other actions based on conditions.
  887. Supports conditional execution based on context data,
  888. with support for multiple condition types.
  889. """
  890. def __init__(
  891. self,
  892. condition: Union[str, Callable],
  893. true_action: BaseAction,
  894. false_action: Optional[BaseAction] = None,
  895. condition_params: Optional[Dict[str, Any]] = None,
  896. **kwargs
  897. ):
  898. """
  899. Initialize conditional action.
  900. Args:
  901. condition: Condition to evaluate (string or callable)
  902. true_action: Action to execute if condition is true
  903. false_action: Action to execute if condition is false (optional)
  904. condition_params: Parameters for condition evaluation
  905. **kwargs: Additional parameters
  906. """
  907. super().__init__(**kwargs)
  908. self.condition = condition
  909. self.true_action = true_action
  910. self.false_action = false_action
  911. self.condition_params = condition_params or {}
  912. self._validate_parameters()
  913. def _validate_parameters(self) -> None:
  914. """Validate conditional action parameters."""
  915. if not isinstance(self.true_action, BaseAction):
  916. raise ActionValidationError("True action must be a BaseAction instance")
  917. if self.false_action is not None and not isinstance(self.false_action, BaseAction):
  918. raise ActionValidationError("False action must be a BaseAction instance")
  919. if not (isinstance(self.condition, str) or callable(self.condition)):
  920. raise ActionValidationError("Condition must be a string or callable")
  921. def _evaluate_condition(self, context: Dict[str, Any]) -> bool:
  922. """
  923. Evaluate the condition.
  924. Args:
  925. context: Execution context
  926. Returns:
  927. bool: True if condition is met
  928. """
  929. if callable(self.condition):
  930. # Call the condition function
  931. try:
  932. return bool(self.condition(context, **self.condition_params))
  933. except Exception as e:
  934. pprint(f"Error evaluating condition function: {e}")
  935. return False
  936. elif isinstance(self.condition, str):
  937. # Simple string-based conditions
  938. return self._evaluate_string_condition(context)
  939. return False
  940. def _evaluate_string_condition(self, context: Dict[str, Any]) -> bool:
  941. """
  942. Evaluate string-based condition.
  943. Args:
  944. context: Execution context
  945. Returns:
  946. bool: True if condition is met
  947. """
  948. condition = self.condition.strip().lower()
  949. # Simple key existence check
  950. if condition.startswith('has_'):
  951. key = condition[4:] # Remove 'has_' prefix
  952. return key in context
  953. # Simple value equality check
  954. if '=' in condition:
  955. key, value = condition.split('=', 1)
  956. key = key.strip()
  957. value = value.strip()
  958. # Try to convert value to appropriate type
  959. if value.lower() in ['true', 'false']:
  960. value = value.lower() == 'true'
  961. elif value.isdigit():
  962. value = int(value)
  963. elif value.replace('.', '', 1).isdigit():
  964. value = float(value)
  965. return context.get(key) == value
  966. # Default: check if condition string is a key with truthy value
  967. return bool(context.get(condition))
  968. def _execute_action(self, context: Dict[str, Any]) -> Any:
  969. """Execute the conditional action."""
  970. condition_result = self._evaluate_condition(context)
  971. pprint(f"Condition '{self.condition}' evaluated to: {condition_result}")
  972. if condition_result:
  973. if self.true_action:
  974. pprint(f"Executing true action: {self.true_action.name}")
  975. result = self.true_action.execute(context)
  976. return {
  977. 'condition_result': True,
  978. 'executed_action': 'true',
  979. 'action_name': self.true_action.name,
  980. 'action_result': result.to_dict()
  981. }
  982. else:
  983. if self.false_action:
  984. pprint(f"Executing false action: {self.false_action.name}")
  985. result = self.false_action.execute(context)
  986. return {
  987. 'condition_result': False,
  988. 'executed_action': 'false',
  989. 'action_name': self.false_action.name,
  990. 'action_result': result.to_dict()
  991. }
  992. return {
  993. 'condition_result': condition_result,
  994. 'executed_action': None,
  995. 'message': 'No action executed based on condition result'
  996. }
  997. def _get_specific_dict(self) -> Dict[str, Any]:
  998. """Get conditional action specific data."""
  999. return {
  1000. 'condition': self.condition if isinstance(self.condition, str) else 'callable',
  1001. 'condition_params': self.condition_params,
  1002. 'true_action': self.true_action.to_dict(),
  1003. 'false_action': self.false_action.to_dict() if self.false_action else None
  1004. }
  1005. @classmethod
  1006. def from_dict(cls, data: Dict[str, Any]) -> 'ConditionalAction':
  1007. """Create ConditionalAction from dictionary."""
  1008. # Recreate actions from their dictionaries
  1009. true_action = ActionFactory.from_dict(data['true_action'])
  1010. false_action = None
  1011. if data.get('false_action'):
  1012. false_action = ActionFactory.from_dict(data['false_action'])
  1013. action = cls(
  1014. condition=data['condition'],
  1015. true_action=true_action,
  1016. false_action=false_action,
  1017. condition_params=data.get('condition_params', {}),
  1018. name=data.get('name'),
  1019. enabled=data.get('enabled', True),
  1020. timeout_seconds=data.get('timeout_seconds'),
  1021. retry_count=data.get('retry_count', 0),
  1022. retry_delay_seconds=data.get('retry_delay_seconds', 1.0),
  1023. is_critical=data.get('is_critical', False)
  1024. )
  1025. # Restore state
  1026. action.execution_count = data.get('execution_count', 0)
  1027. action.success_count = data.get('success_count', 0)
  1028. action.failure_count = data.get('failure_count', 0)
  1029. action.last_executed = data.get('last_executed')
  1030. return action
  1031. # Action factory for dynamic creation
  1032. class ActionFactory:
  1033. """Factory class for creating actions from configuration."""
  1034. _action_classes = {
  1035. 'EventAction': EventAction,
  1036. 'MLTrainingAction': MLTrainingAction,
  1037. 'FunctionAction': FunctionAction,
  1038. 'MultiAction': MultiAction,
  1039. 'ConditionalAction': ConditionalAction
  1040. }
  1041. @classmethod
  1042. def create_action(cls, action_type: str, **kwargs) -> BaseAction:
  1043. """
  1044. Create an action instance.
  1045. Args:
  1046. action_type: Type of action to create
  1047. **kwargs: Action-specific parameters
  1048. Returns:
  1049. BaseAction: Created action instance
  1050. Raises:
  1051. ActionValidationError: If action type is unknown or parameters are invalid
  1052. """
  1053. if action_type not in cls._action_classes:
  1054. raise ActionValidationError(f"Unknown action type: {action_type}")
  1055. action_class = cls._action_classes[action_type]
  1056. return action_class(**kwargs)
  1057. @classmethod
  1058. def from_dict(cls, data: Dict[str, Any]) -> BaseAction:
  1059. """
  1060. Create action from dictionary data.
  1061. Args:
  1062. data: Dictionary containing action configuration
  1063. Returns:
  1064. BaseAction: Created action instance
  1065. """
  1066. action_type = data.get('type')
  1067. if not action_type:
  1068. raise ActionValidationError("Action type not specified in data")
  1069. if action_type not in cls._action_classes:
  1070. raise ActionValidationError(f"Unknown action type: {action_type}")
  1071. action_class = cls._action_classes[action_type]
  1072. return action_class.from_dict(data)
  1073. @classmethod
  1074. def get_supported_types(cls) -> List[str]:
  1075. """Get list of supported action types."""
  1076. return list(cls._action_classes.keys())
  1077. # Convenience functions
  1078. def create_action_from_dict(data: Dict[str, Any]) -> BaseAction:
  1079. """
  1080. Create action from dictionary data.
  1081. Args:
  1082. data: Dictionary containing action configuration
  1083. Returns:
  1084. BaseAction: Created action instance
  1085. """
  1086. return ActionFactory.from_dict(data)
  1087. def get_supported_action_types() -> List[str]:
  1088. """Get list of supported action types."""
  1089. return ActionFactory.get_supported_types()
  1090. def validate_action_config(config: Dict[str, Any]) -> List[str]:
  1091. """
  1092. Validate action configuration.
  1093. Args:
  1094. config: Action configuration dictionary
  1095. Returns:
  1096. List[str]: List of validation errors (empty if valid)
  1097. """
  1098. try:
  1099. action = create_action_from_dict(config)
  1100. return action.validate()
  1101. except Exception as e:
  1102. return [str(e)]
  1103. # Module exports
  1104. __all__ = [
  1105. 'BaseAction',
  1106. 'ActionError',
  1107. 'ActionValidationError',
  1108. 'ActionExecutionError',
  1109. 'ActionTimeoutError',
  1110. 'ActionStatus',
  1111. 'ActionResult',
  1112. 'EventAction',
  1113. 'MLTrainingAction',
  1114. 'FunctionAction',
  1115. 'MultiAction',
  1116. 'ConditionalAction',
  1117. 'ActionFactory',
  1118. 'create_action_from_dict',
  1119. 'get_supported_action_types',
  1120. 'validate_action_config',
  1121. 'pprint'
  1122. ]