agentflow-runtime 1.1.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.
- agentflow_runtime-1.1.0.dist-info/METADATA +55 -0
- agentflow_runtime-1.1.0.dist-info/RECORD +100 -0
- agentflow_runtime-1.1.0.dist-info/WHEEL +4 -0
- agentflow_runtime-1.1.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +0 -0
- src/constants.py +3 -0
- src/ingestion/__init__.py +0 -0
- src/ingestion/cdc/__init__.py +5 -0
- src/ingestion/cdc/normalizer.py +186 -0
- src/ingestion/connectors/__init__.py +0 -0
- src/ingestion/connectors/mysql_cdc.py +63 -0
- src/ingestion/connectors/postgres_cdc.py +68 -0
- src/ingestion/producers/__init__.py +0 -0
- src/ingestion/producers/event_producer.py +237 -0
- src/ingestion/schemas/__init__.py +0 -0
- src/ingestion/schemas/events.py +147 -0
- src/ingestion/tenant_router.py +80 -0
- src/logger.py +41 -0
- src/orchestration/__init__.py +0 -0
- src/orchestration/dags/__init__.py +0 -0
- src/orchestration/dags/daily_batch.py +201 -0
- src/processing/__init__.py +0 -0
- src/processing/event_replayer.py +250 -0
- src/processing/flink_jobs/Dockerfile +55 -0
- src/processing/flink_jobs/__init__.py +0 -0
- src/processing/flink_jobs/checkpointing.py +32 -0
- src/processing/flink_jobs/session_aggregation.py +212 -0
- src/processing/flink_jobs/session_aggregator.py +199 -0
- src/processing/flink_jobs/stream_processor.py +316 -0
- src/processing/iceberg_sink.py +348 -0
- src/processing/local_pipeline.py +452 -0
- src/processing/outbox.py +273 -0
- src/processing/tracing.py +36 -0
- src/processing/transformations/__init__.py +0 -0
- src/processing/transformations/enrichment.py +125 -0
- src/quality/__init__.py +0 -0
- src/quality/monitors/__init__.py +0 -0
- src/quality/monitors/freshness_monitor.py +166 -0
- src/quality/monitors/metrics_collector.py +367 -0
- src/quality/validators/__init__.py +0 -0
- src/quality/validators/schema_validator.py +119 -0
- src/quality/validators/semantic_validator.py +202 -0
- src/serving/__init__.py +0 -0
- src/serving/api/__init__.py +0 -0
- src/serving/api/alert_dispatcher.py +51 -0
- src/serving/api/alerts/__init__.py +38 -0
- src/serving/api/alerts/dispatcher.py +299 -0
- src/serving/api/alerts/escalation.py +290 -0
- src/serving/api/alerts/evaluator.py +81 -0
- src/serving/api/alerts/history.py +115 -0
- src/serving/api/analytics.py +543 -0
- src/serving/api/auth/__init__.py +46 -0
- src/serving/api/auth/key_rotation.py +400 -0
- src/serving/api/auth/manager.py +406 -0
- src/serving/api/auth/middleware.py +331 -0
- src/serving/api/main.py +390 -0
- src/serving/api/middleware/logging.py +41 -0
- src/serving/api/middleware/tracing.py +51 -0
- src/serving/api/rate_limiter.py +76 -0
- src/serving/api/routers/__init__.py +0 -0
- src/serving/api/routers/admin.py +150 -0
- src/serving/api/routers/admin_ui.py +93 -0
- src/serving/api/routers/agent_query.py +639 -0
- src/serving/api/routers/alerts.py +134 -0
- src/serving/api/routers/batch.py +231 -0
- src/serving/api/routers/contracts.py +98 -0
- src/serving/api/routers/deadletter.py +337 -0
- src/serving/api/routers/lineage.py +218 -0
- src/serving/api/routers/search.py +103 -0
- src/serving/api/routers/slo.py +231 -0
- src/serving/api/routers/stream.py +141 -0
- src/serving/api/routers/webhooks.py +93 -0
- src/serving/api/security.py +83 -0
- src/serving/api/telemetry.py +66 -0
- src/serving/api/templates/admin.html +214 -0
- src/serving/api/versioning.py +328 -0
- src/serving/api/webhook_dispatcher.py +423 -0
- src/serving/backends/__init__.py +117 -0
- src/serving/backends/clickhouse_backend.py +310 -0
- src/serving/backends/duckdb_backend.py +268 -0
- src/serving/cache.py +169 -0
- src/serving/db_pool.py +105 -0
- src/serving/masking.py +122 -0
- src/serving/semantic_layer/__init__.py +0 -0
- src/serving/semantic_layer/catalog.py +177 -0
- src/serving/semantic_layer/contract_registry.py +258 -0
- src/serving/semantic_layer/entity_type_registry.py +107 -0
- src/serving/semantic_layer/nl_engine.py +189 -0
- src/serving/semantic_layer/query/__init__.py +3 -0
- src/serving/semantic_layer/query/contracts.py +47 -0
- src/serving/semantic_layer/query/engine.py +81 -0
- src/serving/semantic_layer/query/entity_queries.py +221 -0
- src/serving/semantic_layer/query/metric_queries.py +84 -0
- src/serving/semantic_layer/query/nl_queries.py +305 -0
- src/serving/semantic_layer/query/sql_builder.py +113 -0
- src/serving/semantic_layer/query/sql_guard.py +3 -0
- src/serving/semantic_layer/query_engine.py +5 -0
- src/serving/semantic_layer/schema_evolution.py +175 -0
- src/serving/semantic_layer/search_index.py +337 -0
- src/serving/semantic_layer/sql_guard.py +56 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import UTC, date, datetime
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import duckdb
|
|
15
|
+
import httpx
|
|
16
|
+
import structlog
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import yaml # type: ignore[import-untyped]
|
|
21
|
+
except ImportError: # pragma: no cover
|
|
22
|
+
yaml = None
|
|
23
|
+
|
|
24
|
+
logger = structlog.get_logger()
|
|
25
|
+
|
|
26
|
+
DEFAULT_WEBHOOKS_CONFIG_PATH = Path(os.getenv("AGENTFLOW_WEBHOOKS_FILE", "config/webhooks.yaml"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WebhookFilters(BaseModel):
|
|
30
|
+
event_types: list[str] | None = None
|
|
31
|
+
entity_ids: list[str] | None = None
|
|
32
|
+
min_amount: float | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WebhookRegistration(BaseModel):
|
|
36
|
+
id: str
|
|
37
|
+
url: str
|
|
38
|
+
secret: str
|
|
39
|
+
tenant: str
|
|
40
|
+
filters: WebhookFilters = Field(default_factory=WebhookFilters)
|
|
41
|
+
created_at: datetime
|
|
42
|
+
active: bool = True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WebhookConfig(BaseModel):
|
|
46
|
+
webhooks: list[WebhookRegistration] = Field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_webhook_config_path(app) -> Path:
|
|
50
|
+
configured = getattr(app.state, "webhook_config_path", None)
|
|
51
|
+
return Path(configured) if configured else DEFAULT_WEBHOOKS_CONFIG_PATH
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_webhooks(path: Path) -> list[WebhookRegistration]:
|
|
55
|
+
if not path.exists():
|
|
56
|
+
return []
|
|
57
|
+
raw = path.read_text(encoding="utf-8")
|
|
58
|
+
if not raw.strip():
|
|
59
|
+
return []
|
|
60
|
+
data = yaml.safe_load(raw) if yaml is not None else json.loads(raw)
|
|
61
|
+
config = WebhookConfig.model_validate(data or {})
|
|
62
|
+
return config.webhooks
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def save_webhooks(path: Path, webhooks: list[WebhookRegistration]) -> None:
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
payload = WebhookConfig(webhooks=webhooks).model_dump(mode="json")
|
|
68
|
+
content = (
|
|
69
|
+
yaml.safe_dump(payload, sort_keys=False)
|
|
70
|
+
if yaml is not None
|
|
71
|
+
else json.dumps(payload, indent=2)
|
|
72
|
+
)
|
|
73
|
+
path.write_text(content, encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create_webhook(
|
|
77
|
+
path: Path,
|
|
78
|
+
*,
|
|
79
|
+
url: str,
|
|
80
|
+
tenant: str,
|
|
81
|
+
filters: WebhookFilters,
|
|
82
|
+
) -> WebhookRegistration:
|
|
83
|
+
webhooks = load_webhooks(path)
|
|
84
|
+
registration = WebhookRegistration(
|
|
85
|
+
id=str(uuid.uuid4()),
|
|
86
|
+
url=url,
|
|
87
|
+
secret=secrets.token_urlsafe(32),
|
|
88
|
+
tenant=tenant,
|
|
89
|
+
filters=filters,
|
|
90
|
+
created_at=datetime.now(UTC),
|
|
91
|
+
)
|
|
92
|
+
webhooks.append(registration)
|
|
93
|
+
save_webhooks(path, webhooks)
|
|
94
|
+
return registration
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def list_webhooks(path: Path, tenant: str) -> list[WebhookRegistration]:
|
|
98
|
+
return [
|
|
99
|
+
webhook for webhook in load_webhooks(path) if webhook.tenant == tenant and webhook.active
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_webhook(path: Path, webhook_id: str, tenant: str) -> WebhookRegistration | None:
|
|
104
|
+
for webhook in load_webhooks(path):
|
|
105
|
+
if webhook.id == webhook_id and webhook.tenant == tenant and webhook.active:
|
|
106
|
+
return webhook
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def deactivate_webhook(path: Path, webhook_id: str, tenant: str) -> bool:
|
|
111
|
+
webhooks = load_webhooks(path)
|
|
112
|
+
changed = False
|
|
113
|
+
for webhook in webhooks:
|
|
114
|
+
if webhook.id == webhook_id and webhook.tenant == tenant and webhook.active:
|
|
115
|
+
webhook.active = False
|
|
116
|
+
changed = True
|
|
117
|
+
break
|
|
118
|
+
if changed:
|
|
119
|
+
save_webhooks(path, webhooks)
|
|
120
|
+
return changed
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def ensure_webhook_deliveries_table(conn: duckdb.DuckDBPyConnection) -> None:
|
|
124
|
+
conn.execute(
|
|
125
|
+
"""
|
|
126
|
+
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
127
|
+
delivery_id VARCHAR,
|
|
128
|
+
webhook_id VARCHAR,
|
|
129
|
+
event_id VARCHAR,
|
|
130
|
+
event_type VARCHAR,
|
|
131
|
+
attempt INTEGER,
|
|
132
|
+
status_code INTEGER,
|
|
133
|
+
success BOOLEAN,
|
|
134
|
+
error TEXT,
|
|
135
|
+
delivered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
136
|
+
)
|
|
137
|
+
"""
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_delivery_logs(conn: duckdb.DuckDBPyConnection, webhook_id: str) -> list[dict]:
|
|
142
|
+
ensure_webhook_deliveries_table(conn)
|
|
143
|
+
cursor = conn.execute(
|
|
144
|
+
"""
|
|
145
|
+
SELECT delivery_id, webhook_id, event_id, event_type, attempt,
|
|
146
|
+
status_code, success, error, delivered_at
|
|
147
|
+
FROM webhook_deliveries
|
|
148
|
+
WHERE webhook_id = ?
|
|
149
|
+
ORDER BY delivered_at DESC
|
|
150
|
+
LIMIT 20
|
|
151
|
+
""",
|
|
152
|
+
[webhook_id],
|
|
153
|
+
)
|
|
154
|
+
columns = [description[0] for description in cursor.description]
|
|
155
|
+
return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class WebhookDispatcher:
|
|
159
|
+
def __init__(self, app, poll_interval_seconds: float = 2.0) -> None:
|
|
160
|
+
self.app = app
|
|
161
|
+
self.poll_interval_seconds = poll_interval_seconds
|
|
162
|
+
self.backoff_seconds = [1.0, 5.0, 25.0]
|
|
163
|
+
self.seen_event_ids: set[str] = set()
|
|
164
|
+
self._task: asyncio.Task | None = None
|
|
165
|
+
|
|
166
|
+
def start(self) -> None:
|
|
167
|
+
if self._task is not None and not self._task.done():
|
|
168
|
+
return
|
|
169
|
+
self.mark_existing_events_seen()
|
|
170
|
+
self._task = asyncio.create_task(self.run())
|
|
171
|
+
|
|
172
|
+
async def stop(self) -> None:
|
|
173
|
+
if self._task is None or self._task.done():
|
|
174
|
+
return
|
|
175
|
+
self._task.cancel()
|
|
176
|
+
try:
|
|
177
|
+
await self._task
|
|
178
|
+
except asyncio.CancelledError:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
async def run(self) -> None:
|
|
182
|
+
while True:
|
|
183
|
+
try:
|
|
184
|
+
await self.dispatch_new_events()
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
logger.warning("webhook_dispatcher_error", error=str(exc))
|
|
187
|
+
await asyncio.sleep(self.poll_interval_seconds)
|
|
188
|
+
|
|
189
|
+
def mark_existing_events_seen(self) -> None:
|
|
190
|
+
try:
|
|
191
|
+
for event in self._fetch_pipeline_events():
|
|
192
|
+
event_id = str(event.get("event_id") or "")
|
|
193
|
+
if event_id:
|
|
194
|
+
self.seen_event_ids.add(_seen_event_key(event))
|
|
195
|
+
except duckdb.Error as exc:
|
|
196
|
+
logger.warning("webhook_seen_init_failed", error=str(exc))
|
|
197
|
+
|
|
198
|
+
async def dispatch_new_events(self) -> None:
|
|
199
|
+
path = get_webhook_config_path(self.app)
|
|
200
|
+
webhooks = [webhook for webhook in load_webhooks(path) if webhook.active]
|
|
201
|
+
webhooks_by_tenant: dict[str, list[WebhookRegistration]] = {}
|
|
202
|
+
for webhook in webhooks:
|
|
203
|
+
webhooks_by_tenant.setdefault(webhook.tenant, []).append(webhook)
|
|
204
|
+
|
|
205
|
+
for tenant in sorted(webhooks_by_tenant):
|
|
206
|
+
events = self._fetch_pipeline_events(tenant=tenant)
|
|
207
|
+
for event in events:
|
|
208
|
+
event_id = str(event.get("event_id") or "")
|
|
209
|
+
seen_key = _seen_event_key(event)
|
|
210
|
+
if (
|
|
211
|
+
not event_id
|
|
212
|
+
or event_id in self.seen_event_ids
|
|
213
|
+
or seen_key in self.seen_event_ids
|
|
214
|
+
):
|
|
215
|
+
continue
|
|
216
|
+
self.seen_event_ids.add(seen_key)
|
|
217
|
+
|
|
218
|
+
for webhook in webhooks_by_tenant[tenant]:
|
|
219
|
+
if _matches_filters(event, webhook.filters):
|
|
220
|
+
await self.deliver(webhook, event)
|
|
221
|
+
|
|
222
|
+
async def deliver(self, webhook: WebhookRegistration, event: dict) -> dict:
|
|
223
|
+
conn = self.app.state.query_engine._conn
|
|
224
|
+
ensure_webhook_deliveries_table(conn)
|
|
225
|
+
|
|
226
|
+
delivery_id = str(uuid.uuid4())
|
|
227
|
+
event_type = str(event.get("event_type") or event.get("topic") or "unknown")
|
|
228
|
+
event_id = str(event.get("event_id") or delivery_id)
|
|
229
|
+
body = _event_body(event)
|
|
230
|
+
headers = {
|
|
231
|
+
"Content-Type": "application/json",
|
|
232
|
+
"X-AgentFlow-Event": event_type,
|
|
233
|
+
"X-AgentFlow-Signature": _signature(webhook.secret, body),
|
|
234
|
+
"X-AgentFlow-Delivery": delivery_id,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
attempts = 0
|
|
238
|
+
success = False
|
|
239
|
+
status_code: int | None = None
|
|
240
|
+
error: str | None = None
|
|
241
|
+
|
|
242
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
243
|
+
for attempt in range(1, 4):
|
|
244
|
+
attempts = attempt
|
|
245
|
+
error = None
|
|
246
|
+
try:
|
|
247
|
+
response = await client.post(
|
|
248
|
+
webhook.url,
|
|
249
|
+
content=body,
|
|
250
|
+
headers=headers,
|
|
251
|
+
)
|
|
252
|
+
status_code = response.status_code
|
|
253
|
+
success = 200 <= response.status_code < 300
|
|
254
|
+
_log_delivery(
|
|
255
|
+
conn,
|
|
256
|
+
delivery_id=delivery_id,
|
|
257
|
+
webhook_id=webhook.id,
|
|
258
|
+
event_id=event_id,
|
|
259
|
+
event_type=event_type,
|
|
260
|
+
attempt=attempt,
|
|
261
|
+
status_code=status_code,
|
|
262
|
+
success=success,
|
|
263
|
+
error=None,
|
|
264
|
+
)
|
|
265
|
+
if response.status_code < 500:
|
|
266
|
+
break
|
|
267
|
+
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
|
268
|
+
status_code = None
|
|
269
|
+
success = False
|
|
270
|
+
error = str(exc)
|
|
271
|
+
_log_delivery(
|
|
272
|
+
conn,
|
|
273
|
+
delivery_id=delivery_id,
|
|
274
|
+
webhook_id=webhook.id,
|
|
275
|
+
event_id=event_id,
|
|
276
|
+
event_type=event_type,
|
|
277
|
+
attempt=attempt,
|
|
278
|
+
status_code=None,
|
|
279
|
+
success=False,
|
|
280
|
+
error=error,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if attempt < 3:
|
|
284
|
+
delay = self.backoff_seconds[min(attempt - 1, len(self.backoff_seconds) - 1)]
|
|
285
|
+
await asyncio.sleep(delay)
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
"delivery_id": delivery_id,
|
|
289
|
+
"webhook_id": webhook.id,
|
|
290
|
+
"event_id": event_id,
|
|
291
|
+
"event_type": event_type,
|
|
292
|
+
"success": success,
|
|
293
|
+
"status_code": status_code,
|
|
294
|
+
"error": error,
|
|
295
|
+
"attempts": attempts,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
def _fetch_pipeline_events(self, tenant: str | None = None) -> list[dict]:
|
|
299
|
+
conn = self.app.state.query_engine._conn
|
|
300
|
+
columns = [
|
|
301
|
+
row[1] for row in conn.execute("PRAGMA table_info('pipeline_events')").fetchall()
|
|
302
|
+
]
|
|
303
|
+
if not columns:
|
|
304
|
+
return []
|
|
305
|
+
if tenant is not None and "tenant_id" not in columns and tenant != "default":
|
|
306
|
+
return []
|
|
307
|
+
if "processed_at" in columns:
|
|
308
|
+
order_by = "processed_at"
|
|
309
|
+
elif "created_at" in columns:
|
|
310
|
+
order_by = "created_at"
|
|
311
|
+
else:
|
|
312
|
+
order_by = "event_id"
|
|
313
|
+
sql = "SELECT * FROM pipeline_events" # nosec B608 - order_by is chosen from a fixed column allowlist
|
|
314
|
+
params: list[str] = []
|
|
315
|
+
if tenant is not None and "tenant_id" in columns:
|
|
316
|
+
sql = f"{sql} WHERE COALESCE(tenant_id, 'default') = ?"
|
|
317
|
+
params.append(tenant)
|
|
318
|
+
sql = f"{sql} ORDER BY {order_by} ASC, event_id ASC"
|
|
319
|
+
cursor = conn.execute(sql, params)
|
|
320
|
+
result_columns = [description[0] for description in cursor.description]
|
|
321
|
+
return [dict(zip(result_columns, row, strict=False)) for row in cursor.fetchall()]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _log_delivery(
|
|
325
|
+
conn: duckdb.DuckDBPyConnection,
|
|
326
|
+
*,
|
|
327
|
+
delivery_id: str,
|
|
328
|
+
webhook_id: str,
|
|
329
|
+
event_id: str,
|
|
330
|
+
event_type: str,
|
|
331
|
+
attempt: int,
|
|
332
|
+
status_code: int | None,
|
|
333
|
+
success: bool,
|
|
334
|
+
error: str | None,
|
|
335
|
+
) -> None:
|
|
336
|
+
conn.execute(
|
|
337
|
+
"""
|
|
338
|
+
INSERT INTO webhook_deliveries (
|
|
339
|
+
delivery_id, webhook_id, event_id, event_type, attempt,
|
|
340
|
+
status_code, success, error, delivered_at
|
|
341
|
+
)
|
|
342
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
343
|
+
""",
|
|
344
|
+
[
|
|
345
|
+
delivery_id,
|
|
346
|
+
webhook_id,
|
|
347
|
+
event_id,
|
|
348
|
+
event_type,
|
|
349
|
+
attempt,
|
|
350
|
+
status_code,
|
|
351
|
+
success,
|
|
352
|
+
error,
|
|
353
|
+
datetime.now(UTC),
|
|
354
|
+
],
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _matches_filters(event: dict, filters: WebhookFilters) -> bool:
|
|
359
|
+
event_type = str(event.get("event_type") or event.get("topic") or "")
|
|
360
|
+
if filters.event_types:
|
|
361
|
+
if not any(_event_type_matches(event_type, value) for value in filters.event_types):
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
if filters.entity_ids:
|
|
365
|
+
entity_values = {
|
|
366
|
+
str(event.get(key))
|
|
367
|
+
for key in ("entity_id", "order_id", "user_id", "product_id", "session_id")
|
|
368
|
+
if event.get(key) is not None
|
|
369
|
+
}
|
|
370
|
+
if not entity_values.intersection(filters.entity_ids):
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
if filters.min_amount is not None:
|
|
374
|
+
if not event_type.startswith("order"):
|
|
375
|
+
return False
|
|
376
|
+
amount = (
|
|
377
|
+
event.get("total_amount")
|
|
378
|
+
if event.get("total_amount") is not None
|
|
379
|
+
else event.get("amount")
|
|
380
|
+
)
|
|
381
|
+
if amount is None:
|
|
382
|
+
return False
|
|
383
|
+
try:
|
|
384
|
+
if float(str(amount)) < filters.min_amount:
|
|
385
|
+
return False
|
|
386
|
+
except (TypeError, ValueError):
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
return True
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _seen_event_key(event: dict) -> str:
|
|
393
|
+
event_id = str(event.get("event_id") or "")
|
|
394
|
+
tenant_id = str(event.get("tenant_id") or "default")
|
|
395
|
+
return f"{tenant_id}:{event_id}"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _event_type_matches(event_type: str, requested: str) -> bool:
|
|
399
|
+
return event_type == requested or event_type.startswith(f"{requested}.")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _event_body(event: dict) -> bytes:
|
|
403
|
+
return json.dumps(
|
|
404
|
+
event,
|
|
405
|
+
sort_keys=True,
|
|
406
|
+
separators=(",", ":"),
|
|
407
|
+
default=_json_default,
|
|
408
|
+
).encode("utf-8")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _signature(secret: str, body: bytes) -> str:
|
|
412
|
+
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
413
|
+
return f"sha256={digest}"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _json_default(value: object) -> str | float:
|
|
417
|
+
if isinstance(value, datetime):
|
|
418
|
+
return value.isoformat()
|
|
419
|
+
if isinstance(value, date):
|
|
420
|
+
return value.isoformat()
|
|
421
|
+
if isinstance(value, Decimal):
|
|
422
|
+
return float(value)
|
|
423
|
+
return str(value)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import yaml # type: ignore[import-untyped]
|
|
11
|
+
except ImportError: # pragma: no cover
|
|
12
|
+
yaml = None
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from src.serving.backends.duckdb_backend import DuckDBBackend
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BackendExecutionError(RuntimeError):
|
|
19
|
+
"""Raised when a backend query cannot be executed."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BackendMissingTableError(BackendExecutionError):
|
|
23
|
+
"""Raised when a backend query references a table that does not exist."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, table_name: str | None = None) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.table_name = table_name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ServingBackend(ABC):
|
|
31
|
+
name = "backend"
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def execute(self, sql: str, params: list | None = None) -> list[dict]:
|
|
35
|
+
"""Execute SQL and return rows as dictionaries."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def scalar(self, sql: str, params: list | None = None) -> Any:
|
|
39
|
+
"""Execute SQL and return the first scalar value."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def table_columns(self, table_name: str) -> set[str]:
|
|
43
|
+
"""Return the columns available in a table."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def explain(self, sql: str) -> list[tuple]:
|
|
47
|
+
"""Explain a SQL statement."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def initialize_demo_data(self) -> None:
|
|
51
|
+
"""Create and seed demo data if the backend is empty."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def health(self) -> dict:
|
|
55
|
+
"""Return a lightweight backend health payload."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_serving_config_path() -> Path:
|
|
59
|
+
return Path(os.getenv("AGENTFLOW_SERVING_CONFIG", "config/serving.yaml"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_serving_backend_config(config_path: Path | str | None = None) -> dict:
|
|
63
|
+
path = Path(config_path) if config_path is not None else default_serving_config_path()
|
|
64
|
+
data: dict[str, Any] = {}
|
|
65
|
+
if path.exists():
|
|
66
|
+
raw = path.read_text(encoding="utf-8")
|
|
67
|
+
if yaml is not None:
|
|
68
|
+
data = yaml.safe_load(raw) or {}
|
|
69
|
+
else: # pragma: no cover
|
|
70
|
+
data = json.loads(raw)
|
|
71
|
+
|
|
72
|
+
clickhouse = data.get("clickhouse", {})
|
|
73
|
+
backend_name = os.getenv("SERVING_BACKEND", data.get("backend", "duckdb"))
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"backend": str(backend_name).strip().lower() or "duckdb",
|
|
77
|
+
"clickhouse": {
|
|
78
|
+
"host": os.getenv("CLICKHOUSE_HOST", clickhouse.get("host", "localhost")),
|
|
79
|
+
"port": int(os.getenv("CLICKHOUSE_PORT", clickhouse.get("port", 8123))),
|
|
80
|
+
"user": os.getenv("CLICKHOUSE_USER", clickhouse.get("user", "default")),
|
|
81
|
+
"password": os.getenv("CLICKHOUSE_PASSWORD", clickhouse.get("password", "")),
|
|
82
|
+
"database": os.getenv("CLICKHOUSE_DATABASE", clickhouse.get("database", "agentflow")),
|
|
83
|
+
"secure": (
|
|
84
|
+
str(os.getenv("CLICKHOUSE_SECURE", clickhouse.get("secure", "false"))).lower()
|
|
85
|
+
in {"1", "true", "yes", "on"}
|
|
86
|
+
),
|
|
87
|
+
"timeout_seconds": int(
|
|
88
|
+
os.getenv("CLICKHOUSE_TIMEOUT_SECONDS", clickhouse.get("timeout_seconds", 10))
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def create_backend(
|
|
95
|
+
*,
|
|
96
|
+
duckdb_backend: DuckDBBackend,
|
|
97
|
+
config_path: Path | str | None = None,
|
|
98
|
+
) -> ServingBackend:
|
|
99
|
+
config = load_serving_backend_config(config_path)
|
|
100
|
+
backend_name = config["backend"]
|
|
101
|
+
|
|
102
|
+
if backend_name == "duckdb":
|
|
103
|
+
return duckdb_backend
|
|
104
|
+
if backend_name == "clickhouse":
|
|
105
|
+
from src.serving.backends.clickhouse_backend import ClickHouseBackend
|
|
106
|
+
|
|
107
|
+
clickhouse = config["clickhouse"]
|
|
108
|
+
return ClickHouseBackend(
|
|
109
|
+
host=clickhouse["host"],
|
|
110
|
+
port=clickhouse["port"],
|
|
111
|
+
user=clickhouse["user"],
|
|
112
|
+
password=clickhouse["password"],
|
|
113
|
+
database=clickhouse["database"],
|
|
114
|
+
secure=clickhouse["secure"],
|
|
115
|
+
timeout_seconds=clickhouse["timeout_seconds"],
|
|
116
|
+
)
|
|
117
|
+
raise ValueError(f"Unsupported serving backend '{backend_name}'.")
|