genblaze-core 0.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.
- genblaze_core/__init__.py +110 -0
- genblaze_core/_utils.py +158 -0
- genblaze_core/_version.py +1 -0
- genblaze_core/agents/__init__.py +20 -0
- genblaze_core/agents/evaluator.py +100 -0
- genblaze_core/agents/loop.py +307 -0
- genblaze_core/builders/__init__.py +6 -0
- genblaze_core/builders/run_builder.py +55 -0
- genblaze_core/builders/step_builder.py +67 -0
- genblaze_core/canonical/__init__.py +5 -0
- genblaze_core/canonical/_normalize.py +59 -0
- genblaze_core/canonical/json.py +23 -0
- genblaze_core/exceptions.py +44 -0
- genblaze_core/media/__init__.py +83 -0
- genblaze_core/media/aac.py +97 -0
- genblaze_core/media/base.py +66 -0
- genblaze_core/media/embedder.py +162 -0
- genblaze_core/media/flac.py +90 -0
- genblaze_core/media/jpeg.py +110 -0
- genblaze_core/media/mp3.py +82 -0
- genblaze_core/media/mp4.py +353 -0
- genblaze_core/media/png.py +71 -0
- genblaze_core/media/sidecar.py +124 -0
- genblaze_core/media/wav.py +173 -0
- genblaze_core/media/webp.py +78 -0
- genblaze_core/models/__init__.py +35 -0
- genblaze_core/models/asset.py +100 -0
- genblaze_core/models/enums.py +59 -0
- genblaze_core/models/manifest.py +300 -0
- genblaze_core/models/policy.py +16 -0
- genblaze_core/models/prompt_template.py +42 -0
- genblaze_core/models/run.py +41 -0
- genblaze_core/models/step.py +70 -0
- genblaze_core/observability/__init__.py +24 -0
- genblaze_core/observability/events.py +94 -0
- genblaze_core/observability/logger.py +59 -0
- genblaze_core/observability/span.py +70 -0
- genblaze_core/observability/tracer.py +297 -0
- genblaze_core/pipeline/__init__.py +7 -0
- genblaze_core/pipeline/cache.py +104 -0
- genblaze_core/pipeline/moderation.py +82 -0
- genblaze_core/pipeline/pipeline.py +1422 -0
- genblaze_core/pipeline/result.py +112 -0
- genblaze_core/pipeline/streaming.py +126 -0
- genblaze_core/pipeline/template.py +176 -0
- genblaze_core/providers/__init__.py +17 -0
- genblaze_core/providers/_ffmpeg_utils.py +112 -0
- genblaze_core/providers/base.py +635 -0
- genblaze_core/providers/compositor.py +147 -0
- genblaze_core/providers/progress.py +32 -0
- genblaze_core/providers/registry.py +32 -0
- genblaze_core/providers/transform.py +376 -0
- genblaze_core/py.typed +0 -0
- genblaze_core/runnable/__init__.py +6 -0
- genblaze_core/runnable/base.py +33 -0
- genblaze_core/runnable/config.py +24 -0
- genblaze_core/sinks/__init__.py +6 -0
- genblaze_core/sinks/base.py +43 -0
- genblaze_core/sinks/parquet.py +190 -0
- genblaze_core/storage/__init__.py +19 -0
- genblaze_core/storage/base.py +164 -0
- genblaze_core/storage/sink.py +262 -0
- genblaze_core/storage/transfer.py +549 -0
- genblaze_core/testing.py +338 -0
- genblaze_core/webhooks/__init__.py +6 -0
- genblaze_core/webhooks/notifier.py +294 -0
- genblaze_core/webhooks/sink.py +49 -0
- genblaze_core-0.1.0.dist-info/METADATA +167 -0
- genblaze_core-0.1.0.dist-info/RECORD +70 -0
- genblaze_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""genblaze-core — orchestration framework for media generation."""
|
|
2
|
+
|
|
3
|
+
from genblaze_core._version import __version__
|
|
4
|
+
|
|
5
|
+
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
|
6
|
+
# pipeline (primary entry point)
|
|
7
|
+
"Pipeline": ("genblaze_core.pipeline.pipeline", "Pipeline"),
|
|
8
|
+
"PipelineResult": ("genblaze_core.pipeline.result", "PipelineResult"),
|
|
9
|
+
"StepCompleteEvent": ("genblaze_core.pipeline.result", "StepCompleteEvent"),
|
|
10
|
+
# pipeline cache
|
|
11
|
+
"StepCache": ("genblaze_core.pipeline.cache", "StepCache"),
|
|
12
|
+
# pipeline templates
|
|
13
|
+
"PipelineTemplate": ("genblaze_core.pipeline.template", "PipelineTemplate"),
|
|
14
|
+
"StepTemplate": ("genblaze_core.pipeline.template", "StepTemplate"),
|
|
15
|
+
# moderation
|
|
16
|
+
"ModerationHook": ("genblaze_core.pipeline.moderation", "ModerationHook"),
|
|
17
|
+
"ModerationResult": ("genblaze_core.pipeline.moderation", "ModerationResult"),
|
|
18
|
+
# builders
|
|
19
|
+
"RunBuilder": ("genblaze_core.builders.run_builder", "RunBuilder"),
|
|
20
|
+
"StepBuilder": ("genblaze_core.builders.step_builder", "StepBuilder"),
|
|
21
|
+
# providers
|
|
22
|
+
"BaseProvider": ("genblaze_core.providers.base", "BaseProvider"),
|
|
23
|
+
"ProviderCapabilities": ("genblaze_core.providers.base", "ProviderCapabilities"),
|
|
24
|
+
"SubmitResult": ("genblaze_core.providers.base", "SubmitResult"),
|
|
25
|
+
"SyncProvider": ("genblaze_core.providers.base", "SyncProvider"),
|
|
26
|
+
"validate_asset_url": ("genblaze_core.providers.base", "validate_asset_url"),
|
|
27
|
+
"validate_chain_input_url": ("genblaze_core.providers.base", "validate_chain_input_url"),
|
|
28
|
+
"FFmpegCompositor": ("genblaze_core.providers.compositor", "FFmpegCompositor"),
|
|
29
|
+
"FFmpegTransform": ("genblaze_core.providers.transform", "FFmpegTransform"),
|
|
30
|
+
"ProgressEvent": ("genblaze_core.providers.progress", "ProgressEvent"),
|
|
31
|
+
# observability
|
|
32
|
+
"StreamEvent": ("genblaze_core.observability.events", "StreamEvent"),
|
|
33
|
+
"Tracer": ("genblaze_core.observability.tracer", "Tracer"),
|
|
34
|
+
"NoOpTracer": ("genblaze_core.observability.tracer", "NoOpTracer"),
|
|
35
|
+
"LoggingTracer": ("genblaze_core.observability.tracer", "LoggingTracer"),
|
|
36
|
+
"OTelTracer": ("genblaze_core.observability.tracer", "OTelTracer"),
|
|
37
|
+
"CompositeTracer": ("genblaze_core.observability.tracer", "CompositeTracer"),
|
|
38
|
+
"StructuredLogger": ("genblaze_core.observability.logger", "StructuredLogger"),
|
|
39
|
+
# agents
|
|
40
|
+
"AgentLoop": ("genblaze_core.agents.loop", "AgentLoop"),
|
|
41
|
+
"AgentContext": ("genblaze_core.agents.loop", "AgentContext"),
|
|
42
|
+
"AgentIteration": ("genblaze_core.agents.loop", "AgentIteration"),
|
|
43
|
+
"AgentResult": ("genblaze_core.agents.loop", "AgentResult"),
|
|
44
|
+
"Evaluator": ("genblaze_core.agents.evaluator", "Evaluator"),
|
|
45
|
+
"EvaluationResult": ("genblaze_core.agents.evaluator", "EvaluationResult"),
|
|
46
|
+
"CallableEvaluator": ("genblaze_core.agents.evaluator", "CallableEvaluator"),
|
|
47
|
+
"ThresholdEvaluator": ("genblaze_core.agents.evaluator", "ThresholdEvaluator"),
|
|
48
|
+
# models
|
|
49
|
+
"Manifest": ("genblaze_core.models.manifest", "Manifest"),
|
|
50
|
+
"Run": ("genblaze_core.models.run", "Run"),
|
|
51
|
+
"Step": ("genblaze_core.models.step", "Step"),
|
|
52
|
+
"Asset": ("genblaze_core.models.asset", "Asset"),
|
|
53
|
+
"AudioMetadata": ("genblaze_core.models.asset", "AudioMetadata"),
|
|
54
|
+
"Track": ("genblaze_core.models.asset", "Track"),
|
|
55
|
+
"VideoMetadata": ("genblaze_core.models.asset", "VideoMetadata"),
|
|
56
|
+
"WordTiming": ("genblaze_core.models.asset", "WordTiming"),
|
|
57
|
+
# enums
|
|
58
|
+
"Modality": ("genblaze_core.models.enums", "Modality"),
|
|
59
|
+
"StepType": ("genblaze_core.models.enums", "StepType"),
|
|
60
|
+
"RunStatus": ("genblaze_core.models.enums", "RunStatus"),
|
|
61
|
+
"StepStatus": ("genblaze_core.models.enums", "StepStatus"),
|
|
62
|
+
"PromptVisibility": ("genblaze_core.models.enums", "PromptVisibility"),
|
|
63
|
+
"ProviderErrorCode": ("genblaze_core.models.enums", "ProviderErrorCode"),
|
|
64
|
+
"EmbedPolicy": ("genblaze_core.models.policy", "EmbedPolicy"),
|
|
65
|
+
"PromptTemplate": ("genblaze_core.models.prompt_template", "PromptTemplate"),
|
|
66
|
+
# provider discovery
|
|
67
|
+
"discover_providers": ("genblaze_core.providers.registry", "discover_providers"),
|
|
68
|
+
# sinks
|
|
69
|
+
"BaseSink": ("genblaze_core.sinks.base", "BaseSink"),
|
|
70
|
+
"ParquetSink": ("genblaze_core.sinks.parquet", "ParquetSink"),
|
|
71
|
+
# storage
|
|
72
|
+
"StorageBackend": ("genblaze_core.storage.base", "StorageBackend"),
|
|
73
|
+
"ObjectStorageSink": ("genblaze_core.storage.sink", "ObjectStorageSink"),
|
|
74
|
+
"AssetTransfer": ("genblaze_core.storage.transfer", "AssetTransfer"),
|
|
75
|
+
"KeyStrategy": ("genblaze_core.storage.base", "KeyStrategy"),
|
|
76
|
+
"ObjectLockConfig": ("genblaze_core.storage.base", "ObjectLockConfig"),
|
|
77
|
+
# testing
|
|
78
|
+
"MockProvider": ("genblaze_core.testing", "MockProvider"),
|
|
79
|
+
"MockVideoProvider": ("genblaze_core.testing", "MockVideoProvider"),
|
|
80
|
+
"MockAudioProvider": ("genblaze_core.testing", "MockAudioProvider"),
|
|
81
|
+
"ProviderComplianceTests": ("genblaze_core.testing", "ProviderComplianceTests"),
|
|
82
|
+
# exceptions
|
|
83
|
+
"GenblazeError": ("genblaze_core.exceptions", "GenblazeError"),
|
|
84
|
+
"PipelineTimeoutError": ("genblaze_core.exceptions", "PipelineTimeoutError"),
|
|
85
|
+
"EmbeddingError": ("genblaze_core.exceptions", "EmbeddingError"),
|
|
86
|
+
"StorageError": ("genblaze_core.exceptions", "StorageError"),
|
|
87
|
+
"ProviderError": ("genblaze_core.exceptions", "ProviderError"),
|
|
88
|
+
"ManifestError": ("genblaze_core.exceptions", "ManifestError"),
|
|
89
|
+
"SinkError": ("genblaze_core.exceptions", "SinkError"),
|
|
90
|
+
"WebhookError": ("genblaze_core.exceptions", "WebhookError"),
|
|
91
|
+
# webhooks
|
|
92
|
+
"WebhookNotifier": ("genblaze_core.webhooks.notifier", "WebhookNotifier"),
|
|
93
|
+
"WebhookConfig": ("genblaze_core.webhooks.notifier", "WebhookConfig"),
|
|
94
|
+
"WebhookEvent": ("genblaze_core.webhooks.notifier", "WebhookEvent"),
|
|
95
|
+
"WebhookSink": ("genblaze_core.webhooks.sink", "WebhookSink"),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
__all__ = [*_LAZY_IMPORTS.keys(), "__version__"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def __getattr__(name: str):
|
|
102
|
+
if name in _LAZY_IMPORTS:
|
|
103
|
+
module_path, attr = _LAZY_IMPORTS[name]
|
|
104
|
+
import importlib
|
|
105
|
+
|
|
106
|
+
mod = importlib.import_module(module_path)
|
|
107
|
+
val = getattr(mod, attr)
|
|
108
|
+
globals()[name] = val
|
|
109
|
+
return val
|
|
110
|
+
raise AttributeError(f"module 'genblaze_core' has no attribute {name!r}")
|
genblaze_core/_utils.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Internal utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import ipaddress
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import tempfile
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Coroutine
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import urlparse as _urlparse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def new_id() -> str:
|
|
22
|
+
"""Generate a new UUID4 string."""
|
|
23
|
+
return str(uuid.uuid4())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def utc_now() -> datetime:
|
|
27
|
+
"""Return current UTC datetime."""
|
|
28
|
+
return datetime.now(UTC)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compute_sha256(data: bytes) -> str:
|
|
32
|
+
"""Compute SHA-256 hex digest of raw bytes."""
|
|
33
|
+
return hashlib.sha256(data).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run_async(coro: Coroutine) -> Any:
|
|
37
|
+
"""Run an async coroutine from sync code safely.
|
|
38
|
+
|
|
39
|
+
If there's already a running event loop (e.g. inside Jupyter or an async provider
|
|
40
|
+
called from BaseProvider.invoke), runs in a new thread to avoid RuntimeError.
|
|
41
|
+
Otherwise uses asyncio.run().
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
loop = asyncio.get_running_loop()
|
|
45
|
+
except RuntimeError:
|
|
46
|
+
loop = None
|
|
47
|
+
|
|
48
|
+
if loop is not None and loop.is_running():
|
|
49
|
+
# Already inside an event loop — run in a separate thread
|
|
50
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
51
|
+
return pool.submit(asyncio.run, coro).result()
|
|
52
|
+
return asyncio.run(coro)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# SSRF protection — shared by storage/transfer.py and webhooks/notifier.py
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
# Private/reserved IP ranges blocked to prevent SSRF
|
|
60
|
+
BLOCKED_NETWORKS = [
|
|
61
|
+
ipaddress.ip_network("0.0.0.0/8"), # "This host" — resolves to loopback on Linux
|
|
62
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
63
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
64
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
65
|
+
ipaddress.ip_network("127.0.0.0/8"),
|
|
66
|
+
ipaddress.ip_network("169.254.0.0/16"), # Link-local / IMDS
|
|
67
|
+
ipaddress.ip_network("100.64.0.0/10"), # Carrier-grade NAT
|
|
68
|
+
ipaddress.ip_network("::1/128"), # IPv6 loopback
|
|
69
|
+
ipaddress.ip_network("fc00::/7"), # IPv6 unique local
|
|
70
|
+
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Allowed parent directories for file:// inputs (shared by storage + ffmpeg providers)
|
|
75
|
+
# Deduplicate: /tmp and gettempdir() may resolve to the same or different paths
|
|
76
|
+
ALLOWED_FILE_ROOTS: tuple[Path, ...] = tuple(
|
|
77
|
+
{Path(tempfile.gettempdir()).resolve(), Path("/tmp").resolve()} # noqa: S108
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_ssrf(url: str, *, exc_type: type[Exception] = ValueError) -> None:
|
|
82
|
+
"""Reject non-HTTPS URLs and hostnames resolving to private IP ranges.
|
|
83
|
+
|
|
84
|
+
Shared SSRF guard used by storage transfers and webhook dispatch.
|
|
85
|
+
Callers pass their domain-specific exception type via ``exc_type``.
|
|
86
|
+
"""
|
|
87
|
+
parsed = _urlparse(url)
|
|
88
|
+
if parsed.scheme not in ("https",):
|
|
89
|
+
raise exc_type(f"Only HTTPS URLs are allowed, got: {parsed.scheme}://")
|
|
90
|
+
|
|
91
|
+
host = parsed.hostname or ""
|
|
92
|
+
if host.lower() == "localhost":
|
|
93
|
+
raise exc_type(f"Private/loopback URLs are not allowed: {host}")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
addrinfos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
|
97
|
+
except socket.gaierror as exc:
|
|
98
|
+
raise exc_type(f"Cannot resolve hostname: {host}") from exc
|
|
99
|
+
|
|
100
|
+
for _, _, _, _, sockaddr in addrinfos:
|
|
101
|
+
try:
|
|
102
|
+
ip = ipaddress.ip_address(str(sockaddr[0]))
|
|
103
|
+
except ValueError:
|
|
104
|
+
continue
|
|
105
|
+
if any(ip in net for net in BLOCKED_NETWORKS):
|
|
106
|
+
raise exc_type(f"Private/loopback URLs are not allowed: {host}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Manifest size cap — bounds the JSON payload accepted from disk/media
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Real manifests are O(KB). 16 MiB is generous and bounds OOM blast from
|
|
113
|
+
# malicious media or sidecars that declare absurd payload sizes.
|
|
114
|
+
MAX_MANIFEST_BYTES = 16 * 1024 * 1024
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Credential pattern detection — used by error sanitization (providers/base.py)
|
|
119
|
+
# AND by Pipeline.step build-time rejection of secret-shaped params values.
|
|
120
|
+
# Centralized here so both call sites share one regex of record.
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
_SECRET_PATTERNS = re.compile(
|
|
123
|
+
r"(r8_[A-Za-z0-9]{20,})" # Replicate tokens
|
|
124
|
+
r"|(sk-ant-[A-Za-z0-9\-]{20,})" # Anthropic API keys (before generic sk-)
|
|
125
|
+
r"|(sk-[A-Za-z0-9]{20,})" # OpenAI-style keys
|
|
126
|
+
r"|(AIza[A-Za-z0-9_\-]{30,})" # Google API keys
|
|
127
|
+
r"|(AKIA[A-Z0-9]{16})" # AWS access key IDs
|
|
128
|
+
r"|(Bearer\s+[A-Za-z0-9._\-]{20,})" # Bearer tokens
|
|
129
|
+
r"|(Token\s+[A-Za-z0-9._\-]{20,})" # Token auth headers
|
|
130
|
+
r"|(\bapi[_-]key[=:]\s*[A-Za-z0-9._\-]{20,})", # api_key=... / api-key:...
|
|
131
|
+
re.IGNORECASE,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def jittered_backoff(attempt: int) -> float:
|
|
136
|
+
"""Compute exponential backoff with jitter to avoid thundering herd.
|
|
137
|
+
|
|
138
|
+
Starts at 1s, doubles per attempt, capped at 30s base with up to 25% jitter.
|
|
139
|
+
"""
|
|
140
|
+
base = min(2**attempt, 30)
|
|
141
|
+
return base * (1 + random.uniform(0, 0.25)) # noqa: S311 — jitter, not crypto
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def probe_audio_duration(path: str | Any) -> float | None:
|
|
145
|
+
"""Try to read audio duration from a file using mutagen (optional dep).
|
|
146
|
+
|
|
147
|
+
Returns duration in seconds, or None if mutagen is not installed or
|
|
148
|
+
the file format is not recognized.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
from mutagen import File as MutagenFile
|
|
152
|
+
|
|
153
|
+
audio = MutagenFile(str(path))
|
|
154
|
+
if audio is not None and audio.info is not None:
|
|
155
|
+
return audio.info.length
|
|
156
|
+
except Exception: # noqa: S110 — mutagen is optional, fail gracefully
|
|
157
|
+
pass
|
|
158
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Agent / reasoning layer — evaluate outputs, refine prompts, retry."""
|
|
2
|
+
|
|
3
|
+
from genblaze_core.agents.evaluator import (
|
|
4
|
+
CallableEvaluator,
|
|
5
|
+
EvaluationResult,
|
|
6
|
+
Evaluator,
|
|
7
|
+
ThresholdEvaluator,
|
|
8
|
+
)
|
|
9
|
+
from genblaze_core.agents.loop import AgentContext, AgentIteration, AgentLoop, AgentResult
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AgentContext",
|
|
13
|
+
"AgentIteration",
|
|
14
|
+
"AgentLoop",
|
|
15
|
+
"AgentResult",
|
|
16
|
+
"CallableEvaluator",
|
|
17
|
+
"EvaluationResult",
|
|
18
|
+
"Evaluator",
|
|
19
|
+
"ThresholdEvaluator",
|
|
20
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Evaluator — pluggable quality-gate for pipeline outputs.
|
|
2
|
+
|
|
3
|
+
An ``Evaluator`` inspects a :class:`PipelineResult` and returns an
|
|
4
|
+
:class:`EvaluationResult` describing whether the output meets quality
|
|
5
|
+
requirements. Evaluators can be simple threshold checks, LLM judges,
|
|
6
|
+
vision-model classifiers, or composed chains.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from genblaze_core.pipeline.result import PipelineResult
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class EvaluationResult:
|
|
24
|
+
"""Outcome of evaluating a pipeline result.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
passed: Whether the output meets quality requirements.
|
|
28
|
+
score: Optional numeric score (typically 0.0–1.0). None if not applicable.
|
|
29
|
+
feedback: Human-readable explanation to feed back into a refinement step.
|
|
30
|
+
metadata: Arbitrary structured data (e.g. per-dimension scores, detected issues).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
passed: bool
|
|
34
|
+
score: float | None = None
|
|
35
|
+
feedback: str | None = None
|
|
36
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Evaluator(ABC):
|
|
40
|
+
"""Abstract evaluator. Subclasses implement at least ``evaluate``."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def evaluate(self, result: PipelineResult) -> EvaluationResult:
|
|
44
|
+
"""Judge a completed pipeline result. Must return an EvaluationResult."""
|
|
45
|
+
|
|
46
|
+
async def aevaluate(self, result: PipelineResult) -> EvaluationResult:
|
|
47
|
+
"""Async variant. Default runs ``evaluate`` in a thread."""
|
|
48
|
+
return await asyncio.to_thread(self.evaluate, result)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CallableEvaluator(Evaluator):
|
|
52
|
+
"""Wraps a plain callable as an :class:`Evaluator`.
|
|
53
|
+
|
|
54
|
+
The callable receives the :class:`PipelineResult` and returns either
|
|
55
|
+
an :class:`EvaluationResult` or a bool (passed/not).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
fn: Callable[[PipelineResult], EvaluationResult | bool],
|
|
61
|
+
) -> None:
|
|
62
|
+
self._fn = fn
|
|
63
|
+
|
|
64
|
+
def evaluate(self, result: PipelineResult) -> EvaluationResult:
|
|
65
|
+
out = self._fn(result)
|
|
66
|
+
if isinstance(out, EvaluationResult):
|
|
67
|
+
return out
|
|
68
|
+
return EvaluationResult(passed=bool(out))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ThresholdEvaluator(Evaluator):
|
|
72
|
+
"""Pass/fail based on a numeric score crossing a threshold.
|
|
73
|
+
|
|
74
|
+
``score_fn`` receives the :class:`PipelineResult` and returns a float.
|
|
75
|
+
Useful for wrapping a vision or text quality model that emits a score.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
score_fn: Callable[[PipelineResult], float],
|
|
81
|
+
threshold: float,
|
|
82
|
+
*,
|
|
83
|
+
higher_is_better: bool = True,
|
|
84
|
+
feedback_fn: Callable[[PipelineResult, float], str] | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._score_fn = score_fn
|
|
87
|
+
self._threshold = threshold
|
|
88
|
+
self._higher_is_better = higher_is_better
|
|
89
|
+
self._feedback_fn = feedback_fn
|
|
90
|
+
|
|
91
|
+
def evaluate(self, result: PipelineResult) -> EvaluationResult:
|
|
92
|
+
score = float(self._score_fn(result))
|
|
93
|
+
passed = score >= self._threshold if self._higher_is_better else score <= self._threshold
|
|
94
|
+
feedback = self._feedback_fn(result, score) if self._feedback_fn else None
|
|
95
|
+
return EvaluationResult(
|
|
96
|
+
passed=passed,
|
|
97
|
+
score=score,
|
|
98
|
+
feedback=feedback,
|
|
99
|
+
metadata={"threshold": self._threshold, "higher_is_better": self._higher_is_better},
|
|
100
|
+
)
|