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,992 @@
1
+ """Bounded Rasa CALM to FlowSpec2 import 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
+ DiagnosticSeverity,
12
+ enforce_compatibility_policy,
13
+ )
14
+ from .rasa_shared import (
15
+ FLOWSPEC_FLOW_IDENTIFIER_PATTERN,
16
+ FLOWSPEC_FORMAT,
17
+ RASA_ACTION_IDENTIFIER_PATTERN,
18
+ RASA_DOMAIN_VERSION,
19
+ RASA_FORMAT,
20
+ compatibility_diagnostic,
21
+ domain_button_options,
22
+ flowspec_compilation_diagnostic,
23
+ flowspec_validation_diagnostics,
24
+ mapping_or_none,
25
+ rasa_response_text_supported,
26
+ rasa_set_slots_name_supported,
27
+ rasa_set_slots_value_supported,
28
+ sequence_or_none,
29
+ step_identifier_diagnostics,
30
+ )
31
+
32
+
33
+ def _import_slot_mapping_diagnostics(
34
+ slot_name: str,
35
+ slot_declaration: Mapping[str, Any],
36
+ diagnostics: list[CompatibilityDiagnostic],
37
+ ) -> None:
38
+ source_path = f"$.slots.{slot_name}"
39
+ if "mappings" in slot_declaration:
40
+ mappings = sequence_or_none(slot_declaration.get("mappings"))
41
+ if (
42
+ mappings is None
43
+ or len(mappings) != 1
44
+ or mapping_or_none(mappings[0]) != {"type": "from_llm"}
45
+ ):
46
+ diagnostics.append(
47
+ compatibility_diagnostic(
48
+ "error",
49
+ "RASA_SLOT_MAPPING_UNSUPPORTED",
50
+ f"{source_path}.mappings",
51
+ "only an omitted mapping or one exact from_llm mapping is portable",
52
+ )
53
+ )
54
+ allowed_properties = {"type", "values", "mappings"}
55
+ for property_name in slot_declaration.keys() - allowed_properties:
56
+ diagnostics.append(
57
+ compatibility_diagnostic(
58
+ "error",
59
+ "RASA_SLOT_PROPERTY_UNSUPPORTED",
60
+ f"{source_path}.{property_name}",
61
+ f"Rasa slot property {property_name!r} has no flowspec2 portable equivalent",
62
+ )
63
+ )
64
+
65
+
66
+ def _import_domain_specification(
67
+ slot_name: str,
68
+ slot_declaration: Mapping[str, Any],
69
+ diagnostics: list[CompatibilityDiagnostic],
70
+ ) -> dict[str, Any] | None:
71
+ source_path = f"$.slots.{slot_name}"
72
+ _import_slot_mapping_diagnostics(slot_name, slot_declaration, diagnostics)
73
+ slot_type = slot_declaration.get("type")
74
+ if slot_type == "categorical":
75
+ values = sequence_or_none(slot_declaration.get("values"))
76
+ if values is None or not values or not all(isinstance(value, str) for value in values):
77
+ diagnostics.append(
78
+ compatibility_diagnostic(
79
+ "error",
80
+ "RASA_CATEGORICAL_VALUES_INVALID",
81
+ f"{source_path}.values",
82
+ "a portable categorical slot requires a non-empty list of strings",
83
+ )
84
+ )
85
+ return None
86
+ string_values = cast(Sequence[str], values)
87
+ if len({value.casefold() for value in string_values}) != len(string_values):
88
+ diagnostics.append(
89
+ compatibility_diagnostic(
90
+ "error",
91
+ "RASA_CATEGORICAL_CASE_COLLISION",
92
+ f"{source_path}.values",
93
+ "case-insensitive Rasa categories cannot become distinct flowspec2 tokens",
94
+ )
95
+ )
96
+ return {"type": "categorical", "values": list(string_values)}
97
+ if slot_type == "bool":
98
+ if "values" in slot_declaration:
99
+ diagnostics.append(
100
+ compatibility_diagnostic(
101
+ "error",
102
+ "RASA_SLOT_PROPERTY_UNSUPPORTED",
103
+ f"{source_path}.values",
104
+ "bool slots do not use categorical values in the portable profile",
105
+ )
106
+ )
107
+ return {"type": "bool"}
108
+ if slot_type == "text":
109
+ if "values" in slot_declaration:
110
+ diagnostics.append(
111
+ compatibility_diagnostic(
112
+ "error",
113
+ "RASA_SLOT_PROPERTY_UNSUPPORTED",
114
+ f"{source_path}.values",
115
+ "text slots do not use categorical values in the portable profile",
116
+ )
117
+ )
118
+ return {"type": "free_text"}
119
+ diagnostics.append(
120
+ compatibility_diagnostic(
121
+ "error",
122
+ "RASA_SLOT_TYPE_UNSUPPORTED",
123
+ f"{source_path}.type",
124
+ f"slot type {slot_type!r} is outside categorical, bool and text",
125
+ )
126
+ )
127
+ return None
128
+
129
+
130
+ def _parse_set_slots_payload(payload: object) -> tuple[str, str] | None:
131
+ if (
132
+ not isinstance(payload, str)
133
+ or not payload.startswith("/SetSlots(")
134
+ or not payload.endswith(")")
135
+ ):
136
+ return None
137
+ assignment = payload[len("/SetSlots(") : -1]
138
+ if "=" not in assignment or "," in assignment:
139
+ return None
140
+ slot_name, slot_value = assignment.split("=", 1)
141
+ if not slot_name or not slot_value:
142
+ return None
143
+ if not rasa_set_slots_name_supported(slot_name):
144
+ return None
145
+ if not rasa_set_slots_value_supported(slot_value):
146
+ return None
147
+ return slot_name, slot_value
148
+
149
+
150
+ def _import_buttons(
151
+ buttons_value: object,
152
+ slot_name: str,
153
+ domain_specification: Mapping[str, Any],
154
+ source_path: str,
155
+ diagnostics: list[CompatibilityDiagnostic],
156
+ ) -> dict[str, Any] | None:
157
+ buttons = sequence_or_none(buttons_value)
158
+ if buttons is None or not buttons:
159
+ diagnostics.append(
160
+ compatibility_diagnostic(
161
+ "warning",
162
+ "RASA_STATIC_BUTTONS_UNSUPPORTED",
163
+ source_path,
164
+ "buttons must be a non-empty list of title/payload mappings",
165
+ )
166
+ )
167
+ return None
168
+ if not rasa_set_slots_name_supported(slot_name):
169
+ diagnostics.append(
170
+ compatibility_diagnostic(
171
+ "warning",
172
+ "RASA_SETSLOTS_SLOT_NAME_UNSUPPORTED",
173
+ source_path,
174
+ "portable SetSlots names must be non-empty and forbid '=,()'",
175
+ )
176
+ )
177
+ return None
178
+
179
+ expected_options = domain_button_options(domain_specification)
180
+ expected_titles = dict(expected_options)
181
+ accepts_legacy_boolean_titles = domain_specification.get("type") == "bool"
182
+ parsed_values: list[str] = []
183
+ portable = True
184
+ for button_index, button_value in enumerate(buttons):
185
+ button = mapping_or_none(button_value)
186
+ button_path = f"{source_path}[{button_index}]"
187
+ if button is None or set(button) != {"title", "payload"}:
188
+ diagnostics.append(
189
+ compatibility_diagnostic(
190
+ "warning",
191
+ "RASA_STATIC_BUTTONS_UNSUPPORTED",
192
+ button_path,
193
+ "each portable button must contain exactly title and payload",
194
+ )
195
+ )
196
+ portable = False
197
+ continue
198
+ button_title = button.get("title")
199
+ button_payload = button.get("payload")
200
+ if any(
201
+ isinstance(button_text, str) and not rasa_response_text_supported(button_text)
202
+ for button_text in (button_title, button_payload)
203
+ ):
204
+ diagnostics.append(
205
+ compatibility_diagnostic(
206
+ "error",
207
+ "RASA_RESPONSE_INTERPOLATION_UNSUPPORTED",
208
+ button_path,
209
+ "curly braces in a button title or payload trigger Rasa response interpolation",
210
+ )
211
+ )
212
+ portable = False
213
+ continue
214
+ parsed_assignment = _parse_set_slots_payload(button.get("payload"))
215
+ if parsed_assignment is None:
216
+ diagnostics.append(
217
+ compatibility_diagnostic(
218
+ "warning",
219
+ "RASA_SETSLOTS_PAYLOAD_UNSUPPORTED",
220
+ f"{button_path}.payload",
221
+ "the payload must be one valid case-sensitive /SetSlots(slot=value) command",
222
+ )
223
+ )
224
+ portable = False
225
+ continue
226
+ assigned_slot, assigned_value = parsed_assignment
227
+ if assigned_slot != slot_name:
228
+ diagnostics.append(
229
+ compatibility_diagnostic(
230
+ "error",
231
+ "RASA_BUTTON_SLOT_MISMATCH",
232
+ f"{button_path}.payload",
233
+ "a collect response button may set only its collected slot",
234
+ )
235
+ )
236
+ portable = False
237
+ accepted_titles = {expected_titles.get(assigned_value)}
238
+ if accepts_legacy_boolean_titles:
239
+ accepted_titles.add(assigned_value)
240
+ if button.get("title") not in accepted_titles:
241
+ diagnostics.append(
242
+ compatibility_diagnostic(
243
+ "warning",
244
+ "RASA_BUTTON_TITLE_UNSUPPORTED",
245
+ f"{button_path}.title",
246
+ "the button title must match the domain's canonical presentation",
247
+ )
248
+ )
249
+ portable = False
250
+ parsed_values.append(assigned_value)
251
+
252
+ expected_values = [button_value for button_value, _button_title in expected_options]
253
+ if parsed_values != expected_values:
254
+ diagnostics.append(
255
+ compatibility_diagnostic(
256
+ "warning",
257
+ "RASA_BUTTON_VALUES_MISMATCH",
258
+ source_path,
259
+ "buttons must cover the slot domain exactly and in domain order",
260
+ )
261
+ )
262
+ portable = False
263
+ if not portable:
264
+ return None
265
+ return {"kind": "buttons", "field": slot_name}
266
+
267
+
268
+ def _import_prompt(
269
+ response_key: str,
270
+ slot_name: str,
271
+ responses: Mapping[str, Any],
272
+ domain_name: str,
273
+ domain_specification: Mapping[str, Any],
274
+ diagnostics: list[CompatibilityDiagnostic],
275
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
276
+ source_path = f"$.responses.{response_key}"
277
+ variations = sequence_or_none(responses.get(response_key))
278
+ if variations is None or len(variations) != 1:
279
+ diagnostics.append(
280
+ compatibility_diagnostic(
281
+ "error",
282
+ "RASA_RESPONSE_VARIATIONS_UNSUPPORTED",
283
+ source_path,
284
+ "the portable profile requires exactly one unconditional response variation",
285
+ )
286
+ )
287
+ return None, None
288
+ variation = mapping_or_none(variations[0])
289
+ if variation is None or not isinstance(variation.get("text"), str) or not variation["text"]:
290
+ diagnostics.append(
291
+ compatibility_diagnostic(
292
+ "error",
293
+ "RASA_RESPONSE_TEXT_REQUIRED",
294
+ f"{source_path}[0].text",
295
+ "the portable response variation requires non-empty text",
296
+ )
297
+ )
298
+ return None, None
299
+
300
+ allowed_properties = {"text", "buttons"}
301
+ for property_name in variation.keys() - allowed_properties:
302
+ diagnostics.append(
303
+ compatibility_diagnostic(
304
+ "error",
305
+ "RASA_RESPONSE_PROPERTY_UNSUPPORTED",
306
+ f"{source_path}[0].{property_name}",
307
+ f"conditional or rich response property {property_name!r} is not portable",
308
+ )
309
+ )
310
+ response_text = cast(str, variation["text"])
311
+ if not rasa_response_text_supported(response_text):
312
+ diagnostics.append(
313
+ compatibility_diagnostic(
314
+ "error",
315
+ "RASA_RESPONSE_INTERPOLATION_UNSUPPORTED",
316
+ f"{source_path}[0].text",
317
+ "Rasa response interpolation has no literal flowspec2 prompt equivalent",
318
+ )
319
+ )
320
+ prompt = {"text": response_text}
321
+ interactive: dict[str, Any] | None = None
322
+ if "buttons" in variation:
323
+ interactive = _import_buttons(
324
+ variation["buttons"],
325
+ slot_name,
326
+ domain_specification,
327
+ f"{source_path}[0].buttons",
328
+ diagnostics,
329
+ )
330
+ if interactive is not None:
331
+ interactive["from_domain"] = domain_name
332
+ return prompt, interactive
333
+
334
+
335
+ def _import_collect_step(
336
+ step: Mapping[str, Any],
337
+ flow_identifier: str,
338
+ step_index: int,
339
+ slot_declarations: Mapping[str, Any],
340
+ domain_names: Mapping[str, str],
341
+ flowspec_domains: Mapping[str, Any],
342
+ responses: Mapping[str, Any],
343
+ diagnostics: list[CompatibilityDiagnostic],
344
+ ) -> dict[str, Any] | None:
345
+ source_path = f"$.flows.{flow_identifier}.steps[{step_index}]"
346
+ slot_name = step.get("collect")
347
+ if not isinstance(slot_name, str) or not slot_name:
348
+ diagnostics.append(
349
+ compatibility_diagnostic(
350
+ "error",
351
+ "RASA_COLLECT_SLOT_INVALID",
352
+ f"{source_path}.collect",
353
+ "collect must name a non-empty domain slot",
354
+ )
355
+ )
356
+ return None
357
+ if mapping_or_none(slot_declarations.get(slot_name)) is None or slot_name not in domain_names:
358
+ diagnostics.append(
359
+ compatibility_diagnostic(
360
+ "error",
361
+ "RASA_COLLECT_SLOT_MISSING",
362
+ f"{source_path}.collect",
363
+ f"slot {slot_name!r} has no portable domain declaration",
364
+ )
365
+ )
366
+ return None
367
+
368
+ allowed_properties = {"collect", "id", "description", "utter", "ask_before_filling"}
369
+ for property_name in step.keys() - allowed_properties:
370
+ diagnostics.append(
371
+ compatibility_diagnostic(
372
+ "error",
373
+ "RASA_STEP_CONTROL_UNSUPPORTED",
374
+ f"{source_path}.{property_name}",
375
+ f"collect property {property_name!r} changes control flow "
376
+ "outside the portable profile",
377
+ )
378
+ )
379
+ if "description" in step and not isinstance(step.get("description"), str):
380
+ diagnostics.append(
381
+ compatibility_diagnostic(
382
+ "error",
383
+ "RASA_COLLECT_DESCRIPTION_INVALID",
384
+ f"{source_path}.description",
385
+ "a collect description must be a string",
386
+ )
387
+ )
388
+
389
+ domain_name = domain_names[slot_name]
390
+ domain_specification = cast(Mapping[str, Any], flowspec_domains[domain_name])
391
+ response_key_value = step.get("utter", f"utter_ask_{slot_name}")
392
+ if not isinstance(response_key_value, str):
393
+ diagnostics.append(
394
+ compatibility_diagnostic(
395
+ "error",
396
+ "RASA_UTTER_KEY_INVALID",
397
+ f"{source_path}.utter",
398
+ "utter must name one domain response",
399
+ )
400
+ )
401
+ response_key_value = f"utter_ask_{slot_name}"
402
+ elif not response_key_value.startswith("utter_"):
403
+ diagnostics.append(
404
+ compatibility_diagnostic(
405
+ "error",
406
+ "RASA_RESPONSE_KEY_UNSUPPORTED",
407
+ f"{source_path}.utter",
408
+ "Rasa response keys referenced by collect steps must start with 'utter_'",
409
+ )
410
+ )
411
+ prompt, interactive = _import_prompt(
412
+ response_key_value,
413
+ slot_name,
414
+ responses,
415
+ domain_name,
416
+ domain_specification,
417
+ diagnostics,
418
+ )
419
+
420
+ ask_before_filling = step.get("ask_before_filling", False)
421
+ if not isinstance(ask_before_filling, bool):
422
+ diagnostics.append(
423
+ compatibility_diagnostic(
424
+ "error",
425
+ "RASA_ASK_BEFORE_FILLING_INVALID",
426
+ f"{source_path}.ask_before_filling",
427
+ "ask_before_filling must be boolean",
428
+ )
429
+ )
430
+ ask_before_filling = False
431
+ if ask_before_filling:
432
+ diagnostics.append(
433
+ compatibility_diagnostic(
434
+ "error",
435
+ "RASA_ASK_BEFORE_FILLING_UNSUPPORTED",
436
+ f"{source_path}.ask_before_filling",
437
+ "Rasa clears a prefilled slot before asking, which flowspec2 "
438
+ "portable collect does not do",
439
+ )
440
+ )
441
+
442
+ flowspec_step: dict[str, Any] = {"slot": slot_name}
443
+ if isinstance(step.get("id"), str) and step["id"]:
444
+ flowspec_step["step"] = step["id"]
445
+ if prompt is not None:
446
+ if isinstance(step.get("description"), str):
447
+ prompt["extract_hint"] = step["description"]
448
+ flowspec_step["prompt"] = prompt
449
+ if interactive is not None:
450
+ flowspec_step["interactive"] = interactive
451
+ return flowspec_step
452
+
453
+
454
+ def _import_terminal_action(
455
+ step: Mapping[str, Any],
456
+ flow_identifier: str,
457
+ step_index: int,
458
+ step_count: int,
459
+ declared_actions: frozenset[str],
460
+ diagnostics: list[CompatibilityDiagnostic],
461
+ ) -> tuple[dict[str, Any], dict[str, Any]] | None:
462
+ source_path = f"$.flows.{flow_identifier}.steps[{step_index}]"
463
+ if step_index != step_count - 1:
464
+ diagnostics.append(
465
+ compatibility_diagnostic(
466
+ "error",
467
+ "RASA_ACTION_POSITION_UNSUPPORTED",
468
+ source_path,
469
+ "only one final custom action can represent a flowspec2 terminal",
470
+ )
471
+ )
472
+ return None
473
+ for property_name in step.keys() - {"action", "id"}:
474
+ diagnostics.append(
475
+ compatibility_diagnostic(
476
+ "error",
477
+ "RASA_STEP_CONTROL_UNSUPPORTED",
478
+ f"{source_path}.{property_name}",
479
+ f"terminal action property {property_name!r} changes control flow",
480
+ )
481
+ )
482
+ action_name = step.get("action")
483
+ if not isinstance(action_name, str) or not RASA_ACTION_IDENTIFIER_PATTERN.fullmatch(
484
+ action_name
485
+ ):
486
+ diagnostics.append(
487
+ compatibility_diagnostic(
488
+ "error",
489
+ "RASA_ACTION_NAME_UNSUPPORTED",
490
+ f"{source_path}.action",
491
+ "the final action must be a valid Rasa custom-action identifier",
492
+ )
493
+ )
494
+ return None
495
+ if action_name not in declared_actions:
496
+ diagnostics.append(
497
+ compatibility_diagnostic(
498
+ "error",
499
+ "RASA_ACTION_DECLARATION_MISSING",
500
+ f"{source_path}.action",
501
+ "the final custom action must be listed in domain.yml actions",
502
+ )
503
+ )
504
+ terminal_step_identifier = step.get("id", action_name)
505
+ if not isinstance(terminal_step_identifier, str) or not terminal_step_identifier:
506
+ terminal_step_identifier = action_name
507
+
508
+ diagnostics.append(
509
+ compatibility_diagnostic(
510
+ "warning",
511
+ "RASA_ACTION_ADAPTER_REQUIRED",
512
+ source_path,
513
+ "the imported terminal uses conservative lifecycle defaults: "
514
+ "non-idempotent and reset after every outcome",
515
+ )
516
+ )
517
+ terminal = {
518
+ "step": terminal_step_identifier,
519
+ "tool": action_name,
520
+ "idempotent": False,
521
+ "outcomes": {
522
+ "success": {"reset_next": True},
523
+ "retryable": {"preserve_state": False},
524
+ "fatal": {"reset_next": True},
525
+ },
526
+ }
527
+ return {"terminal": True}, terminal
528
+
529
+
530
+ def _select_rasa_flow(
531
+ flows_document: Mapping[str, Any],
532
+ flow_id: str | None,
533
+ diagnostics: list[CompatibilityDiagnostic],
534
+ ) -> tuple[str, Mapping[str, Any]] | None:
535
+ flows = mapping_or_none(flows_document.get("flows"))
536
+ if flows is None or not flows:
537
+ diagnostics.append(
538
+ compatibility_diagnostic(
539
+ "error",
540
+ "RASA_FLOWS_MAPPING_REQUIRED",
541
+ "$.flows",
542
+ "flows.yml must contain a non-empty flows mapping",
543
+ )
544
+ )
545
+ return None
546
+ string_flow_ids = [candidate_id for candidate_id in flows if isinstance(candidate_id, str)]
547
+ if len(string_flow_ids) != len(flows):
548
+ diagnostics.append(
549
+ compatibility_diagnostic(
550
+ "error",
551
+ "RASA_FLOW_ID_INVALID",
552
+ "$.flows",
553
+ "every Rasa flow id must be a string",
554
+ )
555
+ )
556
+ selected_flow_id = flow_id
557
+ if selected_flow_id is None:
558
+ if len(string_flow_ids) != 1:
559
+ diagnostics.append(
560
+ compatibility_diagnostic(
561
+ "error",
562
+ "RASA_FLOW_SELECTION_REQUIRED",
563
+ "$.flows",
564
+ "flow_id is required when flows.yml contains more than one flow",
565
+ )
566
+ )
567
+ selected_flow_id = string_flow_ids[0] if string_flow_ids else None
568
+ if selected_flow_id is None or selected_flow_id not in flows:
569
+ diagnostics.append(
570
+ compatibility_diagnostic(
571
+ "error",
572
+ "RASA_FLOW_NOT_FOUND",
573
+ "$.flows",
574
+ f"selected flow {selected_flow_id!r} does not exist",
575
+ )
576
+ )
577
+ return None
578
+ selected_flow = mapping_or_none(flows[selected_flow_id])
579
+ if selected_flow is None:
580
+ diagnostics.append(
581
+ compatibility_diagnostic(
582
+ "error",
583
+ "RASA_FLOW_DEFINITION_INVALID",
584
+ f"$.flows.{selected_flow_id}",
585
+ "the selected flow must be a mapping",
586
+ )
587
+ )
588
+ return None
589
+ return selected_flow_id, selected_flow
590
+
591
+
592
+ def _import_flow_property_diagnostics(
593
+ flow_identifier: str,
594
+ flow_definition: Mapping[str, Any],
595
+ diagnostics: list[CompatibilityDiagnostic],
596
+ ) -> None:
597
+ allowed_properties = {
598
+ "description",
599
+ "steps",
600
+ "persisted_slots",
601
+ "run_pattern_completed",
602
+ }
603
+ for property_name in flow_definition.keys() - allowed_properties:
604
+ severity: DiagnosticSeverity = "warning" if property_name == "name" else "error"
605
+ code = (
606
+ "RASA_FLOW_NAME_UNSUPPORTED"
607
+ if property_name == "name"
608
+ else "RASA_FLOW_CONTROL_UNSUPPORTED"
609
+ )
610
+ diagnostics.append(
611
+ compatibility_diagnostic(
612
+ severity,
613
+ code,
614
+ f"$.flows.{flow_identifier}.{property_name}",
615
+ (
616
+ "flowspec2 has no separate human-readable flow-name field"
617
+ if property_name == "name"
618
+ else f"Rasa flow property {property_name!r} is outside the "
619
+ "linear portable profile"
620
+ ),
621
+ )
622
+ )
623
+ if flow_definition.get("run_pattern_completed") is not False:
624
+ diagnostics.append(
625
+ compatibility_diagnostic(
626
+ "error",
627
+ "RASA_COMPLETION_PATTERN_UNSUPPORTED",
628
+ f"$.flows.{flow_identifier}.run_pattern_completed",
629
+ "the portable profile requires run_pattern_completed: false because "
630
+ "Rasa otherwise runs completion-pattern behavior with no flowspec2 equivalent",
631
+ )
632
+ )
633
+
634
+
635
+ def _import_document_property_diagnostics(
636
+ flows_document: Mapping[str, Any],
637
+ domain_document: Mapping[str, Any],
638
+ diagnostics: list[CompatibilityDiagnostic],
639
+ ) -> None:
640
+ for property_name in flows_document.keys() - {"flows", "version"}:
641
+ diagnostics.append(
642
+ compatibility_diagnostic(
643
+ "error",
644
+ "RASA_FLOWS_DOCUMENT_PROPERTY_UNSUPPORTED",
645
+ f"$.{property_name}",
646
+ f"flows.yml property {property_name!r} is outside the portable profile",
647
+ )
648
+ )
649
+ for property_name in domain_document.keys() - {
650
+ "version",
651
+ "slots",
652
+ "responses",
653
+ "actions",
654
+ }:
655
+ diagnostics.append(
656
+ compatibility_diagnostic(
657
+ "error",
658
+ "RASA_DOMAIN_DOCUMENT_PROPERTY_UNSUPPORTED",
659
+ f"$.{property_name}",
660
+ f"domain.yml property {property_name!r} is outside the portable profile",
661
+ )
662
+ )
663
+ for document_name, document, virtual_root in (
664
+ ("flows.yml", flows_document, "flows_document"),
665
+ ("domain.yml", domain_document, "domain_document"),
666
+ ):
667
+ if "version" in document and document.get("version") != RASA_DOMAIN_VERSION:
668
+ diagnostics.append(
669
+ compatibility_diagnostic(
670
+ "error",
671
+ "RASA_DOCUMENT_VERSION_UNSUPPORTED",
672
+ f"$.{virtual_root}.version",
673
+ f"{document_name} version must be {RASA_DOMAIN_VERSION!r} when declared",
674
+ )
675
+ )
676
+
677
+
678
+ def _import_persisted_slots_diagnostics(
679
+ flow_identifier: str,
680
+ flow_definition: Mapping[str, Any],
681
+ referenced_slot_names: set[str],
682
+ diagnostics: list[CompatibilityDiagnostic],
683
+ ) -> None:
684
+ source_path = f"$.flows.{flow_identifier}.persisted_slots"
685
+ if "persisted_slots" not in flow_definition:
686
+ if referenced_slot_names:
687
+ diagnostics.append(
688
+ compatibility_diagnostic(
689
+ "warning",
690
+ "RASA_COLLECTED_SLOT_RESET_UNSUPPORTED",
691
+ source_path,
692
+ "Rasa resets collected slots by default, while flowspec2 data slots persist",
693
+ )
694
+ )
695
+ return
696
+ persisted_slots = sequence_or_none(flow_definition.get("persisted_slots"))
697
+ if persisted_slots is None or not all(
698
+ isinstance(slot_name, str) for slot_name in persisted_slots
699
+ ):
700
+ diagnostics.append(
701
+ compatibility_diagnostic(
702
+ "error",
703
+ "RASA_PERSISTED_SLOTS_INVALID",
704
+ source_path,
705
+ "persisted_slots must be a list of collected slot names",
706
+ )
707
+ )
708
+ return
709
+ persisted_slot_names = cast(Sequence[str], persisted_slots)
710
+ if len(persisted_slot_names) != len(set(persisted_slot_names)):
711
+ diagnostics.append(
712
+ compatibility_diagnostic(
713
+ "error",
714
+ "RASA_PERSISTED_SLOTS_INVALID",
715
+ source_path,
716
+ "persisted_slots must not repeat a slot name",
717
+ )
718
+ )
719
+ unexpected_slot_names = set(persisted_slot_names) - referenced_slot_names
720
+ if unexpected_slot_names:
721
+ diagnostics.append(
722
+ compatibility_diagnostic(
723
+ "error",
724
+ "RASA_PERSISTED_SLOT_NOT_COLLECTED",
725
+ source_path,
726
+ "persisted slots are not collected by the selected flow: "
727
+ f"{sorted(unexpected_slot_names)!r}",
728
+ )
729
+ )
730
+ reset_slot_names = referenced_slot_names - set(persisted_slot_names)
731
+ if reset_slot_names:
732
+ diagnostics.append(
733
+ compatibility_diagnostic(
734
+ "warning",
735
+ "RASA_COLLECTED_SLOT_RESET_UNSUPPORTED",
736
+ source_path,
737
+ "these collected slots reset in Rasa but persist in flowspec2: "
738
+ f"{sorted(reset_slot_names)!r}",
739
+ )
740
+ )
741
+
742
+
743
+ def import_rasa(
744
+ flows_document: Mapping[str, Any],
745
+ domain_document: Mapping[str, Any],
746
+ *,
747
+ flow_id: str | None = None,
748
+ flow_version: str = "1.0.0",
749
+ allow_lossy: bool = False,
750
+ ) -> ConversionOutcome[dict[str, Any]]:
751
+ """Convert one selected Rasa CALM flow from the bounded portable subset."""
752
+
753
+ diagnostics: list[CompatibilityDiagnostic] = []
754
+ _import_document_property_diagnostics(
755
+ flows_document,
756
+ domain_document,
757
+ diagnostics,
758
+ )
759
+ selected_flow = _select_rasa_flow(flows_document, flow_id, diagnostics)
760
+ selected_flow_identifier = (
761
+ selected_flow[0] if selected_flow is not None else flow_id or "invalid_flow"
762
+ )
763
+ flow_definition = selected_flow[1] if selected_flow is not None else {}
764
+ _import_flow_property_diagnostics(selected_flow_identifier, flow_definition, diagnostics)
765
+ if not FLOWSPEC_FLOW_IDENTIFIER_PATTERN.fullmatch(selected_flow_identifier):
766
+ diagnostics.append(
767
+ compatibility_diagnostic(
768
+ "error",
769
+ "RASA_FLOW_ID_UNSUPPORTED",
770
+ f"$.flows.{selected_flow_identifier}",
771
+ "the selected id must already satisfy flowspec2's lowercase "
772
+ "underscore identifier contract",
773
+ )
774
+ )
775
+
776
+ description = flow_definition.get("description")
777
+ if not isinstance(description, str) or not description:
778
+ diagnostics.append(
779
+ compatibility_diagnostic(
780
+ "error",
781
+ "RASA_FLOW_DESCRIPTION_REQUIRED",
782
+ f"$.flows.{selected_flow_identifier}.description",
783
+ "the selected flow requires a non-empty description",
784
+ )
785
+ )
786
+ description = "Invalid imported Rasa flow"
787
+ steps = sequence_or_none(flow_definition.get("steps"))
788
+ if steps is None or not steps:
789
+ diagnostics.append(
790
+ compatibility_diagnostic(
791
+ "error",
792
+ "RASA_FLOW_STEPS_REQUIRED",
793
+ f"$.flows.{selected_flow_identifier}.steps",
794
+ "the selected flow requires a non-empty steps list",
795
+ )
796
+ )
797
+ steps = ()
798
+ step_identifier_diagnostics(
799
+ steps,
800
+ f"$.flows.{selected_flow_identifier}.steps",
801
+ diagnostics,
802
+ )
803
+
804
+ referenced_slot_names = {
805
+ slot_name
806
+ for step_value in steps
807
+ if (step := mapping_or_none(step_value)) is not None
808
+ and isinstance((slot_name := step.get("collect")), str)
809
+ and slot_name
810
+ }
811
+ if referenced_slot_names:
812
+ diagnostics.append(
813
+ compatibility_diagnostic(
814
+ "warning",
815
+ "RASA_LLM_SLOT_SEMANTICS_UNSUPPORTED",
816
+ f"$.flows.{selected_flow_identifier}.steps",
817
+ "Rasa CALM collection can fill or correct slots opportunistically "
818
+ "and processes interruption or repair commands during collection; "
819
+ "imported flowspec2 collection is sequential with explicit "
820
+ "correction and transition routing",
821
+ )
822
+ )
823
+ _import_persisted_slots_diagnostics(
824
+ selected_flow_identifier,
825
+ flow_definition,
826
+ referenced_slot_names,
827
+ diagnostics,
828
+ )
829
+ slot_declarations = mapping_or_none(domain_document.get("slots"))
830
+ if slot_declarations is None:
831
+ if referenced_slot_names:
832
+ diagnostics.append(
833
+ compatibility_diagnostic(
834
+ "error",
835
+ "RASA_DOMAIN_SLOTS_REQUIRED",
836
+ "$.slots",
837
+ "domain.yml must contain a slots mapping for every collected slot",
838
+ )
839
+ )
840
+ slot_declarations = {}
841
+ responses = mapping_or_none(domain_document.get("responses"))
842
+ if responses is None:
843
+ if referenced_slot_names:
844
+ diagnostics.append(
845
+ compatibility_diagnostic(
846
+ "error",
847
+ "RASA_RESPONSES_MAPPING_REQUIRED",
848
+ "$.responses",
849
+ "domain.yml must contain responses for every collected slot",
850
+ )
851
+ )
852
+ responses = {}
853
+
854
+ flowspec_domains: dict[str, Any] = {}
855
+ flowspec_slots: dict[str, Any] = {}
856
+ domain_names: dict[str, str] = {}
857
+ for slot_name in sorted(referenced_slot_names):
858
+ slot_declaration = mapping_or_none(slot_declarations.get(slot_name))
859
+ if slot_declaration is None:
860
+ diagnostics.append(
861
+ compatibility_diagnostic(
862
+ "error",
863
+ "RASA_SLOT_DEFINITION_INVALID",
864
+ f"$.slots.{slot_name}",
865
+ "a collected slot requires a mapping declaration in domain.yml",
866
+ )
867
+ )
868
+ continue
869
+ domain_specification = _import_domain_specification(
870
+ slot_name,
871
+ slot_declaration,
872
+ diagnostics,
873
+ )
874
+ if domain_specification is None:
875
+ continue
876
+ domain_name = f"rasa_{slot_name}"
877
+ domain_names[slot_name] = domain_name
878
+ flowspec_domains[domain_name] = domain_specification
879
+ flowspec_slots[slot_name] = {
880
+ "domain": domain_name,
881
+ "persist": "data",
882
+ "required": True,
883
+ }
884
+
885
+ actions_value = domain_document.get("actions", [])
886
+ actions = sequence_or_none(actions_value)
887
+ if actions is None or not all(isinstance(action_name, str) for action_name in actions):
888
+ diagnostics.append(
889
+ compatibility_diagnostic(
890
+ "error",
891
+ "RASA_ACTIONS_DECLARATION_INVALID",
892
+ "$.actions",
893
+ "domain.yml actions must be a list of custom-action names",
894
+ )
895
+ )
896
+ declared_actions: frozenset[str] = frozenset()
897
+ else:
898
+ declared_actions = frozenset(cast(Sequence[str], actions))
899
+
900
+ flowspec_path: list[dict[str, Any]] = []
901
+ imported_terminal: dict[str, Any] | None = None
902
+ for step_index, step_value in enumerate(steps):
903
+ step = mapping_or_none(step_value)
904
+ source_path = f"$.flows.{selected_flow_identifier}.steps[{step_index}]"
905
+ if step is None:
906
+ diagnostics.append(
907
+ compatibility_diagnostic(
908
+ "error",
909
+ "RASA_STEP_DEFINITION_INVALID",
910
+ source_path,
911
+ "each Rasa flow step must be a mapping",
912
+ )
913
+ )
914
+ continue
915
+ if "collect" in step:
916
+ imported_step = _import_collect_step(
917
+ step,
918
+ selected_flow_identifier,
919
+ step_index,
920
+ slot_declarations,
921
+ domain_names,
922
+ flowspec_domains,
923
+ responses,
924
+ diagnostics,
925
+ )
926
+ if imported_step is not None:
927
+ flowspec_path.append(imported_step)
928
+ continue
929
+ if "action" in step:
930
+ imported_action = _import_terminal_action(
931
+ step,
932
+ selected_flow_identifier,
933
+ step_index,
934
+ len(steps),
935
+ declared_actions,
936
+ diagnostics,
937
+ )
938
+ if imported_action is not None:
939
+ terminal_marker, imported_terminal = imported_action
940
+ flowspec_path.append(terminal_marker)
941
+ continue
942
+ if "collect" not in step:
943
+ step_type = next(
944
+ (
945
+ property_name
946
+ for property_name in ("set_slots", "call", "link", "noop")
947
+ if property_name in step
948
+ ),
949
+ "unknown",
950
+ )
951
+ diagnostics.append(
952
+ compatibility_diagnostic(
953
+ "error",
954
+ "RASA_STEP_TYPE_UNSUPPORTED",
955
+ source_path,
956
+ f"Rasa step type {step_type!r} cannot be imported without "
957
+ "inventing flowspec2 semantics",
958
+ )
959
+ )
960
+ continue
961
+
962
+ imported_flow: dict[str, Any] = {
963
+ "schema": FLOWSPEC_FORMAT,
964
+ "flow": selected_flow_identifier,
965
+ "version": flow_version,
966
+ "route": {"description": description},
967
+ "domains": flowspec_domains,
968
+ "slots": flowspec_slots,
969
+ "path": flowspec_path,
970
+ }
971
+ if imported_terminal is not None:
972
+ imported_flow["terminal"] = imported_terminal
973
+ generated_validation_diagnostics = flowspec_validation_diagnostics(
974
+ imported_flow,
975
+ "RASA_GENERATED_FLOWSPEC_INVALID",
976
+ )
977
+ diagnostics.extend(generated_validation_diagnostics)
978
+ if not generated_validation_diagnostics and (
979
+ compilation_diagnostic := flowspec_compilation_diagnostic(
980
+ imported_flow,
981
+ "RASA_GENERATED_FLOWSPEC_NON_EXECUTABLE",
982
+ )
983
+ ):
984
+ diagnostics.append(compilation_diagnostic)
985
+
986
+ report = CompatibilityReport(
987
+ source_format=RASA_FORMAT,
988
+ target_format=FLOWSPEC_FORMAT,
989
+ diagnostics=tuple(diagnostics),
990
+ )
991
+ enforce_compatibility_policy(report, allow_lossy=allow_lossy)
992
+ return ConversionOutcome(artifact=imported_flow, report=report)