soprano-sdk 0.2.20__py3-none-any.whl → 0.2.21__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.
@@ -17,6 +17,8 @@ class WorkflowKeys:
17
17
  NODE_FIELD_MAP = '_node_field_map'
18
18
  COMPUTED_FIELDS = '_computed_fields'
19
19
  ERROR = 'error'
20
+ TARGET_LANGUAGE = '_target_language'
21
+ TARGET_SCRIPT = '_target_script'
20
22
 
21
23
 
22
24
  class ActionType(Enum):
@@ -70,6 +72,47 @@ DEFAULT_TIMEOUT = 300
70
72
  MAX_ATTEMPTS_MESSAGE = "I'm having trouble understanding your {field}. Please contact customer service for assistance."
71
73
  WORKFLOW_COMPLETE_MESSAGE = "Workflow completed."
72
74
 
75
+ # Humanization defaults
76
+ DEFAULT_HUMANIZATION_ENABLED = True
77
+ DEFAULT_HUMANIZATION_SYSTEM_PROMPT = """You are a helpful assistant that transforms template-based messages into natural, conversational responses.
78
+
79
+ Your task:
80
+ 1. Take the reference message provided and rewrite it naturally
81
+ 2. Maintain ALL factual information and important details from the reference
82
+ 3. Use the conversation history for context and tone matching
83
+ 4. Be warm, professional, and helpful
84
+ 5. Keep the response concise but complete
85
+
86
+ Reference message to humanize:
87
+ {reference_message}
88
+
89
+ Respond with ONLY the humanized message. Do not add explanations or meta-commentary."""
90
+
91
+
92
+ class HumanizationKeys:
93
+ """Keys for humanization agent configuration"""
94
+ ENABLED = 'enabled'
95
+ MODEL = 'model'
96
+ BASE_URL = 'base_url'
97
+ INSTRUCTIONS = 'instructions'
98
+
99
+
100
+ # Localization defaults
101
+ DEFAULT_LOCALIZATION_INSTRUCTIONS = """LANGUAGE REQUIREMENT:
102
+ You MUST respond in {language} using {script} script.
103
+ - All your responses must be in {language}
104
+ - Use the {script} writing system
105
+ - Maintain the same meaning and tone as you would in English
106
+ - Do not mix languages unless quoting the user
107
+ """
108
+
109
+
110
+ class LocalizationKeys:
111
+ """Keys for localization configuration"""
112
+ LANGUAGE = 'language'
113
+ SCRIPT = 'script'
114
+ INSTRUCTIONS = 'instructions'
115
+
73
116
 
74
117
  class MFAConfig(BaseSettings):
