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
flowspec2/derive.py ADDED
@@ -0,0 +1,32 @@
1
+ """Canonical lookup-key encoding shared by derive linking and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from typing import Any
8
+
9
+
10
+ def encode_derive_component(component: Any) -> str:
11
+ """Encode one state value without Python-specific boolean/null spellings."""
12
+
13
+ if component is None or isinstance(component, bool):
14
+ return json.dumps(component, ensure_ascii=False, separators=(",", ":"))
15
+ return str(component)
16
+
17
+
18
+ def encode_derive_key(components: Iterable[Any]) -> str:
19
+ """Encode one complete lookup key using the format's component delimiter."""
20
+
21
+ encoded_components = tuple(encode_derive_component(component) for component in components)
22
+ if not encoded_components:
23
+ raise ValueError("derive lookup keys require at least one component")
24
+ return encoded_components[0] if len(encoded_components) == 1 else "|".join(encoded_components)
25
+
26
+
27
+ def decode_derive_key(lookup_key: str, source_count: int) -> tuple[str, ...]:
28
+ """Decode an authored key while preserving delimiters in a single source token."""
29
+
30
+ if source_count < 1:
31
+ raise ValueError("derive lookup keys require at least one source")
32
+ return (lookup_key,) if source_count == 1 else tuple(lookup_key.split("|"))
@@ -0,0 +1,94 @@
1
+ """Immutable machine-readable diagnostics for flowspec2 validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ DiagnosticSeverity = Literal["warning", "error"]
9
+ CompilationStatus = Literal["not_requested", "skipped", "succeeded", "failed"]
10
+
11
+
12
+ def _require_json_pointer(path: str) -> None:
13
+ if path and not path.startswith("/"):
14
+ raise ValueError(f"diagnostic path must be a JSON Pointer, got {path!r}")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DiagnosticLocation:
19
+ """A related source location expressed as an RFC 6901 JSON Pointer."""
20
+
21
+ path: str
22
+ message: str | None = None
23
+
24
+ def __post_init__(self) -> None:
25
+ _require_json_pointer(self.path)
26
+
27
+ def to_dict(self) -> dict[str, object]:
28
+ """Return the deterministic JSON-compatible representation."""
29
+
30
+ location: dict[str, object] = {"path": self.path}
31
+ if self.message is not None:
32
+ location["message"] = self.message
33
+ return location
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class FlowDiagnostic:
38
+ """One stable validation or compilation finding."""
39
+
40
+ code: str
41
+ severity: DiagnosticSeverity
42
+ path: str
43
+ message: str
44
+ related_locations: tuple[DiagnosticLocation, ...] = ()
45
+ suggested_fix: str | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ _require_json_pointer(self.path)
49
+
50
+ def to_dict(self) -> dict[str, object]:
51
+ """Return the deterministic JSON-compatible representation."""
52
+
53
+ diagnostic: dict[str, object] = {
54
+ "code": self.code,
55
+ "severity": self.severity,
56
+ "path": self.path,
57
+ "message": self.message,
58
+ }
59
+ if self.related_locations:
60
+ diagnostic["related_locations"] = [
61
+ location.to_dict() for location in self.related_locations
62
+ ]
63
+ if self.suggested_fix is not None:
64
+ diagnostic["suggested_fix"] = self.suggested_fix
65
+ return diagnostic
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class FlowCheckReport:
70
+ """The complete deterministic result of checking one flow document."""
71
+
72
+ diagnostics: tuple[FlowDiagnostic, ...]
73
+ compilation: CompilationStatus
74
+
75
+ @property
76
+ def has_errors(self) -> bool:
77
+ return any(diagnostic.severity == "error" for diagnostic in self.diagnostics)
78
+
79
+ @property
80
+ def has_warnings(self) -> bool:
81
+ return any(diagnostic.severity == "warning" for diagnostic in self.diagnostics)
82
+
83
+ @property
84
+ def is_valid(self) -> bool:
85
+ return not self.has_errors
86
+
87
+ def to_dict(self) -> dict[str, object]:
88
+ """Return the deterministic JSON-compatible representation."""
89
+
90
+ return {
91
+ "valid": self.is_valid,
92
+ "compilation": self.compilation,
93
+ "diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
94
+ }
flowspec2/domains.py ADDED
@@ -0,0 +1,489 @@
1
+ """Closed value-domains — the spine and the deterministic/non-deterministic boundary.
2
+
3
+ One ``domains.<X>`` declaration materializes shared artifacts:
4
+
5
+ 1. a Pydantic model (per *slot*, since a domain is reused across slots) whose
6
+ ``@field_validator(mode="before")`` deterministically coerces free
7
+ speech/number/emoji into a **closed token** — the rail the LLM cannot widen;
8
+ 2. ``Model.model_json_schema()`` — the JSON Schema (with the ``enum``) handed to
9
+ constrained decoding at each pause, so the LLM extracts onto a closed token at
10
+ exactly one place;
11
+ 3. ordered interactive values, titles, and descriptions. Categorical domains
12
+ derive them from ``values``/``rows``; boolean domains expose the canonical
13
+ ``true``/``false`` tokens with localized titles.
14
+
15
+ ``interactive.py`` turns those shared options into button and list envelopes,
16
+ so the affordance and the recognizer cannot drift.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import re
23
+ import unicodedata
24
+ from dataclasses import dataclass
25
+ from decimal import Decimal, InvalidOperation
26
+ from typing import Any, Callable, Final, Literal, Mapping, Optional, cast
27
+
28
+ from pydantic import BaseModel, Field, create_model, field_validator
29
+
30
+ # ── normalization primitives ────────────────────────────────────────────────
31
+
32
+ _NUMBER_WORDS = {
33
+ "one": 1,
34
+ "two": 2,
35
+ "three": 3,
36
+ "four": 4,
37
+ "five": 5,
38
+ "six": 6,
39
+ "seven": 7,
40
+ "eight": 8,
41
+ "nine": 9,
42
+ "ten": 10,
43
+ }
44
+
45
+ _AFFIRM_POS = {
46
+ "yes",
47
+ "y",
48
+ "ok",
49
+ "okay",
50
+ "sure",
51
+ "correct",
52
+ "positive",
53
+ "confirm",
54
+ "confirmed",
55
+ "exactly",
56
+ "affirmative",
57
+ "true",
58
+ "1",
59
+ "of course",
60
+ }
61
+ _AFFIRM_NEG = {
62
+ "n",
63
+ "no",
64
+ "wrong",
65
+ "negative",
66
+ "incorrect",
67
+ "disagree",
68
+ "never",
69
+ "false",
70
+ "0",
71
+ }
72
+ _POS_EMOJI = ("👍", "✅", "👌", "🙂", "😊", "🆗")
73
+ _NEG_EMOJI = ("👎", "❌", "🚫", "🙅")
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class DomainInteractiveOption:
78
+ """One closed domain token and its citizen-facing presentation."""
79
+
80
+ value: str | bool
81
+ title: str
82
+ description: str = ""
83
+
84
+
85
+ _BOOLEAN_INTERACTIVE_OPTIONS: Final[tuple[DomainInteractiveOption, ...]] = (
86
+ DomainInteractiveOption(value=True, title="Yes"),
87
+ DomainInteractiveOption(value=False, title="No"),
88
+ )
89
+
90
+
91
+ def strip_accents(value: str) -> str:
92
+ return "".join(
93
+ ch for ch in unicodedata.normalize("NFKD", value) if not unicodedata.combining(ch)
94
+ )
95
+
96
+
97
+ def normalize_text(value: Any, *, accent_fold: bool = True) -> str:
98
+ text = str(value if value is not None else "").strip().lower()
99
+ if accent_fold:
100
+ text = strip_accents(text)
101
+ return re.sub(r"\s+", " ", text)
102
+
103
+
104
+ def categorical_number_bindings(values: list[Any]) -> dict[str, Any]:
105
+ """Return every positional digit/word input accepted for categorical values."""
106
+
107
+ bindings = {
108
+ str(position): domain_value for position, domain_value in enumerate(values, start=1)
109
+ }
110
+ bindings.update(
111
+ {
112
+ number_word: values[position - 1]
113
+ for number_word, position in _NUMBER_WORDS.items()
114
+ if position <= len(values)
115
+ }
116
+ )
117
+ return bindings
118
+
119
+
120
+ def parse_affirmation(value: Any, *, emoji_veto: bool = True) -> Optional[bool]:
121
+ """yes/confirm/👍 -> True, no/negative/👎 -> False, ambiguous -> None.
122
+
123
+ A negative emoji overrides every other signal only when ``emoji_veto`` is
124
+ enabled. Without the veto, recognized words take precedence over emoji so
125
+ mixed input stays deterministic; emoji-only answers remain supported.
126
+ """
127
+ raw = str(value if value is not None else "")
128
+ has_negative_emoji = any(emoji in raw for emoji in _NEG_EMOJI)
129
+ has_positive_emoji = any(emoji in raw for emoji in _POS_EMOJI)
130
+ if emoji_veto and has_negative_emoji:
131
+ return False
132
+ text = normalize_text(raw)
133
+ if not text:
134
+ return None
135
+ if text in _AFFIRM_POS:
136
+ return True
137
+ if text in _AFFIRM_NEG:
138
+ return False
139
+ # natural phrases: token membership
140
+ tokens = set(text.split())
141
+ if tokens & _AFFIRM_NEG:
142
+ return False
143
+ if tokens & _AFFIRM_POS:
144
+ return True
145
+ if has_negative_emoji and has_positive_emoji:
146
+ return None
147
+ if has_negative_emoji:
148
+ return False
149
+ if has_positive_emoji:
150
+ return True
151
+ return None
152
+
153
+
154
+ def interactive_options_for_domain(
155
+ domain_specification: Mapping[str, Any],
156
+ ) -> tuple[DomainInteractiveOption, ...]:
157
+ """Return the ordered renderable options for a closed domain."""
158
+ domain_type = domain_specification.get("type", "categorical")
159
+ if domain_type == "bool":
160
+ return _BOOLEAN_INTERACTIVE_OPTIONS
161
+ if domain_type != "categorical":
162
+ return ()
163
+
164
+ row_descriptions = {
165
+ row_definition["value"]: row_definition.get("description", "")
166
+ for row_definition in domain_specification.get("rows", [])
167
+ if row_definition["value"] is not None
168
+ }
169
+ return tuple(
170
+ DomainInteractiveOption(
171
+ value=domain_value,
172
+ title=domain_value,
173
+ description=row_descriptions.get(domain_value, ""),
174
+ )
175
+ for domain_value in domain_specification.get("values", [])
176
+ if domain_value is not None
177
+ )
178
+
179
+
180
+ # ── per-domain validators ───────────────────────────────────────────────────
181
+
182
+ DomainValidator = Callable[[Any], Any]
183
+
184
+
185
+ def _categorical_validator(spec: dict[str, Any]) -> DomainValidator:
186
+ values: list[Any] = spec["values"]
187
+ norm = spec.get("normalize", {}) or {}
188
+ accent_fold = bool(norm.get("accent_fold", False))
189
+ use_numbers = norm.get("number_words", False)
190
+ synonyms = {
191
+ normalize_text(alias, accent_fold=accent_fold): target
192
+ for alias, target in (norm.get("synonyms") or {}).items()
193
+ }
194
+ by_norm = {
195
+ normalize_text(domain_value, accent_fold=accent_fold): domain_value
196
+ for domain_value in values
197
+ if domain_value is not None
198
+ }
199
+ has_null = any(v is None for v in values)
200
+
201
+ def validate(raw: Any) -> Any:
202
+ if raw is None:
203
+ if has_null:
204
+ return None
205
+ raise ValueError("value is required")
206
+ key = normalize_text(raw, accent_fold=accent_fold)
207
+ if not key:
208
+ if has_null:
209
+ return None
210
+ raise ValueError("value is empty")
211
+ if use_numbers:
212
+ idx = int(key) if key.isdigit() else _NUMBER_WORDS.get(key)
213
+ if idx is not None and 1 <= idx <= len(values):
214
+ return values[idx - 1]
215
+ if key in synonyms:
216
+ return synonyms[key]
217
+ if key in by_norm:
218
+ return by_norm[key]
219
+ raise ValueError(f"value outside the domain: {raw!r}")
220
+
221
+ return validate
222
+
223
+
224
+ def _bool_validator(spec: dict[str, Any]) -> DomainValidator:
225
+ norm = spec.get("normalize", {}) or {}
226
+ use_affirm = norm.get("affirmation", False)
227
+ emoji_veto = bool(norm.get("emoji_veto", False))
228
+ accent_fold = bool(norm.get("accent_fold", False))
229
+ synonyms = {
230
+ normalize_text(alias, accent_fold=accent_fold): target
231
+ for alias, target in (norm.get("synonyms") or {}).items()
232
+ }
233
+
234
+ def validate(raw: Any) -> bool:
235
+ if isinstance(raw, bool):
236
+ return raw
237
+ if (
238
+ use_affirm
239
+ and emoji_veto
240
+ and any(emoji in str(raw if raw is not None else "") for emoji in _NEG_EMOJI)
241
+ ):
242
+ return False
243
+ key = normalize_text(raw, accent_fold=accent_fold)
244
+ synonym = synonyms.get(key)
245
+ if isinstance(synonym, bool):
246
+ return synonym
247
+ if use_affirm:
248
+ result = parse_affirmation(raw, emoji_veto=emoji_veto)
249
+ if result is None:
250
+ raise ValueError(f"ambiguous response: {raw!r}. Use yes/no.")
251
+ return result
252
+ if key in {"true", "1", "yes", "y"}:
253
+ return True
254
+ if key in {"false", "0", "no", "n"}:
255
+ return False
256
+ raise ValueError(f"expected yes/no: {raw!r}")
257
+
258
+ return validate
259
+
260
+
261
+ _BRAZILIAN_TAX_ID_RE = re.compile(r"[^0-9]")
262
+
263
+
264
+ def _brazilian_tax_id_valid(digits: str) -> bool:
265
+ if len(digits) != 11 or len(set(digits)) == 1:
266
+ return False
267
+ for length in (9, 10):
268
+ weights = range(length + 1, 1, -1)
269
+ total = sum(
270
+ int(digit) * weight for digit, weight in zip(digits[:length], weights, strict=True)
271
+ )
272
+ check = (total * 10) % 11 % 10
273
+ if check != int(digits[length]):
274
+ return False
275
+ return True
276
+
277
+
278
+ def _brazilian_tax_id_validator(_spec: dict[str, Any]) -> DomainValidator:
279
+ def validate(raw: Any) -> str:
280
+ digits = _BRAZILIAN_TAX_ID_RE.sub("", str(raw or ""))
281
+ if not _brazilian_tax_id_valid(digits):
282
+ raise ValueError("invalid Brazilian tax ID")
283
+ return digits
284
+
285
+ return validate
286
+
287
+
288
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
289
+
290
+
291
+ def _email_validator(_spec: dict[str, Any]) -> DomainValidator:
292
+ def validate(raw: Any) -> str:
293
+ value = str(raw or "").strip().lower()
294
+ if not _EMAIL_RE.match(value):
295
+ raise ValueError("invalid email address")
296
+ return value
297
+
298
+ return validate
299
+
300
+
301
+ def _name_validator(_spec: dict[str, Any]) -> DomainValidator:
302
+ def validate(raw: Any) -> str:
303
+ value = str(raw or "").strip()
304
+ if len(value) < 2:
305
+ raise ValueError("invalid name")
306
+ return value
307
+
308
+ return validate
309
+
310
+
311
+ def _free_text_validator(spec: dict[str, Any]) -> DomainValidator:
312
+ optional = bool(spec.get("optional"))
313
+
314
+ def validate(raw: Any) -> str:
315
+ value = str(raw or "").strip()
316
+ if not value:
317
+ if optional: # empty is a valid skip for an optional free-text slot
318
+ return ""
319
+ raise ValueError("text is empty")
320
+ return value
321
+
322
+ return validate
323
+
324
+
325
+ def _bounded_numeric_value(value: Decimal, spec: dict[str, Any]) -> Decimal:
326
+ if "minimum" in spec and value < Decimal(str(spec["minimum"])):
327
+ raise ValueError(f"value below minimum: {spec['minimum']}")
328
+ if "maximum" in spec and value > Decimal(str(spec["maximum"])):
329
+ raise ValueError(f"value above maximum: {spec['maximum']}")
330
+ return value
331
+
332
+
333
+ def _integer_validator(spec: dict[str, Any]) -> DomainValidator:
334
+ def validate(raw: Any) -> int:
335
+ if isinstance(raw, bool):
336
+ raise ValueError("invalid integer")
337
+ text = str(raw if raw is not None else "").strip()
338
+ if re.fullmatch(r"[+-]?\d+", text) is None:
339
+ raise ValueError("invalid integer")
340
+ return int(_bounded_numeric_value(Decimal(text), spec))
341
+
342
+ return validate
343
+
344
+
345
+ def _number_validator(spec: dict[str, Any]) -> DomainValidator:
346
+ def validate(raw: Any) -> float:
347
+ if isinstance(raw, bool):
348
+ raise ValueError("invalid number")
349
+ try:
350
+ value = Decimal(str(raw if raw is not None else "").strip())
351
+ except InvalidOperation as error:
352
+ raise ValueError("invalid number") from error
353
+ if not value.is_finite():
354
+ raise ValueError("number must be finite")
355
+ bounded_value = _bounded_numeric_value(value, spec)
356
+ floating_value = float(bounded_value)
357
+ if not math.isfinite(floating_value):
358
+ raise ValueError("number outside the representable range")
359
+ return floating_value
360
+
361
+ return validate
362
+
363
+
364
+ _VALIDATOR_FACTORIES: dict[str, Callable[[dict[str, Any]], DomainValidator]] = {
365
+ "categorical": _categorical_validator,
366
+ "bool": _bool_validator,
367
+ "brazilian_tax_id": _brazilian_tax_id_validator,
368
+ "email": _email_validator,
369
+ "name": _name_validator,
370
+ "free_text": _free_text_validator,
371
+ "integer": _integer_validator,
372
+ "number": _number_validator,
373
+ }
374
+
375
+
376
+ def make_validator(spec: dict[str, Any]) -> DomainValidator:
377
+ return _VALIDATOR_FACTORIES[spec.get("type", "categorical")](spec)
378
+
379
+
380
+ # ── per-slot Pydantic model (the payload_schema source) ──────────────────────
381
+
382
+
383
+ def _field_type(spec: dict[str, Any], nullable: bool) -> Any:
384
+ dtype = spec.get("type", "categorical")
385
+ if dtype == "categorical":
386
+ non_null = [v for v in spec["values"] if v is not None]
387
+ base = Literal[tuple(non_null)] # type: ignore[valid-type]
388
+ return Optional[base] if nullable else base
389
+ if dtype == "bool":
390
+ return Optional[bool] if nullable else bool
391
+ if dtype == "integer":
392
+ return Optional[int] if nullable else int
393
+ if dtype == "number":
394
+ return Optional[float] if nullable else float
395
+ return Optional[str] if nullable else str
396
+
397
+
398
+ def _field_description(
399
+ slot_name: str,
400
+ spec: dict[str, Any],
401
+ extract_hint: Optional[str],
402
+ *,
403
+ nullable: bool,
404
+ ) -> str:
405
+ parts: list[str] = []
406
+ if spec.get("type", "categorical") == "categorical":
407
+ tokens = ", ".join(
408
+ "null" if value is None else str(value)
409
+ for value in spec["values"]
410
+ if value is not None or nullable
411
+ )
412
+ parts.append(f"Interpret the user's input and return ONLY one closed value: {tokens}.")
413
+ elif spec.get("type") == "bool":
414
+ parts.append("Interpret as a boolean: true for yes/affirmative, false for no/negative.")
415
+ elif spec.get("type") == "integer":
416
+ parts.append("Interpret as an integer.")
417
+ elif spec.get("type") == "number":
418
+ parts.append("Interpret as a finite number.")
419
+ if extract_hint:
420
+ parts.append(extract_hint)
421
+ return " ".join(parts) or f"Value for {slot_name}."
422
+
423
+
424
+ def make_slot_model(
425
+ slot_name: str,
426
+ domain_name: str,
427
+ domains: dict[str, Any],
428
+ *,
429
+ nullable: bool = False,
430
+ extract_hint: Optional[str] = None,
431
+ ) -> type[BaseModel]:
432
+ """A one-field Pydantic model whose before-validator enforces the domain.
433
+
434
+ The field name == the slot name so the generated ``model_json_schema()`` (the
435
+ ``payload_schema``) teaches the LLM exactly which key to extract.
436
+ """
437
+ spec = domains[domain_name]
438
+ validator = make_validator(spec)
439
+ field_type = _field_type(spec, nullable)
440
+ description = _field_description(slot_name, spec, extract_hint, nullable=nullable)
441
+
442
+ def _run_validator(_model_class: type[BaseModel], value: Any) -> Any:
443
+ if value is None:
444
+ if nullable:
445
+ return None
446
+ raise ValueError("null requires slots.<name>.nullable:true")
447
+ return validator(value)
448
+
449
+ validators: dict[str, Any] = {
450
+ f"_validate_{slot_name}": field_validator(slot_name, mode="before")(
451
+ classmethod(_run_validator)
452
+ )
453
+ }
454
+ domain_type = spec.get("type", "categorical")
455
+ field_constraints: dict[str, Any] = {}
456
+ if domain_type in {"integer", "number"}:
457
+ field_constraints.update(ge=spec.get("minimum"), le=spec.get("maximum"))
458
+ elif domain_type == "brazilian_tax_id":
459
+ field_constraints["pattern"] = r"^[0-9]{11}$"
460
+ elif domain_type == "email":
461
+ field_constraints["pattern"] = _EMAIL_RE.pattern
462
+ field_constraints["json_schema_extra"] = {"format": "email"}
463
+ elif domain_type == "name":
464
+ field_constraints["min_length"] = 2
465
+ elif domain_type == "free_text" and not spec.get("optional"):
466
+ field_constraints["min_length"] = 1
467
+ field_definitions: dict[str, Any] = {
468
+ slot_name: (
469
+ field_type,
470
+ Field(
471
+ ...,
472
+ description=description,
473
+ **{
474
+ constraint_name: constraint_value
475
+ for constraint_name, constraint_value in field_constraints.items()
476
+ if constraint_value is not None
477
+ },
478
+ ),
479
+ )
480
+ }
481
+ model = cast(
482
+ type[BaseModel],
483
+ create_model(
484
+ f"Slot_{slot_name}",
485
+ __validators__=validators,
486
+ **field_definitions,
487
+ ),
488
+ )
489
+ return model
@@ -0,0 +1,57 @@
1
+ """Non-stable experiments that are excluded from flowspec2 runtime compilation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .v3_lowering import (
6
+ V3PreviewLoweringError,
7
+ V3PreviewLoweringReport,
8
+ lower_v3_preview_to_v2,
9
+ lower_v3_preview_to_v2_report,
10
+ )
11
+ from .v3_preview import (
12
+ V2_SCHEMA_IDENTIFIER,
13
+ V3_PREVIEW_LOSS_POLICY_CONTRACT,
14
+ V3_PREVIEW_SCHEMA_IDENTIFIER,
15
+ CompactByteComparison,
16
+ V3PreviewDocumentMode,
17
+ V3PreviewLossCategory,
18
+ V3PreviewLossDisposition,
19
+ V3PreviewLossEntry,
20
+ V3PreviewLossHandling,
21
+ V3PreviewMigrationError,
22
+ V3PreviewMigrationReport,
23
+ V3PreviewValidationError,
24
+ check_v3_preview,
25
+ compact_json_bytes,
26
+ migrate_v2_to_v3_preview,
27
+ migrate_v2_to_v3_preview_report,
28
+ preview_loss_policy,
29
+ preview_schema,
30
+ validate_v3_preview,
31
+ )
32
+
33
+ __all__ = [
34
+ "V2_SCHEMA_IDENTIFIER",
35
+ "V3_PREVIEW_LOSS_POLICY_CONTRACT",
36
+ "V3_PREVIEW_SCHEMA_IDENTIFIER",
37
+ "CompactByteComparison",
38
+ "V3PreviewDocumentMode",
39
+ "V3PreviewLossCategory",
40
+ "V3PreviewLossDisposition",
41
+ "V3PreviewLossEntry",
42
+ "V3PreviewLossHandling",
43
+ "V3PreviewLoweringError",
44
+ "V3PreviewLoweringReport",
45
+ "V3PreviewMigrationError",
46
+ "V3PreviewMigrationReport",
47
+ "V3PreviewValidationError",
48
+ "check_v3_preview",
49
+ "compact_json_bytes",
50
+ "lower_v3_preview_to_v2",
51
+ "lower_v3_preview_to_v2_report",
52
+ "migrate_v2_to_v3_preview",
53
+ "migrate_v2_to_v3_preview_report",
54
+ "preview_loss_policy",
55
+ "preview_schema",
56
+ "validate_v3_preview",
57
+ ]