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,120 @@
1
+ """Strict RFC 8259 JSON decoding for every external document boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from typing import Any
8
+
9
+
10
+ class StrictJsonError(ValueError):
11
+ """Raised for JSON extensions or ambiguous duplicate object members."""
12
+
13
+
14
+ class StrictJsonValueError(StrictJsonError):
15
+ """Raised with a JSON Pointer for an in-memory non-JSON value."""
16
+
17
+ def __init__(self, message: str, *, path: str) -> None:
18
+ self.path = path
19
+ super().__init__(message)
20
+
21
+
22
+ def _value_error(boundary: str, path_segments: tuple[str | int, ...], detail: str) -> None:
23
+ path = _json_pointer(path_segments)
24
+ raise StrictJsonValueError(f"{boundary} at {path}: {detail}", path=path)
25
+
26
+
27
+ def _json_pointer(path_segments: tuple[str | int, ...]) -> str:
28
+ return (
29
+ "".join(
30
+ "/" + str(path_segment).replace("~", "~0").replace("/", "~1")
31
+ for path_segment in path_segments
32
+ )
33
+ or "/"
34
+ )
35
+
36
+
37
+ def validate_json_value(
38
+ value: Any,
39
+ *,
40
+ boundary: str,
41
+ path_segments: tuple[str | int, ...] = (),
42
+ active_containers: set[int] | None = None,
43
+ ) -> None:
44
+ """Reject Python values that cannot be represented as strict JSON."""
45
+
46
+ if value is None or isinstance(value, (bool, str, int)):
47
+ return
48
+ if isinstance(value, float):
49
+ if not math.isfinite(value):
50
+ _value_error(boundary, path_segments, "non-finite number")
51
+ return
52
+
53
+ traversed_containers = set() if active_containers is None else active_containers
54
+ if isinstance(value, dict):
55
+ container_identifier = id(value)
56
+ if container_identifier in traversed_containers:
57
+ _value_error(boundary, path_segments, "cyclic JSON object")
58
+ traversed_containers.add(container_identifier)
59
+ try:
60
+ for member_name, member_value in value.items():
61
+ if not isinstance(member_name, str):
62
+ _value_error(
63
+ boundary,
64
+ path_segments,
65
+ "object member name has unsupported Python type "
66
+ f"{type(member_name).__name__}",
67
+ )
68
+ validate_json_value(
69
+ member_value,
70
+ boundary=boundary,
71
+ path_segments=(*path_segments, member_name),
72
+ active_containers=traversed_containers,
73
+ )
74
+ finally:
75
+ traversed_containers.remove(container_identifier)
76
+ return
77
+ if isinstance(value, list):
78
+ container_identifier = id(value)
79
+ if container_identifier in traversed_containers:
80
+ _value_error(boundary, path_segments, "cyclic JSON array")
81
+ traversed_containers.add(container_identifier)
82
+ try:
83
+ for element_index, element in enumerate(value):
84
+ validate_json_value(
85
+ element,
86
+ boundary=boundary,
87
+ path_segments=(*path_segments, element_index),
88
+ active_containers=traversed_containers,
89
+ )
90
+ finally:
91
+ traversed_containers.remove(container_identifier)
92
+ return
93
+ _value_error(
94
+ boundary,
95
+ path_segments,
96
+ f"unsupported Python type {type(value).__name__}",
97
+ )
98
+
99
+
100
+ def _reject_nonstandard_constant(constant: str) -> None:
101
+ raise StrictJsonError(f"non-standard JSON numeric constant {constant!r} is not allowed")
102
+
103
+
104
+ def _reject_duplicate_members(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
105
+ decoded_object: dict[str, Any] = {}
106
+ for member_name, member_value in pairs:
107
+ if member_name in decoded_object:
108
+ raise StrictJsonError(f"duplicate JSON object member {member_name!r} is not allowed")
109
+ decoded_object[member_name] = member_value
110
+ return decoded_object
111
+
112
+
113
+ def strict_json_loads(serialized_document: str | bytes | bytearray) -> Any:
114
+ """Decode standards-compliant JSON while rejecting NaN and duplicate keys."""
115
+
116
+ return json.loads(
117
+ serialized_document,
118
+ parse_constant=_reject_nonstandard_constant,
119
+ object_pairs_hook=_reject_duplicate_members,
120
+ )
flowspec2/llm.py ADDED
@@ -0,0 +1,366 @@
1
+ """An LLM driver — the *non-deterministic execution* side of the boundary.
2
+
3
+ flowspec2 pins the rails (states, closed value-domains, transitions). This module
4
+ is the agent that reasons *within* them: given a flow's ``route.description`` and
5
+ non-exclusive ``route.trigger_phrases`` examples it decides whether to enter,
6
+ and at each pause it reads the user's free text/voice and the node's
7
+ ``payload_schema`` (or the interactive options) and
8
+ extracts the **closed token** for the slot. The flowspec2 validators then enforce
9
+ the rail — an out-of-domain extraction is rejected and the node re-asks.
10
+
11
+ Default provider: Google Gemini (``gemini-2.5-flash``), via ``GEMINI_API_KEY``.
12
+ The driver is a thin protocol —
13
+ ``route`` + ``extract`` — so any provider can implement it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import copy
19
+ import json
20
+ import os
21
+ from dataclasses import dataclass
22
+ from typing import Any, Optional
23
+
24
+ from jsonschema import Draft202012Validator
25
+
26
+ from .json_codec import strict_json_loads
27
+ from .models import CORRECTION_TARGETS_SCHEMA_KEY, AgentResponse
28
+
29
+ DEFAULT_ROUTE_SYSTEM_PROMPT = (
30
+ "You route messages to a service catalog. "
31
+ "Given the user's message, decide which service, if any, handles the request. "
32
+ "Respond ONLY with JSON."
33
+ )
34
+
35
+ DEFAULT_EXTRACTION_SYSTEM_PROMPT = (
36
+ "You extract structured data for a conversational flow. "
37
+ "The system asked the user a question; convert the free-text response into a JSON "
38
+ "object with the requested fields, using ONLY permitted values for closed lists. "
39
+ "Interpret synonyms, informal expressions, numbers, and emojis. "
40
+ "Respond ONLY with JSON and no commentary."
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class StructuredOutputRequest:
46
+ """Exact provider-neutral inputs for one closed structured-output turn."""
47
+
48
+ system: str
49
+ prompt: str
50
+ response_schema: dict[str, Any]
51
+
52
+
53
+ def _enum_of(prop: dict[str, Any]) -> Optional[list[Any]]:
54
+ if "enum" in prop:
55
+ return list(prop["enum"])
56
+ if "const" in prop:
57
+ return [prop["const"]]
58
+ if "anyOf" in prop:
59
+ values: list[Any] = []
60
+ nullable = False
61
+ for sub in prop["anyOf"]:
62
+ if "enum" in sub:
63
+ values.extend(sub["enum"])
64
+ if sub.get("type") == "null":
65
+ nullable = True
66
+ if values:
67
+ return values + ([None] if nullable else [])
68
+ return None
69
+
70
+
71
+ def _property_types(property_schema: dict[str, Any]) -> set[str]:
72
+ property_type = property_schema.get("type")
73
+ property_types: set[str] = set()
74
+ if isinstance(property_type, str):
75
+ property_types.add(property_type)
76
+ elif isinstance(property_type, list):
77
+ property_types.update(
78
+ candidate_type for candidate_type in property_type if isinstance(candidate_type, str)
79
+ )
80
+ for union_keyword in ("anyOf", "oneOf"):
81
+ property_types.update(
82
+ nested_schema["type"]
83
+ for nested_schema in property_schema.get(union_keyword, [])
84
+ if isinstance(nested_schema, dict) and isinstance(nested_schema.get("type"), str)
85
+ )
86
+ return property_types
87
+
88
+
89
+ def _fields_spec(
90
+ ar: AgentResponse,
91
+ ) -> list[tuple[str, str, Optional[list[Any]], bool]]:
92
+ """Return ``(field, kind, allowed, nullable)`` for the current extraction."""
93
+ schema = ar.payload_schema or {}
94
+ props = schema.get("properties", {})
95
+ spec: list[tuple[str, str, Optional[list[Any]], bool]] = []
96
+ for name, prop in props.items():
97
+ allowed = _enum_of(prop)
98
+ property_types = _property_types(prop)
99
+ nullable = "null" in property_types or (allowed is not None and None in allowed)
100
+ if allowed is not None:
101
+ spec.append((name, "closed", allowed, nullable))
102
+ elif "boolean" in property_types:
103
+ spec.append((name, "bool", None, nullable))
104
+ elif "integer" in property_types:
105
+ spec.append((name, "integer", None, nullable))
106
+ elif "number" in property_types:
107
+ spec.append((name, "number", None, nullable))
108
+ elif property_types == {"null"}:
109
+ spec.append((name, "null", None, True))
110
+ else:
111
+ spec.append((name, "text", None, nullable))
112
+ if not spec and ar.interactive and ar.interactive.get("field"):
113
+ field = ar.interactive["field"]
114
+ buttons = ar.interactive.get("buttons") or []
115
+ ids = [b.get("id") for b in buttons if b.get("id")]
116
+ if ids and set(ids) <= {"yes", "no"}:
117
+ spec.append((field, "bool", None, False))
118
+ elif ids:
119
+ spec.append((field, "closed", ids, False))
120
+ else:
121
+ spec.append((field, "text", None, False))
122
+ return spec
123
+
124
+
125
+ def _interactive_options(ar: AgentResponse) -> list[str]:
126
+ iv = ar.interactive or {}
127
+ if iv.get("buttons"):
128
+ return [b["title"] for b in iv["buttons"]]
129
+ if iv.get("sections"):
130
+ return [r["title"] for s in iv["sections"] for r in s["rows"]]
131
+ return []
132
+
133
+
134
+ def _route_response_schema(flows: list[dict[str, Any]]) -> dict[str, Any]:
135
+ flow_identifiers = list(dict.fromkeys(flow["flow"] for flow in flows))
136
+ return {
137
+ "type": "object",
138
+ "additionalProperties": False,
139
+ "required": ["service"],
140
+ "properties": {"service": {"enum": [*flow_identifiers, None]}},
141
+ }
142
+
143
+
144
+ def build_route_request(
145
+ text: str,
146
+ flows: list[dict[str, Any]],
147
+ *,
148
+ system_prompt: str = DEFAULT_ROUTE_SYSTEM_PROMPT,
149
+ ) -> StructuredOutputRequest:
150
+ """Render the exact routing request, including non-exclusive trigger examples."""
151
+
152
+ catalog = [
153
+ {
154
+ "service": flow["flow"],
155
+ "description": flow["route"]["description"],
156
+ "trigger_phrases": list(flow["route"].get("trigger_phrases", [])),
157
+ }
158
+ for flow in flows
159
+ ]
160
+ serialized_catalog = json.dumps(
161
+ catalog,
162
+ ensure_ascii=False,
163
+ allow_nan=False,
164
+ separators=(",", ":"),
165
+ sort_keys=True,
166
+ )
167
+ prompt = (
168
+ f"Service catalog as JSON:\n{serialized_catalog}\n\n"
169
+ "Use description as each service's primary definition. "
170
+ "trigger_phrases contains only examples of compatible messages; "
171
+ "do not treat them as an exclusive list or a match guarantee.\n\n"
172
+ f'User message: "{text}"\n\n'
173
+ 'Return {"service": "<service name>"} if one applies, '
174
+ 'or {"service": null} if none applies.'
175
+ )
176
+ return StructuredOutputRequest(
177
+ system=system_prompt,
178
+ prompt=prompt,
179
+ response_schema=_route_response_schema(flows),
180
+ )
181
+
182
+
183
+ def _extraction_response_schema(agent_response: AgentResponse) -> dict[str, Any]:
184
+ payload_schema = agent_response.payload_schema or {}
185
+ properties = copy.deepcopy(payload_schema.get("properties", {}))
186
+ required = list(payload_schema.get("required", []))
187
+ if not properties:
188
+ for field, kind, allowed, nullable in _fields_spec(agent_response):
189
+ if kind == "closed":
190
+ properties[field] = {"enum": allowed or []}
191
+ elif kind == "bool":
192
+ properties[field] = {"type": ["boolean", "null"] if nullable else "boolean"}
193
+ elif kind in {"integer", "number"}:
194
+ properties[field] = {"type": [kind, "null"] if nullable else kind}
195
+ elif kind == "null":
196
+ properties[field] = {"type": "null"}
197
+ else:
198
+ properties[field] = {"type": ["string", "null"] if nullable else "string"}
199
+ required.append(field)
200
+ correction_targets = payload_schema.get(CORRECTION_TARGETS_SCHEMA_KEY)
201
+ allows_correction = (
202
+ isinstance(correction_targets, list)
203
+ and bool(correction_targets)
204
+ and all(isinstance(target, str) for target in correction_targets)
205
+ )
206
+ response_schema: dict[str, Any] = {
207
+ "type": "object",
208
+ "additionalProperties": False,
209
+ "properties": properties,
210
+ }
211
+ if required:
212
+ response_schema["required"] = required
213
+ if allows_correction:
214
+ properties["correction"] = {"enum": correction_targets}
215
+ payload_branch = {
216
+ "required": required,
217
+ "not": {"required": ["correction"]},
218
+ }
219
+ correction_branch = {
220
+ "required": ["correction"],
221
+ "not": {"anyOf": [{"required": [field_name]} for field_name in required]},
222
+ }
223
+ response_schema.pop("required", None)
224
+ response_schema["oneOf"] = [payload_branch, correction_branch]
225
+ Draft202012Validator.check_schema(response_schema)
226
+ return response_schema
227
+
228
+
229
+ def build_extraction_request(
230
+ text: str,
231
+ agent_response: AgentResponse,
232
+ *,
233
+ system_prompt: str = DEFAULT_EXTRACTION_SYSTEM_PROMPT,
234
+ ) -> StructuredOutputRequest:
235
+ """Render the exact extraction request, preserving payload-schema guidance."""
236
+
237
+ spec = _fields_spec(agent_response)
238
+ lines: list[str] = []
239
+ for field, kind, allowed, nullable in spec:
240
+ null_alternative = " or null" if nullable and kind != "closed" else ""
241
+ if kind == "closed":
242
+ values = ", ".join(
243
+ json.dumps(value, ensure_ascii=False, separators=(",", ":"))
244
+ for value in (allowed or [])
245
+ )
246
+ lines.append(f'- "{field}": one of these EXACT values: [{values}]')
247
+ elif kind == "bool":
248
+ lines.append(
249
+ f'- "{field}": true (yes/affirmative), false (no/negative){null_alternative}'
250
+ )
251
+ elif kind == "integer":
252
+ lines.append(f'- "{field}": a JSON integer without quotes{null_alternative}')
253
+ elif kind == "number":
254
+ lines.append(f'- "{field}": a finite JSON number without quotes{null_alternative}')
255
+ elif kind == "null":
256
+ lines.append(f'- "{field}": null')
257
+ else:
258
+ lines.append(
259
+ f'- "{field}": a JSON string containing the supplied text{null_alternative}'
260
+ )
261
+ options = _interactive_options(agent_response)
262
+ options_hint = f"\nOptions offered to the user: {', '.join(options)}." if options else ""
263
+ correction_targets = (agent_response.payload_schema or {}).get(CORRECTION_TARGETS_SCHEMA_KEY)
264
+ correction_rule = (
265
+ "- If the user wants to CORRECT previously supplied data, return only "
266
+ '{"correction": "<identifier>"}, using one of these EXACT identifiers: '
267
+ f"{json.dumps(correction_targets, ensure_ascii=False)}.\n"
268
+ if isinstance(correction_targets, list) and correction_targets
269
+ else ""
270
+ )
271
+ prompt = (
272
+ f'System question: "{agent_response.description}"\n'
273
+ f"Fields to extract:\n" + "\n".join(lines) + options_hint + "\n\n"
274
+ f'User response: "{text}"\n\n'
275
+ "Rules:\n"
276
+ "- Use ONLY permitted values from closed lists.\n"
277
+ f"{correction_rule}"
278
+ "- Return only JSON with the requested fields."
279
+ )
280
+ return StructuredOutputRequest(
281
+ system=system_prompt,
282
+ prompt=prompt,
283
+ response_schema=_extraction_response_schema(agent_response),
284
+ )
285
+
286
+
287
+ class StructuredOutputAgent:
288
+ """Provider-neutral routing and extraction over one closed JSON completion."""
289
+
290
+ def __init__(
291
+ self,
292
+ *,
293
+ route_system_prompt: str = DEFAULT_ROUTE_SYSTEM_PROMPT,
294
+ extraction_system_prompt: str = DEFAULT_EXTRACTION_SYSTEM_PROMPT,
295
+ ) -> None:
296
+ self.route_system_prompt = route_system_prompt
297
+ self.extraction_system_prompt = extraction_system_prompt
298
+
299
+ def _json(
300
+ self,
301
+ system: str,
302
+ prompt: str,
303
+ response_schema: dict[str, Any],
304
+ ) -> dict[str, Any]:
305
+ raise NotImplementedError
306
+
307
+ def route(self, text: str, flows: list[dict[str, Any]]) -> Optional[str]:
308
+ request = build_route_request(text, flows, system_prompt=self.route_system_prompt)
309
+ return self._json(request.system, request.prompt, request.response_schema).get("service")
310
+
311
+ def extract(self, text: str, ar: AgentResponse) -> dict[str, Any]:
312
+ request = build_extraction_request(
313
+ text,
314
+ ar,
315
+ system_prompt=self.extraction_system_prompt,
316
+ )
317
+ return self._json(request.system, request.prompt, request.response_schema)
318
+
319
+
320
+ class GeminiAgent(StructuredOutputAgent):
321
+ """Engine-side driver backed by Google Gemini structured output."""
322
+
323
+ def __init__(
324
+ self,
325
+ model: str = "gemini-2.5-flash",
326
+ api_key: Optional[str] = None,
327
+ *,
328
+ route_system_prompt: str = DEFAULT_ROUTE_SYSTEM_PROMPT,
329
+ extraction_system_prompt: str = DEFAULT_EXTRACTION_SYSTEM_PROMPT,
330
+ ) -> None:
331
+ from google import genai
332
+
333
+ super().__init__(
334
+ route_system_prompt=route_system_prompt,
335
+ extraction_system_prompt=extraction_system_prompt,
336
+ )
337
+ self.model = model
338
+ self.client = genai.Client(api_key=api_key or os.environ["GEMINI_API_KEY"])
339
+
340
+ def _json(
341
+ self,
342
+ system: str,
343
+ prompt: str,
344
+ response_schema: dict[str, Any],
345
+ ) -> dict[str, Any]:
346
+ from google.genai import types
347
+
348
+ response = self.client.models.generate_content(
349
+ model=self.model,
350
+ contents=prompt,
351
+ config=types.GenerateContentConfig(
352
+ system_instruction=system,
353
+ response_mime_type="application/json",
354
+ response_json_schema=response_schema,
355
+ temperature=0.0,
356
+ ),
357
+ )
358
+ try:
359
+ response_document = strict_json_loads(response.text or "{}")
360
+ if isinstance(response_document, dict) and Draft202012Validator(
361
+ response_schema
362
+ ).is_valid(response_document):
363
+ return response_document
364
+ return {}
365
+ except (ValueError, TypeError):
366
+ return {}
flowspec2/models.py ADDED
@@ -0,0 +1,121 @@
1
+ """The three runtime contracts that flow through a compiled graph.
2
+
3
+ These mirror the production ``multi_step_service`` framework so the format maps
4
+ 1:1 onto it:
5
+
6
+ - ``ServiceState`` — the single source of truth that traverses every node.
7
+ ``data`` persists and is visible to the agent; ``payload`` is ephemeral (the
8
+ fields the LLM extracted *this turn*); ``internal`` persists but is hidden.
9
+ There is **no step pointer** — position is rediscovered each turn from which
10
+ slots are filled in their configured partition.
11
+ - ``AgentResponse`` — the contract back to the agent. ``description`` is the
12
+ citizen-facing text / deterministic fallback; ``payload_schema`` is the JSON
13
+ Schema handed to constrained decoding so the LLM extracts the right closed
14
+ token next turn; ``interactive`` optionally renders buttons/list/flow;
15
+ ``log_id`` correlates a warning or error with its structured log record.
16
+ - ``ServiceMetadata`` — autogenerated timestamps, persisted contract provenance,
17
+ and a ``saved`` marker used by the empty-payload reset semantics.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from datetime import datetime
23
+ from typing import Any, Final, Literal, Optional
24
+
25
+ from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, StrictStr
26
+
27
+ from .clock import UtcClock, system_utc_now, utc_timestamp
28
+
29
+ CORRECTION_TARGETS_SCHEMA_KEY: Final[str] = "x-flowspec2-correction-targets"
30
+ CORRECTION_REQUESTED_INTERNAL_KEY: Final[str] = "_flowspec2_correction_requested"
31
+
32
+
33
+ class AgentResponse(BaseModel):
34
+ """What a node hands back: a question (pause) or a result."""
35
+
36
+ service_name: Optional[str] = None
37
+ error_message: Optional[str] = None
38
+ log_id: Optional[str] = Field(default=None, pattern=r"^\d+$")
39
+ description: str = ""
40
+ payload_schema: Optional[dict[str, Any]] = None
41
+ data: dict[str, Any] = Field(default_factory=dict)
42
+ # Optional signal to the tool layer to render this as a WhatsApp interactive
43
+ # (buttons/list/flow) instead of plain text. Shape mirrors the production
44
+ # ``AgentResponse.interactive``. ``None`` => zero effect (back-compat).
45
+ interactive: Optional[dict[str, Any]] = None
46
+
47
+ model_config = ConfigDict(extra="forbid")
48
+
49
+
50
+ class AwaitResumeProvenance(BaseModel):
51
+ """Persisted identity of the external-resume contract and accepted signal."""
52
+
53
+ step: str = Field(pattern=r"^[a-z][a-z0-9_]*$")
54
+ version: str = Field(pattern=r"^[1-9][0-9]*(?:\.[0-9]+){0,2}$")
55
+ digest: str = Field(pattern=r"^[0-9a-f]{64}$")
56
+ correlation_path: str = Field(pattern=r"^\$token(?:\.[A-Za-z][A-Za-z0-9_]*)+$")
57
+ correlation_value: Optional[StrictStr | StrictInt | StrictFloat | StrictBool] = None
58
+ deadline: Optional[datetime] = None
59
+
60
+ model_config = ConfigDict(extra="forbid")
61
+
62
+
63
+ class ServiceMetadata(BaseModel):
64
+ created_at: datetime = Field(default_factory=system_utc_now)
65
+ updated_at: Optional[datetime] = None
66
+ flow_version: Optional[str] = None
67
+ flow_ir_digest: Optional[str] = Field(default=None, pattern=r"^[0-9a-f]{64}$")
68
+ dependency_digest: Optional[str] = Field(default=None, pattern=r"^[0-9a-f]{64}$")
69
+ profile_digest: Optional[str] = Field(default=None, pattern=r"^[0-9a-f]{64}$")
70
+ await_resume: Optional[AwaitResumeProvenance] = None
71
+ # True once the workflow has been persisted at least once. Drives the
72
+ # empty-payload reset rule (never-saved => reset; in-progress => ignore).
73
+ saved: bool = False
74
+
75
+ model_config = ConfigDict(extra="forbid")
76
+
77
+ def model_post_init(self, __context: Any) -> None:
78
+ if self.updated_at is None:
79
+ self.updated_at = self.created_at
80
+
81
+ @classmethod
82
+ def from_clock(
83
+ cls,
84
+ clock: UtcClock,
85
+ *,
86
+ flow_version: str,
87
+ flow_ir_digest: str,
88
+ dependency_digest: str,
89
+ profile_digest: str,
90
+ ) -> ServiceMetadata:
91
+ """Create timestamped metadata bound to one resolved flow contract."""
92
+
93
+ timestamp = utc_timestamp(clock)
94
+ return cls(
95
+ created_at=timestamp,
96
+ updated_at=timestamp,
97
+ flow_version=flow_version,
98
+ flow_ir_digest=flow_ir_digest,
99
+ dependency_digest=dependency_digest,
100
+ profile_digest=profile_digest,
101
+ )
102
+
103
+ def touch(self, clock: UtcClock = system_utc_now) -> None:
104
+ """Advance the update timestamp using an injectable clock."""
105
+
106
+ self.updated_at = utc_timestamp(clock)
107
+
108
+
109
+ class ServiceState(BaseModel):
110
+ """The single object that traverses every node of a compiled flow graph."""
111
+
112
+ user_id: str
113
+ service_name: str
114
+ status: Literal["progress", "completed", "error"] = "progress"
115
+ data: dict[str, Any] = Field(default_factory=dict)
116
+ payload: dict[str, Any] = Field(default_factory=dict)
117
+ internal: dict[str, Any] = Field(default_factory=dict)
118
+ metadata: ServiceMetadata = Field(default_factory=ServiceMetadata)
119
+ agent_response: Optional[AgentResponse] = None
120
+
121
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")