75
118
  """
@@ -1,4 +1,4 @@
1
- from typing import Optional, Dict, Any, Tuple
1
+ from typing import Optional, Dict, Any, Tuple, List
2
2
 
3
3
  import yaml
4
4
  from jinja2 import Environment
@@ -7,7 +7,16 @@ from langgraph.constants import START
7
7
  from langgraph.graph import StateGraph
8
8
  from langgraph.graph.state import CompiledStateGraph
9
9
 
10
- from .constants import WorkflowKeys, MFAConfig
10
+ from .constants import (
11
+ WorkflowKeys,
12
+ MFAConfig,
13
+ DEFAULT_HUMANIZATION_ENABLED,
14
+ DEFAULT_HUMANIZATION_SYSTEM_PROMPT,
15
+ HumanizationKeys,
16
+ DEFAULT_LOCALIZATION_INSTRUCTIONS,
17
+ LocalizationKeys
18
+ )
19
+ from ..agents.factory import AgentFactory
11
20
  from .state import create_state_model
12
21
  from ..nodes.factory import NodeFactory
13
22
  from ..routing.router import WorkflowRouter
@@ -148,6 +157,167 @@ class WorkflowEngine:
148
157
  except Exception as e:
149
158
  raise RuntimeError(f"Failed to build workflow graph: {e}")
150
159
 
160
+ def _aggregate_conversation_history(self, state: Dict[str, Any]) -> List[Dict[str, str]]:
161
+ """Aggregate all conversations from collector nodes in execution order."""
162
+ conversations = state.get(WorkflowKeys.CONVERSATIONS, {})
163
+ node_order = state.get(WorkflowKeys.NODE_EXECUTION_ORDER, [])
164
+ node_field_map = state.get(WorkflowKeys.NODE_FIELD_MAP, {})
165
+
166
+ aggregated = []
167
+ for node_id in node_order:
168
+ field = node_field_map.get(node_id)
169
+ if field:
170
+ conv_key = f"{field}_conversation"
171
+ if conv_messages := conversations.get(conv_key):
172
+ aggregated.extend(conv_messages)
173
+
174
+ return aggregated
175
+
176
+ def _get_humanization_config(self) -> Dict[str, Any]:
177
+ """Get humanization agent configuration from workflow config."""
178
+ return self.config.get('humanization_agent', {})
179
+
180
+ def _should_humanize_outcome(self, outcome: Dict[str, Any]) -> bool:
181
+ """Determine if an outcome should be humanized."""
182
+ # Check workflow-level setting (default: enabled)
183
+ workflow_humanization = self._get_humanization_config()
184
+ workflow_enabled = workflow_humanization.get(
185
+ HumanizationKeys.ENABLED,
186
+ DEFAULT_HUMANIZATION_ENABLED
187
+ )
188
+
189
+ if not workflow_enabled:
190
+ return False
191
+
192
+ # Check per-outcome setting (default: True, inherit from workflow)
193
+ return outcome.get('humanize', True)
194
+
195
+ def _get_humanization_model_config(self) -> Optional[Dict[str, Any]]:
196
+ """Get model config for humanization, with overrides applied."""
197
+ model_config = self.get_config_value('model_config')
198
+ if not model_config:
199
+ return None
200
+
201
+ humanization_config = self._get_humanization_config()
202
+ model_config = model_config.copy() # Don't mutate original
203
+
204
+ # Apply overrides from humanization_agent config
205
+ if model := humanization_config.get(HumanizationKeys.MODEL):
206
+ model_config['model_name'] = model
207
+ if base_url := humanization_config.get(HumanizationKeys.BASE_URL):
208
+ model_config['base_url'] = base_url
209
+
210
+ return model_config
211
+
212
+ def _get_localization_config(self) -> Dict[str, Any]:
213
+ """Get localization configuration from workflow config."""
214
+ return self.config.get('localization', {})
215
+
216
+ def get_localization_instructions(self, state: Dict[str, Any]) -> str:
217
+ """Get localization instructions based on state (per-turn) or YAML defaults.
218
+
219
+ Args:
220
+ state: Current workflow state containing per-turn language/script values
221
+
222
+ Returns:
223
+ Localization instructions string to prepend to agent prompts, or empty string if no localization
224
+ """
225
+ # First check state for per-turn values
226
+ language = state.get(WorkflowKeys.TARGET_LANGUAGE)
227
+ script = state.get(WorkflowKeys.TARGET_SCRIPT)
228
+
229
+ # Fall back to YAML defaults if not in state
230
+ yaml_config = self._get_localization_config()
231
+ if not language:
232
+ language = yaml_config.get(LocalizationKeys.LANGUAGE)
233
+ if not script:
234
+ script = yaml_config.get(LocalizationKeys.SCRIPT)
235
+
236
+ # No localization if neither specified
237
+ if not language and not script:
238
+ return ""
239
+
240
+ # Use custom instructions if provided in YAML
241
+ custom_instructions = yaml_config.get(LocalizationKeys.INSTRUCTIONS)
242
+ if custom_instructions:
243
+ return custom_instructions.format(
244
+ language=language or "the target language",
245
+ script=script or "the appropriate script"
246
+ )
247
+
248
+ return DEFAULT_LOCALIZATION_INSTRUCTIONS.format(
249
+ language=language or "the target language",
250
+ script=script or "the appropriate script"
251
+ )
252
+
253
+ def _humanize_message(self, reference_message: str, state: Dict[str, Any]) -> str:
254
+ """Use LLM to humanize the reference message using conversation context."""
255
+ try:
256
+ model_config = self._get_humanization_model_config()
257
+ if not model_config:
258
+ logger.warning("No model_config found, skipping humanization")
259
+ return reference_message
260
+
261
+ humanization_config = self._get_humanization_config()
262
+
263
+ # Build system prompt
264
+ custom_instructions = humanization_config.get(HumanizationKeys.INSTRUCTIONS)
265
+ if custom_instructions:
266
+ system_prompt = f"{custom_instructions}\n\nReference message to humanize:\n{reference_message}"
267
+ else:
268
+ system_prompt = DEFAULT_HUMANIZATION_SYSTEM_PROMPT.format(
269
+ reference_message=reference_message
270
+ )
271
+
272
+ # Inject localization instructions if specified
273
+ localization_instructions = self.get_localization_instructions(state)
274
+ if localization_instructions:
275
+ system_prompt = f"{localization_instructions}\n\n{system_prompt}"
276
+
277
+ # Aggregate conversation history
278
+ conversation_history = self._aggregate_conversation_history(state)
279
+
280
+ # Create agent for humanization
281
+ framework = self.get_config_value('agent_framework', 'langgraph')
282
+ agent = AgentFactory.create_agent(
283
+ framework=framework,
284
+ name="HumanizationAgent",
285
+ model_config=model_config,
286
+ tools=[],
287
+ system_prompt=system_prompt,
288
+ structured_output_model=None
289
+ )
290
+
291
+ # Invoke agent with conversation history
292
+ if conversation_history:
293
+ messages = conversation_history + [
294
+ {"role": "user", "content": "Please humanize the reference message based on our conversation."}
295
+ ]
296
+ else:
297
+ messages = [
298
+ {"role": "user", "content": "Please humanize the reference message."}
299
+ ]
300
+
301
+ humanized_response = agent.invoke(messages)
302
+
303
+ # Handle different response types
304
+ if isinstance(humanized_response, dict):
305
+ humanized_message = humanized_response.get('content', str(humanized_response))
306
+ else:
307
+ humanized_message = str(humanized_response)
308
+
309
+ # Validate we got a meaningful response
310
+ if not humanized_message or humanized_message.strip() == '':
311
+ logger.warning("Humanization returned empty response, using original message")
312
+ return reference_message
313
+
314
+ logger.info(f"Message humanized successfully: {humanized_message[:100]}...")
315
+ return humanized_message
316
+
317
+ except Exception as e:
318
+ logger.warning(f"Humanization failed, using original message: {e}")
319
+ return reference_message
320
+
151
321
  def get_outcome_message(self, state: Dict[str, Any]) -> str:
152
322
  outcome_id = state.get(WorkflowKeys.OUTCOME_ID)
153
323
  step_id = state.get(WorkflowKeys.STEP_ID)
@@ -156,9 +326,16 @@ class WorkflowEngine:
156
326
  if outcome and 'message' in outcome:
157
327
  message = outcome['message']
158
328
  template_loader = self.get_config_value("template_loader", Environment())
159
- message = template_loader.from_string(message).render(state)
160
- logger.info(f"Outcome message generated in step {step_id}: {message}")
161
- return message
329
+ rendered_message = template_loader.from_string(message).render(state)
330
+
331
+ # Apply humanization if enabled for this outcome
332
+ if self._should_humanize_outcome(outcome):
333
+ final_message = self._humanize_message(rendered_message, state)
334
+ else:
335
+ final_message = rendered_message
336
+
337
+ logger.info(f"Outcome message generated in step {step_id}: {final_message}")
338
+ return final_message
162
339
 
163
340
  if error := state.get("error"):
164
341
  logger.info(f"Outcome error found in step {step_id}: {error}")
@@ -122,8 +122,8 @@ class CollectInputStrategy(ActionStrategy):
122
122
  self.next_step = self.step_config.get("next", None)
123
123
  self.is_structured_output = self.agent_config.get("structured_output", {}).get("enabled", False)
124
124
 
125
- # Out-of-scope detection configuration (enabled by default)
126
- self.enable_out_of_scope = self.agent_config.get("detect_out_of_scope", True)
125
+ # Out-of-scope detection configuration (disabled by default)
126
+ self.enable_out_of_scope = self.agent_config.get("detect_out_of_scope", False)
127
127
  self.scope_description = self.agent_config.get(
128
128
  "scope_description",
129
129
  self.agent_config.get("description", f"collecting {self.field}")
@@ -348,6 +348,11 @@ class CollectInputStrategy(ActionStrategy):
348
348
 
349
349
  instructions = self._render_template_string(instructions, state)
350
350
 
351
+ # Inject localization instructions at the start (per-turn)
352
+ localization_instructions = self.engine_context.get_localization_instructions(state)
353
+ if localization_instructions:
354
+ instructions = f"{localization_instructions}\n\n{instructions}"
355
+
351
356
  if collector_nodes:
352
357
  collector_nodes_for_intent_change = {
353
358
  node_id: node_desc for node_id, node_desc in collector_nodes.items()
@@ -103,7 +103,7 @@ class FollowUpStrategy(ActionStrategy):
103
103
  'closure_patterns',
104
104
  DEFAULT_CLOSURE_PATTERNS
105
105
  )
106
- self.enable_out_of_scope = self.agent_config.get('detect_out_of_scope', True)
106
+ self.enable_out_of_scope = self.agent_config.get('detect_out_of_scope', False)
107
107
  self.scope_description = self.agent_config.get(
108
108
  'scope_description',
109
109
  self.agent_config.get('description', 'answering follow-up questions')
@@ -318,6 +318,11 @@ class FollowUpStrategy(ActionStrategy):
318
318
  scope_description=self.scope_description
319
319
  )
320
320
 
321
+ # Inject localization instructions at the start (per-turn)
322
+ localization_instructions = self.engine_context.get_localization_instructions(state)
323
+ if localization_instructions:
324
+ instructions = f"{localization_instructions}\n\n{instructions}"
325
+
321
326
  framework = self.engine_context.get_config_value('agent_framework', 'langgraph')
322
327
 
323
328
  return AgentFactory.create_agent(
soprano_sdk/tools.py CHANGED
@@ -10,7 +10,7 @@ from .utils.logger import logger
10
10
  from langfuse.langchain import CallbackHandler
11
11
 
12
12
  from .core.engine import load_workflow
13
- from .core.constants import MFAConfig, InterruptType
13
+ from .core.constants import MFAConfig, InterruptType, WorkflowKeys
14
14
 
15
15
 
16
16
  class WorkflowTool:
@@ -54,7 +54,9 @@ class WorkflowTool:
54
54
  self,
55
55
  thread_id: Optional[str] = None,
56
56
  user_message: Optional[str] = None,
57
- initial_context: Optional[Dict[str, Any]] = None
57
+ initial_context: Optional[Dict[str, Any]] = None,
58
+ target_language: Optional[str] = None,
59
+ target_script: Optional[str] = None
58
60
  ) -> str:
59
61
  """Execute the workflow with automatic state detection
