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.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
flowspec2/interactive.py
ADDED
|
@@ -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}
|