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
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
"""Deterministic lowering from the experimental v3 preview to FlowSpec2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Mapping, cast
|
|
9
|
+
|
|
10
|
+
from ..checker import check_flow
|
|
11
|
+
from ..diagnostics import FlowCheckReport, FlowDiagnostic
|
|
12
|
+
from ..profiles import FlowProfile
|
|
13
|
+
from .v3_preview import (
|
|
14
|
+
V2_SCHEMA_IDENTIFIER,
|
|
15
|
+
V3PreviewDocumentMode,
|
|
16
|
+
V3PreviewLossCategory,
|
|
17
|
+
V3PreviewLossHandling,
|
|
18
|
+
migrate_v2_to_v3_preview,
|
|
19
|
+
preview_loss_policy,
|
|
20
|
+
validate_v3_preview,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_SLOT_CONTRACT_KEYS = (
|
|
24
|
+
"persist",
|
|
25
|
+
"required",
|
|
26
|
+
"nullable",
|
|
27
|
+
"prefill_sources",
|
|
28
|
+
"max_attempts",
|
|
29
|
+
"on_exhaust",
|
|
30
|
+
"default",
|
|
31
|
+
"fill_only_when_asked",
|
|
32
|
+
)
|
|
33
|
+
_PREDICATE_REFERENCE_NAMESPACES = {
|
|
34
|
+
"$config": "config",
|
|
35
|
+
"$payload": "payload",
|
|
36
|
+
"$internal": "internal",
|
|
37
|
+
"$address": "address",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class V3PreviewLoweringError(ValueError):
|
|
42
|
+
"""A valid preview cannot produce an executable FlowSpec2 document."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, diagnostics: tuple[FlowDiagnostic, ...]) -> None:
|
|
45
|
+
self.diagnostics = diagnostics
|
|
46
|
+
summary = "; ".join(
|
|
47
|
+
f"{diagnostic.code} at {diagnostic.path or '/'}: {diagnostic.message}"
|
|
48
|
+
for diagnostic in diagnostics
|
|
49
|
+
)
|
|
50
|
+
super().__init__(summary)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class V3PreviewLoweringReport:
|
|
55
|
+
"""Immutable executable lowering result with fixed-point evidence."""
|
|
56
|
+
|
|
57
|
+
_source_json: str = field(repr=False)
|
|
58
|
+
rehydrated_paths: tuple[str, ...]
|
|
59
|
+
flow_check: FlowCheckReport
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def source_document(self) -> dict[str, Any]:
|
|
63
|
+
"""Return a fresh lowered document so callers cannot mutate the report."""
|
|
64
|
+
return cast(dict[str, Any], json.loads(self._source_json))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class _LoweringContext:
|
|
69
|
+
source_document: dict[str, Any]
|
|
70
|
+
reserved_domain_names: set[str]
|
|
71
|
+
domains: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
72
|
+
slots: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
73
|
+
path: list[dict[str, Any]] = field(default_factory=list)
|
|
74
|
+
uses: list[dict[str, Any]] = field(default_factory=list)
|
|
75
|
+
derives: list[dict[str, Any]] = field(default_factory=list)
|
|
76
|
+
override_gates: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
77
|
+
confirmation: dict[str, Any] | None = None
|
|
78
|
+
terminal: dict[str, Any] | None = None
|
|
79
|
+
await_capability: dict[str, Any] | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def lower_v3_preview_to_v2(
|
|
83
|
+
preview_document: Mapping[str, Any],
|
|
84
|
+
*,
|
|
85
|
+
profile: FlowProfile | None = None,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
"""Lower a preview and return a fresh executable FlowSpec2 document."""
|
|
88
|
+
return lower_v3_preview_to_v2_report(preview_document, profile=profile).source_document
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def lower_v3_preview_to_v2_report(
|
|
92
|
+
preview_document: Mapping[str, Any],
|
|
93
|
+
*,
|
|
94
|
+
profile: FlowProfile | None = None,
|
|
95
|
+
) -> V3PreviewLoweringReport:
|
|
96
|
+
"""Lower, compile-check, and prove the exact v3-to-v2-to-v3 fixed point."""
|
|
97
|
+
private_preview = _canonical_clone(preview_document)
|
|
98
|
+
if not isinstance(private_preview, dict):
|
|
99
|
+
_raise_lowering(
|
|
100
|
+
"FLOWSPEC3_LOWERING_INVALID_DOCUMENT",
|
|
101
|
+
"",
|
|
102
|
+
"preview source must be a JSON object",
|
|
103
|
+
)
|
|
104
|
+
validation_mode = (
|
|
105
|
+
V3PreviewDocumentMode.V2_MIGRATION
|
|
106
|
+
if "v2_passthrough" in private_preview
|
|
107
|
+
else V3PreviewDocumentMode.AUTHORING
|
|
108
|
+
)
|
|
109
|
+
validate_v3_preview(private_preview, mode=validation_mode)
|
|
110
|
+
loss_entries = _loss_entries(private_preview)
|
|
111
|
+
_reject_excluded_loss_entries(loss_entries)
|
|
112
|
+
_validate_lowerable_surface(private_preview)
|
|
113
|
+
|
|
114
|
+
source_document = _lower_native_preview(private_preview, loss_entries)
|
|
115
|
+
rehydrated_paths = _rehydrate_loss_entries(source_document, loss_entries)
|
|
116
|
+
if not source_document.get("domains"):
|
|
117
|
+
_raise_lowering(
|
|
118
|
+
"FLOWSPEC3_LOWERING_DOMAINS_REQUIRED",
|
|
119
|
+
"/steps",
|
|
120
|
+
"FlowSpec2 requires at least one local or rehydrated domain declaration",
|
|
121
|
+
)
|
|
122
|
+
flow_check = check_flow(source_document, profile=profile)
|
|
123
|
+
if not flow_check.is_valid or flow_check.compilation != "succeeded":
|
|
124
|
+
raise V3PreviewLoweringError(flow_check.diagnostics)
|
|
125
|
+
|
|
126
|
+
round_trip_preview = migrate_v2_to_v3_preview(source_document)
|
|
127
|
+
if round_trip_preview != private_preview:
|
|
128
|
+
mismatch_path = _first_difference_path(private_preview, round_trip_preview)
|
|
129
|
+
_raise_lowering(
|
|
130
|
+
"FLOWSPEC3_LOWERING_FIXED_POINT_MISMATCH",
|
|
131
|
+
mismatch_path,
|
|
132
|
+
"lowered FlowSpec2 does not migrate back to the exact preview source",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return V3PreviewLoweringReport(
|
|
136
|
+
_source_json=_canonical_json(source_document),
|
|
137
|
+
rehydrated_paths=rehydrated_paths,
|
|
138
|
+
flow_check=flow_check,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _loss_entries(preview_document: dict[str, Any]) -> list[dict[str, Any]]:
|
|
143
|
+
passthrough = cast(dict[str, Any] | None, preview_document.get("v2_passthrough"))
|
|
144
|
+
return [] if passthrough is None else cast(list[dict[str, Any]], passthrough["entries"])
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _reject_excluded_loss_entries(loss_entries: list[dict[str, Any]]) -> None:
|
|
148
|
+
policy_categories = cast(dict[str, dict[str, str]], preview_loss_policy()["categories"])
|
|
149
|
+
diagnostics = tuple(
|
|
150
|
+
FlowDiagnostic(
|
|
151
|
+
code="FLOWSPEC3_LOWERING_EXCLUDED_LOSS",
|
|
152
|
+
severity="error",
|
|
153
|
+
path=f"/v2_passthrough/entries/{loss_index}",
|
|
154
|
+
message=(
|
|
155
|
+
f"loss category {loss_entry['category']!r} requires rejection and cannot be "
|
|
156
|
+
"rehydrated"
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
for loss_index, loss_entry in enumerate(loss_entries)
|
|
160
|
+
if V3PreviewLossHandling(policy_categories[cast(str, loss_entry["category"])]["handling"])
|
|
161
|
+
is V3PreviewLossHandling.REJECT
|
|
162
|
+
)
|
|
163
|
+
if diagnostics:
|
|
164
|
+
raise V3PreviewLoweringError(diagnostics)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _validate_lowerable_surface(preview_document: dict[str, Any]) -> None:
|
|
168
|
+
for step_index, preview_step in enumerate(
|
|
169
|
+
cast(list[dict[str, Any]], preview_document["steps"])
|
|
170
|
+
):
|
|
171
|
+
step_path = f"/steps/{step_index}"
|
|
172
|
+
if domain := preview_step.get("domain"):
|
|
173
|
+
_validate_lowerable_domain(cast(dict[str, Any], domain), f"{step_path}/domain")
|
|
174
|
+
preview_ui = preview_step.get("ui")
|
|
175
|
+
if isinstance(preview_ui, dict) and preview_ui.get("kind") == "flow":
|
|
176
|
+
_raise_lowering(
|
|
177
|
+
"FLOWSPEC3_LOWERING_UNSUPPORTED_FLOW_UI",
|
|
178
|
+
f"{step_path}/ui",
|
|
179
|
+
"FlowSpec2 path steps have no equivalent Meta Flow UI contract",
|
|
180
|
+
)
|
|
181
|
+
if "derive" in preview_step:
|
|
182
|
+
lookup = cast(dict[str, Any], preview_step["lookup"])
|
|
183
|
+
if any(not isinstance(lookup_value, str) for lookup_value in lookup.values()):
|
|
184
|
+
_raise_lowering(
|
|
185
|
+
"FLOWSPEC3_LOWERING_NON_STRING_DERIVE_VALUE",
|
|
186
|
+
f"{step_path}/lookup",
|
|
187
|
+
"FlowSpec2 derive lookup values must be strings",
|
|
188
|
+
)
|
|
189
|
+
derive_default = preview_step.get("default")
|
|
190
|
+
if derive_default is not None and (
|
|
191
|
+
not isinstance(derive_default, (str, dict))
|
|
192
|
+
or isinstance(derive_default, dict)
|
|
193
|
+
and set(derive_default) not in ({"$slot"}, {"$derive"})
|
|
194
|
+
):
|
|
195
|
+
_raise_lowering(
|
|
196
|
+
"FLOWSPEC3_LOWERING_NON_STRING_DERIVE_DEFAULT",
|
|
197
|
+
f"{step_path}/default",
|
|
198
|
+
"FlowSpec2 derive defaults must be strings or source references",
|
|
199
|
+
)
|
|
200
|
+
if isinstance(derive_default, str) and re.fullmatch(
|
|
201
|
+
r"\$from\[[0-9]+\]", derive_default
|
|
202
|
+
):
|
|
203
|
+
_raise_lowering(
|
|
204
|
+
"FLOWSPEC3_LOWERING_AMBIGUOUS_DERIVE_DEFAULT",
|
|
205
|
+
f"{step_path}/default",
|
|
206
|
+
"FlowSpec2 interprets this string as a derive source reference",
|
|
207
|
+
)
|
|
208
|
+
for predicate_key in ("ask_when", "skip_when", "gate"):
|
|
209
|
+
if predicate_key in preview_step:
|
|
210
|
+
_validate_lowerable_predicate_references(
|
|
211
|
+
cast(dict[str, Any], preview_step[predicate_key]),
|
|
212
|
+
f"{step_path}/{predicate_key}",
|
|
213
|
+
)
|
|
214
|
+
preview_ui = cast(dict[str, Any] | None, preview_step.get("ui"))
|
|
215
|
+
if isinstance(preview_ui, dict):
|
|
216
|
+
for option_index, option_gate in enumerate(
|
|
217
|
+
cast(list[dict[str, Any]], preview_ui.get("options_when", []))
|
|
218
|
+
):
|
|
219
|
+
_validate_lowerable_predicate_references(
|
|
220
|
+
cast(dict[str, Any], option_gate["gate"]),
|
|
221
|
+
f"{step_path}/ui/options_when/{option_index}/gate",
|
|
222
|
+
)
|
|
223
|
+
if on_resume := preview_step.get("on_resume"):
|
|
224
|
+
_validate_lowerable_bindings(
|
|
225
|
+
cast(dict[str, Any], on_resume).get("set"),
|
|
226
|
+
"$token.",
|
|
227
|
+
f"{step_path}/on_resume/set",
|
|
228
|
+
)
|
|
229
|
+
enrichment = cast(dict[str, Any], on_resume).get("enrich")
|
|
230
|
+
if isinstance(enrichment, dict):
|
|
231
|
+
_validate_lowerable_bindings(
|
|
232
|
+
enrichment.get("input"), "$token.", f"{step_path}/on_resume/enrich/input"
|
|
233
|
+
)
|
|
234
|
+
_validate_lowerable_bindings(
|
|
235
|
+
enrichment.get("set"), "$result.", f"{step_path}/on_resume/enrich/set"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _validate_lowerable_domain(domain: dict[str, Any], domain_path: str) -> None:
|
|
240
|
+
domain_type = cast(str, domain["type"])
|
|
241
|
+
if domain_type not in {"categorical", "bool"}:
|
|
242
|
+
return
|
|
243
|
+
if (
|
|
244
|
+
domain_type == "categorical"
|
|
245
|
+
and domain.get("accepts_null") is True
|
|
246
|
+
and cast(dict[str, Any], domain.get("normalize", {})).get("number_words") is True
|
|
247
|
+
):
|
|
248
|
+
_raise_lowering(
|
|
249
|
+
"FLOWSPEC3_LOWERING_NULLABLE_NUMBER_WORDS",
|
|
250
|
+
f"{domain_path}/normalize/number_words",
|
|
251
|
+
"nullable categorical number-word normalization has no stable FlowSpec2 round-trip",
|
|
252
|
+
)
|
|
253
|
+
for option_index, option in enumerate(cast(list[dict[str, Any]], domain["options"])):
|
|
254
|
+
option_value = option["value"]
|
|
255
|
+
expected_label = (
|
|
256
|
+
"Yes" if option_value is True else "No" if option_value is False else str(option_value)
|
|
257
|
+
)
|
|
258
|
+
if option["label"] != expected_label:
|
|
259
|
+
_raise_lowering(
|
|
260
|
+
"FLOWSPEC3_LOWERING_CUSTOM_OPTION_LABEL",
|
|
261
|
+
f"{domain_path}/options/{option_index}/label",
|
|
262
|
+
f"FlowSpec2 derives label {expected_label!r} from the canonical option value",
|
|
263
|
+
)
|
|
264
|
+
if domain_type == "bool" and "description" in option:
|
|
265
|
+
_raise_lowering(
|
|
266
|
+
"FLOWSPEC3_LOWERING_BOOLEAN_OPTION_DESCRIPTION",
|
|
267
|
+
f"{domain_path}/options/{option_index}/description",
|
|
268
|
+
"FlowSpec2 boolean domains cannot represent per-option descriptions",
|
|
269
|
+
)
|
|
270
|
+
aliases = cast(list[str], option.get("aliases", []))
|
|
271
|
+
if aliases != sorted(aliases):
|
|
272
|
+
_raise_lowering(
|
|
273
|
+
"FLOWSPEC3_LOWERING_UNORDERED_ALIASES",
|
|
274
|
+
f"{domain_path}/options/{option_index}/aliases",
|
|
275
|
+
"aliases must use canonical lexical order for an exact round-trip",
|
|
276
|
+
)
|
|
277
|
+
null_aliases = cast(list[str], domain.get("null_aliases", []))
|
|
278
|
+
if null_aliases != sorted(null_aliases):
|
|
279
|
+
_raise_lowering(
|
|
280
|
+
"FLOWSPEC3_LOWERING_UNORDERED_ALIASES",
|
|
281
|
+
f"{domain_path}/null_aliases",
|
|
282
|
+
"null aliases must use canonical lexical order for an exact round-trip",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _validate_lowerable_bindings(bindings: Any, reserved_prefix: str, path: str) -> None:
|
|
287
|
+
if not isinstance(bindings, dict):
|
|
288
|
+
return
|
|
289
|
+
for binding_name, binding_value in bindings.items():
|
|
290
|
+
reference_key = reserved_prefix.removesuffix(".")
|
|
291
|
+
if isinstance(binding_value, (dict, list)) and not (
|
|
292
|
+
isinstance(binding_value, dict) and set(binding_value) == {reference_key}
|
|
293
|
+
):
|
|
294
|
+
_raise_lowering(
|
|
295
|
+
"FLOWSPEC3_LOWERING_COMPOSITE_BINDING_LITERAL",
|
|
296
|
+
f"{path}/{_encode_pointer_segment(binding_name)}",
|
|
297
|
+
"FlowSpec2 resume and enrichment binding literals must be JSON scalars",
|
|
298
|
+
)
|
|
299
|
+
if isinstance(binding_value, str) and binding_value.startswith(reserved_prefix):
|
|
300
|
+
_raise_lowering(
|
|
301
|
+
"FLOWSPEC3_LOWERING_AMBIGUOUS_BINDING_LITERAL",
|
|
302
|
+
f"{path}/{_encode_pointer_segment(binding_name)}",
|
|
303
|
+
f"FlowSpec2 interprets strings beginning with {reserved_prefix!r} as references",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _validate_lowerable_predicate_references(predicate: dict[str, Any], path: str) -> None:
|
|
308
|
+
operator, operand = next(iter(predicate.items()))
|
|
309
|
+
if operator in {"and", "or"}:
|
|
310
|
+
for child_index, child_predicate in enumerate(cast(list[dict[str, Any]], operand)):
|
|
311
|
+
_validate_lowerable_predicate_references(
|
|
312
|
+
child_predicate, f"{path}/{operator}/{child_index}"
|
|
313
|
+
)
|
|
314
|
+
return
|
|
315
|
+
if operator == "not":
|
|
316
|
+
_validate_lowerable_predicate_references(cast(dict[str, Any], operand), f"{path}/not")
|
|
317
|
+
return
|
|
318
|
+
operands = [operand] if operator == "is_present" else cast(list[Any], operand)
|
|
319
|
+
for operand_index, predicate_operand in enumerate(operands):
|
|
320
|
+
if not isinstance(predicate_operand, dict):
|
|
321
|
+
continue
|
|
322
|
+
reference_key, reference_path = next(iter(predicate_operand.items()))
|
|
323
|
+
if reference_key in {"$config", "$payload", "$internal"} and "." in reference_path:
|
|
324
|
+
_raise_lowering(
|
|
325
|
+
"FLOWSPEC3_LOWERING_NESTED_STATE_REFERENCE",
|
|
326
|
+
f"{path}/{operator}/{operand_index}",
|
|
327
|
+
f"FlowSpec2 supports only one path segment for {reference_key} references",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _lower_native_preview(
|
|
332
|
+
preview_document: dict[str, Any], loss_entries: list[dict[str, Any]]
|
|
333
|
+
) -> dict[str, Any]:
|
|
334
|
+
source_document: dict[str, Any] = {
|
|
335
|
+
"schema": V2_SCHEMA_IDENTIFIER,
|
|
336
|
+
"flow": preview_document["flow"],
|
|
337
|
+
"version": preview_document["version"],
|
|
338
|
+
"route": _canonical_clone(preview_document["route"]),
|
|
339
|
+
}
|
|
340
|
+
for top_level_key in ("service", "config"):
|
|
341
|
+
if top_level_key in preview_document:
|
|
342
|
+
source_document[top_level_key] = _canonical_clone(preview_document[top_level_key])
|
|
343
|
+
lowering_context = _LoweringContext(
|
|
344
|
+
source_document=source_document,
|
|
345
|
+
reserved_domain_names={
|
|
346
|
+
_decode_pointer(cast(str, loss_entry["source_path"]))[-1]
|
|
347
|
+
for loss_entry in loss_entries
|
|
348
|
+
if loss_entry["category"] == V3PreviewLossCategory.UNUSED_DOMAIN_DECLARATION.value
|
|
349
|
+
},
|
|
350
|
+
)
|
|
351
|
+
for step_index, preview_step in enumerate(
|
|
352
|
+
cast(list[dict[str, Any]], preview_document["steps"])
|
|
353
|
+
):
|
|
354
|
+
_lower_step(preview_step, step_index, lowering_context)
|
|
355
|
+
source_document["path"] = lowering_context.path
|
|
356
|
+
for key, section in (
|
|
357
|
+
("domains", lowering_context.domains),
|
|
358
|
+
("slots", lowering_context.slots),
|
|
359
|
+
("uses", lowering_context.uses),
|
|
360
|
+
("derive", lowering_context.derives),
|
|
361
|
+
):
|
|
362
|
+
if section:
|
|
363
|
+
source_document[key] = section
|
|
364
|
+
if lowering_context.confirmation is not None:
|
|
365
|
+
source_document["confirm"] = lowering_context.confirmation
|
|
366
|
+
if lowering_context.terminal is not None:
|
|
367
|
+
source_document["terminal"] = lowering_context.terminal
|
|
368
|
+
if lowering_context.override_gates:
|
|
369
|
+
source_document["overrides"] = {"gates": lowering_context.override_gates}
|
|
370
|
+
if lowering_context.await_capability is not None:
|
|
371
|
+
source_document["capabilities"] = {"await_external": lowering_context.await_capability}
|
|
372
|
+
return cast(dict[str, Any], _canonical_clone(source_document))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _lower_step(
|
|
376
|
+
preview_step: dict[str, Any], step_index: int, lowering_context: _LoweringContext
|
|
377
|
+
) -> None:
|
|
378
|
+
if "collect" in preview_step:
|
|
379
|
+
_lower_slot_step("collect", "slot", preview_step, step_index, lowering_context)
|
|
380
|
+
return
|
|
381
|
+
if "confirm" in preview_step:
|
|
382
|
+
_lower_slot_step("confirm", "confirm", preview_step, step_index, lowering_context)
|
|
383
|
+
return
|
|
384
|
+
if "use" in preview_step:
|
|
385
|
+
use_reference = cast(str, preview_step["use"])
|
|
386
|
+
lowering_context.path.append({"use": use_reference})
|
|
387
|
+
use_declaration: dict[str, Any] = {"ref": use_reference}
|
|
388
|
+
if "with" in preview_step:
|
|
389
|
+
use_declaration["with"] = _canonical_clone(preview_step["with"])
|
|
390
|
+
lowering_context.uses.append(use_declaration)
|
|
391
|
+
return
|
|
392
|
+
if "derive" in preview_step:
|
|
393
|
+
_lower_derive_step(preview_step, step_index, lowering_context)
|
|
394
|
+
return
|
|
395
|
+
if "submit" in preview_step:
|
|
396
|
+
_lower_submit_step(preview_step, lowering_context)
|
|
397
|
+
return
|
|
398
|
+
if "await" in preview_step:
|
|
399
|
+
_lower_await_step(preview_step, lowering_context)
|
|
400
|
+
return
|
|
401
|
+
_raise_lowering(
|
|
402
|
+
"FLOWSPEC3_LOWERING_UNKNOWN_STEP",
|
|
403
|
+
f"/steps/{step_index}",
|
|
404
|
+
"preview step has no lowerable kind",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _lower_slot_step(
|
|
409
|
+
preview_kind: str,
|
|
410
|
+
v2_kind: str,
|
|
411
|
+
preview_step: dict[str, Any],
|
|
412
|
+
step_index: int,
|
|
413
|
+
lowering_context: _LoweringContext,
|
|
414
|
+
) -> None:
|
|
415
|
+
slot_name = cast(str, preview_step[preview_kind])
|
|
416
|
+
domain_name = _available_domain_name(slot_name, lowering_context)
|
|
417
|
+
lowering_context.domains[domain_name] = _lower_domain(
|
|
418
|
+
cast(dict[str, Any], preview_step["domain"])
|
|
419
|
+
)
|
|
420
|
+
slot_definition = {
|
|
421
|
+
"domain": domain_name,
|
|
422
|
+
**{
|
|
423
|
+
contract_key: _canonical_clone(preview_step[contract_key])
|
|
424
|
+
for contract_key in _SLOT_CONTRACT_KEYS
|
|
425
|
+
if contract_key in preview_step
|
|
426
|
+
},
|
|
427
|
+
}
|
|
428
|
+
if "requires" in preview_step:
|
|
429
|
+
slot_definition["requires"] = [
|
|
430
|
+
_reference_target(slot_reference, "$slot")
|
|
431
|
+
for slot_reference in cast(list[dict[str, str]], preview_step["requires"])
|
|
432
|
+
]
|
|
433
|
+
lowering_context.slots[slot_name] = slot_definition
|
|
434
|
+
|
|
435
|
+
path_step: dict[str, Any] = {v2_kind: slot_name}
|
|
436
|
+
if "id" in preview_step:
|
|
437
|
+
path_step["step"] = preview_step["id"]
|
|
438
|
+
if "prompt" in preview_step:
|
|
439
|
+
path_step["prompt"] = _canonical_clone(preview_step["prompt"])
|
|
440
|
+
if "ui" in preview_step:
|
|
441
|
+
path_step["interactive"] = _lower_ui(cast(dict[str, Any], preview_step["ui"]), domain_name)
|
|
442
|
+
for predicate_key in ("ask_when", "skip_when"):
|
|
443
|
+
if predicate_key in preview_step:
|
|
444
|
+
path_step[predicate_key] = _lower_predicate(
|
|
445
|
+
cast(dict[str, Any], preview_step[predicate_key])
|
|
446
|
+
)
|
|
447
|
+
if "gate" in preview_step:
|
|
448
|
+
step_identifier = cast(str, preview_step.get("id", slot_name))
|
|
449
|
+
lowering_context.override_gates[step_identifier] = _lower_predicate(
|
|
450
|
+
cast(dict[str, Any], preview_step["gate"])
|
|
451
|
+
)
|
|
452
|
+
if "on_reject" in preview_step:
|
|
453
|
+
path_step["on_reject"] = _canonical_clone(preview_step["on_reject"])
|
|
454
|
+
if preview_step.get("correction_hub") is True:
|
|
455
|
+
path_step["correctable"] = True
|
|
456
|
+
step_identifier = cast(str, preview_step.get("id", slot_name))
|
|
457
|
+
lowering_context.confirmation = {
|
|
458
|
+
"step": step_identifier,
|
|
459
|
+
"slot": slot_name,
|
|
460
|
+
"correctable": [
|
|
461
|
+
_reference_target(reference, "$slot")
|
|
462
|
+
for reference in cast(list[dict[str, str]], preview_step["correctable"])
|
|
463
|
+
],
|
|
464
|
+
"on_confirm": _reference_target(
|
|
465
|
+
cast(dict[str, str], preview_step["on_confirm"]), "$step"
|
|
466
|
+
),
|
|
467
|
+
}
|
|
468
|
+
lowering_context.path.append(path_step)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _available_domain_name(slot_name: str, lowering_context: _LoweringContext) -> str:
|
|
472
|
+
domain_name_base = f"domain_{slot_name}"
|
|
473
|
+
domain_name = domain_name_base
|
|
474
|
+
suffix = 2
|
|
475
|
+
unavailable_names = {
|
|
476
|
+
*lowering_context.reserved_domain_names,
|
|
477
|
+
*lowering_context.domains,
|
|
478
|
+
}
|
|
479
|
+
while domain_name in unavailable_names:
|
|
480
|
+
domain_name = f"{domain_name_base}_{suffix}"
|
|
481
|
+
suffix += 1
|
|
482
|
+
return domain_name
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _lower_domain(preview_domain: dict[str, Any]) -> dict[str, Any]:
|
|
486
|
+
domain_type = cast(str, preview_domain["type"])
|
|
487
|
+
domain_definition: dict[str, Any] = {"type": domain_type}
|
|
488
|
+
for domain_key in ("optional", "minimum", "maximum"):
|
|
489
|
+
if domain_key in preview_domain:
|
|
490
|
+
domain_definition[domain_key] = preview_domain[domain_key]
|
|
491
|
+
normalization = cast(dict[str, Any], _canonical_clone(preview_domain.get("normalize", {})))
|
|
492
|
+
if domain_type in {"categorical", "bool"}:
|
|
493
|
+
options = cast(list[dict[str, Any]], preview_domain["options"])
|
|
494
|
+
if domain_type == "categorical":
|
|
495
|
+
domain_definition["values"] = [option["value"] for option in options]
|
|
496
|
+
if preview_domain.get("accepts_null") is True:
|
|
497
|
+
cast(list[Any], domain_definition["values"]).append(None)
|
|
498
|
+
synonyms = {
|
|
499
|
+
alias: option["value"]
|
|
500
|
+
for option in options
|
|
501
|
+
for alias in cast(list[str], option.get("aliases", []))
|
|
502
|
+
}
|
|
503
|
+
if domain_type == "categorical" and preview_domain.get("accepts_null") is True:
|
|
504
|
+
synonyms.update(
|
|
505
|
+
{alias: None for alias in cast(list[str], preview_domain.get("null_aliases", []))}
|
|
506
|
+
)
|
|
507
|
+
if synonyms:
|
|
508
|
+
normalization["synonyms"] = synonyms
|
|
509
|
+
if domain_type == "categorical":
|
|
510
|
+
rows = [
|
|
511
|
+
{"value": option["value"], "description": option["description"]}
|
|
512
|
+
for option in options
|
|
513
|
+
if "description" in option
|
|
514
|
+
]
|
|
515
|
+
if rows:
|
|
516
|
+
domain_definition["rows"] = rows
|
|
517
|
+
if normalization:
|
|
518
|
+
domain_definition["normalize"] = normalization
|
|
519
|
+
return domain_definition
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _lower_ui(preview_ui: dict[str, Any], domain_name: str | None) -> dict[str, Any]:
|
|
523
|
+
interactive = {
|
|
524
|
+
interactive_key: _canonical_clone(interactive_value)
|
|
525
|
+
for interactive_key, interactive_value in preview_ui.items()
|
|
526
|
+
if interactive_key not in {"next_step", "prefill_from", "options_when"}
|
|
527
|
+
}
|
|
528
|
+
if preview_ui["kind"] in {"buttons", "list"}:
|
|
529
|
+
if domain_name is None:
|
|
530
|
+
_raise_lowering(
|
|
531
|
+
"FLOWSPEC3_LOWERING_MISSING_UI_DOMAIN",
|
|
532
|
+
"",
|
|
533
|
+
"choice UI requires a lowered domain",
|
|
534
|
+
)
|
|
535
|
+
interactive["from_domain"] = domain_name
|
|
536
|
+
if "next_step" in preview_ui:
|
|
537
|
+
interactive["next_step"] = _reference_target(
|
|
538
|
+
cast(dict[str, str], preview_ui["next_step"]), "$step"
|
|
539
|
+
)
|
|
540
|
+
if "prefill_from" in preview_ui:
|
|
541
|
+
interactive["prefill_from"] = [
|
|
542
|
+
_reference_target(reference, "$slot")
|
|
543
|
+
for reference in cast(list[dict[str, str]], preview_ui["prefill_from"])
|
|
544
|
+
]
|
|
545
|
+
if "options_when" in preview_ui:
|
|
546
|
+
interactive["options_when"] = [
|
|
547
|
+
{
|
|
548
|
+
"value": option_gate["value"],
|
|
549
|
+
"gate": _lower_predicate(cast(dict[str, Any], option_gate["gate"])),
|
|
550
|
+
}
|
|
551
|
+
for option_gate in cast(list[dict[str, Any]], preview_ui["options_when"])
|
|
552
|
+
]
|
|
553
|
+
return interactive
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _lower_derive_step(
|
|
557
|
+
preview_step: dict[str, Any], step_index: int, lowering_context: _LoweringContext
|
|
558
|
+
) -> None:
|
|
559
|
+
source_references = cast(list[dict[str, str]], preview_step["from"])
|
|
560
|
+
source_names = [_source_reference_target(reference) for reference in source_references]
|
|
561
|
+
derive_definition: dict[str, Any] = {
|
|
562
|
+
"writes": preview_step["derive"],
|
|
563
|
+
"from": source_names,
|
|
564
|
+
"lookup": _canonical_clone(preview_step["lookup"]),
|
|
565
|
+
}
|
|
566
|
+
if "default" in preview_step:
|
|
567
|
+
derive_default = preview_step["default"]
|
|
568
|
+
if isinstance(derive_default, dict):
|
|
569
|
+
source_index = next(
|
|
570
|
+
(
|
|
571
|
+
candidate_index
|
|
572
|
+
for candidate_index, source_reference in enumerate(source_references)
|
|
573
|
+
if source_reference == cast(dict[str, str], derive_default)
|
|
574
|
+
),
|
|
575
|
+
None,
|
|
576
|
+
)
|
|
577
|
+
if source_index is None:
|
|
578
|
+
_raise_lowering(
|
|
579
|
+
"FLOWSPEC3_LOWERING_DERIVE_DEFAULT_OUTSIDE_SOURCES",
|
|
580
|
+
f"/steps/{step_index}/default",
|
|
581
|
+
"derive default reference must also appear in the ordered from list",
|
|
582
|
+
)
|
|
583
|
+
derive_definition["default"] = f"$from[{source_index}]"
|
|
584
|
+
else:
|
|
585
|
+
derive_definition["default"] = derive_default
|
|
586
|
+
lowering_context.derives.append(derive_definition)
|
|
587
|
+
path_step: dict[str, Any] = {"derive": preview_step["derive"]}
|
|
588
|
+
if "id" in preview_step:
|
|
589
|
+
path_step["step"] = preview_step["id"]
|
|
590
|
+
lowering_context.path.append(path_step)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _lower_submit_step(preview_step: dict[str, Any], lowering_context: _LoweringContext) -> None:
|
|
594
|
+
terminal: dict[str, Any] = {
|
|
595
|
+
"step": preview_step["id"],
|
|
596
|
+
"tool": preview_step["submit"],
|
|
597
|
+
"idempotent": preview_step["idempotent"],
|
|
598
|
+
"outcomes": _canonical_clone(preview_step["outcomes"]),
|
|
599
|
+
}
|
|
600
|
+
if "input" in preview_step:
|
|
601
|
+
terminal["input"] = [
|
|
602
|
+
{"param": parameter_name, "slot": _source_reference_target(source_reference)}
|
|
603
|
+
for parameter_name, source_reference in cast(
|
|
604
|
+
dict[str, dict[str, str]], preview_step["input"]
|
|
605
|
+
).items()
|
|
606
|
+
]
|
|
607
|
+
if "outputs" in preview_step:
|
|
608
|
+
terminal["outputs"] = {
|
|
609
|
+
state_key: f"result.{_reference_target(result_reference, '$result')}"
|
|
610
|
+
for state_key, result_reference in cast(
|
|
611
|
+
dict[str, dict[str, str]], preview_step["outputs"]
|
|
612
|
+
).items()
|
|
613
|
+
}
|
|
614
|
+
if "empty_payload" in preview_step:
|
|
615
|
+
terminal["empty_payload"] = _canonical_clone(preview_step["empty_payload"])
|
|
616
|
+
lowering_context.terminal = terminal
|
|
617
|
+
lowering_context.path.append({"terminal": True})
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _lower_await_step(preview_step: dict[str, Any], lowering_context: _LoweringContext) -> None:
|
|
621
|
+
await_capability: dict[str, Any] = {
|
|
622
|
+
"kind": preview_step["await"],
|
|
623
|
+
"step": preview_step["id"],
|
|
624
|
+
"resume_on": preview_step["resume_on"],
|
|
625
|
+
}
|
|
626
|
+
if "on_resume" in preview_step:
|
|
627
|
+
await_capability["on_resume"] = _lower_on_resume(
|
|
628
|
+
cast(dict[str, Any], preview_step["on_resume"])
|
|
629
|
+
)
|
|
630
|
+
if "timeout" in preview_step:
|
|
631
|
+
await_capability["timeout"] = _lower_transition(
|
|
632
|
+
cast(dict[str, Any], preview_step["timeout"])
|
|
633
|
+
)
|
|
634
|
+
if "recovery" in preview_step:
|
|
635
|
+
await_capability["recovery"] = {
|
|
636
|
+
recovery_event: _lower_transition(transition)
|
|
637
|
+
for recovery_event, transition in cast(
|
|
638
|
+
dict[str, dict[str, Any]], preview_step["recovery"]
|
|
639
|
+
).items()
|
|
640
|
+
}
|
|
641
|
+
path_step: dict[str, Any] = {
|
|
642
|
+
"step": preview_step["id"],
|
|
643
|
+
"await_external": True,
|
|
644
|
+
"interactive": _lower_ui(cast(dict[str, Any], preview_step["ui"]), None),
|
|
645
|
+
}
|
|
646
|
+
if "prompt" in preview_step:
|
|
647
|
+
path_step["prompt"] = _canonical_clone(preview_step["prompt"])
|
|
648
|
+
lowering_context.await_capability = await_capability
|
|
649
|
+
lowering_context.path.append(path_step)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _lower_on_resume(preview_on_resume: dict[str, Any]) -> dict[str, Any]:
|
|
653
|
+
on_resume: dict[str, Any] = {}
|
|
654
|
+
if "set" in preview_on_resume:
|
|
655
|
+
on_resume["set"] = _lower_binding_map(
|
|
656
|
+
cast(dict[str, Any], preview_on_resume["set"]), "$token"
|
|
657
|
+
)
|
|
658
|
+
if "enrich" in preview_on_resume:
|
|
659
|
+
preview_enrichment = cast(dict[str, Any], preview_on_resume["enrich"])
|
|
660
|
+
enrichment: dict[str, Any] = {"tool": preview_enrichment["tool"]}
|
|
661
|
+
if "optional" in preview_enrichment:
|
|
662
|
+
enrichment["optional"] = preview_enrichment["optional"]
|
|
663
|
+
if "input" in preview_enrichment:
|
|
664
|
+
enrichment["input"] = _lower_binding_map(
|
|
665
|
+
cast(dict[str, Any], preview_enrichment["input"]), "$token"
|
|
666
|
+
)
|
|
667
|
+
if "set" in preview_enrichment:
|
|
668
|
+
enrichment["set"] = _lower_binding_map(
|
|
669
|
+
cast(dict[str, Any], preview_enrichment["set"]), "$result"
|
|
670
|
+
)
|
|
671
|
+
on_resume["enrich"] = enrichment
|
|
672
|
+
return on_resume
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _lower_binding_map(bindings: dict[str, Any], reference_key: str) -> dict[str, Any]:
|
|
676
|
+
return {
|
|
677
|
+
binding_name: (
|
|
678
|
+
f"{reference_key}.{_reference_target(binding_value, reference_key)}"
|
|
679
|
+
if isinstance(binding_value, dict) and reference_key in binding_value
|
|
680
|
+
else _canonical_clone(binding_value)
|
|
681
|
+
)
|
|
682
|
+
for binding_name, binding_value in bindings.items()
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _lower_transition(preview_transition: dict[str, Any]) -> dict[str, Any]:
|
|
687
|
+
transition_target = preview_transition["goto"]
|
|
688
|
+
transition: dict[str, Any] = {
|
|
689
|
+
"goto": transition_target
|
|
690
|
+
if transition_target == "END"
|
|
691
|
+
else _reference_target(cast(dict[str, str], transition_target), "$step")
|
|
692
|
+
}
|
|
693
|
+
if "set" in preview_transition:
|
|
694
|
+
transition["set"] = _canonical_clone(preview_transition["set"])
|
|
695
|
+
return transition
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _lower_predicate(preview_predicate: dict[str, Any]) -> dict[str, Any]:
|
|
699
|
+
operator, operand = next(iter(preview_predicate.items()))
|
|
700
|
+
if operator in {"and", "or"}:
|
|
701
|
+
return {
|
|
702
|
+
operator: [
|
|
703
|
+
_lower_predicate(child_predicate)
|
|
704
|
+
for child_predicate in cast(list[dict[str, Any]], operand)
|
|
705
|
+
]
|
|
706
|
+
}
|
|
707
|
+
if operator == "not":
|
|
708
|
+
return {operator: _lower_predicate(cast(dict[str, Any], operand))}
|
|
709
|
+
if operator == "is_present":
|
|
710
|
+
return {operator: _lower_predicate_reference(cast(dict[str, str], operand))}
|
|
711
|
+
if operator == "in":
|
|
712
|
+
membership_operands = cast(list[Any], operand)
|
|
713
|
+
return {
|
|
714
|
+
operator: [
|
|
715
|
+
_lower_predicate_reference(cast(dict[str, str], membership_operands[0])),
|
|
716
|
+
_canonical_clone(membership_operands[1]),
|
|
717
|
+
]
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
operator: [
|
|
721
|
+
_lower_predicate_operand(predicate_operand)
|
|
722
|
+
for predicate_operand in cast(list[Any], operand)
|
|
723
|
+
]
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _lower_predicate_operand(predicate_operand: Any) -> Any:
|
|
728
|
+
if isinstance(predicate_operand, dict):
|
|
729
|
+
return _lower_predicate_reference(cast(dict[str, str], predicate_operand))
|
|
730
|
+
if isinstance(predicate_operand, str) and _looks_like_v2_reference(predicate_operand):
|
|
731
|
+
return {"literal": predicate_operand}
|
|
732
|
+
return _canonical_clone(predicate_operand)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _lower_predicate_reference(reference: dict[str, str]) -> str:
|
|
736
|
+
reference_key, reference_path = next(iter(reference.items()))
|
|
737
|
+
if reference_key in {"$slot", "$derive"}:
|
|
738
|
+
return f"slots.{reference_path}"
|
|
739
|
+
return f"{_PREDICATE_REFERENCE_NAMESPACES[reference_key]}.{reference_path}"
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _looks_like_v2_reference(value: str) -> bool:
|
|
743
|
+
namespace, separator, reference_path = value.partition(".")
|
|
744
|
+
return bool(
|
|
745
|
+
separator
|
|
746
|
+
and reference_path
|
|
747
|
+
and namespace in {"slots", "internal", "payload", "config", "address"}
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _source_reference_target(reference: dict[str, str]) -> str:
|
|
752
|
+
reference_key, reference_target = next(iter(reference.items()))
|
|
753
|
+
if reference_key not in {"$slot", "$derive"}:
|
|
754
|
+
_raise_lowering(
|
|
755
|
+
"FLOWSPEC3_LOWERING_INVALID_SOURCE_REFERENCE",
|
|
756
|
+
"",
|
|
757
|
+
f"source reference key {reference_key!r} is not lowerable",
|
|
758
|
+
)
|
|
759
|
+
return reference_target
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _reference_target(reference: dict[str, str], expected_key: str) -> str:
|
|
763
|
+
return reference[expected_key]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _rehydrate_loss_entries(
|
|
767
|
+
source_document: dict[str, Any], loss_entries: list[dict[str, Any]]
|
|
768
|
+
) -> tuple[str, ...]:
|
|
769
|
+
rehydratable_entries = [
|
|
770
|
+
loss_entry
|
|
771
|
+
for loss_entry in loss_entries
|
|
772
|
+
if loss_entry["disposition"] == "compatibility_only"
|
|
773
|
+
]
|
|
774
|
+
use_entries = sorted(
|
|
775
|
+
(
|
|
776
|
+
loss_entry
|
|
777
|
+
for loss_entry in rehydratable_entries
|
|
778
|
+
if loss_entry["category"] == V3PreviewLossCategory.UNUSED_SUBFLOW_DECLARATION.value
|
|
779
|
+
),
|
|
780
|
+
key=lambda loss_entry: int(cast(str, loss_entry["source_path"]).rsplit("/", 1)[1]),
|
|
781
|
+
)
|
|
782
|
+
for loss_entry in rehydratable_entries:
|
|
783
|
+
if loss_entry in use_entries:
|
|
784
|
+
continue
|
|
785
|
+
_prepare_rehydration_parent(source_document, loss_entry)
|
|
786
|
+
_set_pointer(
|
|
787
|
+
source_document,
|
|
788
|
+
cast(str, loss_entry["source_path"]),
|
|
789
|
+
_canonical_clone(loss_entry["source_fragment"]),
|
|
790
|
+
)
|
|
791
|
+
for loss_entry in use_entries:
|
|
792
|
+
_insert_array_pointer(
|
|
793
|
+
source_document,
|
|
794
|
+
cast(str, loss_entry["source_path"]),
|
|
795
|
+
_canonical_clone(loss_entry["source_fragment"]),
|
|
796
|
+
)
|
|
797
|
+
return tuple(cast(str, loss_entry["source_path"]) for loss_entry in rehydratable_entries)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _prepare_rehydration_parent(
|
|
801
|
+
source_document: dict[str, Any], loss_entry: dict[str, Any]
|
|
802
|
+
) -> None:
|
|
803
|
+
category = V3PreviewLossCategory(cast(str, loss_entry["category"]))
|
|
804
|
+
if category in {
|
|
805
|
+
V3PreviewLossCategory.AGENT_CAPABILITY,
|
|
806
|
+
V3PreviewLossCategory.IMPLICIT_AWAIT_CAPABILITY,
|
|
807
|
+
}:
|
|
808
|
+
source_document.setdefault("capabilities", {})
|
|
809
|
+
elif category is V3PreviewLossCategory.UNUSED_DOMAIN_DECLARATION:
|
|
810
|
+
source_document.setdefault("domains", {})
|
|
811
|
+
elif category is V3PreviewLossCategory.UNUSED_SLOT_DECLARATION:
|
|
812
|
+
source_document.setdefault("slots", {})
|
|
813
|
+
elif category is V3PreviewLossCategory.UNUSED_OVERRIDE_GATE:
|
|
814
|
+
overrides = source_document.setdefault("overrides", {})
|
|
815
|
+
if isinstance(overrides, dict):
|
|
816
|
+
overrides.setdefault("gates", {})
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _set_pointer(document: dict[str, Any], pointer: str, fragment: Any) -> None:
|
|
820
|
+
segments = _decode_pointer(pointer)
|
|
821
|
+
parent: Any = document
|
|
822
|
+
for segment in segments[:-1]:
|
|
823
|
+
if not isinstance(parent, dict) or segment not in parent:
|
|
824
|
+
_raise_lowering(
|
|
825
|
+
"FLOWSPEC3_LOWERING_REHYDRATION_PARENT_MISSING",
|
|
826
|
+
pointer,
|
|
827
|
+
"loss entry parent does not exist in the native lowered document",
|
|
828
|
+
)
|
|
829
|
+
parent = parent[segment]
|
|
830
|
+
final_segment = segments[-1]
|
|
831
|
+
if not isinstance(parent, dict):
|
|
832
|
+
_raise_lowering(
|
|
833
|
+
"FLOWSPEC3_LOWERING_REHYDRATION_PARENT_INVALID",
|
|
834
|
+
pointer,
|
|
835
|
+
"loss entry parent is not an object",
|
|
836
|
+
)
|
|
837
|
+
if final_segment in parent:
|
|
838
|
+
_raise_lowering(
|
|
839
|
+
"FLOWSPEC3_LOWERING_REHYDRATION_CONFLICT",
|
|
840
|
+
pointer,
|
|
841
|
+
"loss entry would overwrite native lowered content",
|
|
842
|
+
)
|
|
843
|
+
parent[final_segment] = fragment
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _insert_array_pointer(document: dict[str, Any], pointer: str, fragment: Any) -> None:
|
|
847
|
+
segments = _decode_pointer(pointer)
|
|
848
|
+
if len(segments) != 2 or segments[0] != "uses":
|
|
849
|
+
_raise_lowering(
|
|
850
|
+
"FLOWSPEC3_LOWERING_REHYDRATION_ARRAY_PATH_INVALID",
|
|
851
|
+
pointer,
|
|
852
|
+
"only unused subflow declarations may rehydrate into arrays",
|
|
853
|
+
)
|
|
854
|
+
uses = document.setdefault("uses", [])
|
|
855
|
+
if not isinstance(uses, list):
|
|
856
|
+
_raise_lowering(
|
|
857
|
+
"FLOWSPEC3_LOWERING_REHYDRATION_PARENT_INVALID",
|
|
858
|
+
pointer,
|
|
859
|
+
"uses rehydration parent is not an array",
|
|
860
|
+
)
|
|
861
|
+
insertion_index = int(segments[1])
|
|
862
|
+
if insertion_index > len(uses):
|
|
863
|
+
_raise_lowering(
|
|
864
|
+
"FLOWSPEC3_LOWERING_REHYDRATION_INDEX_INVALID",
|
|
865
|
+
pointer,
|
|
866
|
+
"unused subflow declaration index is beyond the reconstructed uses array",
|
|
867
|
+
)
|
|
868
|
+
uses.insert(insertion_index, fragment)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _decode_pointer(pointer: str) -> list[str]:
|
|
872
|
+
return [segment.replace("~1", "/").replace("~0", "~") for segment in pointer[1:].split("/")]
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _first_difference_path(expected: Any, actual: Any, segments: tuple[str | int, ...] = ()) -> str:
|
|
876
|
+
if type(expected) is not type(actual):
|
|
877
|
+
return _pointer(segments)
|
|
878
|
+
if isinstance(expected, dict):
|
|
879
|
+
expected_keys = set(expected)
|
|
880
|
+
actual_keys = set(cast(dict[str, Any], actual))
|
|
881
|
+
if expected_keys != actual_keys:
|
|
882
|
+
differing_key = sorted(expected_keys ^ actual_keys)[0]
|
|
883
|
+
return _pointer((*segments, differing_key))
|
|
884
|
+
for key in sorted(expected):
|
|
885
|
+
difference = _first_difference_path(expected[key], actual[key], (*segments, key))
|
|
886
|
+
if difference:
|
|
887
|
+
return difference
|
|
888
|
+
return ""
|
|
889
|
+
if isinstance(expected, list):
|
|
890
|
+
actual_list = cast(list[Any], actual)
|
|
891
|
+
if len(expected) != len(actual_list):
|
|
892
|
+
return _pointer((*segments, min(len(expected), len(actual_list))))
|
|
893
|
+
for index, expected_item in enumerate(expected):
|
|
894
|
+
difference = _first_difference_path(
|
|
895
|
+
expected_item, actual_list[index], (*segments, index)
|
|
896
|
+
)
|
|
897
|
+
if difference:
|
|
898
|
+
return difference
|
|
899
|
+
return ""
|
|
900
|
+
return "" if expected == actual else _pointer(segments)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _pointer(segments: tuple[str | int, ...]) -> str:
|
|
904
|
+
return "".join(f"/{_encode_pointer_segment(str(segment))}" for segment in segments)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _encode_pointer_segment(segment: str) -> str:
|
|
908
|
+
return segment.replace("~", "~0").replace("/", "~1")
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _raise_lowering(code: str, path: str, message: str) -> None:
|
|
912
|
+
raise V3PreviewLoweringError(
|
|
913
|
+
(FlowDiagnostic(code=code, severity="error", path=path, message=message),)
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _canonical_clone(value: Any) -> Any:
|
|
918
|
+
return json.loads(_canonical_json(value))
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _canonical_json(value: Any) -> str:
|
|
922
|
+
return json.dumps(
|
|
923
|
+
value,
|
|
924
|
+
ensure_ascii=False,
|
|
925
|
+
allow_nan=False,
|
|
926
|
+
separators=(",", ":"),
|
|
927
|
+
sort_keys=True,
|
|
928
|
+
)
|