60
62
 
@@ -65,16 +67,25 @@ class WorkflowTool:
65
67
  thread_id: Thread ID for state tracking
66
68
  user_message: User's message (used for resume if workflow is interrupted)
67
69
  initial_context: Context to inject for fresh starts (e.g., {"order_id": "123"})
70
+ target_language: Target language for this turn (e.g., "Tamil", "Hindi")
71
+ target_script: Target script for this turn (e.g., "Tamil", "Devanagari")
68
72
 
69
73
  Returns:
70
74
  Final outcome message or interrupt prompt
71
75
  """
72
76
  from langgraph.types import Command
73
77
  from soprano_sdk.utils.tracing import trace_workflow_execution
74
-
78
+
75
79
  if thread_id is None:
76
80
  thread_id = str(uuid.uuid4())
77
-
81
+
82
+ # Build localization state for this turn
83
+ localization_state = {}
84
+ if target_language:
85
+ localization_state[WorkflowKeys.TARGET_LANGUAGE] = target_language
86
+ if target_script:
87
+ localization_state[WorkflowKeys.TARGET_SCRIPT] = target_script
88
+
78
89
  with trace_workflow_execution(
79
90
  workflow_name=self.engine.workflow_name,
80
91
  thread_id=thread_id,
@@ -99,8 +110,10 @@ class WorkflowTool:
99
110
  "filtered_out": list(set(initial_context.keys()) - set(filtered_context.keys()))
100
111
  })
101
112
 
113
+ # Merge localization state with filtered context
114
+ update_state = {**filtered_context, **localization_state}
102
115
  result = self.graph.invoke(
103
- Command(resume=user_message or "", update=filtered_context),
116
+ Command(resume=user_message or "", update=update_state),
104
117
  config=config
105
118
  )
106
119
 
@@ -112,7 +125,9 @@ class WorkflowTool:
112
125
  self.engine.update_context(initial_context)
113
126
  span.add_event("context.updated", {"fields": list(initial_context.keys())})
114
127
 
115
- result = self.graph.invoke(initial_context, config=config)
128
+ # Merge localization state with initial context
129
+ invoke_state = {**(initial_context or {}), **localization_state}
130
+ result = self.graph.invoke(invoke_state, config=config)
116
131
 
117
132
  final_state = self.graph.get_state(config)
118
133
  if not final_state.next and self.checkpointer:
@@ -196,21 +211,35 @@ class WorkflowTool:
196
211
  def resume(
197
212
  self,
198
213
  thread_id: str,
199
- resume_value: Union[str, Dict[str, Any]]
214
+ resume_value: Union[str, Dict[str, Any]],
215
+ target_language: Optional[str] = None,
216
+ target_script: Optional[str] = None
200
217
  ) -> str:
201
218
  """Resume an interrupted workflow with user input or async result
