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/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("|"))
|
flowspec2/diagnostics.py
ADDED
|
@@ -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
|
+
]
|