flowspec2 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,328 @@
1
+ """address@1 — geocode + optional confirmation.
2
+
3
+ Collects a free-text address, geocodes/validates it via the injectable
4
+ ``geocode`` tool, optionally confirms it (Yes/No), and exposes the ``address``
5
+ slot (a dict carrying ``kind`` so the public-square gate can read ``address.kind``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import logging
12
+ from collections.abc import Callable, Mapping
13
+ from typing import Any, Literal
14
+
15
+ from langgraph.graph import END
16
+
17
+ from ..domains import parse_affirmation
18
+ from ..interactive import options_from_domain
19
+ from ..models import CORRECTION_REQUESTED_INTERNAL_KEY, AgentResponse, ServiceState
20
+ from ..nodes import (
21
+ NEXT,
22
+ FlowContext,
23
+ NodeDesc,
24
+ _handle_node_error,
25
+ clear_cascade,
26
+ mark_flow_finished,
27
+ )
28
+ from ..observability import log_event
29
+ from . import SubflowBuild
30
+
31
+ _ADDRESS_COMPLETED_KEY = "address_completed"
32
+ _ADDRESS_DEFAULTED_KEY = "address_defaulted"
33
+ _ADDRESS_SKIPPED_KEY = "address_skipped"
34
+ _ADDRESS_EXHAUSTION_MODES = frozenset({"reask", "skip", "default", "handoff", "END"})
35
+ _AddressCompletionOutcome = Literal["confirmed", "skipped", "defaulted"]
36
+ _AUX = [
37
+ "address_confirmed",
38
+ "address_needs_confirmation",
39
+ "address_attempts",
40
+ _ADDRESS_COMPLETED_KEY,
41
+ _ADDRESS_DEFAULTED_KEY,
42
+ _ADDRESS_SKIPPED_KEY,
43
+ ]
44
+ _ADDRESS_NOT_RESOLVED_ERROR = "the address could not be located"
45
+ _ADDRESS_REQUIRED_ERROR = "the address is required"
46
+ _ADDRESS_CONFIRMATION_ERROR = "the response could not be interpreted as yes or no"
47
+ _ADDRESS_EXHAUSTED_ERROR = "maximum attempts reached; let us try again"
48
+ _ADDRESS_HANDOFF_DESCRIPTION = "I will transfer you to a support agent."
49
+ _ADDRESS_END_DESCRIPTION = "I could not validate the address. Try again later."
50
+ _ADDRESS_PROMPT = "What is the complete address (street, number, and district)?"
51
+ _OPTIONAL_ADDRESS_PROMPT = f"{_ADDRESS_PROMPT} You may skip it if you prefer not to provide it."
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ def _address_payload_schema(required: bool) -> dict[str, Any]:
56
+ """Describe a required address or an optional address with an explicit null skip."""
57
+
58
+ address_type: str | list[str] = "string" if required else ["string", "null"]
59
+ address_description = "Complete address: street, number, and district."
60
+ if not required:
61
+ address_description += " Use null when the user chooses not to provide it."
62
+ return {
63
+ "type": "object",
64
+ "properties": {
65
+ "address": {
66
+ "type": address_type,
67
+ "description": address_description,
68
+ }
69
+ },
70
+ "required": ["address"],
71
+ }
72
+
73
+
74
+ def _address_is_complete(state: ServiceState) -> bool:
75
+ return bool(state.data.get(_ADDRESS_COMPLETED_KEY) or state.data.get("address_confirmed"))
76
+
77
+
78
+ def _complete_address(state: ServiceState, *, outcome: _AddressCompletionOutcome) -> None:
79
+ state.data[_ADDRESS_COMPLETED_KEY] = True
80
+ state.data.pop("address_attempts", None)
81
+ if outcome == "confirmed":
82
+ state.data["address_confirmed"] = True
83
+ elif outcome == "skipped":
84
+ state.data.pop("address", None)
85
+ state.data[_ADDRESS_SKIPPED_KEY] = True
86
+ elif outcome == "defaulted":
87
+ state.data["address"] = None
88
+ state.data[_ADDRESS_DEFAULTED_KEY] = True
89
+ state.agent_response = None
90
+
91
+
92
+ class AddressSubflow:
93
+ name = "address"
94
+ major = 1
95
+
96
+ def build(self, ctx: FlowContext, with_cfg: dict[str, Any]) -> SubflowBuild:
97
+ required = bool(with_cfg.get("required", ctx.config.get("address_required", False)))
98
+ needs_confirmation = bool(with_cfg.get("needs_confirmation", True))
99
+ max_attempts = int(with_cfg.get("max_attempts", ctx.max_attempts))
100
+ on_exhaust = str(with_cfg.get("on_exhaust", "reask"))
101
+ if max_attempts < 1:
102
+ raise ValueError("address@1 max_attempts must be positive")
103
+ if on_exhaust not in _ADDRESS_EXHAUSTION_MODES:
104
+ raise ValueError(f"address@1 has unsupported on_exhaust mode: {on_exhaust!r}")
105
+ payload_schema = _address_payload_schema(required)
106
+ prompt = _ADDRESS_PROMPT if required else _OPTIONAL_ADDRESS_PROMPT
107
+
108
+ ctx.slot_aux["address"] = _AUX
109
+ ctx.dependents.setdefault("address", set()) # populated by the compiler's requires pass
110
+
111
+ def ask(error_message: str | None = None) -> AgentResponse:
112
+ return AgentResponse(
113
+ description=prompt,
114
+ payload_schema=payload_schema,
115
+ error_message=error_message,
116
+ )
117
+
118
+ def confirmation_response(
119
+ state: ServiceState,
120
+ error_message: str | None = None,
121
+ ) -> AgentResponse:
122
+ resolved_address = state.data.get("address") or {}
123
+ body = (
124
+ "Do you confirm the address: "
125
+ f"{resolved_address.get('street', '')}, {resolved_address.get('district', '')}?"
126
+ )
127
+ interactive_specification = (
128
+ options_from_domain(
129
+ {
130
+ "kind": "buttons",
131
+ "field": "confirmation",
132
+ "from_domain": "YesNo",
133
+ "body": body,
134
+ },
135
+ ctx.domains,
136
+ state=state,
137
+ config=ctx.config,
138
+ )
139
+ if "YesNo" in ctx.domains
140
+ else None
141
+ )
142
+ return AgentResponse(
143
+ description=body,
144
+ error_message=error_message,
145
+ interactive=interactive_specification,
146
+ )
147
+
148
+ def exhaust(
149
+ state: ServiceState,
150
+ *,
151
+ reask_response: AgentResponse | None = None,
152
+ ) -> ServiceState:
153
+ if on_exhaust == "skip":
154
+ _complete_address(state, outcome="skipped")
155
+ elif on_exhaust == "default":
156
+ _complete_address(state, outcome="defaulted")
157
+ elif on_exhaust == "handoff":
158
+ state.agent_response = AgentResponse(description=_ADDRESS_HANDOFF_DESCRIPTION)
159
+ elif on_exhaust == "END":
160
+ mark_flow_finished(state, reset_next=True)
161
+ state.status = "completed"
162
+ log_id = log_event(
163
+ logger,
164
+ logging.WARNING,
165
+ "Flow stopped after address collection attempts were exhausted",
166
+ operation="collect_address",
167
+ log_id_generator=ctx.log_id_generator,
168
+ context={"flow": state.service_name, "slot": "address"},
169
+ )
170
+ state.agent_response = AgentResponse(
171
+ description=_ADDRESS_END_DESCRIPTION,
172
+ log_id=log_id,
173
+ )
174
+ else:
175
+ state.data.pop("address_attempts", None)
176
+ state.agent_response = reask_response or ask(_ADDRESS_EXHAUSTED_ERROR)
177
+ return state
178
+
179
+ def register_failed_attempt(
180
+ state: ServiceState,
181
+ error_message: str,
182
+ *,
183
+ response_factory: Callable[[str | None], AgentResponse] = ask,
184
+ ) -> ServiceState:
185
+ address_attempts = int(state.data.get("address_attempts", 0)) + 1
186
+ state.data["address_attempts"] = address_attempts
187
+ if address_attempts >= max_attempts:
188
+ reask_response = (
189
+ response_factory(_ADDRESS_EXHAUSTED_ERROR) if on_exhaust == "reask" else None
190
+ )
191
+ return exhaust(
192
+ state,
193
+ reask_response=reask_response,
194
+ )
195
+ state.agent_response = response_factory(error_message)
196
+ return state
197
+
198
+ # ── collect_address ──────────────────────────────────────────────
199
+ async def collect(state: ServiceState) -> ServiceState:
200
+ state.status = "progress"
201
+ if state.internal.get(CORRECTION_REQUESTED_INTERNAL_KEY) == "address":
202
+ # clear address + its aux + every requires-dependent (sports_court,
203
+ # reference_point) so a corrected address never submits stale
204
+ # downstream data.
205
+ clear_cascade(state, "address", ctx)
206
+ state.internal.pop(CORRECTION_REQUESTED_INTERNAL_KEY, None)
207
+ if _address_is_complete(state):
208
+ state.agent_response = None
209
+ return state
210
+ if "address" in state.payload:
211
+ address_input = state.payload["address"]
212
+ if address_input is None:
213
+ if required:
214
+ return register_failed_attempt(state, _ADDRESS_REQUIRED_ERROR)
215
+ _complete_address(state, outcome="skipped")
216
+ return state
217
+ resolved_address: Mapping[str, Any] | None = None
218
+ try:
219
+ geocode_result = await ctx.tools.call("geocode", address=str(address_input))
220
+ if not isinstance(geocode_result, Mapping):
221
+ raise TypeError("geocode returned a non-object result")
222
+ if geocode_result.get("status") == "ok":
223
+ resolved_address = geocode_result.get("address")
224
+ if not isinstance(resolved_address, Mapping):
225
+ raise ValueError("geocode success did not contain an address object")
226
+ street_name = resolved_address.get("street")
227
+ if not isinstance(street_name, str) or not street_name.strip():
228
+ raise ValueError("geocode success did not contain a meaningful address")
229
+ except Exception as exc: # noqa: BLE001
230
+ error_state = _handle_node_error(
231
+ state,
232
+ exc,
233
+ ctx=ctx,
234
+ operation="collect_address",
235
+ )
236
+ assert error_state.agent_response is not None
237
+ error_state.agent_response.description = (
238
+ "I could not look up the address. Try again."
239
+ )
240
+ error_state.agent_response.payload_schema = payload_schema
241
+ return error_state
242
+
243
+ if geocode_result.get("status") == "ok":
244
+ assert isinstance(resolved_address, Mapping)
245
+ state.data["address"] = copy.deepcopy(dict(resolved_address))
246
+ state.data.pop("address_attempts", None)
247
+ if needs_confirmation and geocode_result.get("needs_confirmation"):
248
+ state.data["address_needs_confirmation"] = True
249
+ state.agent_response = None
250
+ return state
251
+ _complete_address(state, outcome="confirmed")
252
+ return state
253
+ return register_failed_attempt(
254
+ state,
255
+ str(geocode_result.get("error") or _ADDRESS_NOT_RESOLVED_ERROR),
256
+ )
257
+ state.agent_response = ask()
258
+ return state
259
+
260
+ def collect_router(state: ServiceState) -> str:
261
+ if _address_is_complete(state):
262
+ return "address_done"
263
+ if state.data.get("address") and state.data.get("address_needs_confirmation"):
264
+ return "confirm_address"
265
+ return END # asking
266
+
267
+ # ── confirm_address ──────────────────────────────────────────────
268
+ async def confirm(state: ServiceState) -> ServiceState:
269
+ if _address_is_complete(state):
270
+ state.agent_response = None
271
+ return state
272
+ confirmation_field = (
273
+ "address_confirmation"
274
+ if "address_confirmation" in state.payload
275
+ else "confirmation"
276
+ )
277
+ if confirmation_field in state.payload:
278
+ confirmation = parse_affirmation(state.payload[confirmation_field])
279
+ if confirmation is True:
280
+ _complete_address(state, outcome="confirmed")
281
+ return state
282
+ if confirmation is False:
283
+ for key in ["address", *_AUX]:
284
+ state.data.pop(key, None)
285
+ state.agent_response = None
286
+ return state
287
+ return register_failed_attempt(
288
+ state,
289
+ _ADDRESS_CONFIRMATION_ERROR,
290
+ response_factory=lambda error_message: confirmation_response(
291
+ state, error_message
292
+ ),
293
+ )
294
+ state.agent_response = confirmation_response(state)
295
+ return state
296
+
297
+ def confirm_router(state: ServiceState) -> str:
298
+ if _address_is_complete(state):
299
+ return "address_done"
300
+ if not state.data.get("address"):
301
+ return "collect_address" # rejected → re-collect
302
+ return END # asking
303
+
304
+ # ── exit node ────────────────────────────────────────────────────
305
+ async def done(state: ServiceState) -> ServiceState:
306
+ state.agent_response = None
307
+ return state
308
+
309
+ descriptors = [
310
+ NodeDesc(
311
+ "collect_address",
312
+ collect,
313
+ collect_router,
314
+ targets=["confirm_address", "address_done"],
315
+ ),
316
+ NodeDesc(
317
+ "confirm_address",
318
+ confirm,
319
+ confirm_router,
320
+ targets=["collect_address", "address_done"],
321
+ ),
322
+ NodeDesc("address_done", done, lambda s: NEXT),
323
+ ]
324
+ return SubflowBuild(
325
+ descriptors=descriptors,
326
+ entry_id="collect_address",
327
+ node_for_slot={"address": "collect_address"},
328
+ )