applied-cli 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.
@@ -0,0 +1,170 @@
1
+ # ============================================================================
2
+ # Applied Demo Setup Template
3
+ # ============================================================================
4
+ #
5
+ # AGENT INSTRUCTIONS
6
+ # ------------------
7
+ # You are filling in this template to set up a demo shop for a new brand.
8
+ # Follow these steps IN ORDER before writing the spec:
9
+ #
10
+ # 1. BRAND CONTEXT
11
+ # Search "[BrandName] about" or read their homepage to understand what
12
+ # they sell, their tone, and key policies (warranty, subscription, etc.)
13
+ #
14
+ # 2. HELP CENTER URL
15
+ # Search "[BrandName] help center" or "[BrandName] FAQ" to find their
16
+ # support site. Common patterns: help.brand.com, support.brand.com,
17
+ # brand.com/pages/faq. Use the top-level support page URL below.
18
+ #
19
+ # 3. Q&A RESPONSES (aim for 4–5 total)
20
+ # Fetch 3–4 pages from the help center covering different topics
21
+ # (shipping/tracking, returns/exchanges, warranty, product FAQ).
22
+ # Extract real answers — do NOT fabricate content.
23
+ # If you cannot find real content for a topic, OMIT that Q&A entry
24
+ # entirely rather than leaving a placeholder.
25
+ # Keep answers under 100 words. Use natural customer phrasing for
26
+ # questions (e.g. "How do I track my order?" not "Order tracking").
27
+ #
28
+ # 4. AGENT NAME
29
+ # Pick a friendly, gender-neutral first name for the email agent
30
+ # (e.g. Alex, Jordan, Sam, Casey). Not a real employee name.
31
+ #
32
+ # 5. GREETING (chat)
33
+ # Write a warm opening message matching the brand's tone.
34
+ # IMPORTANT: {firstName} in answer text is a RUNTIME token — the
35
+ # backend substitutes the customer's actual first name at send time.
36
+ # Do NOT replace {firstName} with a real name. Leave it as-is.
37
+ # Do NOT include the shop name (e.g. avoid "Welcome to BrandName Demo").
38
+ # Use the brand name directly: "Welcome to [BrandName]."
39
+ #
40
+ # 6. LEAVE ESCALATION TRIGGERS AS-IS
41
+ # Do not change the escalation question text. These are exact-match
42
+ # phrases used to route conversations to a human agent.
43
+ #
44
+ # 7. SIMULATION DISTRIBUTION
45
+ # Fill in the simulation.distribution section at the bottom.
46
+ # If a classified CSV was provided, use the topic/intent frequencies
47
+ # from that data. Otherwise estimate based on the brand's support type:
48
+ # - E-commerce (physical goods): heavy warranty/returns/shipping
49
+ # - SaaS/subscription: heavy billing/cancellation/technical
50
+ # - Marketplace: heavy order issues/refunds/seller disputes
51
+ # Topic and intent names must match the taxonomy exactly.
52
+ # All percent values must sum to 100.
53
+ #
54
+ # 8. REMOVE ALL COMMENT LINES before running shop setup.
55
+ # YAML comments (#) are fine to keep — the CLI ignores them.
56
+ # But remove any unfilled placeholder entries (type: qa with "...")
57
+ # entirely.
58
+ #
59
+ # ============================================================================
60
+
61
+ name: "[BrandName] Demo"
62
+
63
+ brand:
64
+ description: >
65
+ [TODO: 2–3 sentences — what the brand does, what they sell, and any
66
+ key differentiators such as warranty programs, subscription model,
67
+ or notable product features.]
68
+ guardrail: >
69
+ [TODO: Tone and boundary instructions. Example: "Always be helpful
70
+ and concise. Never make commitments outside of established [BrandName]
71
+ policies. Escalate billing disputes and warranty edge-cases to a
72
+ human agent."]
73
+
74
+ agents:
75
+ - modality: chat
76
+ name: "[BrandName] Chat"
77
+ description: >
78
+ [TODO: 1 sentence — AI support agent for [BrandName] customers via
79
+ live chat. What does it handle?]
80
+ auto_reply: true
81
+ responses:
82
+ - type: greeting
83
+ # {firstName} is a RUNTIME token — leave it exactly as {firstName}.
84
+ # The backend replaces it with the customer's first name at send time.
85
+ # Write a warm, brand-appropriate opening under 20 words.
86
+ answer: "Hey {firstName}! Welcome to [BrandName]. How can I help you today?"
87
+
88
+ # Add 4–5 Q&A pairs sourced from the brand's help center.
89
+ # Fetch real pages — do NOT fabricate answers.
90
+ # OMIT an entry entirely if you cannot find real content for it.
91
+ # Common topics: shipping/tracking, returns, warranty, product FAQ.
92
+ - type: qa
93
+ question: "[customer question — natural phrasing, e.g. How do I track my order?]"
94
+ answer: "[answer sourced from help center — under 100 words]"
95
+
96
+ # (copy the qa block above for each additional Q&A — aim for 4–5 total)
97
+
98
+ - type: escalation
99
+ question: "I want to speak to a human"
100
+ - type: escalation
101
+ question: "This is urgent"
102
+
103
+ - modality: email
104
+ name: "[BrandName] Email"
105
+ description: >
106
+ [TODO: 1 sentence — AI support agent for [BrandName] customers via
107
+ email. What does it handle?]
108
+ auto_reply: true
109
+ responses:
110
+ - type: greeting
111
+ # Keep short — the agent writes the full email body.
112
+ # {firstName} is a RUNTIME token — do NOT replace it.
113
+ answer: "Hi {firstName},"
114
+
115
+ - type: signature
116
+ # Replace [AgentName] with a friendly first name (e.g. Alex, Jordan).
117
+ # Use \\\n for a line break within the signature text.
118
+ answer: "Best,\n\n[AgentName]\\\n[BrandName] Customer Support Team"
119
+
120
+ # Same Q&A pairs as chat — copy them here.
121
+ # OMIT any entry where you don't have real content.
122
+ - type: qa
123
+ question: "[same question as chat]"
124
+ answer: "[same answer as chat]"
125
+
126
+ # (copy for each additional Q&A)
127
+
128
+ - type: escalation
129
+ question: "I want to speak to a human"
130
+
131
+ # Optional: include if a historical conversations CSV is available.
132
+ # Remove this section entirely if no CSV is provided.
133
+ #
134
+ # conversations_csv:
135
+ # file_path: "/path/to/classified_conversations.csv"
136
+ # process_labels: true
137
+ # agent_modality: email # which agent receives the imported conversations
138
+ # column_map: # rename CSV columns to match expected headers
139
+ # OriginalIdColumn: ID
140
+ # OriginalBodyColumn: BODY
141
+
142
+ knowledge_base:
143
+ # Search "[BrandName] help center" to find this URL.
144
+ # Use the top-level support/FAQ page (e.g. help.brand.com).
145
+ url: "https://[help-center-url]"
146
+ title: "[BrandName] Help Center"
147
+
148
+ # Simulation populates the analytics graphs with realistic conversation history.
149
+ # Requires a superuser account — if you're not a superuser the step is skipped
150
+ # gracefully. To skip intentionally, remove this section.
151
+ #
152
+ # AGENT INSTRUCTIONS for distribution:
153
+ # 1. If a classified conversations CSV was provided and used above, use the
154
+ # topic/intent frequency breakdown from that data to set percent values.
155
+ # 2. If no CSV is available, make a reasonable guess based on the brand's
156
+ # support patterns (e.g. e-commerce brands: heavy on shipping/returns/warranty).
157
+ # 3. topic and intent names must exactly match what's in the taxonomy file.
158
+ # Run `applied-cli intents list` after setup to verify names.
159
+ # 4. All percent values must sum to exactly 100.
160
+ #
161
+ simulation:
162
+ num_conversations: 500
163
+ date_from: "[YYYY-MM-DD, e.g. 90 days ago]"
164
+ date_to: "[YYYY-MM-DD, e.g. today]"
165
+ delete_previous: false
166
+ distribution:
167
+ - topic: "[TopicName]"
168
+ intent: "[IntentName]"
169
+ percent: [number]
170
+ # Add more entries — all percents must sum to 100
applied_cli/runtime.py ADDED
@@ -0,0 +1,53 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from applied_cli.auth_store import load_credentials
5
+ from applied_cli.config import DEFAULT_BASE_URL, normalize_base_url
6
+ from applied_cli.http import APIError
7
+
8
+
9
+ def resolve_runtime(
10
+ *,
11
+ base_url: Optional[str],
12
+ shop_id: Optional[str],
13
+ api_token: Optional[str],
14
+ ) -> tuple[str, str, str]:
15
+ requested_profile = os.getenv("APPLIED_PROFILE")
16
+ creds = load_credentials(profile=requested_profile)
17
+ resolved_base_url = normalize_base_url(
18
+ base_url
19
+ or os.getenv("APPLIED_BASE_URL")
20
+ or os.getenv("APPLIED_ENDPOINT")
21
+ or (creds.base_url if creds else "")
22
+ or DEFAULT_BASE_URL
23
+ )
24
+ resolved_shop_id = shop_id or os.getenv("APPLIED_SHOP_ID") or (
25
+ creds.shop_id if creds else ""
26
+ )
27
+ resolved_token = api_token or os.getenv("APPLIED_API_TOKEN") or (
28
+ creds.api_token if creds else ""
29
+ )
30
+
31
+ if not resolved_base_url:
32
+ raise APIError(
33
+ "Missing base URL.",
34
+ code="MISSING_BASE_URL",
35
+ hint="Set via --base-url, APPLIED_BASE_URL, or run `applied-cli auth login`.",
36
+ retryable=False,
37
+ )
38
+ if not resolved_shop_id:
39
+ raise APIError(
40
+ "Missing shop ID.",
41
+ code="MISSING_SHOP_ID",
42
+ hint="Set via --shop-id, APPLIED_SHOP_ID, or run `applied-cli auth login`.",
43
+ retryable=False,
44
+ )
45
+ if not resolved_token:
46
+ raise APIError(
47
+ "Missing API token.",
48
+ code="MISSING_API_TOKEN",
49
+ hint="Set via --api-token, APPLIED_API_TOKEN, or run `applied-cli auth login`.",
50
+ retryable=False,
51
+ )
52
+
53
+ return resolved_base_url, resolved_shop_id, resolved_token
@@ -0,0 +1,398 @@
1
+ """
2
+ Spec file loader and validator for `applied-cli shop setup`.
3
+
4
+ Supports YAML and JSON formats. Returns a fully-validated dict ready for the
5
+ setup command to consume without further type-checking.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from applied_cli.commands._normalize import MODALITY_ALIASES, RESPONSE_TYPE_ALIASES
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Internal helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ # Matches unfilled template placeholders in two styles:
21
+ # {BrandName} — curly-brace, uppercase-first (NOT {firstName} which is runtime)
22
+ # [BrandName] — square-bracket, uppercase-first, NOT followed by ( (which would be a markdown link)
23
+ # [TODO: ...] — square-bracket TODO marker
24
+ _TEMPLATE_PLACEHOLDER_RE = re.compile(
25
+ r"\{[A-Z][^}]*\}" # {BrandName} style
26
+ r"|"
27
+ r"\[[A-Z][^\]]*\](?!\()" # [BrandName] style, not a markdown link [text](url)
28
+ )
29
+
30
+ def _err(path: str, message: str) -> None:
31
+ raise ValueError(f"spec.{path}: {message}")
32
+
33
+
34
+ def _check_placeholder(value: str, *, path: str) -> None:
35
+ """Raise if value looks like an unfilled template placeholder."""
36
+ if value.strip() in {"...", "[fill in]", ""}:
37
+ _err(path, "contains an unfilled template placeholder — replace with real content")
38
+ match = _TEMPLATE_PLACEHOLDER_RE.search(value)
39
+ if match:
40
+ _err(
41
+ path,
42
+ f"contains unfilled template placeholder '{match.group()}' — replace with real content"
43
+ " ({firstName} is a runtime token and is allowed)",
44
+ )
45
+
46
+
47
+ def _require_str(value: Any, *, path: str, allow_empty: bool = False) -> str:
48
+ if value is None:
49
+ _err(path, "is required")
50
+ if not isinstance(value, str):
51
+ _err(path, "must be a string")
52
+ s = value.strip()
53
+ if not allow_empty and not s:
54
+ _err(path, "cannot be empty")
55
+ return s
56
+
57
+
58
+ def _optional_str(value: Any, *, path: str) -> str | None:
59
+ if value is None:
60
+ return None
61
+ if not isinstance(value, str):
62
+ _err(path, "must be a string")
63
+ return value.strip() or None
64
+
65
+
66
+ def _optional_bool(value: Any, *, path: str, default: bool) -> bool:
67
+ if value is None:
68
+ return default
69
+ if not isinstance(value, bool):
70
+ _err(path, "must be true or false")
71
+ return value
72
+
73
+
74
+ def _optional_int(value: Any, *, path: str, min_val: int | None = None) -> int | None:
75
+ if value is None:
76
+ return None
77
+ if not isinstance(value, int) or isinstance(value, bool):
78
+ _err(path, "must be an integer")
79
+ if min_val is not None and value < min_val:
80
+ _err(path, f"must be >= {min_val}")
81
+ return value
82
+
83
+
84
+ def _normalize_modality(raw: Any, *, path: str) -> str:
85
+ s = _require_str(raw, path=path)
86
+ key = s.lower()
87
+ if key not in MODALITY_ALIASES:
88
+ _err(path, f"must be one of: {', '.join(sorted(MODALITY_ALIASES))}")
89
+ return MODALITY_ALIASES[key]
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Response validation
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def _validate_response(raw: Any, *, path: str) -> dict[str, Any]:
97
+ if not isinstance(raw, dict):
98
+ _err(path, "must be an object")
99
+
100
+ raw_type = raw.get("type")
101
+ if not isinstance(raw_type, str):
102
+ _err(f"{path}.type", "is required")
103
+ type_key = raw_type.strip().lower()
104
+ if type_key not in RESPONSE_TYPE_ALIASES:
105
+ _err(f"{path}.type", f"must be one of: {', '.join(sorted(RESPONSE_TYPE_ALIASES))}")
106
+ response_type = RESPONSE_TYPE_ALIASES[type_key]
107
+
108
+ # greeting/signature use fixed question labels; escalation answers are trigger-only
109
+ if response_type == "greeting":
110
+ question = _optional_str(raw.get("question"), path=f"{path}.question") or "Greeting message"
111
+ answer = _require_str(raw.get("answer"), path=f"{path}.answer")
112
+ _check_placeholder(answer, path=f"{path}.answer")
113
+ elif response_type == "signature":
114
+ question = _optional_str(raw.get("question"), path=f"{path}.question") or "Message signature"
115
+ answer = _require_str(raw.get("answer"), path=f"{path}.answer")
116
+ _check_placeholder(answer, path=f"{path}.answer")
117
+ elif response_type == "escalate":
118
+ question = _require_str(raw.get("question"), path=f"{path}.question")
119
+ _check_placeholder(question, path=f"{path}.question")
120
+ answer = _optional_str(raw.get("answer"), path=f"{path}.answer") or ""
121
+ else:
122
+ question = _require_str(raw.get("question"), path=f"{path}.question")
123
+ _check_placeholder(question, path=f"{path}.question")
124
+ answer = _require_str(raw.get("answer"), path=f"{path}.answer")
125
+ _check_placeholder(answer, path=f"{path}.answer")
126
+
127
+ return {
128
+ "type": response_type,
129
+ "question": question,
130
+ "answer": answer,
131
+ }
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Agent validation
136
+ # ---------------------------------------------------------------------------
137
+
138
+ def _validate_agent(raw: Any, *, path: str, brand_guardrail: str | None) -> dict[str, Any]:
139
+ if not isinstance(raw, dict):
140
+ _err(path, "must be an object")
141
+
142
+ modality = _normalize_modality(raw.get("modality"), path=f"{path}.modality")
143
+ name = _optional_str(raw.get("name"), path=f"{path}.name")
144
+ description = _optional_str(raw.get("description"), path=f"{path}.description")
145
+
146
+ # Guardrail: use agent-level if set, fall back to brand.guardrail
147
+ guardrail_raw = raw.get("guardrail")
148
+ if guardrail_raw is not None:
149
+ guardrail = _optional_str(guardrail_raw, path=f"{path}.guardrail")
150
+ else:
151
+ guardrail = brand_guardrail
152
+
153
+ auto_reply = _optional_bool(raw.get("auto_reply"), path=f"{path}.auto_reply", default=True)
154
+ escalation_mode = _optional_str(raw.get("escalation_mode"), path=f"{path}.escalation_mode")
155
+ response_delay = _optional_int(
156
+ raw.get("response_delay_in_seconds"),
157
+ path=f"{path}.response_delay_in_seconds",
158
+ min_val=0,
159
+ )
160
+
161
+ responses_raw = raw.get("responses") or []
162
+ if not isinstance(responses_raw, list):
163
+ _err(f"{path}.responses", "must be an array")
164
+ responses = [
165
+ _validate_response(r, path=f"{path}.responses[{i}]")
166
+ for i, r in enumerate(responses_raw)
167
+ ]
168
+
169
+ return {
170
+ "modality": modality,
171
+ "name": name, # may be None — caller fills in default
172
+ "description": description,
173
+ "guardrail": guardrail,
174
+ "auto_reply": auto_reply,
175
+ "escalation_mode": escalation_mode,
176
+ "response_delay_in_seconds": response_delay,
177
+ "responses": responses,
178
+ }
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Optional section validators
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def _validate_conversations_csv(raw: Any, *, path: str) -> dict[str, Any]:
186
+ if not isinstance(raw, dict):
187
+ _err(path, "must be an object")
188
+
189
+ file_path = _optional_str(raw.get("file_path"), path=f"{path}.file_path")
190
+ url = _optional_str(raw.get("url"), path=f"{path}.url")
191
+ if not file_path and not url:
192
+ _err(path, "requires either file_path or url")
193
+ if file_path and url:
194
+ _err(path, "provide either file_path or url, not both")
195
+
196
+ process_labels = _optional_bool(
197
+ raw.get("process_labels"), path=f"{path}.process_labels", default=True
198
+ )
199
+ agent_modality_raw = raw.get("agent_modality")
200
+ agent_modality: str | None = None
201
+ if agent_modality_raw is not None:
202
+ agent_modality = _normalize_modality(agent_modality_raw, path=f"{path}.agent_modality")
203
+
204
+ # column_map: optional dict renaming CSV columns before upload.
205
+ # Keys are original column names (case-insensitive match), values are target names.
206
+ # Example: {"ConversationId": "ID", "conversation": "BODY"}
207
+ column_map_raw = raw.get("column_map")
208
+ column_map: dict[str, str] | None = None
209
+ if column_map_raw is not None:
210
+ if not isinstance(column_map_raw, dict):
211
+ _err(f"{path}.column_map", "must be an object mapping original to target column names")
212
+ column_map = {str(k): str(v) for k, v in column_map_raw.items()}
213
+
214
+ return {
215
+ "file_path": file_path,
216
+ "url": url,
217
+ "process_labels": process_labels,
218
+ "agent_modality": agent_modality,
219
+ "column_map": column_map,
220
+ }
221
+
222
+
223
+ def _validate_taxonomy(raw: Any, *, path: str) -> dict[str, Any]:
224
+ if not isinstance(raw, dict):
225
+ _err(path, "must be an object")
226
+
227
+ file_path = _require_str(raw.get("file_path"), path=f"{path}.file_path")
228
+ if not Path(file_path).exists():
229
+ _err(f"{path}.file_path", f"file not found: {file_path}")
230
+
231
+ return {"file_path": file_path}
232
+
233
+
234
+ def _validate_knowledge_base(raw: Any, *, path: str) -> dict[str, Any]:
235
+ if not isinstance(raw, dict):
236
+ _err(path, "must be an object")
237
+
238
+ url = _require_str(raw.get("url"), path=f"{path}.url")
239
+ title = _optional_str(raw.get("title"), path=f"{path}.title")
240
+
241
+ return {"url": url, "title": title}
242
+
243
+
244
+ def _validate_simulation(raw: Any, *, path: str) -> dict[str, Any]:
245
+ if not isinstance(raw, dict):
246
+ _err(path, "must be an object")
247
+
248
+ num_conversations = _optional_int(
249
+ raw.get("num_conversations"), path=f"{path}.num_conversations", min_val=1
250
+ ) or 20
251
+ date_from = _require_str(raw.get("date_from"), path=f"{path}.date_from")
252
+ date_to = _require_str(raw.get("date_to"), path=f"{path}.date_to")
253
+ delete_previous = _optional_bool(
254
+ raw.get("delete_previous"), path=f"{path}.delete_previous", default=False
255
+ )
256
+
257
+ distribution_raw = raw.get("distribution")
258
+ if not isinstance(distribution_raw, list) or not distribution_raw:
259
+ _err(f"{path}.distribution", "is required and must be a non-empty array")
260
+ distribution: list[dict[str, Any]] = []
261
+ total_pct = 0
262
+ for i, item in enumerate(distribution_raw):
263
+ item_path = f"{path}.distribution[{i}]"
264
+ if not isinstance(item, dict):
265
+ _err(item_path, "must be an object")
266
+ topic = _require_str(item.get("topic"), path=f"{item_path}.topic")
267
+ intent = _require_str(item.get("intent"), path=f"{item_path}.intent")
268
+ pct_raw = item.get("percent")
269
+ if not isinstance(pct_raw, (int, float)) or isinstance(pct_raw, bool):
270
+ _err(f"{item_path}.percent", "must be a number")
271
+ pct = float(pct_raw)
272
+ if pct <= 0:
273
+ _err(f"{item_path}.percent", "must be > 0")
274
+ total_pct += pct
275
+ distribution.append({"topic": topic, "intent": intent, "percent": pct})
276
+
277
+ if abs(total_pct - 100.0) > 0.5:
278
+ _err(f"{path}.distribution", f"percent values must sum to 100 (got {total_pct})")
279
+
280
+ return {
281
+ "num_conversations": num_conversations,
282
+ "date_from": date_from,
283
+ "date_to": date_to,
284
+ "delete_previous": delete_previous,
285
+ "distribution": distribution,
286
+ }
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Top-level loader
291
+ # ---------------------------------------------------------------------------
292
+
293
+ def load_and_validate_shop_spec(spec_path: str) -> dict[str, Any]:
294
+ """Load and validate a shop setup spec file (YAML or JSON).
295
+
296
+ Returns a fully-validated dict:
297
+ {
298
+ "name": str,
299
+ "brand": {"description": str|None, "guardrail": str|None},
300
+ "agents": [{"modality", "name", "description", "guardrail", "auto_reply",
301
+ "escalation_mode", "response_delay_in_seconds", "responses"}, ...],
302
+ "conversations_csv": {...} | None,
303
+ "knowledge_base": {...} | None,
304
+ "simulation": {...} | None,
305
+ }
306
+ """
307
+ path = Path(spec_path)
308
+ try:
309
+ raw_text = path.read_text(encoding="utf-8")
310
+ except OSError as exc:
311
+ raise ValueError(f"spec: cannot read file: {exc}") from exc
312
+
313
+ payload: Any
314
+ suffix = path.suffix.lower()
315
+ if suffix in {".yaml", ".yml"}:
316
+ try:
317
+ import yaml # type: ignore[import-untyped]
318
+
319
+ payload = yaml.safe_load(raw_text)
320
+ except ImportError as exc:
321
+ raise ValueError(
322
+ "pyyaml is required to load YAML spec files. "
323
+ "Run: pip install pyyaml"
324
+ ) from exc
325
+ except Exception as exc:
326
+ raise ValueError(f"spec: invalid YAML — {exc}") from exc
327
+ else:
328
+ try:
329
+ payload = json.loads(raw_text)
330
+ except json.JSONDecodeError as exc:
331
+ raise ValueError(f"spec: invalid JSON — {exc}") from exc
332
+
333
+ if not isinstance(payload, dict):
334
+ raise ValueError("spec: root must be an object")
335
+
336
+ # --- name ---
337
+ name = _require_str(payload.get("name"), path="name")
338
+ _check_placeholder(name, path="name")
339
+
340
+ # --- brand (optional) ---
341
+ brand_raw = payload.get("brand")
342
+ brand_description: str | None = None
343
+ brand_guardrail: str | None = None
344
+ if brand_raw is not None:
345
+ if not isinstance(brand_raw, dict):
346
+ _err("brand", "must be an object")
347
+ brand_description = _optional_str(brand_raw.get("description"), path="brand.description")
348
+ if brand_description:
349
+ _check_placeholder(brand_description, path="brand.description")
350
+ brand_guardrail = _optional_str(brand_raw.get("guardrail"), path="brand.guardrail")
351
+ if brand_guardrail:
352
+ _check_placeholder(brand_guardrail, path="brand.guardrail")
353
+
354
+ # --- agents (required, min 1) ---
355
+ agents_raw = payload.get("agents")
356
+ if not isinstance(agents_raw, list) or not agents_raw:
357
+ _err("agents", "is required and must be a non-empty array")
358
+ agents: list[dict[str, Any]] = []
359
+ for i, agent_raw in enumerate(agents_raw):
360
+ validated = _validate_agent(
361
+ agent_raw,
362
+ path=f"agents[{i}]",
363
+ brand_guardrail=brand_guardrail,
364
+ )
365
+ # Fill in default name if not specified
366
+ if not validated["name"]:
367
+ modality_label = validated["modality"].replace("_", " ").title()
368
+ validated["name"] = f"{name} {modality_label}"
369
+ agents.append(validated)
370
+
371
+ # --- optional sections ---
372
+ conversations_csv: dict[str, Any] | None = None
373
+ if "conversations_csv" in payload and payload["conversations_csv"] is not None:
374
+ conversations_csv = _validate_conversations_csv(
375
+ payload["conversations_csv"], path="conversations_csv"
376
+ )
377
+
378
+ taxonomy: dict[str, Any] | None = None
379
+ if "taxonomy" in payload and payload["taxonomy"] is not None:
380
+ taxonomy = _validate_taxonomy(payload["taxonomy"], path="taxonomy")
381
+
382
+ knowledge_base: dict[str, Any] | None = None
383
+ if "knowledge_base" in payload and payload["knowledge_base"] is not None:
384
+ knowledge_base = _validate_knowledge_base(payload["knowledge_base"], path="knowledge_base")
385
+
386
+ simulation: dict[str, Any] | None = None
387
+ if "simulation" in payload and payload["simulation"] is not None:
388
+ simulation = _validate_simulation(payload["simulation"], path="simulation")
389
+
390
+ return {
391
+ "name": name,
392
+ "brand": {"description": brand_description, "guardrail": brand_guardrail},
393
+ "agents": agents,
394
+ "conversations_csv": conversations_csv,
395
+ "taxonomy": taxonomy,
396
+ "knowledge_base": knowledge_base,
397
+ "simulation": simulation,
398
+ }