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/json_codec.py
ADDED
|
@@ -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")
|