fusion-core 0.12.1__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.
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import logging as _stdlib_logging
4
+ from importlib.metadata import PackageNotFoundError
5
+ from importlib.metadata import version as _pkg_version
6
+
7
+ try:
8
+ __version__ = _pkg_version("fusion-core")
9
+ except PackageNotFoundError:
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ _logger = _stdlib_logging.getLogger("fusion_core")
13
+ if not _logger.handlers:
14
+ _handler = _stdlib_logging.NullHandler()
15
+ _logger.addHandler(_handler)
16
+
17
+ from fusion_core.config import ( # noqa: E402
18
+ admin_key,
19
+ clear_cache,
20
+ client_key,
21
+ default_gateway_base_url,
22
+ default_guard_sock,
23
+ default_mlx_base_url,
24
+ gateway_admin_key,
25
+ gateway_base_url,
26
+ get_env,
27
+ load_api_key,
28
+ load_dotenv,
29
+ load_settings,
30
+ profile,
31
+ resolve_api_key,
32
+ service_data_dir,
33
+ service_port,
34
+ )
35
+ from fusion_core.dsl_schema import ( # noqa: E402
36
+ SOCRATIC_DSL_V3_SCHEMA,
37
+ validate_dsl,
38
+ validate_dsl_str,
39
+ )
40
+ from fusion_core.errors import FusionError # noqa: E402
41
+ from fusion_core.guard_client import ( # noqa: E402
42
+ AllChainsVerification,
43
+ ChainVerification,
44
+ FusionGuardClient,
45
+ GuardEngineError,
46
+ GuardError,
47
+ GuardInvalidParamsError,
48
+ GuardInvalidRequestError,
49
+ GuardMethodNotFoundError,
50
+ GuardParseError,
51
+ GuardRateLimitError,
52
+ GuardRule,
53
+ GuardUnauthorizedError,
54
+ GuardVerdict,
55
+ RedactResult,
56
+ StaleEpochError,
57
+ )
58
+ from fusion_core.http_client import ( # noqa: E402
59
+ close_all,
60
+ gateway_circuit_breaker_ok,
61
+ get_async_client,
62
+ get_metrics_snapshot,
63
+ reset_metrics,
64
+ set_metrics_callback,
65
+ with_retry,
66
+ )
67
+ from fusion_core.logging import get_logger, setup_logging # noqa: E402
68
+ from fusion_core.mcp_client import ( # noqa: E402
69
+ McpToolClient,
70
+ McpToolError,
71
+ known_servers,
72
+ )
73
+ from fusion_core.mcp_registry import ( # noqa: E402
74
+ ApprovalVerdict,
75
+ McpRegistryError,
76
+ McpServerRecord,
77
+ RegistryIntegrityError,
78
+ RegistryLookupError,
79
+ check_approval,
80
+ load_registry,
81
+ resolve_server,
82
+ )
83
+ from fusion_core.mlx_client import ( # noqa: E402
84
+ EmbeddingResponse,
85
+ FusionMLXClient,
86
+ LLMResponse,
87
+ create_async_client,
88
+ )
89
+ from fusion_core.parse import ( # noqa: E402
90
+ ParseError,
91
+ parse_llm_json,
92
+ parse_llm_json_lenient,
93
+ parse_llm_json_safe,
94
+ strip_code_fence,
95
+ )
96
+ from fusion_core.proc import set_process_title # noqa: E402
97
+ from fusion_core.prompt import PromptManager # noqa: E402
98
+ from fusion_core.prompt_registry import ( # noqa: E402
99
+ PromptVersionError,
100
+ PromptVersionRecord,
101
+ PromptVersionRegistry,
102
+ SnapshotIntegrityError,
103
+ UnknownPromptVersionError,
104
+ compute_version_id,
105
+ is_valid_version_id,
106
+ )
107
+
108
+ try:
109
+ from fusion_core.tenant import ( # noqa: F401
110
+ TenantContext,
111
+ TenantContextError,
112
+ TenantMiddleware,
113
+ current,
114
+ decode_jwt_claims,
115
+ from_mapping,
116
+ has_scope,
117
+ install_tenant_middleware,
118
+ reset,
119
+ set_context,
120
+ tenant_context_from_token,
121
+ )
122
+
123
+ _tenant_available = True
124
+ except ImportError:
125
+ _tenant_available = False
126
+ _logger.debug("fusion_core.tenant unavailable (missing dep?)")
127
+
128
+ __all__ = [
129
+ "__version__",
130
+ "ParseError",
131
+ "parse_llm_json",
132
+ "parse_llm_json_lenient",
133
+ "parse_llm_json_safe",
134
+ "strip_code_fence",
135
+ "get_env",
136
+ "load_api_key",
137
+ "load_dotenv",
138
+ "FusionError",
139
+ "resolve_api_key",
140
+ "load_settings",
141
+ "clear_cache",
142
+ "client_key",
143
+ "admin_key",
144
+ "gateway_admin_key",
145
+ "gateway_base_url",
146
+ "service_port",
147
+ "service_data_dir",
148
+ "profile",
149
+ "default_mlx_base_url",
150
+ "default_gateway_base_url",
151
+ "default_guard_sock",
152
+ "get_logger",
153
+ "setup_logging",
154
+ "FusionMLXClient",
155
+ "LLMResponse",
156
+ "EmbeddingResponse",
157
+ "create_async_client",
158
+ "McpServerRecord",
159
+ "ApprovalVerdict",
160
+ "McpRegistryError",
161
+ "RegistryIntegrityError",
162
+ "RegistryLookupError",
163
+ "load_registry",
164
+ "resolve_server",
165
+ "check_approval",
166
+ "McpToolClient",
167
+ "McpToolError",
168
+ "known_servers",
169
+ "get_async_client",
170
+ "with_retry",
171
+ "gateway_circuit_breaker_ok",
172
+ "close_all",
173
+ "set_metrics_callback",
174
+ "get_metrics_snapshot",
175
+ "reset_metrics",
176
+ "PromptManager",
177
+ "set_process_title",
178
+ "PromptVersionRegistry",
179
+ "PromptVersionRecord",
180
+ "PromptVersionError",
181
+ "UnknownPromptVersionError",
182
+ "SnapshotIntegrityError",
183
+ "compute_version_id",
184
+ "is_valid_version_id",
185
+ "SOCRATIC_DSL_V3_SCHEMA",
186
+ "validate_dsl",
187
+ "validate_dsl_str",
188
+ "FusionGuardClient",
189
+ "GuardVerdict",
190
+ "GuardRule",
191
+ "RedactResult",
192
+ "ChainVerification",
193
+ "AllChainsVerification",
194
+ "GuardError",
195
+ "GuardParseError",
196
+ "GuardInvalidRequestError",
197
+ "GuardMethodNotFoundError",
198
+ "GuardInvalidParamsError",
199
+ "GuardRateLimitError",
200
+ "GuardUnauthorizedError",
201
+ "GuardEngineError",
202
+ "StaleEpochError",
203
+ ]
204
+
205
+ try:
206
+ from fusion_core.http import create_app, install_auth, standard_error_handler # noqa: F401
207
+
208
+ __all__.extend(["create_app", "install_auth", "standard_error_handler"])
209
+ except ImportError:
210
+ _logger.debug("fastapi not installed; fusion_core.http factory unavailable")
211
+
212
+ if _tenant_available:
213
+ __all__.extend(
214
+ [
215
+ "TenantContext",
216
+ "TenantContextError",
217
+ "TenantMiddleware",
218
+ "current",
219
+ "decode_jwt_claims",
220
+ "from_mapping",
221
+ "has_scope",
222
+ "install_tenant_middleware",
223
+ "reset",
224
+ "set_context",
225
+ "tenant_context_from_token",
226
+ ]
227
+ )
fusion_core/config.py ADDED
@@ -0,0 +1,291 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ DEFAULT_MLX_PORT = 11434
12
+ DEFAULT_GATEWAY_PORT = 11432
13
+
14
+ _SETTINGS_CACHE: dict[str, tuple[dict, float]] = {}
15
+
16
+ _BOOL_TRUE = {"1", "true", "yes", "on", "y", "t"}
17
+ _BOOL_FALSE = {"0", "false", "no", "off", "n", "f"}
18
+
19
+
20
+ def default_mlx_base_url() -> str:
21
+ return os.environ.get("FUSION_MLX_URL", f"http://localhost:{DEFAULT_MLX_PORT}/v1")
22
+
23
+
24
+ def default_gateway_base_url() -> str:
25
+ return os.environ.get("FUSION_GATEWAY_URL", f"http://localhost:{DEFAULT_GATEWAY_PORT}")
26
+
27
+
28
+ DEFAULT_GUARD_SOCK = "/tmp/fusion-guard.sock"
29
+
30
+
31
+ def default_guard_sock() -> str:
32
+ return os.environ.get("FUSION_GUARD_SOCK", DEFAULT_GUARD_SOCK)
33
+
34
+
35
+ def get_env(key: str, default: Any = None, *, cast: type = str) -> Any:
36
+ raw = os.environ.get(key)
37
+ if raw is None or raw == "":
38
+ return default
39
+ if cast is bool:
40
+ return raw.strip().lower() in _BOOL_TRUE
41
+ try:
42
+ return cast(raw)
43
+ except (TypeError, ValueError) as exc:
44
+ logger.warning("get_env cast %s for %s failed: %s", cast.__name__, key, exc)
45
+ return default
46
+
47
+
48
+ def load_settings(path: str | Path | None = None) -> dict:
49
+ if path is None:
50
+ path = Path.home() / ".fusion-mlx" / "settings.json"
51
+ key = str(path)
52
+ p = Path(path)
53
+ try:
54
+ mtime = p.stat().st_mtime
55
+ except FileNotFoundError:
56
+ raise FileNotFoundError(f"settings file not found: {key}") from None
57
+ cached = _SETTINGS_CACHE.get(key)
58
+ if cached is not None:
59
+ data, cached_mtime = cached
60
+ if cached_mtime == mtime:
61
+ return data
62
+ logger.info("load_settings cache invalidated (mtime changed) for %s", key)
63
+ try:
64
+ data = json.loads(p.read_text(encoding="utf-8"))
65
+ except json.JSONDecodeError as exc:
66
+ logger.warning("load_settings malformed json %s: %s", key, exc)
67
+ raise
68
+ except OSError as exc:
69
+ logger.warning("load_settings read %s failed: %s", key, exc)
70
+ raise
71
+ if not isinstance(data, dict):
72
+ raise ValueError(f"settings file {key} is not a json object")
73
+ _SETTINGS_CACHE[key] = (data, mtime)
74
+ return data
75
+
76
+
77
+ def resolve_api_key(
78
+ explicit: str | None = None,
79
+ *,
80
+ env_var: str = "FUSION_MLX_API_KEY",
81
+ settings_path: str | Path | None = None,
82
+ ) -> str:
83
+ if explicit:
84
+ return explicit
85
+ key = os.environ.get(env_var, "")
86
+ if key:
87
+ return key
88
+ try:
89
+ cfg = load_settings(settings_path)
90
+ except FileNotFoundError:
91
+ logger.debug("resolve_api_key: settings.json absent, no api_key resolved")
92
+ return ""
93
+ auth = cfg.get("auth", {})
94
+ if not isinstance(auth, dict):
95
+ logger.warning(
96
+ "resolve_api_key: auth is not a dict in settings, got %s; cannot read api_key",
97
+ type(auth).__name__,
98
+ )
99
+ return ""
100
+ raw = auth.get("api_key", "")
101
+ if raw is None:
102
+ logger.warning("resolve_api_key: auth.api_key is null in settings.json")
103
+ return ""
104
+ if not isinstance(raw, str):
105
+ logger.warning("resolve_api_key: auth.api_key not a string, got %s", type(raw).__name__)
106
+ return ""
107
+ return raw
108
+
109
+
110
+ def load_api_key(name: str = "fusion-mlx") -> str:
111
+ short = name.removeprefix("fusion-")
112
+ env_var = f"FUSION_{short.upper().replace('-', '_')}_API_KEY"
113
+ key = resolve_api_key(env_var=env_var)
114
+ if not key:
115
+ raise KeyError(f"api key not found for {name}: set env {env_var} or auth.api_key in settings.json")
116
+ return key
117
+
118
+
119
+ def clear_cache() -> None:
120
+ _SETTINGS_CACHE.clear()
121
+ logger.debug("config cache cleared")
122
+
123
+
124
+ # --- dotenv env-file loading (issue #47, config-conventions §1) ---
125
+
126
+
127
+ def _parse_dotenv_line(line: str) -> tuple[str, str] | None:
128
+ stripped = line.strip()
129
+ if not stripped or stripped.startswith("#"):
130
+ return None
131
+ if stripped.startswith("export "):
132
+ stripped = stripped[len("export ") :].lstrip()
133
+ key, sep, value = stripped.partition("=")
134
+ if not sep or not key.strip() or not key.strip().replace("_", "a").replace("-", "a").isalnum():
135
+ logger.debug("load_dotenv: skipping malformed line: %s", line.strip()[:60])
136
+ return None
137
+ key = key.strip()
138
+ value = value.strip()
139
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
140
+ value = value[1:-1]
141
+ else:
142
+ # strip trailing inline comment on unquoted values
143
+ if " #" in value:
144
+ value = value.split(" #", 1)[0].rstrip()
145
+ return key, value
146
+
147
+
148
+ def load_dotenv(path: str | Path | None = None, *, override: bool = False) -> dict[str, str]:
149
+ # Load KEY=VALUE pairs from a dotenv file into os.environ.
150
+ # Resolution: explicit path arg -> ENV_FILE env var -> no-op (empty dict).
151
+ # Default does NOT overwrite variables already set in the environment
152
+ # (standard dotenv semantics); override=True forces them.
153
+ if path is None:
154
+ env_file = os.environ.get("ENV_FILE", "")
155
+ if not env_file:
156
+ logger.debug("load_dotenv: no path arg and ENV_FILE unset, no-op")
157
+ return {}
158
+ path = env_file
159
+ p = Path(path)
160
+ try:
161
+ text = p.read_text(encoding="utf-8")
162
+ except FileNotFoundError:
163
+ logger.warning("load_dotenv: env file not found: %s", p)
164
+ raise
165
+ loaded: dict[str, str] = {}
166
+ for line in text.splitlines():
167
+ parsed = _parse_dotenv_line(line)
168
+ if parsed is None:
169
+ continue
170
+ key, value = parsed
171
+ if not override and os.environ.get(key, "") != "":
172
+ logger.debug("load_dotenv: %s already set, keeping existing value", key)
173
+ continue
174
+ os.environ[key] = value
175
+ loaded[key] = value
176
+ logger.info("load_dotenv: %d vars loaded from %s (override=%s)", len(loaded), p, override)
177
+ return loaded
178
+
179
+
180
+ # --- config-conventions single-point resolution (config-conventions.md §1/§3) ---
181
+
182
+ _DEFAULT_GATEWAY_BASE_URL = "http://127.0.0.1:11432"
183
+ _ANON_WARNED: set[str] = set()
184
+
185
+
186
+ def profile() -> str:
187
+ return os.environ.get("FUSION_PROFILE", "dev")
188
+
189
+
190
+ def _resolve_secret(env: str, *, hint: str) -> str:
191
+ key = os.environ.get(env, "")
192
+ if key:
193
+ return key
194
+ if profile() == "prod":
195
+ raise RuntimeError(f"{env} not set — {hint}, see architecture/config-conventions.md")
196
+ if env not in _ANON_WARNED:
197
+ _ANON_WARNED.add(env)
198
+ logger.warning("anonymous dev mode: %s not set (%s)", env, hint)
199
+ return ""
200
+
201
+
202
+ def client_key() -> str:
203
+ for env in ("FUSION_CLIENT_KEY", "FUSION_MLX_API_KEY", "MLX_API_KEY"):
204
+ key = os.environ.get(env, "")
205
+ if key:
206
+ if env != "FUSION_CLIENT_KEY":
207
+ logger.warning("%s is deprecated, set FUSION_CLIENT_KEY instead (config-conventions §6)", env)
208
+ return key
209
+ if profile() == "prod":
210
+ raise RuntimeError(
211
+ "FUSION_CLIENT_KEY not set — obtain from gateway admin API, see architecture/config-conventions.md"
212
+ )
213
+ if "FUSION_CLIENT_KEY" not in _ANON_WARNED:
214
+ _ANON_WARNED.add("FUSION_CLIENT_KEY")
215
+ logger.warning("anonymous dev mode: FUSION_CLIENT_KEY not set (obtain from gateway admin API)")
216
+ return ""
217
+
218
+
219
+ def admin_key() -> str:
220
+ return _resolve_secret("FUSION_ADMIN_KEY", hint="obtain from gateway admin API")
221
+
222
+
223
+ def gateway_base_url() -> str:
224
+ url = os.environ.get("FUSION_GATEWAY_BASE_URL", "")
225
+ if url:
226
+ return url
227
+ legacy = os.environ.get("FUSION_GATEWAY_URL", "")
228
+ if legacy:
229
+ logger.warning("FUSION_GATEWAY_URL is deprecated, set FUSION_GATEWAY_BASE_URL instead (config-conventions §6)")
230
+ return legacy
231
+ return _DEFAULT_GATEWAY_BASE_URL
232
+
233
+
234
+ def service_port(app: str, default: int | None = None) -> int:
235
+ if not app:
236
+ raise ValueError("service_port: app name required")
237
+ short = app.removeprefix("fusion-")
238
+ env = f"FUSION_{short.upper().replace('-', '_')}_PORT"
239
+ raw = os.environ.get(env, "")
240
+ if raw:
241
+ try:
242
+ return int(raw)
243
+ except ValueError as exc:
244
+ raise ValueError(f"{env} is not an integer: {raw!r}") from exc
245
+ if default is not None:
246
+ return default
247
+ raise ValueError(
248
+ f"{env} not set and no default given — "
249
+ "register the port in architecture/port-registry.yaml and set the env or pass default="
250
+ )
251
+
252
+
253
+ def service_data_dir(app: str, default: str | Path | None = None) -> Path:
254
+ if not app:
255
+ raise ValueError("service_data_dir: app name required")
256
+ short = app.removeprefix("fusion-")
257
+ raw = os.environ.get(f"FUSION_{short.upper().replace('-', '_')}_DATA_DIR", "")
258
+ if raw:
259
+ return Path(raw)
260
+ if default is not None:
261
+ return Path(default)
262
+ return Path.home() / f".{short}"
263
+
264
+
265
+ def gateway_admin_key(settings_path: str | Path | None = None) -> str:
266
+ key = os.environ.get("FUSION_GATEWAY_API_KEY", "")
267
+ if key:
268
+ return key
269
+ cfg: dict = {}
270
+ try:
271
+ cfg = load_settings(settings_path)
272
+ except (FileNotFoundError, OSError, ValueError) as exc:
273
+ logger.debug("gateway_admin_key: settings unavailable: %s", exc)
274
+ shell = cfg.get("shell", {})
275
+ if isinstance(shell, dict):
276
+ raw = shell.get("gateway_admin_key", "")
277
+ if raw is None:
278
+ logger.warning("gateway_admin_key: shell.gateway_admin_key is null in settings.json")
279
+ elif not isinstance(raw, str):
280
+ logger.warning("gateway_admin_key: shell.gateway_admin_key not a string, got %s", type(raw).__name__)
281
+ elif raw:
282
+ return raw
283
+ if profile() == "prod":
284
+ raise RuntimeError(
285
+ "FUSION_GATEWAY_API_KEY not set and shell.gateway_admin_key absent — "
286
+ "obtain from gateway admin API, see architecture/config-conventions.md"
287
+ )
288
+ if "FUSION_GATEWAY_API_KEY" not in _ANON_WARNED:
289
+ _ANON_WARNED.add("FUSION_GATEWAY_API_KEY")
290
+ logger.warning("anonymous dev mode: FUSION_GATEWAY_API_KEY not set (obtain from gateway admin API)")
291
+ return ""
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ _VISUAL_TYPES = (
9
+ "array_grid",
10
+ "tape_diagram",
11
+ "geometry_2d",
12
+ "isometric_3d",
13
+ "track_timeline",
14
+ "bucket_divider",
15
+ "data_chart",
16
+ "flow_card",
17
+ )
18
+
19
+ _FALLBACK_TYPES = ("tape_diagram", "flow_card")
20
+
21
+ SOCRATIC_DSL_V3_SCHEMA: dict = {
22
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
23
+ "title": "Socratic DSL v3.0",
24
+ "description": "Canonical JSON Schema for the Fusion Socratic DSL v3.0 (k12-train-model spec).",
25
+ "type": "object",
26
+ "required": ["meta", "parameters", "entities", "pipeline"],
27
+ "additionalProperties": False,
28
+ "properties": {
29
+ "meta": {
30
+ "type": "object",
31
+ "required": ["id", "grade", "topic", "visual_type", "fallback_type"],
32
+ "additionalProperties": False,
33
+ "properties": {
34
+ "id": {"type": "string"},
35
+ "grade": {"type": "number"},
36
+ "topic": {"type": "string"},
37
+ "visual_type": {"type": "string", "enum": list(_VISUAL_TYPES)},
38
+ "fallback_type": {"type": "string", "enum": list(_FALLBACK_TYPES)},
39
+ },
40
+ },
41
+ "parameters": {
42
+ "type": "object",
43
+ "additionalProperties": {
44
+ "type": "object",
45
+ "required": ["label", "value", "unit"],
46
+ "additionalProperties": False,
47
+ "properties": {
48
+ "label": {"type": "string"},
49
+ "value": {"type": "number"},
50
+ "unit": {"type": "string"},
51
+ },
52
+ },
53
+ },
54
+ "entities": {
55
+ "type": "array",
56
+ "items": {
57
+ "type": "object",
58
+ "required": ["id", "type", "label", "properties"],
59
+ "additionalProperties": False,
60
+ "properties": {
61
+ "id": {"type": "string"},
62
+ "type": {"type": "string"},
63
+ "label": {"type": "string"},
64
+ "properties": {"type": "object"},
65
+ },
66
+ },
67
+ },
68
+ "pipeline": {
69
+ "type": "array",
70
+ "items": {
71
+ "type": "object",
72
+ "required": ["step", "title", "formula", "eval", "result_unit", "visual_state"],
73
+ "additionalProperties": False,
74
+ "properties": {
75
+ "step": {"type": "number"},
76
+ "title": {"type": "string"},
77
+ "formula": {"type": "string"},
78
+ "eval": {"type": "string"},
79
+ "result_unit": {"type": "string"},
80
+ "visual_state": {
81
+ "type": "object",
82
+ "required": ["progress_range", "active_entities", "action"],
83
+ "additionalProperties": False,
84
+ "properties": {
85
+ "progress_range": {
86
+ "type": "array",
87
+ "items": {"type": "number"},
88
+ "minItems": 2,
89
+ "maxItems": 2,
90
+ },
91
+ "active_entities": {"type": "array", "items": {"type": "string"}},
92
+ "action": {"type": "string"},
93
+ },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ },
99
+ }
100
+
101
+ _VALIDATOR = None
102
+
103
+
104
+ def _get_validator():
105
+ global _VALIDATOR
106
+ if _VALIDATOR is None:
107
+ try:
108
+ from jsonschema import Draft202012Validator
109
+ except ImportError as exc:
110
+ raise ImportError(
111
+ "dsl_schema requires jsonschema: pip install 'fusion-core[schema]' or pip install jsonschema"
112
+ ) from exc
113
+ _VALIDATOR = Draft202012Validator(SOCRATIC_DSL_V3_SCHEMA)
114
+ logger.debug("dsl_schema validator initialized (jsonschema Draft202012)")
115
+ return _VALIDATOR
116
+
117
+
118
+ def validate_dsl(dsl_dict: dict) -> bool:
119
+ if not isinstance(dsl_dict, dict):
120
+ logger.warning("validate_dsl expects dict, got %s", type(dsl_dict).__name__)
121
+ return False
122
+ validator = _get_validator()
123
+ errors = sorted(validator.iter_errors(dsl_dict), key=lambda e: list(e.path))
124
+ if not errors:
125
+ return True
126
+ first = errors[0]
127
+ path = ".".join(str(p) for p in first.path) or "<root>"
128
+ logger.warning("dsl validation failed at %s: %s", path, first.message)
129
+ return False
130
+
131
+
132
+ def validate_dsl_str(dsl_str: str) -> tuple[bool, str]:
133
+ if not isinstance(dsl_str, str):
134
+ return False, f"expected str, got {type(dsl_str).__name__}"
135
+ if not dsl_str.strip():
136
+ return False, "empty input"
137
+ try:
138
+ parsed = json.loads(dsl_str)
139
+ except json.JSONDecodeError as exc:
140
+ logger.debug("validate_dsl_str json parse fail: %s", exc)
141
+ return False, f"json parse error: {exc}"
142
+ if not isinstance(parsed, dict):
143
+ return False, f"expected dict, got {type(parsed).__name__}"
144
+ validator = _get_validator()
145
+ errors = sorted(validator.iter_errors(parsed), key=lambda e: list(e.path))
146
+ if not errors:
147
+ return True, ""
148
+ first = errors[0]
149
+ path = ".".join(str(p) for p in first.path) or "<root>"
150
+ return False, f"{path}: {first.message}"