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,151 @@
1
+ """Snowflake correlation IDs and structured logging helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ import os
8
+ import socket
9
+ import time
10
+ from collections.abc import Callable, Mapping
11
+ from threading import Lock
12
+
13
+ SNOWFLAKE_EPOCH_MILLISECONDS = 1_704_067_200_000
14
+ SNOWFLAKE_TIMESTAMP_BITS = 41
15
+ SNOWFLAKE_WORKER_BITS = 10
16
+ SNOWFLAKE_SEQUENCE_BITS = 12
17
+ SNOWFLAKE_MAX_WORKER_ID = (1 << SNOWFLAKE_WORKER_BITS) - 1
18
+ SNOWFLAKE_MAX_SEQUENCE = (1 << SNOWFLAKE_SEQUENCE_BITS) - 1
19
+ SNOWFLAKE_MAX_TIMESTAMP_DELTA = (1 << SNOWFLAKE_TIMESTAMP_BITS) - 1
20
+ SNOWFLAKE_WORKER_SHIFT = SNOWFLAKE_SEQUENCE_BITS
21
+ SNOWFLAKE_TIMESTAMP_SHIFT = SNOWFLAKE_WORKER_BITS + SNOWFLAKE_SEQUENCE_BITS
22
+
23
+ ClockMilliseconds = Callable[[], int]
24
+ _LOG_RECORD_RESERVED_FIELDS = frozenset(logging.makeLogRecord({}).__dict__) | {
25
+ "asctime",
26
+ "log_id",
27
+ "message",
28
+ "operation",
29
+ }
30
+
31
+
32
+ def _system_clock_milliseconds() -> int:
33
+ return time.time_ns() // 1_000_000
34
+
35
+
36
+ class SnowflakeIdGenerator:
37
+ """Generate ordered decimal Snowflake IDs with an injectable clock."""
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ worker_id: int,
43
+ clock_milliseconds: ClockMilliseconds = _system_clock_milliseconds,
44
+ epoch_milliseconds: int = SNOWFLAKE_EPOCH_MILLISECONDS,
45
+ ) -> None:
46
+ if isinstance(worker_id, bool) or not 0 <= worker_id <= SNOWFLAKE_MAX_WORKER_ID:
47
+ raise ValueError(
48
+ f"worker_id must be between 0 and {SNOWFLAKE_MAX_WORKER_ID}, got {worker_id!r}"
49
+ )
50
+ if isinstance(epoch_milliseconds, bool) or epoch_milliseconds < 0:
51
+ raise ValueError("epoch_milliseconds must be a non-negative integer")
52
+ self.worker_id = worker_id
53
+ self._clock_milliseconds = clock_milliseconds
54
+ self._epoch_milliseconds = epoch_milliseconds
55
+ self._last_timestamp_milliseconds: int | None = None
56
+ self._sequence = 0
57
+ self._lock = Lock()
58
+
59
+ def generate(self) -> str:
60
+ """Return the next ID, advancing logical time across clock rollback or saturation."""
61
+
62
+ with self._lock:
63
+ clock_timestamp = self._clock_milliseconds()
64
+ if isinstance(clock_timestamp, bool) or not isinstance(clock_timestamp, int):
65
+ raise TypeError("clock_milliseconds must return an integer")
66
+ if clock_timestamp < self._epoch_milliseconds:
67
+ raise ValueError("clock timestamp precedes the configured Snowflake epoch")
68
+
69
+ last_timestamp = self._last_timestamp_milliseconds
70
+ logical_timestamp = (
71
+ clock_timestamp if last_timestamp is None else max(clock_timestamp, last_timestamp)
72
+ )
73
+ if last_timestamp is not None and logical_timestamp == last_timestamp:
74
+ self._sequence += 1
75
+ if self._sequence > SNOWFLAKE_MAX_SEQUENCE:
76
+ logical_timestamp += 1
77
+ self._sequence = 0
78
+ else:
79
+ self._sequence = 0
80
+
81
+ timestamp_delta = logical_timestamp - self._epoch_milliseconds
82
+ if timestamp_delta > SNOWFLAKE_MAX_TIMESTAMP_DELTA:
83
+ raise OverflowError("Snowflake timestamp exceeds the configured bit allocation")
84
+
85
+ self._last_timestamp_milliseconds = logical_timestamp
86
+ snowflake_id = (
87
+ (timestamp_delta << SNOWFLAKE_TIMESTAMP_SHIFT)
88
+ | (self.worker_id << SNOWFLAKE_WORKER_SHIFT)
89
+ | self._sequence
90
+ )
91
+ return str(snowflake_id)
92
+
93
+
94
+ def _configured_worker_id() -> int:
95
+ configured_worker_id = os.environ.get("FLOWSPEC2_SNOWFLAKE_WORKER_ID")
96
+ if configured_worker_id is not None:
97
+ try:
98
+ worker_id = int(configured_worker_id)
99
+ except ValueError as error:
100
+ raise ValueError("FLOWSPEC2_SNOWFLAKE_WORKER_ID must be an integer") from error
101
+ if not 0 <= worker_id <= SNOWFLAKE_MAX_WORKER_ID:
102
+ raise ValueError("FLOWSPEC2_SNOWFLAKE_WORKER_ID is outside the supported worker range")
103
+ return worker_id
104
+
105
+ process_identity = f"{socket.gethostname()}:{os.getpid()}".encode("utf-8")
106
+ identity_digest = hashlib.blake2s(process_identity, digest_size=2).digest()
107
+ return int.from_bytes(identity_digest, byteorder="big") & SNOWFLAKE_MAX_WORKER_ID
108
+
109
+
110
+ _default_generator_lock = Lock()
111
+ _default_generator_process_id: int | None = None
112
+ _default_generator: SnowflakeIdGenerator | None = None
113
+
114
+
115
+ def default_log_id_generator() -> SnowflakeIdGenerator:
116
+ """Return the process-local generator, recreating it after a process fork."""
117
+
118
+ global _default_generator
119
+ global _default_generator_process_id
120
+
121
+ process_id = os.getpid()
122
+ with _default_generator_lock:
123
+ if _default_generator is None or _default_generator_process_id != process_id:
124
+ _default_generator = SnowflakeIdGenerator(worker_id=_configured_worker_id())
125
+ _default_generator_process_id = process_id
126
+ return _default_generator
127
+
128
+
129
+ def log_event(
130
+ logger: logging.Logger,
131
+ level: int,
132
+ message: str,
133
+ *,
134
+ operation: str,
135
+ log_id_generator: SnowflakeIdGenerator | None = None,
136
+ context: Mapping[str, object] | None = None,
137
+ exc_info: bool = False,
138
+ ) -> str:
139
+ """Emit one structured event and return its correlation ID."""
140
+
141
+ if context:
142
+ conflicting_fields = _LOG_RECORD_RESERVED_FIELDS.intersection(context)
143
+ if conflicting_fields:
144
+ conflicts = ", ".join(sorted(conflicting_fields))
145
+ raise ValueError(f"logging context cannot override reserved fields: {conflicts}")
146
+ log_id = (log_id_generator or default_log_id_generator()).generate()
147
+ extra: dict[str, object] = {"log_id": log_id, "operation": operation}
148
+ if context:
149
+ extra.update(context)
150
+ logger.log(level, message, extra=extra, exc_info=exc_info)
151
+ return log_id
@@ -0,0 +1,89 @@
1
+ """The frozen object-form predicate grammar.
2
+
3
+ A predicate is a single-key object over five read-only namespaces. There is no
4
+ string sugar and no arbitrary computation — every gate, ``ask_when``,
5
+ ``send_when`` and correction route compiles to a call to :func:`evaluate`, which
6
+ is a pure boolean function over ``ServiceState`` + the flow ``config`` block.
7
+
8
+ Grammar (one operator per object)::
9
+
10
+ {"in": ["slots.x", ["a", "b"]]} # resolved(x) in the literal list
11
+ {"eq": ["slots.x", "literal"]} # resolved(x) == literal (either side may be a ref)
12
+ {"eq": ["slots.x", {"literal": "config.flag"}]} # namespaced literal
13
+ {"ne": ["slots.x", "literal"]}
14
+ {"is_present": "slots.x"} # filled and non-null/non-empty
15
+ {"and": [<pred>, ...]}
16
+ {"or": [<pred>, ...]}
17
+ {"not": <pred>}
18
+
19
+ Namespaces: ``slots.`` (state.data), ``internal.`` (state.internal),
20
+ ``payload.`` (state.payload), ``config.`` (the flow config block),
21
+ ``address.`` (state.data["address"]).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, TypeGuard
27
+
28
+ from .models import ServiceState
29
+
30
+ NAMESPACES = ("slots", "internal", "payload", "config", "address")
31
+
32
+
33
+ def _is_ref(value: Any) -> TypeGuard[str]:
34
+ return isinstance(value, str) and "." in value and value.split(".", 1)[0] in NAMESPACES
35
+
36
+
37
+ def _resolve(ref: str, state: ServiceState, config: dict[str, Any]) -> Any:
38
+ ns, key = ref.split(".", 1)
39
+ if ns == "slots":
40
+ return state.data.get(key)
41
+ if ns == "internal":
42
+ return state.internal.get(key)
43
+ if ns == "payload":
44
+ return state.payload.get(key)
45
+ if ns == "config":
46
+ return config.get(key)
47
+ if ns == "address":
48
+ resolved: Any = state.data.get("address") or {}
49
+ for path_segment in key.split("."):
50
+ if not isinstance(resolved, dict) or path_segment not in resolved:
51
+ return None
52
+ resolved = resolved[path_segment]
53
+ return resolved
54
+ raise ValueError(f"unknown predicate namespace: {ns!r}")
55
+
56
+
57
+ def _operand(value: Any, state: ServiceState, config: dict[str, Any]) -> Any:
58
+ """Resolve namespaced strings and unwrap explicit ambiguous literals."""
59
+ if isinstance(value, dict) and set(value) == {"literal"}:
60
+ return value["literal"]
61
+ return _resolve(value, state, config) if _is_ref(value) else value
62
+
63
+
64
+ def evaluate(pred: dict[str, Any], state: ServiceState, config: dict[str, Any]) -> bool:
65
+ """Evaluate a predicate against state + config. Pure, side-effect free."""
66
+ if not isinstance(pred, dict) or len(pred) != 1:
67
+ raise ValueError(f"predicate must be a single-key object, got {pred!r}")
68
+ ((op, val),) = pred.items()
69
+
70
+ if op == "in":
71
+ ref, options = val
72
+ return _operand(ref, state, config) in options
73
+ if op == "eq":
74
+ left, right = val
75
+ return bool(_operand(left, state, config) == _operand(right, state, config))
76
+ if op == "ne":
77
+ left, right = val
78
+ return bool(_operand(left, state, config) != _operand(right, state, config))
79
+ if op == "is_present":
80
+ resolved = _resolve(val, state, config)
81
+ return resolved is not None and resolved != ""
82
+ if op == "and":
83
+ return all(evaluate(p, state, config) for p in val)
84
+ if op == "or":
85
+ return any(evaluate(p, state, config) for p in val)
86
+ if op == "not":
87
+ return not evaluate(val, state, config)
88
+
89
+ raise ValueError(f"unknown predicate operator: {op!r}")
flowspec2/profiles.py ADDED
@@ -0,0 +1,118 @@
1
+ """Runtime profiles bind portable flow contracts to executable capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from typing import Any, Final
10
+
11
+ from .subflows import SubflowRegistry, default_subflows
12
+ from .tools import ToolRegistry, default_tool_registry
13
+
14
+ REFERENCE_PROFILE_ID: Final[str] = "flowspec2/reference@2"
15
+ REFERENCE_DOMAIN_TYPES: Final[frozenset[str]] = frozenset(
16
+ {"categorical", "bool", "free_text", "brazilian_tax_id", "email", "name", "integer", "number"}
17
+ )
18
+ REFERENCE_CAPABILITIES: Final[frozenset[str]] = frozenset(
19
+ {
20
+ "auto_flow",
21
+ "await_external",
22
+ "external_authentication",
23
+ "geocoding",
24
+ "handoff",
25
+ "identity_lookup",
26
+ "location_in",
27
+ "media_in",
28
+ "media_out",
29
+ "session_reset",
30
+ "tts",
31
+ }
32
+ )
33
+
34
+
35
+ def capability_is_requested(capability_value: Any) -> bool:
36
+ """Return whether a normalized capability value requests host support."""
37
+
38
+ return (
39
+ capability_value is True
40
+ or isinstance(capability_value, Mapping)
41
+ and bool(capability_value)
42
+ or isinstance(capability_value, (list, tuple))
43
+ and bool(capability_value)
44
+ )
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class FlowProfile:
49
+ """Named registries and host features against which a flow is checked."""
50
+
51
+ identifier: str
52
+ tools: ToolRegistry
53
+ subflows: SubflowRegistry
54
+ capabilities: frozenset[str]
55
+ domain_types: frozenset[str]
56
+ allow_legacy_contracts: bool = False
57
+
58
+ def __post_init__(self) -> None:
59
+ if not self.identifier.strip():
60
+ raise ValueError("profile identifier must be non-empty")
61
+ if any(not capability.strip() for capability in self.capabilities):
62
+ raise ValueError("profile capabilities must be non-empty strings")
63
+ if any(not domain_type.strip() for domain_type in self.domain_types):
64
+ raise ValueError("profile domain types must be non-empty strings")
65
+
66
+ def as_dict(self) -> dict[str, Any]:
67
+ """Return the complete owned runtime-profile contract."""
68
+
69
+ return {
70
+ "identifier": self.identifier,
71
+ "allow_legacy_contracts": self.allow_legacy_contracts,
72
+ "capabilities": sorted(self.capabilities),
73
+ "domain_types": sorted(self.domain_types),
74
+ "tools": {
75
+ tool_name: tool_definition.as_dict()
76
+ for tool_name, tool_definition in sorted(self.tools.definitions.items())
77
+ },
78
+ "subflows": {
79
+ subflow_reference: subflow_definition.as_dict()
80
+ for subflow_reference, subflow_definition in sorted(
81
+ self.subflows.definitions.items()
82
+ )
83
+ },
84
+ }
85
+
86
+ def canonical_json(self) -> str:
87
+ """Serialize the complete profile contract deterministically."""
88
+
89
+ return json.dumps(
90
+ self.as_dict(),
91
+ ensure_ascii=False,
92
+ allow_nan=False,
93
+ separators=(",", ":"),
94
+ sort_keys=True,
95
+ )
96
+
97
+ @property
98
+ def digest(self) -> str:
99
+ """Return the content identity used by IR and authoring provenance."""
100
+
101
+ return hashlib.sha256(self.canonical_json().encode("utf-8")).hexdigest()
102
+
103
+
104
+ def reference_profile(
105
+ *,
106
+ tools: ToolRegistry | None = None,
107
+ subflows: SubflowRegistry | None = None,
108
+ ) -> FlowProfile:
109
+ """Build the executable profile shipped with the reference runtime."""
110
+
111
+ return FlowProfile(
112
+ identifier=REFERENCE_PROFILE_ID,
113
+ tools=tools or default_tool_registry(),
114
+ subflows=subflows or default_subflows(),
115
+ capabilities=REFERENCE_CAPABILITIES,
116
+ domain_types=REFERENCE_DOMAIN_TYPES,
117
+ allow_legacy_contracts=False,
118
+ )
flowspec2/py.typed ADDED
File without changes