quantalogic 0.61.3__py3-none-any.whl → 0.92__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 (35) hide show
  1. quantalogic/agent.py +0 -1
  2. quantalogic/flow/__init__.py +16 -34
  3. quantalogic/main.py +11 -6
  4. quantalogic/tools/action_gen.py +1 -1
  5. quantalogic/tools/tool.py +8 -500
  6. quantalogic-0.92.dist-info/METADATA +448 -0
  7. {quantalogic-0.61.3.dist-info → quantalogic-0.92.dist-info}/RECORD +10 -33
  8. {quantalogic-0.61.3.dist-info → quantalogic-0.92.dist-info}/WHEEL +1 -1
  9. quantalogic-0.92.dist-info/entry_points.txt +3 -0
  10. quantalogic/codeact/__init__.py +0 -0
  11. quantalogic/codeact/agent.py +0 -499
  12. quantalogic/codeact/cli.py +0 -232
  13. quantalogic/codeact/constants.py +0 -9
  14. quantalogic/codeact/events.py +0 -78
  15. quantalogic/codeact/llm_util.py +0 -76
  16. quantalogic/codeact/prompts/error_format.j2 +0 -11
  17. quantalogic/codeact/prompts/generate_action.j2 +0 -26
  18. quantalogic/codeact/prompts/generate_program.j2 +0 -39
  19. quantalogic/codeact/prompts/response_format.j2 +0 -11
  20. quantalogic/codeact/tools_manager.py +0 -135
  21. quantalogic/codeact/utils.py +0 -135
  22. quantalogic/flow/flow.py +0 -960
  23. quantalogic/flow/flow_extractor.py +0 -723
  24. quantalogic/flow/flow_generator.py +0 -294
  25. quantalogic/flow/flow_manager.py +0 -637
  26. quantalogic/flow/flow_manager_schema.py +0 -255
  27. quantalogic/flow/flow_mermaid.py +0 -365
  28. quantalogic/flow/flow_validator.py +0 -479
  29. quantalogic/flow/flow_yaml.linkedin.md +0 -31
  30. quantalogic/flow/flow_yaml.md +0 -767
  31. quantalogic/flow/templates/prompt_check_inventory.j2 +0 -1
  32. quantalogic/flow/templates/system_check_inventory.j2 +0 -1
  33. quantalogic-0.61.3.dist-info/METADATA +0 -900
  34. quantalogic-0.61.3.dist-info/entry_points.txt +0 -6
  35. {quantalogic-0.61.3.dist-info → quantalogic-0.92.dist-info}/LICENSE +0 -0
