dystopic 0.6.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.
- dystopic/__init__.py +65 -0
- dystopic/_logging.py +32 -0
- dystopic/_secret_store.py +102 -0
- dystopic/_transport.py +175 -0
- dystopic/_version.py +3 -0
- dystopic/artifacts.py +250 -0
- dystopic/async_client.py +1096 -0
- dystopic/cli/__init__.py +27 -0
- dystopic/cli/_agent_payload.py +374 -0
- dystopic/cli/_agent_wizard.py +367 -0
- dystopic/cli/_agents_cmd.py +457 -0
- dystopic/cli/_auth_login_browser.py +170 -0
- dystopic/cli/_ci_init_cmd.py +271 -0
- dystopic/cli/_ci_manage_cmd.py +275 -0
- dystopic/cli/_ci_report.py +583 -0
- dystopic/cli/_ci_review_cmd.py +1665 -0
- dystopic/cli/_cli_extra.py +29 -0
- dystopic/cli/_constants.py +7 -0
- dystopic/cli/_dystopic_yaml.py +176 -0
- dystopic/cli/_ephemeral.py +57 -0
- dystopic/cli/_gate_norm.py +52 -0
- dystopic/cli/_gha.py +127 -0
- dystopic/cli/_junit.py +67 -0
- dystopic/cli/_mcp_shim_entry.py +23 -0
- dystopic/cli/_odyssey_cli.py +174 -0
- dystopic/cli/_odyssey_group.py +31 -0
- dystopic/cli/_progress.py +101 -0
- dystopic/cli/_progress_rich.py +59 -0
- dystopic/cli/_project_config.py +499 -0
- dystopic/cli/_repo_cmd.py +159 -0
- dystopic/cli/_runs_cmd.py +47 -0
- dystopic/cli/_runs_export.py +60 -0
- dystopic/cli/_scenarios_cmd.py +137 -0
- dystopic/cli/_scorer_merge.py +101 -0
- dystopic/cli/_seed_csv.py +40 -0
- dystopic/cli/_seeds_cmd.py +108 -0
- dystopic/cli/_suites_cmd.py +114 -0
- dystopic/cli/_test_cmd.py +279 -0
- dystopic/cli/_test_review_cmd.py +389 -0
- dystopic/cli/main.py +2061 -0
- dystopic/client.py +2255 -0
- dystopic/config.py +161 -0
- dystopic/errors.py +89 -0
- dystopic/odyssey/__init__.py +218 -0
- dystopic/odyssey/_apierrors.py +99 -0
- dystopic/odyssey/_constants.py +31 -0
- dystopic/odyssey/_debug.py +203 -0
- dystopic/odyssey/adapters/__init__.py +26 -0
- dystopic/odyssey/adapters/_actor.py +116 -0
- dystopic/odyssey/adapters/_fidelity.py +29 -0
- dystopic/odyssey/adapters/anthropic.py +377 -0
- dystopic/odyssey/adapters/autogen.py +18 -0
- dystopic/odyssey/adapters/crewai.py +18 -0
- dystopic/odyssey/adapters/langchain.py +964 -0
- dystopic/odyssey/adapters/metagpt.py +18 -0
- dystopic/odyssey/adapters/openai_agents.py +945 -0
- dystopic/odyssey/adapters/strands.py +328 -0
- dystopic/odyssey/auth.py +86 -0
- dystopic/odyssey/cli/__init__.py +25 -0
- dystopic/odyssey/cli/_creds.py +23 -0
- dystopic/odyssey/cli/_templates.py +753 -0
- dystopic/odyssey/cli/agents.py +288 -0
- dystopic/odyssey/cli/dev.py +892 -0
- dystopic/odyssey/cli/doctor.py +789 -0
- dystopic/odyssey/cli/dump_agent.py +297 -0
- dystopic/odyssey/cli/init.py +214 -0
- dystopic/odyssey/cli/main.py +227 -0
- dystopic/odyssey/cli/mcp.py +202 -0
- dystopic/odyssey/cli/tunnels.py +445 -0
- dystopic/odyssey/context.py +133 -0
- dystopic/odyssey/envelope.py +463 -0
- dystopic/odyssey/fidelity.py +185 -0
- dystopic/odyssey/handler.py +383 -0
- dystopic/odyssey/headers.py +25 -0
- dystopic/odyssey/labels.py +79 -0
- dystopic/odyssey/lifecycle.py +111 -0
- dystopic/odyssey/mcp.py +150 -0
- dystopic/odyssey/mcp_local.py +65 -0
- dystopic/odyssey/mcp_rewrite.py +135 -0
- dystopic/odyssey/mcp_shim.py +476 -0
- dystopic/odyssey/mcp_stdio.py +194 -0
- dystopic/odyssey/proxy.py +911 -0
- dystopic/odyssey/reachability.py +69 -0
- dystopic/odyssey/registration.py +1468 -0
- dystopic/odyssey/response.py +143 -0
- dystopic/odyssey/tool_endpoints.py +783 -0
- dystopic/odyssey/tools.py +132 -0
- dystopic/odyssey/topology.py +136 -0
- dystopic/odyssey/traces.py +632 -0
- dystopic/odyssey/version_diff.py +13 -0
- dystopic/py.typed +0 -0
- dystopic/types.py +230 -0
- dystopic/version_diff.py +747 -0
- dystopic-0.6.0.dist-info/METADATA +567 -0
- dystopic-0.6.0.dist-info/RECORD +98 -0
- dystopic-0.6.0.dist-info/WHEEL +4 -0
- dystopic-0.6.0.dist-info/entry_points.txt +4 -0
- dystopic-0.6.0.dist-info/licenses/LICENSE +13 -0
dystopic/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from ._logging import enable_debug_logging, get_logger
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .artifacts import (
|
|
4
|
+
ARTIFACT_BUNDLE_TEMPLATE_KINDS,
|
|
5
|
+
ARTIFACT_BUNDLE_TYPE,
|
|
6
|
+
ArtifactValidationError,
|
|
7
|
+
artifact_bundle_template,
|
|
8
|
+
load_artifact_bundle,
|
|
9
|
+
validate_artifact_bundle,
|
|
10
|
+
)
|
|
11
|
+
from .async_client import AsyncDystopicClient
|
|
12
|
+
from .client import DEFAULT_BASE_URL, DystopicClient
|
|
13
|
+
from .config import (
|
|
14
|
+
clear_config,
|
|
15
|
+
config_path,
|
|
16
|
+
load_config,
|
|
17
|
+
resolve_credentials,
|
|
18
|
+
save_config,
|
|
19
|
+
unset_config_key,
|
|
20
|
+
)
|
|
21
|
+
from .errors import (
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
DystopicAPIError,
|
|
25
|
+
DystopicConnectionError,
|
|
26
|
+
DystopicError,
|
|
27
|
+
DystopicTimeoutError,
|
|
28
|
+
ForbiddenError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
ServerError,
|
|
32
|
+
ValidationError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ARTIFACT_BUNDLE_TYPE",
|
|
37
|
+
"ARTIFACT_BUNDLE_TEMPLATE_KINDS",
|
|
38
|
+
"ArtifactValidationError",
|
|
39
|
+
"AsyncDystopicClient",
|
|
40
|
+
"AuthenticationError",
|
|
41
|
+
"ConflictError",
|
|
42
|
+
"DEFAULT_BASE_URL",
|
|
43
|
+
"ForbiddenError",
|
|
44
|
+
"NotFoundError",
|
|
45
|
+
"DystopicAPIError",
|
|
46
|
+
"DystopicConnectionError",
|
|
47
|
+
"DystopicClient",
|
|
48
|
+
"DystopicError",
|
|
49
|
+
"DystopicTimeoutError",
|
|
50
|
+
"RateLimitError",
|
|
51
|
+
"ServerError",
|
|
52
|
+
"ValidationError",
|
|
53
|
+
"__version__",
|
|
54
|
+
"artifact_bundle_template",
|
|
55
|
+
"clear_config",
|
|
56
|
+
"config_path",
|
|
57
|
+
"enable_debug_logging",
|
|
58
|
+
"get_logger",
|
|
59
|
+
"load_artifact_bundle",
|
|
60
|
+
"load_config",
|
|
61
|
+
"resolve_credentials",
|
|
62
|
+
"save_config",
|
|
63
|
+
"unset_config_key",
|
|
64
|
+
"validate_artifact_bundle",
|
|
65
|
+
]
|
dystopic/_logging.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
_LOGGER_NAME = "dystopic"
|
|
7
|
+
_DEBUG_ENV_VAR = "DYSTOPIC_DEBUG"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_logger() -> logging.Logger:
|
|
11
|
+
"""Return the SDK's root logger.
|
|
12
|
+
|
|
13
|
+
Library callers can configure handlers/level via standard ``logging``
|
|
14
|
+
APIs. By default the logger has a NullHandler so importing the SDK
|
|
15
|
+
does not produce log output unless the caller opts in.
|
|
16
|
+
"""
|
|
17
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
18
|
+
if not logger.handlers:
|
|
19
|
+
logger.addHandler(logging.NullHandler())
|
|
20
|
+
if os.getenv(_DEBUG_ENV_VAR) and logger.level == logging.NOTSET:
|
|
21
|
+
logger.setLevel(logging.DEBUG)
|
|
22
|
+
return logger
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def enable_debug_logging() -> None:
|
|
26
|
+
"""Convenience helper for CLI/--verbose: stream DEBUG records to stderr."""
|
|
27
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
28
|
+
logger.setLevel(logging.DEBUG)
|
|
29
|
+
if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
|
|
30
|
+
handler = logging.StreamHandler()
|
|
31
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s"))
|
|
32
|
+
logger.addHandler(handler)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""OS-keyring-backed storage for the Dystopic API key.
|
|
2
|
+
|
|
3
|
+
The only secret we persist is the API key. This module owns every interaction
|
|
4
|
+
with the ``keyring`` library plus the decision to fall back to the plaintext
|
|
5
|
+
``0600`` config file when no usable keychain exists. ``keyring`` is imported
|
|
6
|
+
lazily and every call is guarded so an SDK-only consumer (or a headless box)
|
|
7
|
+
degrades to the file rather than crashing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
15
|
+
|
|
16
|
+
__all__ = ["SERVICE", "account_for", "get_secret", "set_secret", "delete_secret"]
|
|
17
|
+
|
|
18
|
+
SERVICE = "dystopic"
|
|
19
|
+
|
|
20
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def account_for(base_url: str) -> str:
|
|
24
|
+
"""Canonical keyring account for a base URL.
|
|
25
|
+
|
|
26
|
+
Must be byte-identical on the read and write paths, so we lowercase the
|
|
27
|
+
scheme+host, drop a default port, and strip a trailing slash. A scheme-less
|
|
28
|
+
input (e.g. ``localhost:8000`` or ``api.pipelines.tech``) is treated as
|
|
29
|
+
``https://…`` so ``urlsplit`` populates the host instead of mis-parsing it.
|
|
30
|
+
"""
|
|
31
|
+
raw = base_url.strip()
|
|
32
|
+
if "://" not in raw:
|
|
33
|
+
raw = "https://" + raw
|
|
34
|
+
parts = urlsplit(raw)
|
|
35
|
+
scheme = (parts.scheme or "https").lower()
|
|
36
|
+
host = (parts.hostname or "").lower()
|
|
37
|
+
port = parts.port
|
|
38
|
+
netloc = host if port in (None, _DEFAULT_PORTS.get(scheme)) else f"{host}:{port}"
|
|
39
|
+
path = parts.path.rstrip("/")
|
|
40
|
+
return urlunsplit((scheme, netloc, path, "", ""))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _force_disabled() -> bool:
|
|
44
|
+
"""True when we must not touch the keychain (opt-out / CI / headless Linux)."""
|
|
45
|
+
if os.getenv("DYSTOPIC_DISABLE_KEYRING"):
|
|
46
|
+
return True
|
|
47
|
+
if os.getenv("CI"):
|
|
48
|
+
return True
|
|
49
|
+
if sys.platform.startswith("linux") and not os.getenv("DBUS_SESSION_BUS_ADDRESS"):
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_secret(account: str) -> str | None:
|
|
55
|
+
"""Return the stored secret, or None on miss / no usable backend."""
|
|
56
|
+
if _force_disabled():
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
import keyring
|
|
60
|
+
|
|
61
|
+
return keyring.get_password(SERVICE, account)
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def set_secret(account: str, secret: str) -> bool:
|
|
67
|
+
"""Store ``secret`` and verify it by reading back.
|
|
68
|
+
|
|
69
|
+
Returns True ONLY when the value is confirmed present in the keychain — the
|
|
70
|
+
caller relies on this before deleting any plaintext copy. Best-effort cleanup
|
|
71
|
+
on a partial/failed write.
|
|
72
|
+
"""
|
|
73
|
+
if _force_disabled():
|
|
74
|
+
return False
|
|
75
|
+
try:
|
|
76
|
+
import keyring
|
|
77
|
+
except Exception:
|
|
78
|
+
return False
|
|
79
|
+
try:
|
|
80
|
+
keyring.set_password(SERVICE, account, secret)
|
|
81
|
+
stored = keyring.get_password(SERVICE, account)
|
|
82
|
+
except Exception:
|
|
83
|
+
return False
|
|
84
|
+
if stored != secret:
|
|
85
|
+
try:
|
|
86
|
+
keyring.delete_password(SERVICE, account)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
return False
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def delete_secret(account: str) -> None:
|
|
94
|
+
"""Best-effort removal of the stored secret. Never raises."""
|
|
95
|
+
if _force_disabled():
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
import keyring
|
|
99
|
+
|
|
100
|
+
keyring.delete_password(SERVICE, account)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
dystopic/_transport.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Shared HTTP transport helpers used by both the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._logging import get_logger
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .config import DEFAULT_BASE_URL
|
|
15
|
+
from .errors import (
|
|
16
|
+
DystopicAPIError,
|
|
17
|
+
DystopicError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
error_for_status,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_LOGGER = get_logger()
|
|
23
|
+
|
|
24
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
|
|
25
|
+
SAFE_RETRY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
26
|
+
TERMINAL_STATUSES = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"completed",
|
|
29
|
+
"failed",
|
|
30
|
+
"cancelled",
|
|
31
|
+
"partially_cancelled",
|
|
32
|
+
"completed_with_partial_results",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_user_agent() -> str:
|
|
38
|
+
py_impl = platform.python_implementation()
|
|
39
|
+
py_version = "{}.{}.{}".format(*sys.version_info[:3])
|
|
40
|
+
return f"dystopic/{__version__} {py_impl}/{py_version} httpx/{httpx.__version__}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
USER_AGENT = build_user_agent()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_base_url(base_url: str) -> str:
|
|
47
|
+
return base_url.rstrip("/")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def url_for(base_url: str, path: str) -> str:
|
|
51
|
+
normalized = path if path.startswith("/") else f"/{path}"
|
|
52
|
+
return f"{base_url}{normalized}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_headers(
|
|
56
|
+
api_key: str | None,
|
|
57
|
+
*,
|
|
58
|
+
accept: str = "application/json",
|
|
59
|
+
idempotency_key: str | None = None,
|
|
60
|
+
extra: Mapping[str, str] | None = None,
|
|
61
|
+
) -> dict[str, str]:
|
|
62
|
+
headers: dict[str, str] = {
|
|
63
|
+
"Accept": accept,
|
|
64
|
+
"User-Agent": USER_AGENT,
|
|
65
|
+
}
|
|
66
|
+
if api_key:
|
|
67
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
68
|
+
if idempotency_key:
|
|
69
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
70
|
+
if extra:
|
|
71
|
+
headers.update(extra)
|
|
72
|
+
return headers
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def should_retry(
|
|
76
|
+
method: str,
|
|
77
|
+
response: httpx.Response,
|
|
78
|
+
attempt: int,
|
|
79
|
+
max_retries: int,
|
|
80
|
+
has_idempotency_key: bool,
|
|
81
|
+
) -> bool:
|
|
82
|
+
if attempt >= max_retries:
|
|
83
|
+
return False
|
|
84
|
+
if response.status_code not in RETRYABLE_STATUS_CODES:
|
|
85
|
+
return False
|
|
86
|
+
return method.upper() in SAFE_RETRY_METHODS or has_idempotency_key
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def retry_delay(response: httpx.Response, attempt: int) -> float:
|
|
90
|
+
retry_after = response.headers.get("Retry-After")
|
|
91
|
+
if retry_after:
|
|
92
|
+
try:
|
|
93
|
+
return max(0.0, float(retry_after))
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
return float(min(2**attempt, 30))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_retry_after(response: httpx.Response) -> float | None:
|
|
100
|
+
retry_after = response.headers.get("Retry-After")
|
|
101
|
+
if retry_after is None:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
return max(0.0, float(retry_after))
|
|
105
|
+
except ValueError:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def raise_api_error(response: httpx.Response) -> None:
|
|
110
|
+
"""Pick the most specific DystopicAPIError subclass for ``response``."""
|
|
111
|
+
try:
|
|
112
|
+
body: Any = response.json()
|
|
113
|
+
except ValueError:
|
|
114
|
+
body = response.text
|
|
115
|
+
|
|
116
|
+
error_cls = error_for_status(response.status_code)
|
|
117
|
+
message = error_message(body)
|
|
118
|
+
|
|
119
|
+
_LOGGER.debug(
|
|
120
|
+
"dystopic api error: %s %s -> %s %s",
|
|
121
|
+
response.request.method if response.request else "?",
|
|
122
|
+
response.request.url if response.request else "?",
|
|
123
|
+
response.status_code,
|
|
124
|
+
message,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if error_cls is RateLimitError:
|
|
128
|
+
raise RateLimitError(
|
|
129
|
+
response.status_code,
|
|
130
|
+
message,
|
|
131
|
+
body=body,
|
|
132
|
+
retry_after=parse_retry_after(response),
|
|
133
|
+
)
|
|
134
|
+
raise error_cls(response.status_code, message, body=body)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def error_message(body: Any) -> str:
|
|
138
|
+
if not isinstance(body, dict):
|
|
139
|
+
return str(body)
|
|
140
|
+
|
|
141
|
+
detail = body.get("detail", body)
|
|
142
|
+
if isinstance(detail, str):
|
|
143
|
+
return detail
|
|
144
|
+
if isinstance(detail, list):
|
|
145
|
+
formatted_errors = []
|
|
146
|
+
for item in detail:
|
|
147
|
+
if not isinstance(item, dict):
|
|
148
|
+
formatted_errors.append(str(item))
|
|
149
|
+
continue
|
|
150
|
+
loc = item.get("loc")
|
|
151
|
+
location = ".".join(str(part) for part in loc) if isinstance(loc, list) else None
|
|
152
|
+
msg = item.get("msg", item)
|
|
153
|
+
formatted_errors.append(f"{location}: {msg}" if location else str(msg))
|
|
154
|
+
return "; ".join(formatted_errors)
|
|
155
|
+
return str(detail)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = [
|
|
159
|
+
"DEFAULT_BASE_URL",
|
|
160
|
+
"DystopicAPIError",
|
|
161
|
+
"DystopicError",
|
|
162
|
+
"RETRYABLE_STATUS_CODES",
|
|
163
|
+
"SAFE_RETRY_METHODS",
|
|
164
|
+
"TERMINAL_STATUSES",
|
|
165
|
+
"USER_AGENT",
|
|
166
|
+
"build_headers",
|
|
167
|
+
"build_user_agent",
|
|
168
|
+
"error_message",
|
|
169
|
+
"normalize_base_url",
|
|
170
|
+
"parse_retry_after",
|
|
171
|
+
"raise_api_error",
|
|
172
|
+
"retry_delay",
|
|
173
|
+
"should_retry",
|
|
174
|
+
"url_for",
|
|
175
|
+
]
|
dystopic/_version.py
ADDED
dystopic/artifacts.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import DystopicError
|
|
9
|
+
|
|
10
|
+
ARTIFACT_BUNDLE_TYPE = "dystopic.artifact.bundle.v1"
|
|
11
|
+
ARTIFACT_BUNDLE_TEMPLATE_KINDS = {
|
|
12
|
+
"bundle",
|
|
13
|
+
"prompts",
|
|
14
|
+
"criteria",
|
|
15
|
+
"evaluations",
|
|
16
|
+
"tool-endpoints",
|
|
17
|
+
"tool-bindings",
|
|
18
|
+
"tool-ground-truths",
|
|
19
|
+
}
|
|
20
|
+
ARTIFACT_BUNDLE_SECTIONS = (
|
|
21
|
+
"prompts",
|
|
22
|
+
"criteria",
|
|
23
|
+
"evaluations",
|
|
24
|
+
"experiments",
|
|
25
|
+
"tool_endpoints",
|
|
26
|
+
"tool_bindings",
|
|
27
|
+
"tool_ground_truths",
|
|
28
|
+
)
|
|
29
|
+
TEMPLATE_ORG_ID = 2
|
|
30
|
+
TEMPLATE_WORKFLOW_ID = 3086
|
|
31
|
+
TEMPLATE_CRITERION_IDS = {
|
|
32
|
+
"exact_match": 7,
|
|
33
|
+
"contains_keywords": 8,
|
|
34
|
+
"safety": 9,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ArtifactValidationError(DystopicError):
|
|
39
|
+
"""Raised when an artifact manifest cannot be imported safely."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def artifact_bundle_template(kind: str = "prompts") -> dict[str, Any]:
|
|
43
|
+
if kind not in ARTIFACT_BUNDLE_TEMPLATE_KINDS:
|
|
44
|
+
raise ArtifactValidationError(
|
|
45
|
+
"Artifact bundle template kind must be one of: "
|
|
46
|
+
f"{sorted(ARTIFACT_BUNDLE_TEMPLATE_KINDS)}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Keep templates focused: one bundle should normally import one artifact family.
|
|
50
|
+
normalized_kind = "prompts" if kind == "bundle" else kind
|
|
51
|
+
bundle = {
|
|
52
|
+
"artifact_type": ARTIFACT_BUNDLE_TYPE,
|
|
53
|
+
"name": f"demo-{normalized_kind}-bundle",
|
|
54
|
+
"description": f"Portable {normalized_kind} configs that can be versioned in git.",
|
|
55
|
+
"prompts": [],
|
|
56
|
+
"criteria": [],
|
|
57
|
+
"evaluations": [],
|
|
58
|
+
"experiments": [],
|
|
59
|
+
"tool_endpoints": [],
|
|
60
|
+
"tool_bindings": [],
|
|
61
|
+
"tool_ground_truths": [],
|
|
62
|
+
}
|
|
63
|
+
if normalized_kind == "prompts":
|
|
64
|
+
bundle["prompts"] = [
|
|
65
|
+
{
|
|
66
|
+
"name": "Concise demo prompt",
|
|
67
|
+
"description": "Created from a Dystopic artifact bundle",
|
|
68
|
+
"prompt_text": "Write a concise answer for {{input}}.",
|
|
69
|
+
"prompt_placeholders": [{"name": "input"}],
|
|
70
|
+
"role": "user",
|
|
71
|
+
"scope_type": "org",
|
|
72
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "Detailed demo prompt",
|
|
76
|
+
"description": "Created from a Dystopic artifact bundle",
|
|
77
|
+
"prompt_text": "Write a detailed answer for {{input}} with examples.",
|
|
78
|
+
"prompt_placeholders": [{"name": "input"}],
|
|
79
|
+
"role": "user",
|
|
80
|
+
"scope_type": "org",
|
|
81
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
elif normalized_kind == "criteria":
|
|
85
|
+
bundle["criteria"] = [
|
|
86
|
+
{
|
|
87
|
+
"name": "Exact Match",
|
|
88
|
+
"display_label": "Exact Match",
|
|
89
|
+
"type": "programmatic",
|
|
90
|
+
"config": {"subtype": "exact_match"},
|
|
91
|
+
"output_schema": {"type": "boolean"},
|
|
92
|
+
"scope_type": "org",
|
|
93
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"name": "Contains Keywords",
|
|
97
|
+
"display_label": "Contains Keywords",
|
|
98
|
+
"type": "programmatic",
|
|
99
|
+
"config": {
|
|
100
|
+
"subtype": "contains_keywords",
|
|
101
|
+
"keywords": ["required phrase", "important keyword"],
|
|
102
|
+
"mode": "any",
|
|
103
|
+
"case_sensitive": False,
|
|
104
|
+
},
|
|
105
|
+
"output_schema": {"type": "boolean"},
|
|
106
|
+
"scope_type": "org",
|
|
107
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
elif normalized_kind == "evaluations":
|
|
111
|
+
bundle["evaluations"] = [
|
|
112
|
+
{
|
|
113
|
+
"name": "Quality evaluation",
|
|
114
|
+
"description": "Created from a Dystopic artifact bundle",
|
|
115
|
+
"criteria": [
|
|
116
|
+
{"criterion_id": TEMPLATE_CRITERION_IDS["exact_match"]},
|
|
117
|
+
{"criterion_id": TEMPLATE_CRITERION_IDS["contains_keywords"]},
|
|
118
|
+
],
|
|
119
|
+
"scope_type": "org",
|
|
120
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"name": "Safety evaluation",
|
|
124
|
+
"description": "Created from a Dystopic artifact bundle",
|
|
125
|
+
"criteria": [{"criterion_id": TEMPLATE_CRITERION_IDS["safety"]}],
|
|
126
|
+
"scope_type": "org",
|
|
127
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
128
|
+
},
|
|
129
|
+
]
|
|
130
|
+
elif normalized_kind == "tool-endpoints":
|
|
131
|
+
bundle["tool_endpoints"] = [
|
|
132
|
+
{
|
|
133
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
134
|
+
"name": "Docs MCP endpoint",
|
|
135
|
+
"description": "MCP server that exposes documentation search tools",
|
|
136
|
+
"endpoint_type": "mcp_server",
|
|
137
|
+
"url": "https://tools.example.com/mcp",
|
|
138
|
+
"auth_type": "none",
|
|
139
|
+
"auth_config": {},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
143
|
+
"name": "Support MCP endpoint",
|
|
144
|
+
"description": "MCP server that exposes support lookup tools",
|
|
145
|
+
"endpoint_type": "mcp_server",
|
|
146
|
+
"url": "https://support-tools.example.com/mcp",
|
|
147
|
+
"auth_type": "none",
|
|
148
|
+
"auth_config": {},
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
elif normalized_kind == "tool-bindings":
|
|
152
|
+
bundle["tool_bindings"] = [
|
|
153
|
+
{
|
|
154
|
+
"workflow_id": TEMPLATE_WORKFLOW_ID,
|
|
155
|
+
"node_id": "replace-with-node-id",
|
|
156
|
+
"field_id": "replace-with-field-id",
|
|
157
|
+
"endpoint_id": "replace-with-tool-endpoint-id",
|
|
158
|
+
"selected_tools": ["search_docs"],
|
|
159
|
+
"max_tool_rounds": 3,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"workflow_id": TEMPLATE_WORKFLOW_ID,
|
|
163
|
+
"node_id": "replace-with-node-id",
|
|
164
|
+
"field_id": "replace-with-second-field-id",
|
|
165
|
+
"endpoint_id": "replace-with-tool-endpoint-id",
|
|
166
|
+
"selected_tools": ["fetch_url"],
|
|
167
|
+
"max_tool_rounds": 2,
|
|
168
|
+
},
|
|
169
|
+
]
|
|
170
|
+
elif normalized_kind == "tool-ground-truths":
|
|
171
|
+
bundle["tool_ground_truths"] = [
|
|
172
|
+
{
|
|
173
|
+
"workflow_id": TEMPLATE_WORKFLOW_ID,
|
|
174
|
+
"node_id": "replace-with-node-id",
|
|
175
|
+
"field_id": "replace-with-field-id",
|
|
176
|
+
"expected_tools": [{"name": "search_docs"}],
|
|
177
|
+
"excluded_tools": [],
|
|
178
|
+
"ordering_matters": False,
|
|
179
|
+
"source": "manual",
|
|
180
|
+
"confidence": 1.0,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"workflow_id": TEMPLATE_WORKFLOW_ID,
|
|
184
|
+
"node_id": "replace-with-node-id",
|
|
185
|
+
"field_id": "replace-with-second-field-id",
|
|
186
|
+
"expected_tools": [{"name": "fetch_url"}],
|
|
187
|
+
"excluded_tools": [{"name": "delete_record"}],
|
|
188
|
+
"ordering_matters": True,
|
|
189
|
+
"source": "manual",
|
|
190
|
+
"confidence": 0.9,
|
|
191
|
+
},
|
|
192
|
+
]
|
|
193
|
+
return bundle
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_artifact_bundle(path: str | Path) -> dict[str, Any]:
|
|
197
|
+
with Path(path).open(encoding="utf-8") as handle:
|
|
198
|
+
payload = json.load(handle)
|
|
199
|
+
if not isinstance(payload, dict):
|
|
200
|
+
raise ArtifactValidationError("Artifact bundle file must contain a JSON object")
|
|
201
|
+
return validate_artifact_bundle(payload)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def validate_artifact_bundle(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
205
|
+
if payload.get("artifact_type") != ARTIFACT_BUNDLE_TYPE:
|
|
206
|
+
raise ArtifactValidationError(
|
|
207
|
+
f"Artifact bundle must include artifact_type='{ARTIFACT_BUNDLE_TYPE}'"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
bundle = dict(payload)
|
|
211
|
+
for section in ARTIFACT_BUNDLE_SECTIONS:
|
|
212
|
+
value = bundle.get(section, [])
|
|
213
|
+
if not isinstance(value, list):
|
|
214
|
+
raise ArtifactValidationError(f"Artifact bundle field '{section}' must be a list")
|
|
215
|
+
for index, item in enumerate(value):
|
|
216
|
+
if not isinstance(item, dict):
|
|
217
|
+
raise ArtifactValidationError(
|
|
218
|
+
f"Artifact bundle field '{section}[{index}]' must be a JSON object"
|
|
219
|
+
)
|
|
220
|
+
bundle[section] = value
|
|
221
|
+
|
|
222
|
+
for index, experiment in enumerate(bundle["experiments"]):
|
|
223
|
+
_require_experiment_field(experiment, index, "project_id")
|
|
224
|
+
_require_experiment_field(experiment, index, "workflow_id")
|
|
225
|
+
payload_value = experiment.get("payload")
|
|
226
|
+
if not isinstance(payload_value, dict):
|
|
227
|
+
raise ArtifactValidationError(
|
|
228
|
+
f"Artifact bundle experiment[{index}] must include a JSON object payload"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
for index, endpoint in enumerate(bundle["tool_endpoints"]):
|
|
232
|
+
_require_artifact_field(endpoint, "tool_endpoints", index, "org_id")
|
|
233
|
+
for index, binding in enumerate(bundle["tool_bindings"]):
|
|
234
|
+
_require_artifact_field(binding, "tool_bindings", index, "workflow_id")
|
|
235
|
+
for index, ground_truth in enumerate(bundle["tool_ground_truths"]):
|
|
236
|
+
_require_artifact_field(ground_truth, "tool_ground_truths", index, "workflow_id")
|
|
237
|
+
|
|
238
|
+
return bundle
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _require_experiment_field(experiment: Mapping[str, Any], index: int, field: str) -> None:
|
|
242
|
+
if experiment.get(field) is None:
|
|
243
|
+
raise ArtifactValidationError(f"Artifact bundle experiment[{index}] must include '{field}'")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _require_artifact_field(
|
|
247
|
+
artifact: Mapping[str, Any], section: str, index: int, field: str
|
|
248
|
+
) -> None:
|
|
249
|
+
if artifact.get(field) is None:
|
|
250
|
+
raise ArtifactValidationError(f"{section}[{index}] must include '{field}'")
|