flowspec2 1.0.0__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 (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,876 @@
1
+ """FlowSpec2 to bounded Rasa CALM export conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping, Sequence, cast
6
+
7
+ from .models import (
8
+ CompatibilityDiagnostic,
9
+ CompatibilityReport,
10
+ ConversionOutcome,
11
+ enforce_compatibility_policy,
12
+ )
13
+ from .rasa_shared import (
14
+ FLOWSPEC_FORMAT,
15
+ RASA_ACTION_IDENTIFIER_PATTERN,
16
+ RASA_DOMAIN_VERSION,
17
+ RASA_FORMAT,
18
+ RasaBundle,
19
+ compatibility_diagnostic,
20
+ domain_button_options,
21
+ flowspec_compilation_diagnostic,
22
+ flowspec_validation_diagnostics,
23
+ mapping_or_none,
24
+ rasa_response_key,
25
+ rasa_response_text_supported,
26
+ rasa_set_slots_name_supported,
27
+ rasa_set_slots_value_supported,
28
+ sequence_or_none,
29
+ set_slots_payload,
30
+ step_identifier_diagnostics,
31
+ )
32
+
33
+
34
+ def _export_domain_specification(
35
+ domain_name: str,
36
+ domain_specification: Mapping[str, Any],
37
+ diagnostics: list[CompatibilityDiagnostic],
38
+ ) -> dict[str, Any] | None:
39
+ source_path = f"$.domains.{domain_name}"
40
+ domain_type = domain_specification.get("type", "categorical")
41
+ rasa_slot: dict[str, Any]
42
+
43
+ if domain_type == "categorical":
44
+ raw_values = sequence_or_none(domain_specification.get("values"))
45
+ if raw_values is None or not raw_values:
46
+ diagnostics.append(
47
+ compatibility_diagnostic(
48
+ "error",
49
+ "RASA_CATEGORICAL_VALUES_INVALID",
50
+ f"{source_path}.values",
51
+ "a Rasa categorical slot requires a non-empty list of string values",
52
+ )
53
+ )
54
+ return None
55
+ invalid_value_indexes = [
56
+ value_index
57
+ for value_index, category_value in enumerate(raw_values)
58
+ if not isinstance(category_value, str)
59
+ ]
60
+ for value_index in invalid_value_indexes:
61
+ diagnostics.append(
62
+ compatibility_diagnostic(
63
+ "error",
64
+ "RASA_CATEGORICAL_VALUE_UNSUPPORTED",
65
+ f"{source_path}.values[{value_index}]",
66
+ "Rasa categorical values in this profile must be strings; null is not portable",
67
+ )
68
+ )
69
+ string_values = [
70
+ category_value for category_value in raw_values if isinstance(category_value, str)
71
+ ]
72
+ normalized_values = [category_value.casefold() for category_value in string_values]
73
+ if len(normalized_values) != len(set(normalized_values)):
74
+ diagnostics.append(
75
+ compatibility_diagnostic(
76
+ "error",
77
+ "RASA_CATEGORICAL_CASE_COLLISION",
78
+ f"{source_path}.values",
79
+ "Rasa coerces categorical values case-insensitively, so "
80
+ "these values are not distinct",
81
+ )
82
+ )
83
+ rasa_slot = {"type": "categorical", "values": string_values}
84
+ elif domain_type == "bool":
85
+ rasa_slot = {"type": "bool"}
86
+ elif domain_type == "free_text":
87
+ rasa_slot = {"type": "text"}
88
+ elif domain_type in {"brazilian_tax_id", "email", "name"}:
89
+ rasa_slot = {"type": "text"}
90
+ diagnostics.append(
91
+ compatibility_diagnostic(
92
+ "error",
93
+ "RASA_DOMAIN_VALIDATION_UNSUPPORTED",
94
+ f"{source_path}.type",
95
+ f"flowspec2 {domain_type!r} validation is not enforced by a Rasa text slot",
96
+ )
97
+ )
98
+ else:
99
+ diagnostics.append(
100
+ compatibility_diagnostic(
101
+ "error",
102
+ "RASA_DOMAIN_TYPE_UNSUPPORTED",
103
+ f"{source_path}.type",
104
+ f"domain type {domain_type!r} is outside the Rasa portable profile",
105
+ )
106
+ )
107
+ return None
108
+
109
+ rasa_slot["mappings"] = [{"type": "from_llm"}]
110
+ if domain_specification.get("normalize"):
111
+ diagnostics.append(
112
+ compatibility_diagnostic(
113
+ "error",
114
+ "RASA_NORMALIZATION_UNSUPPORTED",
115
+ f"{source_path}.normalize",
116
+ "flowspec2 deterministic normalization has no equivalent Rasa slot contract",
117
+ )
118
+ )
119
+ if domain_specification.get("rows"):
120
+ diagnostics.append(
121
+ compatibility_diagnostic(
122
+ "warning",
123
+ "RASA_LIST_ROW_DESCRIPTIONS_UNSUPPORTED",
124
+ f"{source_path}.rows",
125
+ "Rasa static buttons do not preserve flowspec2 list-row descriptions",
126
+ )
127
+ )
128
+ if domain_specification.get("optional"):
129
+ diagnostics.append(
130
+ compatibility_diagnostic(
131
+ "error",
132
+ "RASA_OPTIONAL_EMPTY_TEXT_UNSUPPORTED",
133
+ f"{source_path}.optional",
134
+ "Rasa text slots do not preserve flowspec2's empty-string skip contract",
135
+ )
136
+ )
137
+ return rasa_slot
138
+
139
+
140
+ def _export_slot_declaration_diagnostics(
141
+ slot_name: str,
142
+ slot_declaration: Mapping[str, Any],
143
+ diagnostics: list[CompatibilityDiagnostic],
144
+ ) -> None:
145
+ source_path = f"$.slots.{slot_name}"
146
+ if slot_declaration.get("persist", "data") != "data":
147
+ diagnostics.append(
148
+ compatibility_diagnostic(
149
+ "error",
150
+ "RASA_SLOT_PARTITION_UNSUPPORTED",
151
+ f"{source_path}.persist",
152
+ "Rasa slots cannot preserve flowspec2 internal or payload partitions",
153
+ )
154
+ )
155
+ if slot_declaration.get("nullable"):
156
+ diagnostics.append(
157
+ compatibility_diagnostic(
158
+ "error",
159
+ "RASA_NULLABLE_SLOT_UNSUPPORTED",
160
+ f"{source_path}.nullable",
161
+ "the Rasa portable profile does not preserve explicit-null collection semantics",
162
+ )
163
+ )
164
+ if slot_declaration.get("requires"):
165
+ diagnostics.append(
166
+ compatibility_diagnostic(
167
+ "error",
168
+ "RASA_SLOT_DEPENDENCY_UNSUPPORTED",
169
+ f"{source_path}.requires",
170
+ "slot precedence and correction invalidation are control-flow semantics",
171
+ )
172
+ )
173
+ if slot_declaration.get("prefill_sources"):
174
+ diagnostics.append(
175
+ compatibility_diagnostic(
176
+ "error",
177
+ "RASA_PREFILL_SOURCES_UNSUPPORTED",
178
+ f"{source_path}.prefill_sources",
179
+ "Rasa from_llm mappings do not restrict prefill by flowspec2 source channel",
180
+ )
181
+ )
182
+ if "max_attempts" in slot_declaration:
183
+ diagnostics.append(
184
+ compatibility_diagnostic(
185
+ "error",
186
+ "RASA_MAX_ATTEMPTS_UNSUPPORTED",
187
+ f"{source_path}.max_attempts",
188
+ "Rasa collect steps do not preserve flowspec2 exhaustion control flow",
189
+ )
190
+ )
191
+ if "on_exhaust" in slot_declaration:
192
+ diagnostics.append(
193
+ compatibility_diagnostic(
194
+ "error",
195
+ "RASA_EXHAUSTION_TRANSITION_UNSUPPORTED",
196
+ f"{source_path}.on_exhaust",
197
+ "Rasa collect steps do not preserve flowspec2 exhaustion transitions",
198
+ )
199
+ )
200
+ if "default" in slot_declaration:
201
+ diagnostics.append(
202
+ compatibility_diagnostic(
203
+ "error",
204
+ "RASA_SLOT_DEFAULT_UNSUPPORTED",
205
+ f"{source_path}.default",
206
+ "a Rasa initial value is not equivalent to a flowspec2 exhaustion default",
207
+ )
208
+ )
209
+ if slot_declaration.get("fill_only_when_asked"):
210
+ diagnostics.append(
211
+ compatibility_diagnostic(
212
+ "error",
213
+ "RASA_FILL_ONLY_WHEN_ASKED_UNSUPPORTED",
214
+ f"{source_path}.fill_only_when_asked",
215
+ "Rasa from_llm mappings may fill the slot outside its collect step",
216
+ )
217
+ )
218
+
219
+
220
+ def _export_buttons(
221
+ slot_name: str,
222
+ domain_specification: Mapping[str, Any],
223
+ source_path: str,
224
+ diagnostics: list[CompatibilityDiagnostic],
225
+ ) -> list[dict[str, str]] | None:
226
+ button_options = domain_button_options(domain_specification)
227
+ if not button_options:
228
+ diagnostics.append(
229
+ compatibility_diagnostic(
230
+ "warning",
231
+ "RASA_STATIC_BUTTONS_UNSUPPORTED",
232
+ source_path,
233
+ "this domain cannot be materialized as static Rasa SetSlots buttons",
234
+ )
235
+ )
236
+ return None
237
+ if not rasa_response_text_supported(slot_name):
238
+ diagnostics.append(
239
+ compatibility_diagnostic(
240
+ "error",
241
+ "RASA_RESPONSE_INTERPOLATION_UNSUPPORTED",
242
+ f"{source_path}.field",
243
+ "curly braces in a SetSlots name trigger Rasa response interpolation",
244
+ )
245
+ )
246
+ return None
247
+ if not rasa_set_slots_name_supported(slot_name):
248
+ diagnostics.append(
249
+ compatibility_diagnostic(
250
+ "warning",
251
+ "RASA_SETSLOTS_SLOT_NAME_UNSUPPORTED",
252
+ f"{source_path}.field",
253
+ "portable SetSlots names must be non-empty and forbid '=,()'",
254
+ )
255
+ )
256
+ return None
257
+
258
+ interpolated_values = [
259
+ button_value
260
+ for button_value, _button_title in button_options
261
+ if not rasa_response_text_supported(button_value)
262
+ ]
263
+ for button_value in interpolated_values:
264
+ diagnostics.append(
265
+ compatibility_diagnostic(
266
+ "error",
267
+ "RASA_RESPONSE_INTERPOLATION_UNSUPPORTED",
268
+ source_path,
269
+ "curly braces in a button title or payload trigger Rasa response "
270
+ f"interpolation: {button_value!r}",
271
+ )
272
+ )
273
+ unsupported_values = [
274
+ button_value
275
+ for button_value, _button_title in button_options
276
+ if rasa_response_text_supported(button_value)
277
+ and not rasa_set_slots_value_supported(button_value)
278
+ ]
279
+ for button_value in unsupported_values:
280
+ diagnostics.append(
281
+ compatibility_diagnostic(
282
+ "warning",
283
+ "RASA_SETSLOTS_VALUE_UNSUPPORTED",
284
+ source_path,
285
+ "portable SetSlots values must be non-empty and forbid ',()'; "
286
+ f"'=' remains valid: {button_value!r}",
287
+ )
288
+ )
289
+ if interpolated_values or unsupported_values:
290
+ return None
291
+ return [
292
+ {
293
+ "title": button_title,
294
+ "payload": set_slots_payload(slot_name, button_value),
295
+ }
296
+ for button_value, button_title in button_options
297
+ ]
298
+
299
+
300
+ def _export_interactive(
301
+ interactive: Mapping[str, Any],
302
+ slot_name: str,
303
+ domain_name: str,
304
+ domain_specification: Mapping[str, Any],
305
+ source_path: str,
306
+ diagnostics: list[CompatibilityDiagnostic],
307
+ ) -> list[dict[str, str]] | None:
308
+ interactive_kind = interactive.get("kind")
309
+ control_properties = (
310
+ "options_when",
311
+ "out_of_band",
312
+ "next_step",
313
+ "meta_flow_ref",
314
+ "prefill_from",
315
+ )
316
+ for property_name in control_properties:
317
+ if interactive.get(property_name):
318
+ diagnostics.append(
319
+ compatibility_diagnostic(
320
+ "error",
321
+ "RASA_INTERACTIVE_CONTROL_UNSUPPORTED",
322
+ f"{source_path}.{property_name}",
323
+ f"interactive control {property_name!r} has no portable Rasa equivalent",
324
+ )
325
+ )
326
+ if interactive_kind != "buttons":
327
+ diagnostics.append(
328
+ compatibility_diagnostic(
329
+ "warning",
330
+ "RASA_INTERACTIVE_KIND_UNSUPPORTED",
331
+ f"{source_path}.kind",
332
+ f"interactive kind {interactive_kind!r} is reduced to a text response",
333
+ )
334
+ )
335
+ return None
336
+ if interactive.get("field") != slot_name:
337
+ diagnostics.append(
338
+ compatibility_diagnostic(
339
+ "error",
340
+ "RASA_INTERACTIVE_FIELD_MISMATCH",
341
+ f"{source_path}.field",
342
+ "a Rasa collect response may set only the slot being collected",
343
+ )
344
+ )
345
+ return None
346
+ if interactive.get("from_domain") != domain_name:
347
+ diagnostics.append(
348
+ compatibility_diagnostic(
349
+ "error",
350
+ "RASA_INTERACTIVE_DOMAIN_MISMATCH",
351
+ f"{source_path}.from_domain",
352
+ "static buttons must be materialized from the collected slot's domain",
353
+ )
354
+ )
355
+ return None
356
+ if any(interactive.get(property_name) for property_name in control_properties):
357
+ return None
358
+ return _export_buttons(
359
+ slot_name,
360
+ domain_specification,
361
+ source_path,
362
+ diagnostics,
363
+ )
364
+
365
+
366
+ def _export_collection_step(
367
+ flow_identifier: str,
368
+ step_index: int,
369
+ step: Mapping[str, Any],
370
+ slot_name: str,
371
+ is_confirmation: bool,
372
+ slots: Mapping[str, Any],
373
+ domains: Mapping[str, Any],
374
+ responses: dict[str, list[dict[str, Any]]],
375
+ diagnostics: list[CompatibilityDiagnostic],
376
+ ) -> dict[str, Any] | None:
377
+ source_path = f"$.path[{step_index}]"
378
+ slot_declaration = mapping_or_none(slots.get(slot_name))
379
+ if slot_declaration is None:
380
+ diagnostics.append(
381
+ compatibility_diagnostic(
382
+ "error",
383
+ "RASA_SLOT_DECLARATION_MISSING",
384
+ f"{source_path}.{'confirm' if is_confirmation else 'slot'}",
385
+ f"slot {slot_name!r} has no usable declaration",
386
+ )
387
+ )
388
+ return None
389
+ domain_name = slot_declaration.get("domain")
390
+ domain_specification = (
391
+ mapping_or_none(domains.get(domain_name)) if isinstance(domain_name, str) else None
392
+ )
393
+ if domain_specification is None:
394
+ diagnostics.append(
395
+ compatibility_diagnostic(
396
+ "error",
397
+ "RASA_DOMAIN_REFERENCE_MISSING",
398
+ f"$.slots.{slot_name}.domain",
399
+ f"domain {domain_name!r} has no usable declaration",
400
+ )
401
+ )
402
+ return None
403
+ if is_confirmation and domain_specification.get("type", "categorical") != "bool":
404
+ diagnostics.append(
405
+ compatibility_diagnostic(
406
+ "error",
407
+ "RASA_CONFIRM_DOMAIN_UNSUPPORTED",
408
+ f"{source_path}.confirm",
409
+ "only a bool-domain confirmation is portable to Rasa",
410
+ )
411
+ )
412
+ if is_confirmation:
413
+ diagnostics.append(
414
+ compatibility_diagnostic(
415
+ "error",
416
+ "RASA_CONFIRM_SEMANTICS_UNSUPPORTED",
417
+ f"{source_path}.confirm",
418
+ "Rasa collect cannot preserve flowspec2 confirmation exhaustion "
419
+ "without changing skip-if-filled behavior",
420
+ )
421
+ )
422
+
423
+ for control_property in ("ask_when", "skip_when", "on_reject"):
424
+ if control_property in step:
425
+ diagnostics.append(
426
+ compatibility_diagnostic(
427
+ "error",
428
+ "RASA_STEP_CONTROL_UNSUPPORTED",
429
+ f"{source_path}.{control_property}",
430
+ f"step control {control_property!r} is not part of the linear portable profile",
431
+ )
432
+ )
433
+ if step.get("correctable"):
434
+ diagnostics.append(
435
+ compatibility_diagnostic(
436
+ "error",
437
+ "RASA_CORRECTION_HUB_UNSUPPORTED",
438
+ f"{source_path}.correctable",
439
+ "flowspec2 correction routing cannot be represented as a plain Rasa collect step",
440
+ )
441
+ )
442
+
443
+ rasa_step: dict[str, Any] = {"collect": slot_name}
444
+ if isinstance(step.get("step"), str):
445
+ rasa_step["id"] = step["step"]
446
+ prompt = mapping_or_none(step.get("prompt")) or {}
447
+ if isinstance(prompt.get("extract_hint"), str):
448
+ rasa_step["description"] = prompt["extract_hint"]
449
+ if prompt.get("verbatim"):
450
+ diagnostics.append(
451
+ compatibility_diagnostic(
452
+ "warning",
453
+ "RASA_VERBATIM_PROMPT_UNSUPPORTED",
454
+ f"{source_path}.prompt.verbatim",
455
+ "Rasa does not carry flowspec2's prohibition on prompt rephrasing",
456
+ )
457
+ )
458
+
459
+ response_key = rasa_response_key(flow_identifier, slot_name, step_index)
460
+ rasa_step["utter"] = response_key
461
+ response_text_value = prompt.get(
462
+ "text",
463
+ "Do you confirm?" if is_confirmation else f"Provide {slot_name}.",
464
+ )
465
+ if not isinstance(response_text_value, str) or not response_text_value:
466
+ diagnostics.append(
467
+ compatibility_diagnostic(
468
+ "error",
469
+ "RASA_RESPONSE_TEXT_REQUIRED",
470
+ f"{source_path}.prompt.text",
471
+ "the portable Rasa response requires non-empty prompt text",
472
+ )
473
+ )
474
+ response_text = ""
475
+ else:
476
+ response_text = response_text_value
477
+ if response_text and not rasa_response_text_supported(response_text):
478
+ diagnostics.append(
479
+ compatibility_diagnostic(
480
+ "error",
481
+ "RASA_RESPONSE_INTERPOLATION_UNSUPPORTED",
482
+ f"{source_path}.prompt.text",
483
+ "curly braces are literal in flowspec2 but trigger Rasa response interpolation",
484
+ )
485
+ )
486
+ response_variation: dict[str, Any] = {"text": response_text}
487
+ interactive = mapping_or_none(step.get("interactive"))
488
+ if interactive is not None:
489
+ buttons = _export_interactive(
490
+ interactive,
491
+ slot_name,
492
+ cast(str, domain_name),
493
+ domain_specification,
494
+ f"{source_path}.interactive",
495
+ diagnostics,
496
+ )
497
+ if buttons is not None:
498
+ response_variation["buttons"] = buttons
499
+ responses[response_key] = [response_variation]
500
+ return rasa_step
501
+
502
+
503
+ def _export_top_level_diagnostics(
504
+ flow_document: Mapping[str, Any],
505
+ diagnostics: list[CompatibilityDiagnostic],
506
+ ) -> None:
507
+ diagnostics.append(
508
+ compatibility_diagnostic(
509
+ "warning",
510
+ "RASA_FLOW_VERSION_UNSUPPORTED",
511
+ "$.version",
512
+ "Rasa flows.yml has no canonical field for the flowspec2 document version",
513
+ )
514
+ )
515
+ if any(
516
+ isinstance(step, Mapping) and ("slot" in step or "confirm" in step)
517
+ for step in cast(Sequence[Any], flow_document.get("path", ()))
518
+ ):
519
+ diagnostics.append(
520
+ compatibility_diagnostic(
521
+ "warning",
522
+ "RASA_LLM_SLOT_SEMANTICS_UNSUPPORTED",
523
+ "$.path",
524
+ "Rasa CALM collection can fill or correct slots opportunistically "
525
+ "and processes interruption or repair commands during collection; "
526
+ "flowspec2 collection is sequential with explicit correction and "
527
+ "transition routing",
528
+ )
529
+ )
530
+ route = mapping_or_none(flow_document.get("route")) or {}
531
+ if route.get("trigger_phrases"):
532
+ diagnostics.append(
533
+ compatibility_diagnostic(
534
+ "warning",
535
+ "RASA_TRIGGER_PHRASES_UNSUPPORTED",
536
+ "$.route.trigger_phrases",
537
+ "free-text trigger phrases are not Rasa nlu_trigger intent identifiers",
538
+ )
539
+ )
540
+ if "entry_args_schema" in route:
541
+ diagnostics.append(
542
+ compatibility_diagnostic(
543
+ "error",
544
+ "RASA_ENTRY_ARGUMENTS_UNSUPPORTED",
545
+ "$.route.entry_args_schema",
546
+ "Rasa flows do not preserve flowspec2's closed entry-argument schema",
547
+ )
548
+ )
549
+ if flow_document.get("service"):
550
+ diagnostics.append(
551
+ compatibility_diagnostic(
552
+ "warning",
553
+ "RASA_SERVICE_METADATA_UNSUPPORTED",
554
+ "$.service",
555
+ "municipal service identity metadata is not represented in Rasa flows.yml",
556
+ )
557
+ )
558
+ for property_name, code, message in (
559
+ (
560
+ "config",
561
+ "RASA_CONFIG_CONTROL_UNSUPPORTED",
562
+ "flow guardrail configuration has no portable Rasa equivalent",
563
+ ),
564
+ (
565
+ "entry",
566
+ "RASA_ENTRY_ACTION_UNSUPPORTED",
567
+ "the best-effort entry side effect is not a linear collect step",
568
+ ),
569
+ (
570
+ "uses",
571
+ "RASA_SUBFLOW_UNSUPPORTED",
572
+ "versioned built-in subflows cannot be expanded without changing their contracts",
573
+ ),
574
+ (
575
+ "derive",
576
+ "RASA_DERIVATION_UNSUPPORTED",
577
+ "lookup derivations are deterministic control-flow constructs",
578
+ ),
579
+ (
580
+ "confirm",
581
+ "RASA_CORRECTION_ROUTER_UNSUPPORTED",
582
+ "the top-level correction router has no plain collect equivalent",
583
+ ),
584
+ (
585
+ "overrides",
586
+ "RASA_OVERRIDE_GATES_UNSUPPORTED",
587
+ "override gates require conditional Rasa control flow",
588
+ ),
589
+ (
590
+ "auto_flow",
591
+ "RASA_AUTO_FLOW_UNSUPPORTED",
592
+ "WhatsApp Flow send/resume behavior is outside the Rasa portable profile",
593
+ ),
594
+ ):
595
+ if flow_document.get(property_name):
596
+ diagnostics.append(
597
+ compatibility_diagnostic("error", code, f"$.{property_name}", message)
598
+ )
599
+
600
+ capabilities = mapping_or_none(flow_document.get("capabilities")) or {}
601
+ for capability_name, capability_value in capabilities.items():
602
+ if not capability_value:
603
+ continue
604
+ if capability_name == "await_external":
605
+ diagnostics.append(
606
+ compatibility_diagnostic(
607
+ "error",
608
+ "RASA_EXTERNAL_WAIT_UNSUPPORTED",
609
+ "$.capabilities.await_external",
610
+ "suspend/resume and recovery transitions require profile-aware runtime support",
611
+ )
612
+ )
613
+ else:
614
+ diagnostics.append(
615
+ compatibility_diagnostic(
616
+ "error",
617
+ "RASA_CAPABILITY_UNSUPPORTED",
618
+ f"$.capabilities.{capability_name}",
619
+ f"agent capability {capability_name!r} is omitted from the Rasa bundle",
620
+ )
621
+ )
622
+
623
+
624
+ def export_rasa(
625
+ flow_document: Mapping[str, Any],
626
+ *,
627
+ allow_lossy: bool = False,
628
+ ) -> ConversionOutcome[RasaBundle]:
629
+ """Project one flowspec2 document into the bounded Rasa CALM profile."""
630
+
631
+ source_document = dict(flow_document)
632
+ diagnostics = list(
633
+ flowspec_validation_diagnostics(
634
+ source_document,
635
+ "FLOWSPEC_INVALID_DOCUMENT",
636
+ )
637
+ )
638
+ if diagnostics:
639
+ report = CompatibilityReport(
640
+ source_format=FLOWSPEC_FORMAT,
641
+ target_format=RASA_FORMAT,
642
+ diagnostics=tuple(diagnostics),
643
+ )
644
+ enforce_compatibility_policy(report, allow_lossy=allow_lossy)
645
+ raise AssertionError("an invalid source document must block Rasa export")
646
+
647
+ if compilation_diagnostic := flowspec_compilation_diagnostic(
648
+ source_document,
649
+ "FLOWSPEC_NON_EXECUTABLE",
650
+ ):
651
+ diagnostics.append(compilation_diagnostic)
652
+
653
+ _export_top_level_diagnostics(source_document, diagnostics)
654
+ flow_identifier = cast(str, source_document["flow"])
655
+ route = cast(Mapping[str, Any], source_document["route"])
656
+ domains = cast(Mapping[str, Any], source_document["domains"])
657
+ slots = cast(Mapping[str, Any], source_document.get("slots", {}))
658
+
659
+ referenced_domain_names = {
660
+ slot_declaration["domain"]
661
+ for slot_value in slots.values()
662
+ if (slot_declaration := mapping_or_none(slot_value)) is not None
663
+ and isinstance(slot_declaration.get("domain"), str)
664
+ }
665
+ converted_domains: dict[str, dict[str, Any]] = {}
666
+ for domain_name, domain_value in domains.items():
667
+ domain_specification = cast(Mapping[str, Any], domain_value)
668
+ converted_domain = _export_domain_specification(
669
+ domain_name,
670
+ domain_specification,
671
+ diagnostics,
672
+ )
673
+ if converted_domain is not None:
674
+ converted_domains[domain_name] = converted_domain
675
+ if domain_name not in referenced_domain_names:
676
+ diagnostics.append(
677
+ compatibility_diagnostic(
678
+ "warning",
679
+ "RASA_UNREFERENCED_DOMAIN_UNSUPPORTED",
680
+ f"$.domains.{domain_name}",
681
+ "Rasa domain.yml has no standalone registry for an "
682
+ "unreferenced flowspec2 domain",
683
+ )
684
+ )
685
+
686
+ rasa_slots: dict[str, Any] = {}
687
+ for slot_name, slot_value in slots.items():
688
+ slot_declaration = cast(Mapping[str, Any], slot_value)
689
+ _export_slot_declaration_diagnostics(slot_name, slot_declaration, diagnostics)
690
+ referenced_domain_name = slot_declaration.get("domain")
691
+ referenced_domain_specification = (
692
+ mapping_or_none(domains.get(referenced_domain_name))
693
+ if isinstance(referenced_domain_name, str)
694
+ else None
695
+ )
696
+ if referenced_domain_specification is None:
697
+ diagnostics.append(
698
+ compatibility_diagnostic(
699
+ "error",
700
+ "RASA_DOMAIN_REFERENCE_MISSING",
701
+ f"$.slots.{slot_name}.domain",
702
+ f"domain {referenced_domain_name!r} has no usable declaration",
703
+ )
704
+ )
705
+ continue
706
+ converted_slot = converted_domains.get(cast(str, referenced_domain_name))
707
+ if converted_slot is not None:
708
+ rasa_slots[slot_name] = converted_slot
709
+
710
+ responses: dict[str, list[dict[str, Any]]] = {}
711
+ rasa_steps: list[dict[str, Any]] = []
712
+ collected_slot_names: list[str] = []
713
+ actions: list[str] = []
714
+ terminal_marker_seen = False
715
+ for step_index, step_value in enumerate(cast(Sequence[Any], source_document["path"])):
716
+ step = cast(Mapping[str, Any], step_value)
717
+ if isinstance(step.get("slot"), str):
718
+ converted_step = _export_collection_step(
719
+ flow_identifier,
720
+ step_index,
721
+ step,
722
+ cast(str, step["slot"]),
723
+ False,
724
+ slots,
725
+ domains,
726
+ responses,
727
+ diagnostics,
728
+ )
729
+ if converted_step is not None:
730
+ rasa_steps.append(converted_step)
731
+ collected_slot_names.append(cast(str, step["slot"]))
732
+ elif isinstance(step.get("confirm"), str):
733
+ converted_step = _export_collection_step(
734
+ flow_identifier,
735
+ step_index,
736
+ step,
737
+ cast(str, step["confirm"]),
738
+ True,
739
+ slots,
740
+ domains,
741
+ responses,
742
+ diagnostics,
743
+ )
744
+ if converted_step is not None:
745
+ rasa_steps.append(converted_step)
746
+ collected_slot_names.append(cast(str, step["confirm"]))
747
+ elif step.get("terminal") is True:
748
+ terminal_marker_seen = True
749
+ terminal = mapping_or_none(source_document.get("terminal"))
750
+ if terminal is None:
751
+ diagnostics.append(
752
+ compatibility_diagnostic(
753
+ "error",
754
+ "RASA_TERMINAL_DECLARATION_MISSING",
755
+ f"$.path[{step_index}].terminal",
756
+ "the terminal marker has no usable terminal declaration",
757
+ )
758
+ )
759
+ continue
760
+ tool_name = terminal.get("tool")
761
+ if not isinstance(tool_name, str) or not RASA_ACTION_IDENTIFIER_PATTERN.fullmatch(
762
+ tool_name
763
+ ):
764
+ diagnostics.append(
765
+ compatibility_diagnostic(
766
+ "error",
767
+ "RASA_ACTION_NAME_UNSUPPORTED",
768
+ "$.terminal.tool",
769
+ "the terminal tool is not a valid portable Rasa custom-action name",
770
+ )
771
+ )
772
+ continue
773
+ terminal_step: dict[str, Any] = {"action": tool_name}
774
+ if isinstance(terminal.get("step"), str):
775
+ terminal_step["id"] = terminal["step"]
776
+ rasa_steps.append(terminal_step)
777
+ actions.append(tool_name)
778
+ diagnostics.append(
779
+ compatibility_diagnostic(
780
+ "warning",
781
+ "RASA_ACTION_ADAPTER_REQUIRED",
782
+ "$.terminal",
783
+ "the custom action must implement flowspec2 inputs, "
784
+ "idempotency, outputs and outcome lifecycle",
785
+ )
786
+ )
787
+ else:
788
+ step_kind = next(
789
+ (
790
+ property_name
791
+ for property_name in ("use", "derive", "await_external")
792
+ if property_name in step
793
+ ),
794
+ "unknown",
795
+ )
796
+ diagnostics.append(
797
+ compatibility_diagnostic(
798
+ "error",
799
+ "RASA_PATH_STEP_UNSUPPORTED",
800
+ f"$.path[{step_index}]",
801
+ f"path step kind {step_kind!r} is outside the linear Rasa portable profile",
802
+ )
803
+ )
804
+
805
+ step_identifier_diagnostics(rasa_steps, "$.path", diagnostics)
806
+ if source_document.get("terminal") and not terminal_marker_seen:
807
+ diagnostics.append(
808
+ compatibility_diagnostic(
809
+ "error",
810
+ "RASA_TERMINAL_UNREACHABLE",
811
+ "$.terminal",
812
+ "the terminal declaration has no marker in the source path",
813
+ )
814
+ )
815
+ for unreferenced_slot_name in sorted(set(slots) - set(collected_slot_names)):
816
+ diagnostics.append(
817
+ compatibility_diagnostic(
818
+ "warning",
819
+ "RASA_UNREFERENCED_SLOT_UNSUPPORTED",
820
+ f"$.slots.{unreferenced_slot_name}",
821
+ "Rasa import scope is limited to slots collected by the selected "
822
+ "flow, so this declaration will not survive a round trip",
823
+ )
824
+ )
825
+ required_slot_names = {
826
+ slot_name
827
+ for slot_name, slot_value in slots.items()
828
+ if (slot_declaration := mapping_or_none(slot_value)) is not None
829
+ and slot_declaration.get("required") is True
830
+ }
831
+ uncollected_required_slot_names = required_slot_names - set(collected_slot_names)
832
+ if uncollected_required_slot_names:
833
+ diagnostics.append(
834
+ compatibility_diagnostic(
835
+ "error",
836
+ "RASA_REQUIRED_SLOT_NOT_COLLECTED",
837
+ "$.slots",
838
+ "required slots have no portable collect step: "
839
+ f"{sorted(uncollected_required_slot_names)!r}",
840
+ )
841
+ )
842
+ if not collected_slot_names:
843
+ diagnostics.append(
844
+ compatibility_diagnostic(
845
+ "error",
846
+ "RASA_NO_PORTABLE_STEPS",
847
+ "$.path",
848
+ "the source flow produced no portable Rasa collect steps",
849
+ )
850
+ )
851
+
852
+ rasa_flow: dict[str, Any] = {
853
+ "description": route["description"],
854
+ "run_pattern_completed": False,
855
+ "persisted_slots": list(dict.fromkeys(collected_slot_names)),
856
+ "steps": rasa_steps,
857
+ }
858
+ flows_document = {"flows": {flow_identifier: rasa_flow}}
859
+ domain_document: dict[str, Any] = {
860
+ "version": RASA_DOMAIN_VERSION,
861
+ "slots": rasa_slots,
862
+ "responses": responses,
863
+ }
864
+ if actions:
865
+ domain_document["actions"] = list(dict.fromkeys(actions))
866
+
867
+ report = CompatibilityReport(
868
+ source_format=FLOWSPEC_FORMAT,
869
+ target_format=RASA_FORMAT,
870
+ diagnostics=tuple(diagnostics),
871
+ )
872
+ enforce_compatibility_policy(report, allow_lossy=allow_lossy)
873
+ return ConversionOutcome(
874
+ artifact=RasaBundle(flows_document, domain_document),
875
+ report=report,
876
+ )