quantalogic/flow/flow.py DELETED
@@ -1,960 +0,0 @@
1
- #!/usr/bin/env -S uv run
2
- # /// script
3
- # requires-python = ">=3.12"
4
- # dependencies = [
5
- # "loguru>=0.7.2", # Logging utility
6
- # "litellm>=1.0.0", # LLM integration
7
- # "pydantic>=2.0.0", # Data validation and settings
8
- # "anyio>=4.0.0", # Async utilities
9
- # "jinja2>=3.1.0", # Templating engine
10
- # "instructor" # Structured LLM output with litellm integration
11
- # ]
12
- # ///
13
-
14
- import asyncio
15
- import inspect
16
- import os
17
- from dataclasses import dataclass
18
- from enum import Enum
19
- from pathlib import Path
20
- from typing import Any, Callable, Dict, List, Optional, Tuple, Type
21
-
22
- import instructor
23
- from jinja2 import Environment, FileSystemLoader, Template, TemplateNotFound
24
- from litellm import acompletion
25
- from loguru import logger
26
- from pydantic import BaseModel, ValidationError
27
-
28
-
29
- # Define event types and structure for observer system
30
- class WorkflowEventType(Enum):
31
- NODE_STARTED = "node_started"
32
- NODE_COMPLETED = "node_completed"
33
- NODE_FAILED = "node_failed"
34
- TRANSITION_EVALUATED = "transition_evaluated"
35
- WORKFLOW_STARTED = "workflow_started"
36
- WORKFLOW_COMPLETED = "workflow_completed"
37
- SUB_WORKFLOW_ENTERED = "sub_workflow_entered"
38
- SUB_WORKFLOW_EXITED = "sub_workflow_exited"
39
-
40
-
41
- @dataclass
42
- class WorkflowEvent:
43
- event_type: WorkflowEventType
44
- node_name: Optional[str]
45
- context: Dict[str, Any]
46
- result: Optional[Any] = None
47
- exception: Optional[Exception] = None
48
- transition_from: Optional[str] = None
49
- transition_to: Optional[str] = None
50
- sub_workflow_name: Optional[str] = None
51
- usage: Optional[Dict[str, Any]] = None
52
-
53
-
54
- WorkflowObserver = Callable[[WorkflowEvent], None]
55
-
56
-
57
- class SubWorkflowNode:
58
- def __init__(self, sub_workflow: "Workflow", inputs: Dict[str, Any], output: str):
59
- """Initialize a sub-workflow node with flexible inputs mapping."""
60
- self.sub_workflow = sub_workflow
61
- self.inputs = inputs
62
- self.output = output
63
-
64
- async def __call__(self, engine: "WorkflowEngine"):
65
- """Execute the sub-workflow with the engine's context using inputs mapping."""
66
- sub_context = {}
67
- for sub_key, mapping in self.inputs.items():
68
- if callable(mapping):
69
- sub_context[sub_key] = mapping(engine.context)
70
- elif isinstance(mapping, str):
71
- sub_context[sub_key] = engine.context.get(mapping)
72
- else:
73
- sub_context[sub_key] = mapping
74
- sub_engine = self.sub_workflow.build(parent_engine=engine)
75
- result = await sub_engine.run(sub_context)
76
- return result.get(self.output)
77
-
78
-
79
- class WorkflowEngine:
80
- def __init__(self, workflow, parent_engine: Optional["WorkflowEngine"] = None):
81
- """Initialize the WorkflowEngine with a workflow and optional parent for sub-workflows."""
82
- self.workflow = workflow
83
- self.context: Dict[str, Any] = {}
84
- self.observers: List[WorkflowObserver] = []
85
- self.parent_engine = parent_engine
86
-
87
- def add_observer(self, observer: WorkflowObserver) -> None:
88
- """Register an event observer callback."""
89
- if observer not in self.observers:
90
- self.observers.append(observer)
91
- logger.debug(f"Added observer: {observer}")
92
- if self.parent_engine:
93
- self.parent_engine.add_observer(observer)
94
-
95
- def remove_observer(self, observer: WorkflowObserver) -> None:
96
- """Remove an event observer callback."""
97
- if observer in self.observers:
98
- self.observers.remove(observer)
99
- logger.debug(f"Removed observer: {observer}")
100
-
101
- async def _notify_observers(self, event: WorkflowEvent) -> None:
102
- """Asynchronously notify all observers of an event."""
103
- tasks = []
104
- for observer in self.observers:
105
- try:
106
- if asyncio.iscoroutinefunction(observer):
107
- tasks.append(observer(event))
108
- else:
109
- observer(event)
110
- except Exception as e:
111
- logger.error(f"Observer {observer} failed for {event.event_type.value}: {e}")
112
- if tasks:
113
- await asyncio.gather(*tasks)
114
-
115
- async def run(self, initial_context: Dict[str, Any]) -> Dict[str, Any]:
116
- """Execute the workflow starting from the entry node with event notifications."""
117
- self.context = initial_context.copy()
118
- await self._notify_observers(
119
- WorkflowEvent(event_type=WorkflowEventType.WORKFLOW_STARTED, node_name=None, context=self.context)
120
- )
121
-
122
- current_node = self.workflow.start_node
123
- while current_node:
124
- logger.info(f"Executing node: {current_node}")
125
- await self._notify_observers(
126
- WorkflowEvent(event_type=WorkflowEventType.NODE_STARTED, node_name=current_node, context=self.context)
127
- )
128
-
129
- node_func = self.workflow.nodes.get(current_node)
130
- if not node_func:
131
- logger.error(f"Node {current_node} not found")
132
- exc = ValueError(f"Node {current_node} not found")
133
- await self._notify_observers(
134
- WorkflowEvent(
135
- event_type=WorkflowEventType.NODE_FAILED,
136
- node_name=current_node,
137
- context=self.context,
138
- exception=exc,
139
- )
140
- )
141
- break
142
-
143
- input_mappings = self.workflow.node_input_mappings.get(current_node, {})
144
- inputs = {}
145
- for key, mapping in input_mappings.items():
146
- if callable(mapping):
147
- inputs[key] = mapping(self.context)
148
- elif isinstance(mapping, str):
149
- inputs[key] = self.context.get(mapping)
150
- else:
151
- inputs[key] = mapping
152
- for param in self.workflow.node_inputs[current_node]:
153
- if param not in inputs:
154
- inputs[param] = self.context.get(param)
155
-
156
- result = None
157
- exception = None
158
-
159
- if isinstance(node_func, SubWorkflowNode):
160
- await self._notify_observers(
161
- WorkflowEvent(
162
- event_type=WorkflowEventType.SUB_WORKFLOW_ENTERED,
163
- node_name=current_node,
164
- context=self.context,
165
- sub_workflow_name=current_node,
166
- )
167
- )
168
-
169
- try:
170
- if isinstance(node_func, SubWorkflowNode):
171
- result = await node_func(self)
172
- usage = None
173
- else:
174
- result = await node_func(**inputs)
175
- usage = getattr(node_func, "usage", None)
176
- output_key = self.workflow.node_outputs[current_node]
177
- if output_key:
178
- self.context[output_key] = result
179
- elif isinstance(result, dict):
180
- self.context.update(result)
181
- logger.debug(f"Updated context with {result} from node {current_node}")
182
- await self._notify_observers(
183
- WorkflowEvent(
184
- event_type=WorkflowEventType.NODE_COMPLETED,
185
- node_name=current_node,
186
- context=self.context,
187
- result=result,
188
- usage=usage,
189
- )
190
- )
191
- except Exception as e:
192
- logger.error(f"Error executing node {current_node}: {e}")
193
- exception = e
194
- await self._notify_observers(
195
- WorkflowEvent(
196
- event_type=WorkflowEventType.NODE_FAILED,
197
- node_name=current_node,
198
- context=self.context,
199
- exception=e,
200
- )
201
- )
202
- raise
203
- finally:
204
- if isinstance(node_func, SubWorkflowNode):
205
- await self._notify_observers(
206
- WorkflowEvent(
207
- event_type=WorkflowEventType.SUB_WORKFLOW_EXITED,
208
- node_name=current_node,
209
- context=self.context,
210
- sub_workflow_name=current_node,
211
- result=result,
212
- exception=exception,
213
- )
214
- )
215
-
216
- next_nodes = self.workflow.transitions.get(current_node, [])
217
- current_node = None
218
- for next_node, condition in next_nodes:
219
- await self._notify_observers(
220
- WorkflowEvent(
221
- event_type=WorkflowEventType.TRANSITION_EVALUATED,
222
- node_name=None,
223
- context=self.context,
224
- transition_from=current_node,
225
- transition_to=next_node,
226
- )
227
- )
228
- if condition is None or condition(self.context):
229
- current_node = next_node
230
- break
231
-
232
- logger.info("Workflow execution completed")
233
- await self._notify_observers(
234
- WorkflowEvent(event_type=WorkflowEventType.WORKFLOW_COMPLETED, node_name=None, context=self.context)
235
- )
236
- return self.context
237
-
238
-
239
- class Workflow:
240
- def __init__(self, start_node: str):
241
- """Initialize a workflow with a starting node.
242
-
243
- Args:
244
- start_node: The name of the initial node in the workflow.
245
- """
246
- self.start_node = start_node
247
- self.nodes: Dict[str, Callable] = {}
248
- self.node_inputs: Dict[str, List[str]] = {}
249
- self.node_outputs: Dict[str, Optional[str]] = {}
250
- self.transitions: Dict[str, List[Tuple[str, Optional[Callable]]]] = {}
251
- self.node_input_mappings: Dict[str, Dict[str, Any]] = {}
252
- self.current_node = None
253
- self._observers: List[WorkflowObserver] = []
254
- self._register_node(start_node)
255
- self.current_node = start_node
256
-
257
- def _register_node(self, name: str):
258
- """Register a node without modifying the current node."""
259
- if name not in Nodes.NODE_REGISTRY:
260
- raise ValueError(f"Node {name} not registered")
261
- func, inputs, output = Nodes.NODE_REGISTRY[name]
262
- self.nodes[name] = func
263
- self.node_inputs[name] = inputs
264
- self.node_outputs[name] = output
265
-
266
- def node(self, name: str, inputs_mapping: Optional[Dict[str, Any]] = None):
267
- """Add a node to the workflow chain with an optional inputs mapping.
268
-
269
- Args:
270
- name: The name of the node to add.
271
- inputs_mapping: Optional dictionary mapping node inputs to context keys or callables.
272
-
273
- Returns:
274
- Self for method chaining.
275
- """
276
- self._register_node(name)
277
- if inputs_mapping:
278
- self.node_input_mappings[name] = inputs_mapping
279
- logger.debug(f"Added inputs mapping for node {name}: {inputs_mapping}")
280
- self.current_node = name
281
- return self
282
-
283
- def sequence(self, *nodes: str):
284
- """Add a sequence of nodes to execute in order.
285
-
286
- Args:
287
- *nodes: Variable number of node names to execute sequentially.
288
-
289
- Returns:
290
- Self for method chaining.
291
- """
292
- if not nodes:
293
- return self
294
- for node in nodes:
295
- if node not in Nodes.NODE_REGISTRY:
296
- raise ValueError(f"Node {node} not registered")
297
- func, inputs, output = Nodes.NODE_REGISTRY[node]
298
- self.nodes[node] = func
299
- self.node_inputs[node] = inputs
300
- self.node_outputs[node] = output
301
- for i in range(len(nodes) - 1):
302
- self.transitions.setdefault(nodes[i], []).append((nodes[i + 1], None))
303
- self.current_node = nodes[-1]
304
- return self
305
-
306
- def then(self, next_node: str, condition: Optional[Callable] = None):
307
- """Add a transition to the next node with an optional condition.
308
-
309
- Args:
310
- next_node: Name of the node to transition to.
311
- condition: Optional callable taking context and returning a boolean.
312
-
313
- Returns:
314
- Self for method chaining.
315
- """
316
- if next_node not in self.nodes:
317
- self._register_node(next_node)
318
- if self.current_node:
319
- self.transitions.setdefault(self.current_node, []).append((next_node, condition))
320
- logger.debug(f"Added transition from {self.current_node} to {next_node} with condition {condition}")
321
- else:
322
- logger.warning("No current node set for transition")
323
- self.current_node = next_node
324
- return self
325
-
326
- def branch(
327
- self,
328
- branches: List[Tuple[str, Optional[Callable]]],
329
- default: Optional[str] = None,
330
- next_node: Optional[str] = None,
331
- ) -> "Workflow":
332
- """Add multiple conditional branches from the current node with an optional default and next node.
333
-
334
- Args:
335
- branches: List of tuples (next_node, condition), where condition takes context and returns a boolean.
336
- default: Optional node to transition to if no branch conditions are met.
337
- next_node: Optional node to set as current_node after branching (e.g., for convergence).
338
-
339
- Returns:
340
- Self for method chaining.
341
- """
342
- if not self.current_node:
343
- logger.warning("No current node set for branching")
344
- return self
345
- for next_node_name, condition in branches:
346
- if next_node_name not in self.nodes:
347
- self._register_node(next_node_name)
348
- self.transitions.setdefault(self.current_node, []).append((next_node_name, condition))
349
- logger.debug(f"Added branch from {self.current_node} to {next_node_name} with condition {condition}")
350
- if default:
351
- if default not in self.nodes:
352
- self._register_node(default)
353
- self.transitions.setdefault(self.current_node, []).append((default, None))
354
- logger.debug(f"Added default transition from {self.current_node} to {default}")
355
- self.current_node = next_node # Explicitly set next_node if provided
356
- return self
357
-
358
- def converge(self, convergence_node: str) -> "Workflow":
359
- """Set a convergence point for all previous branches.
360
-
361
- Args:
362
- convergence_node: Name of the node where branches converge.
363
-
364
- Returns:
365
- Self for method chaining.
366
- """
367
- if convergence_node not in self.nodes:
368
- self._register_node(convergence_node)
369
- for node in self.nodes:
370
- if (node not in self.transitions or not self.transitions[node]) and node != convergence_node:
371
- self.transitions.setdefault(node, []).append((convergence_node, None))
372
- logger.debug(f"Added convergence from {node} to {convergence_node}")
373
- self.current_node = convergence_node
374
- return self
375
-
376
- def parallel(self, *nodes: str):
377
- """Add parallel nodes to execute concurrently.
378
-
379
- Args:
380
- *nodes: Variable number of node names to execute in parallel.
381
-
382
- Returns:
383
- Self for method chaining.
384
- """
385
- if self.current_node:
386
- for node in nodes:
387
- self.transitions.setdefault(self.current_node, []).append((node, None))
388
- self.current_node = None
389
- return self
390
-
391
- def add_observer(self, observer: WorkflowObserver) -> "Workflow":
392
- """Add an event observer callback to the workflow.
393
-
394
- Args:
395
- observer: Callable to handle workflow events.
396
-
397
- Returns:
398
- Self for method chaining.
399
- """
400
- if observer not in self._observers:
401
- self._observers.append(observer)
402
- logger.debug(f"Added observer to workflow: {observer}")
403
- return self
404
-
405
- def add_sub_workflow(self, name: str, sub_workflow: "Workflow", inputs: Dict[str, Any], output: str):
406
- """Add a sub-workflow as a node with flexible inputs mapping.
407
-
408
- Args:
409
- name: Name of the sub-workflow node.
410
- sub_workflow: The Workflow instance to embed.
411
- inputs: Dictionary mapping sub-workflow inputs to context keys or callables.
412
- output: Context key for the sub-workflow's result.
413
-
414
- Returns:
415
- Self for method chaining.
416
- """
417
- sub_node = SubWorkflowNode(sub_workflow, inputs, output)
418
- self.nodes[name] = sub_node
419
- self.node_inputs[name] = []
420
- self.node_outputs[name] = output
421
- self.current_node = name
422
- logger.debug(f"Added sub-workflow {name} with inputs {inputs} and output {output}")
423
- return self
424
-
425
- def build(self, parent_engine: Optional["WorkflowEngine"] = None) -> WorkflowEngine:
426
- """Build and return a WorkflowEngine instance with registered observers.
427
-
428
- Args:
429
- parent_engine: Optional parent WorkflowEngine for sub-workflows.
430
-
431
- Returns:
432
- Configured WorkflowEngine instance.
433
- """
434
- engine = WorkflowEngine(self, parent_engine=parent_engine)
435
- for observer in self._observers:
436
- engine.add_observer(observer)
437
- return engine
438
-
439
-
440
- class Nodes:
441
- NODE_REGISTRY: Dict[str, Tuple[Callable, List[str], Optional[str]]] = {}
442
-
443
- @classmethod
444
- def define(cls, output: Optional[str] = None):
445
- """Decorator for defining simple workflow nodes.
446
-
447
- Args:
448
- output: Optional context key for the node's result.
449
-
450
- Returns:
451
- Decorator function wrapping the node logic.
452
- """
453
- def decorator(func: Callable) -> Callable:
454
- async def wrapped_func(**kwargs):
455
- try:
456
- if asyncio.iscoroutinefunction(func):
457
- result = await func(**kwargs)
458
- else:
459
- result = func(**kwargs)
460
- logger.debug(f"Node {func.__name__} executed with result: {result}")
461
- return result
462
- except Exception as e:
463
- logger.error(f"Error in node {func.__name__}: {e}")
464
- raise
465
- sig = inspect.signature(func)
466
- inputs = [param.name for param in sig.parameters.values()]
467
- logger.debug(f"Registering node {func.__name__} with inputs {inputs} and output {output}")
468
- cls.NODE_REGISTRY[func.__name__] = (wrapped_func, inputs, output)
469
- return wrapped_func
470
- return decorator
471
-
472
- @classmethod
473
- def validate_node(cls, output: str):
474
- """Decorator for nodes that validate inputs and return a string.
475
-
476
- Args:
477
- output: Context key for the validation result.
478
-
479
- Returns:
480
- Decorator function wrapping the validation logic.
481
- """
482
- def decorator(func: Callable) -> Callable:
483
- async def wrapped_func(**kwargs):
484
- try:
485
- if asyncio.iscoroutinefunction(func):
486
- result = await func(**kwargs)
487
- else:
488
- result = func(**kwargs)
489
- if not isinstance(result, str):
490
- raise ValueError(f"Validation node {func.__name__} must return a string")
491
- logger.info(f"Validation result from {func.__name__}: {result}")
492
- return result
493
- except Exception as e:
494
- logger.error(f"Validation error in {func.__name__}: {e}")
495
- raise
496
- sig = inspect.signature(func)
497
- inputs = [param.name for param in sig.parameters.values()]
498
- logger.debug(f"Registering node {func.__name__} with inputs {inputs} and output {output}")
499
- cls.NODE_REGISTRY[func.__name__] = (wrapped_func, inputs, output)
500
- return wrapped_func
501
- return decorator
502
-
503
- @classmethod
504
- def transform_node(cls, output: str, transformer: Callable[[Any], Any]):
505
- """Decorator for nodes that transform their inputs.
506
-
507
- Args:
508
- output: Context key for the transformed result.
509
- transformer: Callable to transform the input.
510
-
511
- Returns:
512
- Decorator function wrapping the transformation logic.
513
- """
514
- def decorator(func: Callable) -> Callable:
515
- async def wrapped_func(**kwargs):
516
- try:
517
- input_key = list(kwargs.keys())[0] if kwargs else None
518
- if input_key:
519
- transformed_input = transformer(kwargs[input_key])
520
- kwargs[input_key] = transformed_input
521
- if asyncio.iscoroutinefunction(func):
522
- result = await func(**kwargs)
523
- else:
524
- result = func(**kwargs)
525
- logger.debug(f"Transformed node {func.__name__} executed with result: {result}")
526
- return result
527
- except Exception as e:
528
- logger.error(f"Error in transform node {func.__name__}: {e}")
529
- raise
530
- sig = inspect.signature(func)
531
- inputs = [param.name for param in sig.parameters.values()]
532
- logger.debug(f"Registering node {func.__name__} with inputs {inputs} and output {output}")
533
- cls.NODE_REGISTRY[func.__name__] = (wrapped_func, inputs, output)
534
- return wrapped_func
535
- return decorator
536
-
537
- @staticmethod
538
- def _load_prompt_from_file(prompt_file: str, context: Dict[str, Any]) -> str:
539
- """Load and render a Jinja2 template from an external file."""
540
- try:
541
- file_path = Path(prompt_file).resolve()
542
- directory = file_path.parent
543
- filename = file_path.name
544
- env = Environment(loader=FileSystemLoader(directory))
545
- template = env.get_template(filename)
546
- return template.render(**context)
547
- except TemplateNotFound as e:
548
- logger.error(f"Jinja2 template file '{prompt_file}' not found: {e}")
549
- raise ValueError(f"Prompt file '{prompt_file}' not found")
550
- except Exception as e:
551
- logger.error(f"Error loading or rendering prompt file '{prompt_file}': {e}")
552
- raise
553
-
554
- @staticmethod
555
- def _render_template(template: str, template_file: Optional[str], context: Dict[str, Any]) -> str:
556
- """Render a Jinja2 template from either a string or an external file."""
557
- if template_file:
558
- return Nodes._load_prompt_from_file(template_file, context)
559
- try:
560
- return Template(template).render(**context)
561
- except Exception as e:
562
- logger.error(f"Error rendering template: {e}")
563
- raise
564
-
565
- @classmethod
566
- def llm_node(
567
- cls,
568
- system_prompt: str = "",
569
- system_prompt_file: Optional[str] = None,
570
- output: str = "",
571
- prompt_template: str = "",
572
- prompt_file: Optional[str] = None,
573
- temperature: float = 0.7,
574
- max_tokens: int = 2000,
575
- top_p: float = 1.0,
576
- presence_penalty: float = 0.0,
577
- frequency_penalty: float = 0.0,
578
- model: Callable[[Dict[str, Any]], str] = lambda ctx: "gpt-3.5-turbo",
579
- **kwargs,
580
- ):
581
- """Decorator for creating LLM nodes with plain text output, supporting dynamic parameters.
582
-
583
- Args:
584
- system_prompt: Inline system prompt defining LLM behavior.
585
- system_prompt_file: Path to a system prompt template file (overrides system_prompt).
586
- output: Context key for the LLM's result.
587
- prompt_template: Inline Jinja2 template for the user prompt.
588
- prompt_file: Path to a user prompt template file (overrides prompt_template).
589
- temperature: Randomness control (0.0 to 1.0).
590
- max_tokens: Maximum response length.
591
- top_p: Nucleus sampling parameter (0.0 to 1.0).
592
- presence_penalty: Penalty for repetition (-2.0 to 2.0).
593
- frequency_penalty: Penalty for frequent words (-2.0 to 2.0).
594
- model: Callable or string to determine the LLM model dynamically from context.
595
- **kwargs: Additional parameters for the LLM call.
596
-
597
- Returns:
598
- Decorator function wrapping the LLM logic.
599
- """
600
- def decorator(func: Callable) -> Callable:
601
- async def wrapped_func(model_param: str = None, **func_kwargs):
602
- system_prompt_to_use = func_kwargs.pop("system_prompt", system_prompt)
603
- system_prompt_file_to_use = func_kwargs.pop("system_prompt_file", system_prompt_file)
604
-
605
- if system_prompt_file_to_use:
606
- system_content = cls._load_prompt_from_file(system_prompt_file_to_use, func_kwargs)
607
- else:
608
- system_content = system_prompt_to_use
609
-
610
- prompt_template_to_use = func_kwargs.pop("prompt_template", prompt_template)
611
- prompt_file_to_use = func_kwargs.pop("prompt_file", prompt_file)
612
- temperature_to_use = func_kwargs.pop("temperature", temperature)
613
- max_tokens_to_use = func_kwargs.pop("max_tokens", max_tokens)
614
- top_p_to_use = func_kwargs.pop("top_p", top_p)
615
- presence_penalty_to_use = func_kwargs.pop("presence_penalty", presence_penalty)
616
- frequency_penalty_to_use = func_kwargs.pop("frequency_penalty", frequency_penalty)
617
-
618
- # Prioritize model from func_kwargs (workflow mapping), then model_param, then default
619
- model_to_use = func_kwargs.get("model", model_param if model_param is not None else model(func_kwargs))
620
- logger.debug(f"Selected model for {func.__name__}: {model_to_use}")
621
-
622
- sig = inspect.signature(func)
623
- template_vars = {k: v for k, v in func_kwargs.items() if k in sig.parameters}
624
- prompt = cls._render_template(prompt_template_to_use, prompt_file_to_use, template_vars)
625
- messages = [
626
- {"role": "system", "content": system_content},
627
- {"role": "user", "content": prompt},
628
- ]
629
-
630
- truncated_prompt = prompt[:200] + "..." if len(prompt) > 200 else prompt
631
- logger.info(f"LLM node {func.__name__} using model: {model_to_use}")
632
- logger.debug(f"System prompt: {system_content[:100]}...")
633
- logger.debug(f"User prompt preview: {truncated_prompt}")
634
-
635
- try:
636
- response = await acompletion(
637
- model=model_to_use,
638
- messages=messages,
639
- temperature=temperature_to_use,
640
- max_tokens=max_tokens_to_use,
641
- top_p=top_p_to_use,
642
- presence_penalty=presence_penalty_to_use,
643
- frequency_penalty=frequency_penalty_to_use,
644
- drop_params=True,
645
- **kwargs,
646
- )
647
- content = response.choices[0].message.content.strip()
648
- wrapped_func.usage = {
649
- "prompt_tokens": response.usage.prompt_tokens,
650
- "completion_tokens": response.usage.completion_tokens,
651
- "total_tokens": response.usage.total_tokens,
652
- "cost": getattr(response, "cost", None),
653
- }
654
- logger.debug(f"LLM output from {func.__name__}: {content[:50]}...")
655
- return content
656
- except Exception as e:
657
- logger.error(f"Error in LLM node {func.__name__}: {e}")
658
- raise
659
- sig = inspect.signature(func)
660
- inputs = ['model'] + [param.name for param in sig.parameters.values()]
661
- logger.debug(f"Registering node {func.__name__} with inputs {inputs} and output {output}")
662
- cls.NODE_REGISTRY[func.__name__] = (wrapped_func, inputs, output)
663
- return wrapped_func
664
- return decorator
665
-
666
- @classmethod
667
- def structured_llm_node(
668
- cls,
669
- system_prompt: str = "",
670
- system_prompt_file: Optional[str] = None,
671
- output: str = "",
672
- response_model: Type[BaseModel] = None,
673
- prompt_template: str = "",
674
- prompt_file: Optional[str] = None,
675
- temperature: float = 0.7,
676
- max_tokens: int = 2000,
677
- top_p: float = 1.0,
678
- presence_penalty: float = 0.0,
679
- frequency_penalty: float = 0.0,
680
- model: Callable[[Dict[str, Any]], str] = lambda ctx: "gpt-3.5-turbo",
681
- **kwargs,
682
- ):
683
- """Decorator for creating LLM nodes with structured output, supporting dynamic parameters.
684
-
685
- Args:
686
- system_prompt: Inline system prompt defining LLM behavior.
687
- system_prompt_file: Path to a system prompt template file (overrides system_prompt).
688
- output: Context key for the LLM's structured result.
689
- response_model: Pydantic model class for structured output.
690
- prompt_template: Inline Jinja2 template for the user prompt.
691
- prompt_file: Path to a user prompt template file (overrides prompt_template).
692
- temperature: Randomness control (0.0 to 1.0).
693
- max_tokens: Maximum response length.
694
- top_p: Nucleus sampling parameter (0.0 to 1.0).
695
- presence_penalty: Penalty for repetition (-2.0 to 2.0).
696
- frequency_penalty: Penalty for frequent words (-2.0 to 2.0).
697
- model: Callable or string to determine the LLM model dynamically from context.
698
- **kwargs: Additional parameters for the LLM call.
699
-
700
- Returns:
701
- Decorator function wrapping the structured LLM logic.
702
- """
703
- try:
704
- client = instructor.from_litellm(acompletion)
705
- except ImportError:
706
- logger.error("Instructor not installed. Install with 'pip install instructor[litellm]'")
707
- raise ImportError("Instructor is required for structured_llm_node")
708
-
709
- def decorator(func: Callable) -> Callable:
710
- async def wrapped_func(model_param: str = None, **func_kwargs):
711
- system_prompt_to_use = func_kwargs.pop("system_prompt", system_prompt)
712
- system_prompt_file_to_use = func_kwargs.pop("system_prompt_file", system_prompt_file)
713
-
714
- if system_prompt_file_to_use:
715
- system_content = cls._load_prompt_from_file(system_prompt_file_to_use, func_kwargs)
716
- else:
717
- system_content = system_prompt_to_use
718
-
719
- prompt_template_to_use = func_kwargs.pop("prompt_template", prompt_template)
720
- prompt_file_to_use = func_kwargs.pop("prompt_file", prompt_file)
721
- temperature_to_use = func_kwargs.pop("temperature", temperature)
722
- max_tokens_to_use = func_kwargs.pop("max_tokens", max_tokens)
723
- top_p_to_use = func_kwargs.pop("top_p", top_p)
724
- presence_penalty_to_use = func_kwargs.pop("presence_penalty", presence_penalty)
725
- frequency_penalty_to_use = func_kwargs.pop("frequency_penalty", frequency_penalty)
726
-
727
- # Prioritize model from func_kwargs (workflow mapping), then model_param, then default
728
- model_to_use = func_kwargs.get("model", model_param if model_param is not None else model(func_kwargs))
729
- logger.debug(f"Selected model for {func.__name__}: {model_to_use}")
730
-
731
- sig = inspect.signature(func)
732
- template_vars = {k: v for k, v in func_kwargs.items() if k in sig.parameters}
733
- prompt = cls._render_template(prompt_template_to_use, prompt_file_to_use, template_vars)
734
- messages = [
735
- {"role": "system", "content": system_content},
736
- {"role": "user", "content": prompt},
737
- ]
738
-
739
- truncated_prompt = prompt[:200] + "..." if len(prompt) > 200 else prompt
740
- logger.info(f"Structured LLM node {func.__name__} using model: {model_to_use}")
741
- logger.debug(f"System prompt: {system_content[:100]}...")
742
- logger.debug(f"User prompt preview: {truncated_prompt}")
743
- logger.debug(f"Expected response model: {response_model.__name__}")
744
-
745
- try:
746
- structured_response, raw_response = await client.chat.completions.create_with_completion(
747
- model=model_to_use,
748
- messages=messages,
749
- response_model=response_model,
750
- temperature=temperature_to_use,
751
- max_tokens=max_tokens_to_use,
752
- top_p=top_p_to_use,
753
- presence_penalty=presence_penalty_to_use,
754
- frequency_penalty=frequency_penalty_to_use,
755
- drop_params=True,
756
- **kwargs,
757
- )
758
- wrapped_func.usage = {
759
- "prompt_tokens": raw_response.usage.prompt_tokens,
760
- "completion_tokens": raw_response.usage.completion_tokens,
761
- "total_tokens": raw_response.usage.total_tokens,
762
- "cost": getattr(raw_response, "cost", None),
763
- }
764
- logger.debug(f"Structured output from {func.__name__}: {structured_response}")
765
- return structured_response
766
- except ValidationError as e:
767
- logger.error(f"Validation error in {func.__name__}: {e}")
768
- raise
769
- except Exception as e:
770
- logger.error(f"Error in structured LLM node {func.__name__}: {e}")
771
- raise
772
- sig = inspect.signature(func)
773
- inputs = ['model'] + [param.name for param in sig.parameters.values()]
774
- logger.debug(f"Registering node {func.__name__} with inputs {inputs} and output {output}")
775
- cls.NODE_REGISTRY[func.__name__] = (wrapped_func, inputs, output)
776
- return wrapped_func
777
- return decorator
778
-
779
- @classmethod
780
- def template_node(
781
- cls,
782
- output: str,
783
- template: str = "",
784
- template_file: Optional[str] = None,
785
- ):
786
- """Decorator for creating nodes that apply a Jinja2 template to inputs.
787
-
788
- Args:
789
- output: Context key for the rendered result.
790
- template: Inline Jinja2 template string.
791
- template_file: Path to a template file (overrides template).
792
-
793
- Returns:
794
- Decorator function wrapping the template logic.
795
- """
796
- def decorator(func: Callable) -> Callable:
797
- async def wrapped_func(**func_kwargs):
798
- template_to_use = func_kwargs.pop("template", template)
799
- template_file_to_use = func_kwargs.pop("template_file", template_file)
800
-
801
- sig = inspect.signature(func)
802
- expected_params = [p.name for p in sig.parameters.values() if p.name != 'rendered_content']
803
- template_vars = {k: v for k, v in func_kwargs.items() if k in expected_params}
804
- rendered_content = cls._render_template(template_to_use, template_file_to_use, template_vars)
805
-
806
- filtered_kwargs = {k: v for k, v in func_kwargs.items() if k in expected_params}
807
-
808
- try:
809
- if asyncio.iscoroutinefunction(func):
810
- result = await func(rendered_content=rendered_content, **filtered_kwargs)
811
- else:
812
- result = func(rendered_content=rendered_content, **filtered_kwargs)
813
- logger.debug(f"Template node {func.__name__} rendered: {rendered_content[:50]}...")
814
- return result
815
- except Exception as e:
816
- logger.error(f"Error in template node {func.__name__}: {e}")
817
- raise
818
- sig = inspect.signature(func)
819
- inputs = [param.name for param in sig.parameters.values()]
820
- if 'rendered_content' not in inputs:
821
- inputs.insert(0, 'rendered_content')
822
- logger.debug(f"Registering node {func.__name__} with inputs {inputs} and output {output}")
823
- cls.NODE_REGISTRY[func.__name__] = (wrapped_func, inputs, output)
824
- return wrapped_func
825
- return decorator
826
-
827
-
828
- # Add a templates directory path at the module level
829
- TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
830
-
831
- # Helper function to get template paths
832
- def get_template_path(template_name):
833
- return os.path.join(TEMPLATES_DIR, template_name)
834
-
835
-
836
- async def example_workflow():
837
- class OrderDetails(BaseModel):
838
- order_id: str
839
- items_in_stock: List[str]
840
- items_out_of_stock: List[str]
841
-
842
- async def progress_monitor(event: WorkflowEvent):
843
- print(f"[{event.event_type.value}] {event.node_name or 'Workflow'}")
844
- if event.result is not None:
845
- print(f"Result: {event.result}")
846
- if event.exception is not None:
847
- print(f"Exception: {event.exception}")
848
-
849
- class TokenUsageObserver:
850
- def __init__(self):
851
- self.total_prompt_tokens = 0
852
- self.total_completion_tokens = 0
853
- self.total_cost = 0.0
854
- self.node_usages = {}
855
-
856
- def __call__(self, event: WorkflowEvent):
857
- if event.event_type == WorkflowEventType.NODE_COMPLETED and event.usage:
858
- usage = event.usage
859
- self.total_prompt_tokens += usage.get("prompt_tokens", 0)
860
- self.total_completion_tokens += usage.get("completion_tokens", 0)
861
- if usage.get("cost") is not None:
862
- self.total_cost += usage["cost"]
863
- self.node_usages[event.node_name] = usage
864
- if event.event_type == WorkflowEventType.WORKFLOW_COMPLETED:
865
- print(f"Total prompt tokens: {self.total_prompt_tokens}")
866
- print(f"Total completion tokens: {self.total_completion_tokens}")
867
- print(f"Total cost: {self.total_cost}")
868
- for node, usage in self.node_usages.items():
869
- print(f"Node {node}: {usage}")
870
-
871
- @Nodes.validate_node(output="validation_result")
872
- async def validate_order(order: Dict[str, Any]) -> str:
873
- return "Order validated" if order.get("items") else "Invalid order"
874
-
875
- @Nodes.structured_llm_node(
876
- system_prompt_file=get_template_path("system_check_inventory.j2"),
877
- output="inventory_status",
878
- response_model=OrderDetails,
879
- prompt_file=get_template_path("prompt_check_inventory.j2"),
880
- )
881
- async def check_inventory(items: List[str]) -> OrderDetails:
882
- return OrderDetails(order_id="123", items_in_stock=["item1"], items_out_of_stock=[])
883
-
884
- @Nodes.define(output="payment_status")
885
- async def process_payment(order: Dict[str, Any]) -> str:
886
- return "Payment processed"
887
-
888
- @Nodes.define(output="shipping_confirmation")
889
- async def arrange_shipping(order: Dict[str, Any]) -> str:
890
- return "Shipping arranged"
891
-
892
- @Nodes.define(output="order_status")
893
- async def update_order_status(shipping_confirmation: str) -> str:
894
- return "Order updated"
895
-
896
- @Nodes.define(output="email_status")
897
- async def send_confirmation_email(shipping_confirmation: str) -> str:
898
- return "Email sent"
899
-
900
- @Nodes.define(output="notification_status")
901
- async def notify_customer_out_of_stock(inventory_status: OrderDetails) -> str:
902
- return "Customer notified of out-of-stock"
903
-
904
- @Nodes.transform_node(output="transformed_items", transformer=lambda x: [item.upper() for item in x])
905
- async def transform_items(items: List[str]) -> List[str]:
906
- return items
907
-
908
- @Nodes.template_node(
909
- output="formatted_message",
910
- template="Order contains: {{ items | join(', ') }}",
911
- )
912
- async def format_order_message(rendered_content: str, items: List[str]) -> str:
913
- return rendered_content
914
-
915
- payment_shipping_sub_wf = Workflow("process_payment").sequence("process_payment", "arrange_shipping")
916
-
917
- token_observer = TokenUsageObserver()
918
-
919
- workflow = (
920
- Workflow("validate_order")
921
- .add_observer(progress_monitor)
922
- .add_observer(token_observer)
923
- .node("validate_order", inputs_mapping={"order": "customer_order"})
924
- .node("transform_items")
925
- .node("format_order_message", inputs_mapping={
926
- "items": "items",
927
- "template": "Custom order: {{ items | join(', ') }}"
928
- })
929
- .node("check_inventory", inputs_mapping={
930
- "model": lambda ctx: "gemini/gemini-2.0-flash",
931
- "items": "transformed_items",
932
- "temperature": 0.5,
933
- "max_tokens": 1000
934
- })
935
- .add_sub_workflow(
936
- "payment_shipping",
937
- payment_shipping_sub_wf,
938
- inputs={"order": lambda ctx: {"items": ctx["items"]}},
939
- output="shipping_confirmation"
940
- )
941
- .branch(
942
- [
943
- ("payment_shipping", lambda ctx: len(ctx.get("inventory_status").items_out_of_stock) == 0 if ctx.get("inventory_status") else False),
944
- ("notify_customer_out_of_stock", lambda ctx: len(ctx.get("inventory_status").items_out_of_stock) > 0 if ctx.get("inventory_status") else True)
945
- ],
946
- next_node="update_order_status"
947
- )
948
- .converge("update_order_status")
949
- .sequence("update_order_status", "send_confirmation_email")
950
- )
951
-
952
- initial_context = {"customer_order": {"items": ["item1", "item2"]}, "items": ["item1", "item2"]}
953
- engine = workflow.build()
954
- result = await engine.run(initial_context)
955
- logger.info(f"Workflow result: {result}")
956
-
957
-
958
- if __name__ == "__main__":
959
- logger.info("Initializing Quantalogic Flow Package")
960
- asyncio.run(example_workflow())