202
219
 
203
220
  Args:
204
221
  thread_id: Thread ID of the interrupted workflow
205
222
  resume_value: User's response (str) or async operation result (dict)
223
+ target_language: Target language for this turn (e.g., "Tamil", "Hindi")
224
+ target_script: Target script for this turn (e.g., "Tamil", "Devanagari")
206
225
 
207
226
  Returns:
208
227
  Either another interrupt prompt/async metadata or final outcome message
209
228
  """
210
229
  from langgraph.types import Command
211
230
 
231
+ # Build localization state for this turn
232
+ localization_state = {}
233
+ if target_language:
234
+ localization_state[WorkflowKeys.TARGET_LANGUAGE] = target_language
235
+ if target_script:
236
+ localization_state[WorkflowKeys.TARGET_SCRIPT] = target_script
237
+
212
238
  config = {"configurable": {"thread_id": thread_id}}
213
- result = self.graph.invoke(Command(resume=resume_value), config=config)
239
+ if localization_state:
240
+ result = self.graph.invoke(Command(resume=resume_value, update=localization_state), config=config)
241
+ else:
242
+ result = self.graph.invoke(Command(resume=resume_value), config=config)
214
243
 
215
244
  # Check if workflow needs more input or has another async operation
216
245
  if "__interrupt__" in result and result["__interrupt__"]:
@@ -22,6 +22,48 @@ WORKFLOW_SCHEMA = {
22
22
  "default": "langgraph",
23
23
  "description": "Agent framework to use for all agents in this workflow (default: langgraph)"
24
24
  },
25
+ "humanization_agent": {
26
+ "type": "object",
27
+ "description": "Configuration for LLM-powered message humanization (enabled by default)",
28
+ "properties": {
29
+ "enabled": {
30
+ "type": "boolean",
31
+ "default": True,
32
+ "description": "Whether to enable LLM humanization for outcome messages (default: true)"
33
+ },
34
+ "model": {
35
+ "type": "string",
36
+ "description": "Model to use for humanization (overrides model_config.model_name)"
37
+ },
38
+ "base_url": {
39
+ "type": "string",
40
+ "format": "uri",
41
+ "description": "Base URL for humanization model (overrides model_config.base_url)"
42
+ },
43
+ "instructions": {
44
+ "type": "string",
45
+ "description": "Custom system prompt for the humanization agent"
46
+ }
47
+ }
48
+ },
49
+ "localization": {
50
+ "type": "object",
51
+ "description": "Default localization configuration (can be overridden per-turn via execute() parameters)",
52
+ "properties": {
53
+ "language": {
54
+ "type": "string",
55
+ "description": "Default target language (e.g., Tamil, Hindi, Spanish)"
56
+ },
57
+ "script": {
58
+ "type": "string",
59
+ "description": "Default target script/writing system (e.g., Tamil, Devanagari, Latin)"
60
+ },
61
+ "instructions": {
62
+ "type": "string",
63
+ "description": "Custom instructions for language/script requirements"
64
+ }
65
+ }
66
+ },
25
67
  "data": {
26
68
  "type": "array",
27
69
  "description": "Data fields used in the workflow",
@@ -290,6 +332,11 @@ WORKFLOW_SCHEMA = {
290
332
  "message": {
291
333
  "type": "string",
292
334
  "description": "Outcome message (supports {field} placeholders)"
335
+ },
336
+ "humanize": {
337
+ "type": "boolean",
338
+ "default": True,
339
+ "description": "Whether to apply LLM humanization to this outcome's message (default: true)"
293
340
  }
294
341
  }
295
342
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soprano-sdk
3
- Version: 0.2.20
3
+ Version: 0.2.21
4
4
  Summary: YAML-driven workflow engine with AI agent integration for building conversational SOPs
5
5
  Author: Arvind Thangamani
6
6
  License: MIT
@@ -54,6 +54,8 @@ A YAML-driven workflow engine with AI agent integration for building conversatio
54
54
  - **Follow-up Conversations**: Handle user follow-up questions with full workflow context
55
55
  - **Intent Detection**: Route users between collector nodes based on detected intent
56
56
  - **Out-of-Scope Detection**: Signal when user queries are unrelated to the current workflow
57
+ - **Outcome Humanization**: LLM-powered transformation of outcome messages into natural, context-aware responses
58
+ - **Per-Turn Localization**: Dynamic language and script switching for multi-language support
57
59
 
58
60
  ## Installation
59
61
 
@@ -364,7 +366,7 @@ Data collector and follow-up nodes can detect when user queries are unrelated to
364
366
  **Configuration:**
365
367
  ```yaml
366
368
  agent:
367
- detect_out_of_scope: true # Enabled by default
369
+ detect_out_of_scope: true # Disabled by default, set to true to enable
368
370
  scope_description: "collecting order information for returns" # Optional
369
371
  ```
370
372
 
@@ -373,6 +375,145 @@ agent:
373
375
  __OUT_OF_SCOPE_INTERRUPT__|{thread_id}|{workflow_name}|{"reason":"...","user_message":"..."}
374
376
  ```
375
377
 
378
+ ## Outcome Humanization
379
+
380
+ Outcome messages can be automatically humanized using an LLM to transform template-based messages into natural, context-aware responses. This feature uses the full conversation history to generate responses that match the tone and context of the interaction.
381
+
382
+ ### How It Works
383
+
384
+ 1. **Template rendering**: The outcome message template is first rendered with state values (e.g., `{{order_id}}` → `1234`)
385
+ 2. **LLM humanization**: The rendered message is passed to an LLM along with the conversation history
386
+ 3. **Natural response**: The LLM generates a warm, conversational response while preserving all factual details
387
+
388
+ ### Configuration
389
+
390
+ Humanization is **enabled by default**. Configure it at the workflow level:
391
+
392
+ ```yaml
393
+ name: "Return Processing Workflow"
394
+ version: "1.0"
395
+
396
+ # Humanization configuration (optional - enabled by default)
397
+ humanization_agent:
398
+ model: "gpt-4o" # Override model for humanization (optional)
399
+ base_url: "https://custom-api.com/v1" # Override base URL (optional)
400
+ instructions: | # Custom instructions (optional)
401
+ You are a friendly customer service representative.
402
+ Rewrite the message to be warm and empathetic.
403
+ Always thank the customer for their patience.
404
+
405
+ outcomes:
406
+ - id: success
407
+ type: success
408
+ message: "Return approved for order {{order_id}}. Reason: {{return_reason}}."
409
+
410
+ - id: technical_error
411
+ type: failure
412
+ humanize: false # Disable humanization for this specific outcome
413
+ message: "Error code: {{error_code}}. Contact support."
414
+ ```
415
+
416
+ ### Example Transformation
417
+
418
+ | Template Message | Humanized Response |
419
+ |-----------------|-------------------|
420
+ | `"Return approved for order 1234. Reason: damaged item."` | `"Great news! I've approved the return for your order #1234. I completely understand about the damaged item - that's so frustrating. You'll receive an email shortly with return instructions. Is there anything else I can help you with?"` |
421
+
422
+ ### Disabling Humanization
423
+
424
+ **Globally** (for entire workflow):
425
+ ```yaml
426
+ humanization_agent:
427
+ enabled: false
428
+ ```
429
+
430
+ **Per-outcome**:
431
+ ```yaml
432
+ outcomes:
433
+ - id: error_code
434
+ type: failure
435
+ humanize: false # Keep exact message for debugging/logging
436
+ message: "Error: {{error_code}}"
437
+ ```
438
+
439
+ ### Model Configuration
440
+
441
+ The humanization agent inherits the workflow's runtime `model_config`. You can override specific settings:
442
+
443
+ ```python
444
+ config = {
445
+ "model_config": {
446
+ "model_name": "gpt-4o-mini", # Base model for all agents
447
+ "api_key": os.getenv("OPENAI_API_KEY"),
448
+ }
449
+ }
450
+
451
+ # In YAML, humanization_agent.model overrides model_name for humanization only
452
+ ```
453
+
454
+ ## Per-Turn Localization
455
+
456
+ The framework supports per-turn localization, allowing dynamic language and script switching during workflow execution. Each call to `execute()` can specify a different target language/script.
457
+
458
+ ### How It Works
459
+
460
+ 1. **Per-turn parameters**: Pass `target_language` and `target_script` to `execute()`
461
+ 2. **Instruction injection**: Localization instructions are prepended to agent system prompts
462
+ 3. **No extra LLM calls**: The same agent that generates the response handles localization
463
+
464
+ ### Usage
465
+
466
+ **Per-turn language switching:**
467
+ ```python
468
+ from soprano_sdk import WorkflowTool
469
+
470
+ tool = WorkflowTool(
471
+ yaml_path="return_workflow.yaml",
472
+ name="return_processor",
473
+ description="Process returns",
474
+ checkpointer=checkpointer,
475
+ config=config
476
+ )
477
+
478
+ # Turn 1: English (no localization)
479
+ result = tool.execute(thread_id="123", user_message="hi")
480
+
481
+ # Turn 2: Switch to Tamil
482
+ result = tool.execute(
483
+ thread_id="123",
484
+ user_message="my order id is 1234",
485
+ target_language="Tamil",
486
+ target_script="Tamil"
487
+ )
488
+
489
+ # Turn 3: Back to English (no localization params)
490
+ result = tool.execute(thread_id="123", user_message="yes")
491
+ ```
492
+
493
+ ### YAML Defaults (Optional)
494
+
495
+ You can set default localization in the workflow YAML. These are used when `target_language`/`target_script` are not passed to `execute()`:
496
+
497
+ ```yaml
498
+ name: "Return Workflow"
499
+ version: "1.0"
500
+
501
+ localization:
502
+ language: "Tamil"
503
+ script: "Tamil"
504
+ instructions: | # Optional: custom instructions
505
+ Use formal Tamil suitable for customer service.
506
+ Always be polite and respectful.
507
+
508
+ # ... rest of workflow
509
+ ```
510
+
511
+ ### Key Points
512
+
513
+ - **Localization affects**: Data collector prompts, follow-up responses, and humanized outcome messages
514
+ - **Outcome messages require humanization**: If `humanize: false`, outcome messages stay in English (template output)
515
+ - **Per-turn override**: Runtime parameters always override YAML defaults
516
+
376
517
  ## Examples
377
518
 
378
519
  See the `examples/` directory for complete workflow examples:
@@ -518,6 +659,8 @@ Contributions are welcome! Please open an issue or submit a pull request.
518
659
  - ✅ Thread ID strategies and examples
519
660
  - ✅ Follow-up node for conversational Q&A
520
661
  - ✅ Out-of-scope detection for multi-workflow routing
662
+ - ✅ Outcome humanization with LLM
663
+ - ✅ Per-turn localization for multi-language support
521
664
  - Additional action types (webhook, conditional branching, parallel execution)
522
665
  - More workflow examples (customer onboarding, support ticketing, approval flows)
523
666
  - Workflow testing utilities
@@ -1,6 +1,6 @@
1
1
  soprano_sdk/__init__.py,sha256=YZVl_SwQ0C-E_5_f1AwUe_hPcbgCt8k7k4_WAHM8vjE,243
2
2
  soprano_sdk/engine.py,sha256=EFK91iTHjp72otLN6Kg-yeLye2J3CAKN0QH4FI2taL8,14838
3
- soprano_sdk/tools.py,sha256=g2G86-PpSsnWkEmukRl0mVSj0ANk_Q39QrulFvfvA1M,12116
3
+ soprano_sdk/tools.py,sha256=D2EGT513OGjAA5h0RSy79OA34jgM30S8d49AG4JdL4M,13653
4
4
  soprano_sdk/agents/__init__.py,sha256=Yzbtv6iP_ABRgZo0IUjy9vDofEvLFbOjuABw758176A,636
5
5
  soprano_sdk/agents/adaptor.py,sha256=5y9e2F_ZILOPrkunvv0GhjVdniAnOOTaD4j3Ig-xd3c,5041
6
6
  soprano_sdk/agents/factory.py,sha256=CvXhpvtjf_Hb4Ce8WKAa0FzyY5Jm6lssvIIfSXu7jPY,9788
@@ -8,17 +8,17 @@ soprano_sdk/agents/structured_output.py,sha256=VnSRmgarbgsozQSdCr9fVSjclwi04GJ6N
8
8
  soprano_sdk/authenticators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  soprano_sdk/authenticators/mfa.py,sha256=xnur1lxbx4r_pUaNPD8vtQiNccQTjbPnqgi_vqdRZaQ,7336
10
10
  soprano_sdk/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- soprano_sdk/core/constants.py,sha256=SpFf2_G0-qWYWLxbom-G9vF3lsX1jzJEXI9rVyRRC7I,3584
12
- soprano_sdk/core/engine.py,sha256=RcUsWEyC9VljTxsv11-jTpmdO4k8lejK_5_OpHR3pS8,12330
11
+ soprano_sdk/core/constants.py,sha256=gByXYM7Lz7I7Sm2-tnn8alpaxuyuU_jfBmCpkigJHy4,4973
12
+ soprano_sdk/core/engine.py,sha256=VvqV4N6mDKHmsw8NBXkegyWdwWOivjQVe1R2nv3zBTw,19704
13
13
  soprano_sdk/core/rollback_strategies.py,sha256=NjDTtBCZlqyDql5PSwI9SMDLK7_BNlTxbW_cq_5gV0g,7783
14
14
  soprano_sdk/core/state.py,sha256=BZWL5W9zecjNe-IFg_4zv0lSLPqRST67mN01Zi8H2mk,2976
15
15
  soprano_sdk/nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  soprano_sdk/nodes/async_function.py,sha256=v6WujLKm8NXX2iAkJ7Gz_QIVCtWFrpC6nnPyyfuCxXs,9354
17
17
  soprano_sdk/nodes/base.py,sha256=gjgAe5dqNk5ItOETLhwF6is7l9BJtrAouLXFOiDKA3E,2601
18
18
  soprano_sdk/nodes/call_function.py,sha256=afYBmj5Aditbkvb_7gD3CsXBEEUohcsC1_cdHfcOunE,5847
19
- soprano_sdk/nodes/collect_input.py,sha256=5b_QbFQ3eIgohhchvDY_-o3Q66I5llWaqIoKkB56t5A,27744
19
+ soprano_sdk/nodes/collect_input.py,sha256=h4fLJPvLPRz2k6l-KfyeJLOwWAr3ALjtzyRWmJgAyiQ,28021
20
20
  soprano_sdk/nodes/factory.py,sha256=i37whA0ugMSwbmWk6H798Suhq6J108a5VenuFqxCVu8,1863
21
- soprano_sdk/nodes/follow_up.py,sha256=UKy6OswSoMeJ0U5Basf5CAvrbFzERvmwYB-ii8JAt6U,13873
21
+ soprano_sdk/nodes/follow_up.py,sha256=iVCKCCuqgU-kkLBi4iYBa_bB7j5QI8U2KNGHpgYPmRA,14165
22
22
  soprano_sdk/routing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
23
  soprano_sdk/routing/router.py,sha256=ECZn7NIVrVQ5l5eZr8sfVNT0MShixnQEsqh4YO6XwRs,3882
24
24
  soprano_sdk/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -28,9 +28,9 @@ soprano_sdk/utils/template.py,sha256=MG_B9TMx1ShpnSGo7s7TO-VfQzuFByuRNhJTvZ668kM
28
28
  soprano_sdk/utils/tool.py,sha256=hWN826HIKmLdswLCTURLH8hWlb2WU0MB8nIUErbpB-8,1877
29
29
  soprano_sdk/utils/tracing.py,sha256=gSHeBDLe-MbAZ9rkzpCoGFveeMdR9KLaA6tteB0IWjk,1991
30
30
  soprano_sdk/validation/__init__.py,sha256=ImChmO86jYHU90xzTttto2-LmOUOmvY_ibOQaLRz5BA,262
31
- soprano_sdk/validation/schema.py,sha256=THGjMdS9qqAiSJgWjhDZ8jwHA_WaPgjKop_8CfHeTeY,15607
31
+ soprano_sdk/validation/schema.py,sha256=NGnxP3TKcB-cylVcdOkxYhTPBbHnbmKTG_EamfA1VdY,17682
32
32
  soprano_sdk/validation/validator.py,sha256=f-e2MMRL70asOIXr_0Fsd5CgGKVRiQp7AaYsHA45Km0,8792
33
- soprano_sdk-0.2.20.dist-info/METADATA,sha256=q_6TFRVADa6_NV3MKKuAqVESfka8WpZT6IXOlqjNFW8,15563
34
- soprano_sdk-0.2.20.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
35
- soprano_sdk-0.2.20.dist-info/licenses/LICENSE,sha256=A1aBauSjPNtVehOXJe3WuvdU2xvM9H8XmigFMm6665s,1073
36
- soprano_sdk-0.2.20.dist-info/RECORD,,
33
+ soprano_sdk-0.2.21.dist-info/METADATA,sha256=YalPq6Je3QAYK_JW5cJrDnVkF9iBgqYcctM93OZXMaE,20410
34
+ soprano_sdk-0.2.21.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
35
+ soprano_sdk-0.2.21.dist-info/licenses/LICENSE,sha256=A1aBauSjPNtVehOXJe3WuvdU2xvM9H8XmigFMm6665s,1073
36
+ soprano_sdk-0.2.21.dist-info/RECORD,,