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.
- applied_cli/__init__.py +2 -0
- applied_cli/auth_store.py +263 -0
- applied_cli/commands/__init__.py +2 -0
- applied_cli/commands/_hints.py +11 -0
- applied_cli/commands/_normalize.py +79 -0
- applied_cli/commands/_parsers.py +58 -0
- applied_cli/commands/_ui.py +33 -0
- applied_cli/commands/agent.py +1231 -0
- applied_cli/commands/auth.py +739 -0
- applied_cli/commands/chat.py +379 -0
- applied_cli/commands/coverage.py +348 -0
- applied_cli/commands/discover.py +1006 -0
- applied_cli/commands/fix.py +1204 -0
- applied_cli/commands/insights.py +614 -0
- applied_cli/commands/intents.py +447 -0
- applied_cli/commands/rate.py +508 -0
- applied_cli/commands/responses.py +604 -0
- applied_cli/commands/shop.py +1757 -0
- applied_cli/commands/simulate.py +330 -0
- applied_cli/commands/spec.py +238 -0
- applied_cli/config.py +50 -0
- applied_cli/error_reporting.py +38 -0
- applied_cli/http.py +1614 -0
- applied_cli/main.py +90 -0
- applied_cli/mcp_server.py +738 -0
- applied_cli/presets/demo.yaml +170 -0
- applied_cli/runtime.py +53 -0
- applied_cli/shop_spec.py +398 -0
- applied_cli/spec_workflow.py +432 -0
- applied_cli-0.1.0.dist-info/METADATA +176 -0
- applied_cli-0.1.0.dist-info/RECORD +34 -0
- applied_cli-0.1.0.dist-info/WHEEL +5 -0
- applied_cli-0.1.0.dist-info/entry_points.txt +3 -0
- applied_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
applied_cli/shop_spec.py
ADDED
|
@@ -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
|
+
}
|