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,300 @@
1
+ """WhatsApp interactive envelope builders and closed-domain projections.
2
+
3
+ These materialize button and list envelopes from the closed options provided by
4
+ ``domains.py``. Categorical options retain their declared values/rows; boolean
5
+ options use canonical ``true``/``false`` identifiers with ``Yes``/``No``
6
+ titles. Limits mirror the Meta Cloud API (and the production
7
+ ``whatsapp_interactive``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+ from urllib.parse import urlsplit
14
+
15
+ from .domains import DomainInteractiveOption, interactive_options_for_domain
16
+ from .models import ServiceState
17
+ from .predicates import evaluate
18
+
19
+ BUTTON_TITLE_MAX = 20
20
+ BUTTON_ID_MAX = 256
21
+ MAX_BUTTONS = 3
22
+ LIST_ROWS_TOTAL_MAX = 10
23
+ ROW_ID_MAX = 200
24
+ ROW_TITLE_MAX = 24
25
+ ROW_DESC_MAX = 72
26
+ BODY_MAX = 1024
27
+ CTA_URL_MAX = 2000
28
+
29
+
30
+ def _err(message: str) -> dict[str, Any]:
31
+ return {"status": "error", "error": message}
32
+
33
+
34
+ def _body_error(body: str) -> dict[str, Any] | None:
35
+ if not isinstance(body, str):
36
+ return _err("body must be a string")
37
+ if len(body) > BODY_MAX:
38
+ return _err(f"body exceeds {BODY_MAX} characters")
39
+ return None
40
+
41
+
42
+ def _valid_identifier(identifier: Any, maximum_length: int) -> bool:
43
+ return (
44
+ isinstance(identifier, str)
45
+ and bool(identifier.strip())
46
+ and len(identifier) <= maximum_length
47
+ )
48
+
49
+
50
+ def interactive_option_identifier(value: str | bool) -> str:
51
+ """Return the exact payload identifier used for a rendered domain option."""
52
+
53
+ return str(value).lower()
54
+
55
+
56
+ def build_buttons(body: str, buttons: list[dict[str, str]]) -> dict[str, Any]:
57
+ if body_error := _body_error(body):
58
+ return body_error
59
+ if not 1 <= len(buttons) <= MAX_BUTTONS:
60
+ return _err(f"buttons must be 1..{MAX_BUTTONS}")
61
+ seen_identifiers: set[str] = set()
62
+ normalized_buttons: list[dict[str, str]] = []
63
+ for button in buttons:
64
+ identifier, title = button.get("id"), button.get("title")
65
+ if not _valid_identifier(identifier, BUTTON_ID_MAX):
66
+ return _err(f"button id must be non-empty and at most {BUTTON_ID_MAX} characters")
67
+ assert isinstance(identifier, str)
68
+ if not isinstance(title, str) or not title.strip():
69
+ return _err("button needs id+title")
70
+ if len(title) > BUTTON_TITLE_MAX:
71
+ return _err(f"button title >{BUTTON_TITLE_MAX}: {title!r}")
72
+ if identifier in seen_identifiers:
73
+ return _err(f"duplicate button id: {identifier}")
74
+ seen_identifiers.add(identifier)
75
+ normalized_buttons.append({"id": identifier, "title": title})
76
+ return {
77
+ "status": "ok",
78
+ "type": "interactive",
79
+ "interactive": {
80
+ "type": "button",
81
+ "body": {"text": body},
82
+ "action": {
83
+ "buttons": [{"type": "reply", "reply": button} for button in normalized_buttons]
84
+ },
85
+ },
86
+ }
87
+
88
+
89
+ def build_list(
90
+ body: str, sections: list[dict[str, Any]], button_label: str = "Escolher"
91
+ ) -> dict[str, Any]:
92
+ if body_error := _body_error(body):
93
+ return body_error
94
+ if not isinstance(button_label, str) or not button_label.strip():
95
+ return _err("button_label must be non-empty")
96
+ if len(button_label) > BUTTON_TITLE_MAX:
97
+ return _err("button_label too long")
98
+
99
+ row_count = 0
100
+ seen_identifiers: set[str] = set()
101
+ for section in sections:
102
+ if not isinstance(section, dict):
103
+ return _err("each list section must be an object")
104
+ section_title = section.get("title")
105
+ if section_title is not None and (
106
+ not isinstance(section_title, str)
107
+ or not section_title.strip()
108
+ or len(section_title) > ROW_TITLE_MAX
109
+ ):
110
+ return _err(f"section title must be non-empty and at most {ROW_TITLE_MAX} characters")
111
+ rows = section.get("rows")
112
+ if not isinstance(rows, list):
113
+ return _err("each list section needs rows")
114
+ row_count += len(rows)
115
+ for row in rows:
116
+ if not isinstance(row, dict):
117
+ return _err("each list row must be an object")
118
+ identifier = row.get("id")
119
+ title = row.get("title")
120
+ description = row.get("description", "")
121
+ if not _valid_identifier(identifier, ROW_ID_MAX):
122
+ return _err(f"row id must be non-empty and at most {ROW_ID_MAX} characters")
123
+ assert isinstance(identifier, str)
124
+ if identifier in seen_identifiers:
125
+ return _err(f"duplicate row id: {identifier}")
126
+ if not isinstance(title, str) or not title.strip():
127
+ return _err("row title must be non-empty")
128
+ if len(title) > ROW_TITLE_MAX:
129
+ return _err(f"row title >{ROW_TITLE_MAX}: {title!r}")
130
+ if not isinstance(description, str):
131
+ return _err("row description must be a string")
132
+ if len(description) > ROW_DESC_MAX:
133
+ return _err(f"row description >{ROW_DESC_MAX}: {description!r}")
134
+ seen_identifiers.add(identifier)
135
+
136
+ if not 1 <= row_count <= LIST_ROWS_TOTAL_MAX:
137
+ return _err(f"list rows total must be 1..{LIST_ROWS_TOTAL_MAX}")
138
+ return {
139
+ "status": "ok",
140
+ "type": "interactive",
141
+ "interactive": {
142
+ "type": "list",
143
+ "body": {"text": body},
144
+ "action": {"button": button_label, "sections": sections},
145
+ },
146
+ }
147
+
148
+
149
+ def build_flow(
150
+ flow_id: str, body: str, cta: str = "Preencher", flow_token: str = ""
151
+ ) -> dict[str, Any]:
152
+ if body_error := _body_error(body):
153
+ return body_error
154
+ if not _valid_identifier(flow_id, BUTTON_ID_MAX):
155
+ return _err(f"flow_id must be non-empty and at most {BUTTON_ID_MAX} characters")
156
+ if not isinstance(cta, str) or not cta.strip():
157
+ return _err("flow cta must be non-empty")
158
+ if len(cta) > BUTTON_TITLE_MAX:
159
+ return _err(f"flow cta exceeds {BUTTON_TITLE_MAX} characters")
160
+ if not isinstance(flow_token, str):
161
+ return _err("flow_token must be a string")
162
+ return {
163
+ "status": "ok",
164
+ "type": "interactive",
165
+ "interactive": {
166
+ "type": "flow",
167
+ "body": {"text": body},
168
+ "action": {
169
+ "name": "flow",
170
+ "parameters": {
171
+ "flow_id": flow_id,
172
+ "flow_cta": cta,
173
+ "flow_token": flow_token,
174
+ "flow_action": "navigate",
175
+ },
176
+ },
177
+ },
178
+ }
179
+
180
+
181
+ def build_cta_url(body: str, url: str, display_text: str) -> dict[str, Any]:
182
+ if body_error := _body_error(body):
183
+ return body_error
184
+ if not isinstance(display_text, str) or not display_text.strip():
185
+ return _err("cta display_text must be non-empty")
186
+ if len(display_text) > BUTTON_TITLE_MAX:
187
+ return _err(f"cta display_text exceeds {BUTTON_TITLE_MAX} characters")
188
+ if (
189
+ not isinstance(url, str)
190
+ or len(url) > CTA_URL_MAX
191
+ or any(character.isspace() for character in url)
192
+ ):
193
+ return _err("cta url must be a bounded HTTPS URL without whitespace")
194
+ try:
195
+ parsed_url = urlsplit(url)
196
+ except ValueError:
197
+ return _err("cta url must be a valid HTTPS URL")
198
+ if parsed_url.scheme != "https" or not parsed_url.hostname:
199
+ return _err("cta url must be a valid HTTPS URL")
200
+ return {
201
+ "status": "ok",
202
+ "type": "interactive",
203
+ "interactive": {
204
+ "type": "cta_url",
205
+ "body": {"text": body},
206
+ "action": {
207
+ "name": "cta_url",
208
+ "parameters": {"display_text": display_text, "url": url},
209
+ },
210
+ },
211
+ }
212
+
213
+
214
+ def _visible_options(
215
+ domain_specification: dict[str, Any],
216
+ options_when: Optional[list[dict[str, Any]]],
217
+ state: Optional[ServiceState],
218
+ config: dict[str, Any],
219
+ ) -> list[DomainInteractiveOption]:
220
+ domain_options = list(interactive_options_for_domain(domain_specification))
221
+ if not options_when:
222
+ return domain_options
223
+ gates = {
224
+ conditional_option["value"]: conditional_option["gate"]
225
+ for conditional_option in options_when
226
+ }
227
+ visible_options: list[DomainInteractiveOption] = []
228
+ for domain_option in domain_options:
229
+ gate = gates.get(domain_option.value)
230
+ if gate is None or (state is not None and evaluate(gate, state, config)):
231
+ visible_options.append(domain_option)
232
+ return visible_options
233
+
234
+
235
+ def options_from_domain(
236
+ interactive: dict[str, Any],
237
+ domains: dict[str, Any],
238
+ *,
239
+ state: Optional[ServiceState] = None,
240
+ config: Optional[dict[str, Any]] = None,
241
+ ) -> Optional[dict[str, Any]]:
242
+ """Build the citizen-facing interactive spec attached to ``AgentResponse``."""
243
+ config = config or {}
244
+ kind = interactive["kind"]
245
+ field = interactive["field"]
246
+ body = interactive.get("body", "")
247
+ spec_name = interactive.get("from_domain")
248
+
249
+ if kind in ("buttons", "list") and spec_name:
250
+ spec = domains[spec_name]
251
+ domain_options = _visible_options(
252
+ spec,
253
+ interactive.get("options_when"),
254
+ state,
255
+ config,
256
+ )
257
+ if not domain_options:
258
+ return None
259
+ seen_values: set[tuple[type[str] | type[bool], str | bool]] = set()
260
+ seen_identifiers: set[str] = set()
261
+ for domain_option in domain_options:
262
+ typed_value = (type(domain_option.value), domain_option.value)
263
+ if typed_value in seen_values:
264
+ raise ValueError(f"duplicate interactive option value: {domain_option.value!r}")
265
+ identifier = interactive_option_identifier(domain_option.value)
266
+ identifier_maximum = BUTTON_ID_MAX if kind == "buttons" else ROW_ID_MAX
267
+ if not _valid_identifier(identifier, identifier_maximum):
268
+ raise ValueError(f"invalid {kind} option identifier: {identifier!r}")
269
+ if identifier in seen_identifiers:
270
+ raise ValueError(f"duplicate interactive option identifier: {identifier!r}")
271
+ seen_values.add(typed_value)
272
+ seen_identifiers.add(identifier)
273
+ if kind == "buttons":
274
+ buttons = [
275
+ {
276
+ "id": interactive_option_identifier(domain_option.value),
277
+ "title": domain_option.title,
278
+ }
279
+ for domain_option in domain_options
280
+ ]
281
+ return {"body": body, "field": field, "buttons": buttons}
282
+ rows = [
283
+ {
284
+ "id": interactive_option_identifier(domain_option.value),
285
+ "title": domain_option.title,
286
+ "description": domain_option.description,
287
+ }
288
+ for domain_option in domain_options
289
+ ]
290
+ return {"body": body, "field": field, "sections": [{"title": "Options", "rows": rows}]}
291
+
292
+ if interactive.get("out_of_band"):
293
+ return {
294
+ "body": body,
295
+ "field": field,
296
+ "out_of_band_sent": True,
297
+ "next_step": interactive.get("next_step"),
298
+ }
299
+
300
+ return {"body": body, "field": field}